Compare commits
73 Commits
1c92b8eaf1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc002adbd2 | ||
|
|
3c7758a599 | ||
|
|
7df27ce66d | ||
|
|
b217342a96 | ||
|
|
8a1fbceb38 | ||
|
|
3f3253d41b | ||
|
|
63908f9e9d | ||
|
|
ed6cfed312 | ||
|
|
ab6b76fcb8 | ||
|
|
b52ff875be | ||
|
|
66d68c652c | ||
|
|
d3b326e78c | ||
|
|
b1eaf41681 | ||
|
|
bd7e499ab7 | ||
|
|
44144ebf9d | ||
|
|
314a74982c | ||
|
|
76d3f00f29 | ||
|
|
214410d2fb | ||
|
|
199922272f | ||
|
|
6e54efe5e4 | ||
|
|
5dd53cebac | ||
|
|
0de0df9357 | ||
|
|
20fb8ea2db | ||
|
|
8c9a7b73dc | ||
|
|
910c50d870 | ||
|
|
2f49fdc9b7 | ||
|
|
0439a84e6d | ||
|
|
0815269f1b | ||
|
|
9b535354fb | ||
|
|
e01422e443 | ||
|
|
2208cee87f | ||
|
|
138e223361 | ||
|
|
441ec20059 | ||
|
|
929aeec641 | ||
|
|
4a885de520 | ||
|
|
c84ad6c4db | ||
|
|
acd7de76d9 | ||
|
|
9c4a2343f5 | ||
|
|
1d828adc27 | ||
|
|
4c7ab4fd35 | ||
|
|
0fa2c3c90f | ||
|
|
cbefaa2bf7 | ||
|
|
e35eae2a72 | ||
|
|
5c940847af | ||
|
|
80e2c099d9 | ||
|
|
70f94ce0f2 | ||
|
|
a24947187a | ||
|
|
c65e3455e6 | ||
|
|
31be887882 | ||
|
|
3eeec3c60e | ||
|
|
face71ef5d | ||
|
|
a727c1b069 | ||
|
|
918d84f2d2 | ||
|
|
a30060050b | ||
|
|
8f420711ae | ||
|
|
be96ecc324 | ||
|
|
bba000d308 | ||
|
|
e8b0733304 | ||
|
|
f43cb165c6 | ||
|
|
0c7580aa44 | ||
|
|
a91a2f415d | ||
|
|
0c8ae05919 | ||
|
|
1bde570035 | ||
|
|
5ba31b7291 | ||
|
|
46846200da | ||
|
|
ba6004ebd6 | ||
|
|
b5e307e142 | ||
|
|
98e982c260 | ||
|
|
8f796b2403 | ||
|
|
d0174fed3e | ||
|
|
0b704e0e27 | ||
|
|
e8aaf9ff15 | ||
|
|
44d7c796c9 |
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "subscriptions" ADD COLUMN "mp_preference_id" TEXT;
|
||||||
@@ -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")
|
||||||
|
|||||||
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();
|
||||||
|
});
|
||||||
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();
|
||||||
|
});
|
||||||
@@ -45,6 +45,10 @@ import metricasRoutes from './routes/metricas.routes.js';
|
|||||||
|
|
||||||
const app: Express = express();
|
const app: Express = express();
|
||||||
|
|
||||||
|
// Trust proxy — la app corre detrás de Cloudflare/nginx. Necesario para que
|
||||||
|
// express-rate-limit lea correctamente X-Forwarded-For sin lanzar warnings.
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
// Security. Helmet default incluye un CSP restrictivo que puede chocar con el
|
// Security. Helmet default incluye un CSP restrictivo que puede chocar con el
|
||||||
// frontend cuando éste embebe recursos propios (ej: /terminos embebe el PDF de
|
// frontend cuando éste embebe recursos propios (ej: /terminos embebe el PDF de
|
||||||
// /legal/). Dejamos CSP off en el API y centralizamos los headers de seguridad
|
// /legal/). Dejamos CSP off en el API y centralizamos los headers de seguridad
|
||||||
|
|||||||
@@ -71,9 +71,9 @@ class TenantConnectionManager {
|
|||||||
user: connectionOverride?.user ?? this.dbConfig.user,
|
user: connectionOverride?.user ?? this.dbConfig.user,
|
||||||
password: connectionOverride?.password ?? this.dbConfig.password,
|
password: connectionOverride?.password ?? this.dbConfig.password,
|
||||||
database: databaseName,
|
database: databaseName,
|
||||||
max: 3,
|
max: 10,
|
||||||
idleTimeoutMillis: 300_000,
|
idleTimeoutMillis: 300_000,
|
||||||
connectionTimeoutMillis: 10_000,
|
connectionTimeoutMillis: 30_000,
|
||||||
};
|
};
|
||||||
|
|
||||||
pool = new Pool(poolConfig);
|
pool = new Pool(poolConfig);
|
||||||
@@ -187,11 +187,13 @@ class TenantConnectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove idle pools (not accessed in last 5 minutes).
|
* Remove idle pools (not accessed in last 12 hours).
|
||||||
|
* SAT syncs (initial/daily) can run for hours in background;
|
||||||
|
* a 5-minute timeout caused 'pool already ended' errors mid-sync.
|
||||||
*/
|
*/
|
||||||
private cleanupIdlePools(): void {
|
private cleanupIdlePools(): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const maxIdle = 5 * 60 * 1000;
|
const maxIdle = 12 * 60 * 60 * 1000;
|
||||||
|
|
||||||
for (const [tenantId, entry] of this.pools.entries()) {
|
for (const [tenantId, entry] of this.pools.entries()) {
|
||||||
if (now - entry.lastAccess.getTime() > maxIdle) {
|
if (now - entry.lastAccess.getTime() > maxIdle) {
|
||||||
|
|||||||
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -125,7 +125,9 @@ export async function resolverAlertaManual(req: Request, res: Response, next: Ne
|
|||||||
export async function getAlertasAutomaticas(req: Request, res: Response, next: NextFunction) {
|
export async function getAlertasAutomaticas(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||||
|
console.log(`[AlertasCtrl] GET /automaticas tenant=${req.user!.tenantId} contribuyente=${contribuyenteId || 'null'} user=${req.user!.userId} role=${req.user!.role}`);
|
||||||
const alertas = await generarAlertasAutomaticas(req.tenantPool!, req.user!.tenantId, contribuyenteId || null);
|
const alertas = await generarAlertasAutomaticas(req.tenantPool!, req.user!.tenantId, contribuyenteId || null);
|
||||||
|
console.log(`[AlertasCtrl] GET /automaticas devuelve ${alertas.length} alertas: ${alertas.map(a => a.id).join(', ') || 'ninguna'}`);
|
||||||
res.json(alertas);
|
res.json(alertas);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -333,7 +335,7 @@ export async function getCancelados(req: Request, res: Response, next: NextFunct
|
|||||||
total_mxn as "totalMxn", fecha_cancelacion as "fechaCancelacion"
|
total_mxn as "totalMxn", fecha_cancelacion as "fechaCancelacion"
|
||||||
FROM cfdis
|
FROM cfdis
|
||||||
WHERE status IN ('Cancelado', '0')
|
WHERE status IN ('Cancelado', '0')
|
||||||
AND fecha_emision >= $1::date
|
AND (fecha_emision - interval '1 hour') >= $1::date
|
||||||
${cf}
|
${cf}
|
||||||
ORDER BY fecha_emision DESC
|
ORDER BY fecha_emision DESC
|
||||||
`, [hace5.toISOString().split('T')[0]]);
|
`, [hace5.toISOString().split('T')[0]]);
|
||||||
@@ -364,7 +366,7 @@ export async function getCancelacionesPeriodoAnterior(req: Request, res: Respons
|
|||||||
FROM cfdis
|
FROM cfdis
|
||||||
WHERE status IN ('Cancelado', '0')
|
WHERE status IN ('Cancelado', '0')
|
||||||
AND fecha_cancelacion >= $1::date
|
AND fecha_cancelacion >= $1::date
|
||||||
AND fecha_emision < $1::date
|
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < $1::date
|
||||||
${cf}
|
${cf}
|
||||||
ORDER BY fecha_cancelacion DESC
|
ORDER BY fecha_cancelacion DESC
|
||||||
`, [inicioMes]);
|
`, [inicioMes]);
|
||||||
|
|||||||
197
apps/api/src/controllers/asignaciones.controller.ts
Normal file
197
apps/api/src/controllers/asignaciones.controller.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import * as asignacionesService from '../services/asignaciones.service.js';
|
||||||
|
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
|
||||||
|
import { AppError } from '../middlewares/error.middleware.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida que el auxiliar pertenezca al supervisor (o que el caller sea owner).
|
||||||
|
* Owner puede asignar a cualquier auxiliar del tenant.
|
||||||
|
* La relación se infiere desde carteras (directas y subcarteras) con fallback
|
||||||
|
* a la tabla legacy auxiliar_supervisores.
|
||||||
|
*/
|
||||||
|
async function validarAuxiliarDelSupervisor(
|
||||||
|
pool: import('pg').Pool,
|
||||||
|
supervisorUserId: string,
|
||||||
|
auxiliarUserId: string,
|
||||||
|
callerRole: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (callerRole === 'owner') return;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT 1 FROM (
|
||||||
|
SELECT c.auxiliar_user_id
|
||||||
|
FROM carteras c
|
||||||
|
WHERE c.supervisor_user_id = $1
|
||||||
|
AND c.auxiliar_user_id = $2
|
||||||
|
UNION
|
||||||
|
SELECT sub.auxiliar_user_id
|
||||||
|
FROM carteras sub
|
||||||
|
JOIN carteras p ON p.id = sub.parent_id
|
||||||
|
WHERE p.supervisor_user_id = $1
|
||||||
|
AND sub.auxiliar_user_id = $2
|
||||||
|
UNION
|
||||||
|
SELECT auxiliar_user_id FROM auxiliar_supervisores
|
||||||
|
WHERE supervisor_user_id = $1 AND auxiliar_user_id = $2
|
||||||
|
) t LIMIT 1`,
|
||||||
|
[supervisorUserId, auxiliarUserId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new AppError(403, 'El auxiliar no pertenece a tu equipo');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida que el auxiliar tenga al contribuyente en alguna de sus subcarteras.
|
||||||
|
* Si no hay ningún auxiliar con ese contribuyente en su subcartera, la asignación
|
||||||
|
* se rechaza (el supervisor debe agregar el contribuyente a una subcartera primero).
|
||||||
|
*/
|
||||||
|
async function validarAuxiliarEnSubcartera(
|
||||||
|
pool: import('pg').Pool,
|
||||||
|
contribuyenteId: string,
|
||||||
|
auxiliarUserId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const elegibles = await asignacionesService.getAuxiliaresElegibles(pool, contribuyenteId);
|
||||||
|
if (elegibles.length === 0) {
|
||||||
|
throw new AppError(403, 'Ningún auxiliar tiene este contribuyente en su subcartera');
|
||||||
|
}
|
||||||
|
if (!elegibles.includes(auxiliarUserId)) {
|
||||||
|
throw new AppError(403, 'El auxiliar no tiene este contribuyente en ninguna de sus subcarteras');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Obligaciones ──
|
||||||
|
|
||||||
|
export async function asignarObligacion(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const contribuyenteId = String(req.params.id);
|
||||||
|
const obligacionId = String(req.params.obligacionId);
|
||||||
|
const schema = z.object({ auxiliarUserId: z.string().uuid() });
|
||||||
|
const { auxiliarUserId } = schema.parse(req.body);
|
||||||
|
|
||||||
|
await validarAuxiliarDelSupervisor(
|
||||||
|
req.tenantPool!,
|
||||||
|
req.user!.userId,
|
||||||
|
auxiliarUserId,
|
||||||
|
req.user!.role,
|
||||||
|
);
|
||||||
|
await validarAuxiliarEnSubcartera(
|
||||||
|
req.tenantPool!,
|
||||||
|
contribuyenteId,
|
||||||
|
auxiliarUserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
await asignacionesService.asignarObligacion(
|
||||||
|
req.tenantPool!,
|
||||||
|
obligacionId,
|
||||||
|
auxiliarUserId,
|
||||||
|
req.user!.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ message: 'Obligación asignada' });
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function desasignarObligacion(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const obligacionId = String(req.params.obligacionId);
|
||||||
|
await asignacionesService.desasignarObligacion(req.tenantPool!, obligacionId);
|
||||||
|
res.json({ message: 'Asignación de obligación eliminada' });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tareas ──
|
||||||
|
|
||||||
|
export async function asignarTarea(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const tareaId = String(req.params.id);
|
||||||
|
const schema = z.object({ auxiliarUserId: z.string().uuid() });
|
||||||
|
const { auxiliarUserId } = schema.parse(req.body);
|
||||||
|
|
||||||
|
await validarAuxiliarDelSupervisor(
|
||||||
|
req.tenantPool!,
|
||||||
|
req.user!.userId,
|
||||||
|
auxiliarUserId,
|
||||||
|
req.user!.role,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Obtener contribuyenteId de la tarea para validar subcartera
|
||||||
|
const { rows } = await req.tenantPool!.query<{ contribuyente_id: string }>(
|
||||||
|
`SELECT contribuyente_id FROM tareas_catalogo WHERE id = $1 LIMIT 1`,
|
||||||
|
[tareaId],
|
||||||
|
);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
await validarAuxiliarEnSubcartera(
|
||||||
|
req.tenantPool!,
|
||||||
|
rows[0].contribuyente_id,
|
||||||
|
auxiliarUserId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await asignacionesService.asignarTarea(
|
||||||
|
req.tenantPool!,
|
||||||
|
tareaId,
|
||||||
|
auxiliarUserId,
|
||||||
|
req.user!.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ message: 'Tarea asignada' });
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function desasignarTarea(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const tareaId = String(req.params.id);
|
||||||
|
await asignacionesService.desasignarTarea(req.tenantPool!, tareaId);
|
||||||
|
res.json({ message: 'Asignación de tarea eliminada' });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Listados ──
|
||||||
|
|
||||||
|
export async function listPorSupervisor(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const data = await asignacionesService.getAsignacionesPorSupervisor(
|
||||||
|
req.tenantPool!,
|
||||||
|
req.user!.userId,
|
||||||
|
req.user!.role,
|
||||||
|
);
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPorAuxiliar(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const data = await asignacionesService.getAsignacionesPorAuxiliar(
|
||||||
|
req.tenantPool!,
|
||||||
|
req.user!.userId,
|
||||||
|
);
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSinAsignar(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const entidadIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
|
||||||
|
const [obligaciones, tareas] = await Promise.all([
|
||||||
|
asignacionesService.getObligacionesSinAsignar(req.tenantPool!, entidadIds),
|
||||||
|
asignacionesService.getTareasSinAsignar(req.tenantPool!, entidadIds),
|
||||||
|
]);
|
||||||
|
res.json({ obligaciones, tareas });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAuxiliaresElegibles(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const contribuyenteId = String(req.params.contribuyenteId);
|
||||||
|
const auxIds = await asignacionesService.getAuxiliaresElegibles(req.tenantPool!, contribuyenteId);
|
||||||
|
res.json({ auxiliares: auxIds });
|
||||||
|
} catch (err) { 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ñ]');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
import * as cfdiService from '../services/cfdi.service.js';
|
import * as cfdiService from '../services/cfdi.service.js';
|
||||||
import { AppError } from '../middlewares/error.middleware.js';
|
import { AppError } from '../middlewares/error.middleware.js';
|
||||||
|
import AdmZip from 'adm-zip';
|
||||||
import { GRUPO_PF_EMPRESARIAL, GRUPO_PM_OTROS } from '../services/dashboard.service.js';
|
import { GRUPO_PF_EMPRESARIAL, GRUPO_PM_OTROS } from '../services/dashboard.service.js';
|
||||||
import { getRegimenesIgnoradosClaves } from '../services/regimen.service.js';
|
import { getRegimenesIgnoradosClaves } from '../services/regimen.service.js';
|
||||||
import { resolveContribuyenteContext } from '../utils/contribuyente-context.js';
|
import { resolveContribuyenteContext } from '../utils/contribuyente-context.js';
|
||||||
@@ -75,6 +76,50 @@ export async function getXml(req: Request, res: Response, next: NextFunction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function downloadXmlsZip(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.tenantPool) {
|
||||||
|
return next(new AppError(400, 'Tenant no configurado'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: CfdiFilters = {
|
||||||
|
tipo: req.body.tipo as any,
|
||||||
|
tipoComprobante: req.body.tipoComprobante as any,
|
||||||
|
estado: req.body.estado as any,
|
||||||
|
fechaInicio: req.body.fechaInicio as string,
|
||||||
|
fechaFin: req.body.fechaFin as string,
|
||||||
|
rfc: req.body.rfc as string,
|
||||||
|
emisor: req.body.emisor as string,
|
||||||
|
receptor: req.body.receptor as string,
|
||||||
|
search: req.body.search as string,
|
||||||
|
contribuyenteId: req.body.contribuyenteId as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cfdis = await cfdiService.getCfdiXmlsForZip(req.tenantPool, filters);
|
||||||
|
const zip = new AdmZip();
|
||||||
|
let added = 0;
|
||||||
|
|
||||||
|
for (const cfdi of cfdis) {
|
||||||
|
if (cfdi.xml) {
|
||||||
|
const filename = `${cfdi.uuid || 'cfdi'}.xml`;
|
||||||
|
zip.addFile(filename, Buffer.from(cfdi.xml, 'utf8'));
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (added === 0) {
|
||||||
|
return next(new AppError(404, 'No se encontraron XMLs para los filtros aplicados'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipBuffer = zip.toBuffer();
|
||||||
|
res.set('Content-Type', 'application/zip');
|
||||||
|
res.set('Content-Disposition', `attachment; filename="cfdis-${Date.now()}.zip"`);
|
||||||
|
res.send(zipBuffer);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function listConceptos(req: Request, res: Response, next: NextFunction) {
|
export async function listConceptos(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!req.tenantPool) return next(new AppError(400, 'Tenant no configurado'));
|
if (!req.tenantPool) return next(new AppError(400, 'Tenant no configurado'));
|
||||||
@@ -239,13 +284,28 @@ export async function drillDown(req: Request, res: Response, next: NextFunction)
|
|||||||
) ${NO_IGNORADO_EMISOR.replace('regimen_fiscal_emisor', `CASE WHEN ${esEmisor} THEN regimen_fiscal_emisor ELSE regimen_fiscal_receptor END`)}`;
|
) ${NO_IGNORADO_EMISOR.replace('regimen_fiscal_emisor', `CASE WHEN ${esEmisor} THEN regimen_fiscal_emisor ELSE regimen_fiscal_receptor END`)}`;
|
||||||
} else if (bucketStr === 'gastos') {
|
} else if (bucketStr === 'gastos') {
|
||||||
// Las E PUE se exhiben en su propia card "NCs Recibidas" — no entran aquí.
|
// Las E PUE se exhiben en su propia card "NCs Recibidas" — no entran aquí.
|
||||||
|
// La nómina emitida (tipo_comprobante = 'N') SÍ entra: el patrón la emite
|
||||||
|
// (lado emisor) y es un gasto/egreso para sus libros — alineado con
|
||||||
|
// calcularEgresosPorRegimen en dashboard.service.ts.
|
||||||
where += ` AND (
|
where += ` AND (
|
||||||
|
(
|
||||||
${esReceptor} AND (
|
${esReceptor} AND (
|
||||||
(tipo_comprobante = 'I' AND metodo_pago = 'PUE')
|
(tipo_comprobante = 'I' AND metodo_pago = 'PUE')
|
||||||
OR (tipo_comprobante = 'P')
|
OR (tipo_comprobante = 'P')
|
||||||
)
|
)
|
||||||
AND regimen_fiscal_receptor IN (${TODOS_REGS})
|
AND regimen_fiscal_receptor IN (${TODOS_REGS})
|
||||||
) ${NO_IGNORADO_RECEPTOR}`;
|
)
|
||||||
|
OR (
|
||||||
|
${esEmisor} AND tipo_comprobante = 'N'
|
||||||
|
AND regimen_fiscal_emisor IN (${TODOS_REGS})
|
||||||
|
)
|
||||||
|
)`;
|
||||||
|
if (ignorados.length > 0) {
|
||||||
|
where += ` AND (
|
||||||
|
(${esReceptor} AND regimen_fiscal_receptor NOT IN (${ignorados.map(r => `'${r}'`).join(',')}))
|
||||||
|
OR (${esEmisor} AND tipo_comprobante = 'N' AND regimen_fiscal_emisor NOT IN (${ignorados.map(r => `'${r}'`).join(',')}))
|
||||||
|
)`;
|
||||||
|
}
|
||||||
} else if (bucketStr === 'causado') {
|
} else if (bucketStr === 'causado') {
|
||||||
where += ` AND (
|
where += ` AND (
|
||||||
${esEmisor} AND (
|
${esEmisor} AND (
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ 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
|
// Admin y Vendedor (platform_sales) pueden crear invitaciones
|
||||||
const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin');
|
const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin', 'platform_sales');
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return res.status(403).json({ message: 'Solo administradores pueden crear invitaciones' });
|
return res.status(403).json({ message: 'Solo administradores o vendedores pueden crear invitaciones' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const invitation = await clientInvitationService.createInvitation({
|
const invitation = await clientInvitationService.createInvitation({
|
||||||
@@ -70,7 +70,7 @@ export async function registerFromInvitation(req: Request, res: Response, next:
|
|||||||
|
|
||||||
export async function resendInvitation(req: Request, res: Response, next: NextFunction) {
|
export async function resendInvitation(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin');
|
const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin', 'platform_sales');
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return res.status(403).json({ message: 'No autorizado' });
|
return res.status(403).json({ message: 'No autorizado' });
|
||||||
}
|
}
|
||||||
@@ -88,7 +88,7 @@ export async function resendInvitation(req: Request, res: Response, next: NextFu
|
|||||||
|
|
||||||
export async function listInvitations(req: Request, res: Response, next: NextFunction) {
|
export async function listInvitations(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin');
|
const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin', 'platform_sales');
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return res.status(403).json({ message: 'No autorizado' });
|
return res.status(403).json({ message: 'No autorizado' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export async function uploadFiel(req: Request, res: Response, next: NextFunction
|
|||||||
return next(new AppError(400, 'cerFile, keyFile y password son requeridos'));
|
return next(new AppError(400, 'cerFile, keyFile y password son requeridos'));
|
||||||
}
|
}
|
||||||
const contribuyenteId = String(req.params.id);
|
const contribuyenteId = String(req.params.id);
|
||||||
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId);
|
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId, req.user!.tenantId);
|
||||||
if (!contrib) return next(new AppError(404, 'Contribuyente no encontrado'));
|
if (!contrib) return next(new AppError(404, 'Contribuyente no encontrado'));
|
||||||
|
|
||||||
const result = await fielService.uploadFielContribuyente(req.tenantPool!, contribuyenteId, cerFile, keyFile, password);
|
const result = await fielService.uploadFielContribuyente(req.tenantPool!, contribuyenteId, cerFile, keyFile, password);
|
||||||
@@ -62,7 +62,7 @@ export async function deleteFiel(req: Request, res: Response, next: NextFunction
|
|||||||
export async function createOrg(req: Request, res: Response, next: NextFunction) {
|
export async function createOrg(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const contribuyenteId = String(req.params.id);
|
const contribuyenteId = String(req.params.id);
|
||||||
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId);
|
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId, req.user!.tenantId);
|
||||||
if (!contrib) return next(new AppError(404, 'Contribuyente no encontrado'));
|
if (!contrib) return next(new AppError(404, 'Contribuyente no encontrado'));
|
||||||
|
|
||||||
const result = await facturapiService.createOrgContribuyente(req.tenantPool!, contribuyenteId, contrib.nombre);
|
const result = await facturapiService.createOrgContribuyente(req.tenantPool!, contribuyenteId, contrib.nombre);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import * as contribuyenteService from '../services/contribuyente.service.js';
|
import * as contribuyenteService from '../services/contribuyente.service.js';
|
||||||
|
import * as carteraService from '../services/cartera.service.js';
|
||||||
import { AppError } from '../middlewares/error.middleware.js';
|
import { AppError } from '../middlewares/error.middleware.js';
|
||||||
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
|
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
|
||||||
import { adjustDespachoOverage } from '../services/payment/addon.service.js';
|
import { adjustDespachoOverage } from '../services/payment/addon.service.js';
|
||||||
@@ -40,14 +41,31 @@ const updateSchema = createSchema.partial();
|
|||||||
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);
|
const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds, req.user!.tenantId);
|
||||||
return res.json({ data: rows });
|
|
||||||
|
// Batch lookup de nombres de supervisores
|
||||||
|
const supervisorIds = [...new Set(rows.map(r => r.supervisorUserId).filter(Boolean))] as string[];
|
||||||
|
const supervisorNames: Record<string, string> = {};
|
||||||
|
if (supervisorIds.length > 0) {
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { id: { in: supervisorIds } },
|
||||||
|
select: { id: true, nombre: true },
|
||||||
|
});
|
||||||
|
for (const u of users) supervisorNames[u.id] = u.nombre;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
data: rows.map(r => ({
|
||||||
|
...r,
|
||||||
|
supervisorNombre: r.supervisorUserId ? (supervisorNames[r.supervisorUserId] ?? null) : null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
} catch (err) { return next(err); }
|
} catch (err) { return next(err); }
|
||||||
}
|
}
|
||||||
|
|
||||||
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));
|
const row = await contribuyenteService.getContribuyenteById(req.tenantPool!, String(req.params.id), req.user!.tenantId);
|
||||||
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); }
|
||||||
@@ -77,6 +95,19 @@ export async function create(req: Request, res: Response, next: NextFunction) {
|
|||||||
|
|
||||||
const row = await contribuyenteService.createContribuyente(req.tenantPool!, data);
|
const row = await contribuyenteService.createContribuyente(req.tenantPool!, data);
|
||||||
|
|
||||||
|
// Si se asignó un supervisor, agregar el contribuyente a todas las carteras
|
||||||
|
// top-level de ese supervisor para que aparezca directamente en su vista.
|
||||||
|
if (data.supervisorUserId) {
|
||||||
|
try {
|
||||||
|
const carteras = await carteraService.listCarteras(req.tenantPool!, data.supervisorUserId);
|
||||||
|
await Promise.all(
|
||||||
|
carteras.map(c => carteraService.addEntidadToCartera(req.tenantPool!, c.id, row.id))
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Contribuyente] Auto-assign to cartera failed (non-blocking):', err.message || err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ajuste de overage despacho: si el tenant pasa de 100 a 101+ RFCs, crea
|
// Ajuste de overage despacho: si el tenant pasa de 100 a 101+ RFCs, crea
|
||||||
// el addon y devuelve paymentUrl para que el frontend redirija al usuario.
|
// el addon y devuelve paymentUrl para que el frontend redirija al usuario.
|
||||||
// Fail-soft: si falla el addon, el contribuyente queda creado y se loguea.
|
// Fail-soft: si falla el addon, el contribuyente queda creado y se loguea.
|
||||||
@@ -139,6 +170,15 @@ export async function addClienteAcceso(req: Request, res: Response, next: NextFu
|
|||||||
const { userId } = req.body;
|
const { userId } = req.body;
|
||||||
if (!userId || typeof userId !== 'string') return next(new AppError(400, 'userId requerido'));
|
if (!userId || typeof userId !== 'string') return next(new AppError(400, 'userId requerido'));
|
||||||
const entidadId = String(req.params.id);
|
const entidadId = String(req.params.id);
|
||||||
|
|
||||||
|
// Seguridad: supervisor solo puede asignar contribuyentes que supervise
|
||||||
|
if (req.user!.role === 'supervisor') {
|
||||||
|
const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
|
||||||
|
if (!visibleIds.includes(entidadId)) {
|
||||||
|
return next(new AppError(403, 'No tienes acceso a este contribuyente'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await req.tenantPool!.query(
|
await req.tenantPool!.query(
|
||||||
'INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
'INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||||
[userId, entidadId],
|
[userId, entidadId],
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ export async function consultarManual(req: Request, res: Response, next: NextFun
|
|||||||
// Declaraciones provisionales
|
// Declaraciones provisionales
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar'];
|
const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'];
|
||||||
|
|
||||||
function canUpload(req: Request): boolean {
|
function canUpload(req: Request): boolean {
|
||||||
return ROLES_UPLOAD.includes(req.user!.role);
|
return ROLES_UPLOAD.includes(req.user!.role);
|
||||||
@@ -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', 'SUELDOS', 'DIOT', 'OTRO'])).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,13 +124,15 @@ 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!,
|
||||||
tenantId: req.user!.tenantId,
|
tenantId: req.viewingTenantId ?? req.user!.tenantId,
|
||||||
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,
|
||||||
@@ -229,6 +236,9 @@ export async function consultarConstanciaManual(req: Request, res: Response, nex
|
|||||||
res.json(constancia);
|
res.json(constancia);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.message?.includes('FIEL')) return res.status(400).json({ error: error.message });
|
if (error.message?.includes('FIEL')) return res.status(400).json({ error: error.message });
|
||||||
|
if (error.message?.includes('Timeout') || error.name === 'TimeoutError') {
|
||||||
|
return res.status(504).json({ error: 'El portal del SAT no respondió a tiempo. Intenta de nuevo en unos minutos.' });
|
||||||
|
}
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,7 +293,7 @@ export async function crearExtra(req: Request, res: Response, next: NextFunction
|
|||||||
// Notificación fire-and-forget a owners del despacho + supervisor del RFC.
|
// Notificación fire-and-forget a owners del despacho + supervisor del RFC.
|
||||||
notifyDocumentoSubido({
|
notifyDocumentoSubido({
|
||||||
pool: req.tenantPool!,
|
pool: req.tenantPool!,
|
||||||
tenantId: req.user!.tenantId,
|
tenantId: req.viewingTenantId ?? req.user!.tenantId,
|
||||||
contribuyenteId: contribuyenteId ?? null,
|
contribuyenteId: contribuyenteId ?? null,
|
||||||
subidoPor: req.user!.email,
|
subidoPor: req.user!.email,
|
||||||
kind: 'extra',
|
kind: 'extra',
|
||||||
@@ -331,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); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
downloadPdfContribuyente,
|
downloadPdfContribuyente,
|
||||||
downloadXmlContribuyente,
|
downloadXmlContribuyente,
|
||||||
sendInvoiceByEmailContribuyente,
|
sendInvoiceByEmailContribuyente,
|
||||||
|
getCustomizationContribuyente,
|
||||||
|
uploadLogoContribuyente,
|
||||||
|
updateColorContribuyente,
|
||||||
} from '../services/contribuyente-facturapi.service.js';
|
} from '../services/contribuyente-facturapi.service.js';
|
||||||
import { parseXml } from '../services/sat/sat-parser.service.js';
|
import { parseXml } from '../services/sat/sat-parser.service.js';
|
||||||
import * as tenantsService from '../services/tenants.service.js';
|
import * as tenantsService from '../services/tenants.service.js';
|
||||||
@@ -15,6 +18,7 @@ import { prisma } from '../config/database.js';
|
|||||||
import { AppError } from '../middlewares/error.middleware.js';
|
import { AppError } from '../middlewares/error.middleware.js';
|
||||||
import { hasPlatformRole } from '../utils/platform-admin.js';
|
import { hasPlatformRole } from '../utils/platform-admin.js';
|
||||||
import { auditFromReq } from '../utils/audit.js';
|
import { auditFromReq } from '../utils/audit.js';
|
||||||
|
import { recomputarSaldoPendiente } from '../utils/saldo.js';
|
||||||
|
|
||||||
function effectiveTenantId(req: Request): string {
|
function effectiveTenantId(req: Request): string {
|
||||||
return req.viewingTenantId || req.user!.tenantId;
|
return req.viewingTenantId || req.user!.tenantId;
|
||||||
@@ -134,6 +138,17 @@ export async function emitir(req: Request, res: Response, next: NextFunction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Validar fecha de emisión (solo I, E, T) ──
|
||||||
|
const tipo = req.body.type || 'I';
|
||||||
|
if (tipo !== 'P' && req.body.fechaEmision) {
|
||||||
|
const fecha = new Date(req.body.fechaEmision);
|
||||||
|
const now = new Date();
|
||||||
|
const minDate = new Date(now.getTime() - 72 * 60 * 60 * 1000);
|
||||||
|
if (isNaN(fecha.getTime()) || fecha > now || fecha < minDate) {
|
||||||
|
throw new AppError(400, 'La fecha de emisión debe estar entre 72 horas en el pasado y el momento actual');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reservar timbre — si falla emisión en Facturapi, revertimos abajo
|
// Reservar timbre — si falla emisión en Facturapi, revertimos abajo
|
||||||
const consumedTimbre = await facturapiService.consumeTimbre(tenantId);
|
const consumedTimbre = await facturapiService.consumeTimbre(tenantId);
|
||||||
|
|
||||||
@@ -272,6 +287,11 @@ export async function emitir(req: Request, res: Response, next: NextFunction) {
|
|||||||
contribuyenteId ?? null, xmlString,
|
contribuyenteId ?? null, xmlString,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Inicializar saldo pendiente para I/PPD (igual que el flujo SAT)
|
||||||
|
if (parsed.tipoComprobante === 'I' && parsed.metodoPago === 'PPD' && parsed.uuid) {
|
||||||
|
await recomputarSaldoPendiente(pool, [parsed.uuid]);
|
||||||
|
}
|
||||||
|
|
||||||
// Enviar por email si el receptor tiene email — ruteado a la org correcta
|
// Enviar por email si el receptor tiene email — ruteado a la org correcta
|
||||||
const customerEmail = req.body.customer?.email;
|
const customerEmail = req.body.customer?.email;
|
||||||
if (customerEmail) {
|
if (customerEmail) {
|
||||||
@@ -325,7 +345,7 @@ export async function cancelar(req: Request, res: Response, next: NextFunction)
|
|||||||
try {
|
try {
|
||||||
const tenantId = effectiveTenantId(req);
|
const tenantId = effectiveTenantId(req);
|
||||||
const { uuid } = req.params;
|
const { uuid } = req.params;
|
||||||
const { motive, substitution } = req.body;
|
const { motive, substitution, contribuyenteId: bodyContribuyenteId } = req.body;
|
||||||
|
|
||||||
const pool = req.tenantPool!;
|
const pool = req.tenantPool!;
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
@@ -340,6 +360,12 @@ export async function cancelar(req: Request, res: Response, next: NextFunction)
|
|||||||
const facturapiId = rows[0].facturapi_id;
|
const facturapiId = rows[0].facturapi_id;
|
||||||
const cfdiContribuyenteId = rows[0].contribuyente_id as string | null;
|
const cfdiContribuyenteId = rows[0].contribuyente_id as string | null;
|
||||||
|
|
||||||
|
// En modelo multi-contribuyente: si el caller envía un contribuyenteId,
|
||||||
|
// solo puede cancelar facturas de ESE contribuyente.
|
||||||
|
if (bodyContribuyenteId && cfdiContribuyenteId && bodyContribuyenteId !== cfdiContribuyenteId) {
|
||||||
|
return res.status(403).json({ message: 'No tienes permiso para cancelar esta factura' });
|
||||||
|
}
|
||||||
|
|
||||||
const result = cfdiContribuyenteId
|
const result = cfdiContribuyenteId
|
||||||
? await cancelInvoiceContribuyente(pool, cfdiContribuyenteId, facturapiId, motive || '02', substitution)
|
? await cancelInvoiceContribuyente(pool, cfdiContribuyenteId, facturapiId, motive || '02', substitution)
|
||||||
: await facturapiService.cancelInvoice(tenantId, facturapiId, motive || '02', substitution);
|
: await facturapiService.cancelInvoice(tenantId, facturapiId, motive || '02', substitution);
|
||||||
@@ -454,6 +480,38 @@ export async function updateColor(req: Request, res: Response, next: NextFunctio
|
|||||||
} catch (error) { next(error); }
|
} catch (error) { next(error); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Personalización per-contribuyente ──
|
||||||
|
|
||||||
|
export async function getCustomizationContribuyenteCtrl(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const contribuyenteId = String(req.params.id);
|
||||||
|
const data = await getCustomizationContribuyente(req.tenantPool!, contribuyenteId);
|
||||||
|
res.json(data || {});
|
||||||
|
} catch (error) { next(error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadLogoContribuyenteCtrl(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const contribuyenteId = String(req.params.id);
|
||||||
|
const { logo } = req.body;
|
||||||
|
if (!logo) return res.status(400).json({ message: 'Logo es requerido (base64)' });
|
||||||
|
const result = await uploadLogoContribuyente(req.tenantPool!, contribuyenteId, logo);
|
||||||
|
if (!result.success) return res.status(400).json({ message: result.message });
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) { next(error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateColorContribuyenteCtrl(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const contribuyenteId = String(req.params.id);
|
||||||
|
const { color } = req.body;
|
||||||
|
if (!color) return res.status(400).json({ message: 'Color es requerido' });
|
||||||
|
const result = await updateColorContribuyente(req.tenantPool!, contribuyenteId, color);
|
||||||
|
if (!result.success) return res.status(400).json({ message: result.message });
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) { next(error); }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Datos fiscales del tenant ──
|
// ── Datos fiscales del tenant ──
|
||||||
|
|
||||||
// Schema Zod para preferencias de auto-facturación
|
// Schema Zod para preferencias de auto-facturación
|
||||||
@@ -522,7 +580,13 @@ export async function searchConceptos(req: Request, res: Response, next: NextFun
|
|||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
if (q.length >= 2) {
|
if (q.length >= 2) {
|
||||||
params.push(`%${q}%`);
|
params.push(`%${q}%`);
|
||||||
whereSearch = `AND (cc.descripcion ILIKE $1 OR cc.clave_prod_serv ILIKE $1)`;
|
whereSearch = `AND (cc.descripcion ILIKE $${params.length} OR cc.clave_prod_serv ILIKE $${params.length})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let whereContribuyente = '';
|
||||||
|
if (contribuyenteId) {
|
||||||
|
params.push(contribuyenteId);
|
||||||
|
whereContribuyente = `AND c.contribuyente_id = $${params.length}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rows } = await pool.query(`
|
const { rows } = await pool.query(`
|
||||||
@@ -547,6 +611,7 @@ export async function searchConceptos(req: Request, res: Response, next: NextFun
|
|||||||
WHERE c.status NOT IN ('Cancelado', '0')
|
WHERE c.status NOT IN ('Cancelado', '0')
|
||||||
${whereType}
|
${whereType}
|
||||||
${whereSearch}
|
${whereSearch}
|
||||||
|
${whereContribuyente}
|
||||||
ORDER BY cc.clave_prod_serv, cc.descripcion, c.fecha_emision DESC
|
ORDER BY cc.clave_prod_serv, cc.descripcion, c.fecha_emision DESC
|
||||||
LIMIT 30
|
LIMIT 30
|
||||||
`, params);
|
`, params);
|
||||||
@@ -650,7 +715,7 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
|
|||||||
const q = (req.query.q as string || '').trim();
|
const q = (req.query.q as string || '').trim();
|
||||||
if (q.length < 3) return res.json([]);
|
if (q.length < 3) return res.json([]);
|
||||||
|
|
||||||
const contribuyenteId = (req.query.contribuyenteId as string || '').trim();
|
const contribuyenteId = (req.query.contribuyenteId as string || '').replace(/[^a-f0-9-]/gi, '');
|
||||||
const pool = req.tenantPool!;
|
const pool = req.tenantPool!;
|
||||||
|
|
||||||
// RFC del tenant despacho para excluirlo (no se factura a sí mismo)
|
// RFC del tenant despacho para excluirlo (no se factura a sí mismo)
|
||||||
@@ -661,10 +726,17 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
|
|||||||
});
|
});
|
||||||
const tenantRfc = tenant?.rfc || '';
|
const tenantRfc = tenant?.rfc || '';
|
||||||
|
|
||||||
// Búsqueda en el catálogo completo de RFCs. El contribuyente activo solo
|
const params: any[] = [tenantRfc, `%${q}%`];
|
||||||
// filtra CFDIs relacionados / PPD, no el autocompleto de RFCs — de lo
|
let whereContribuyente = '';
|
||||||
// contrario no se podría facturar a un cliente nuevo que nunca haya
|
if (contribuyenteId) {
|
||||||
// aparecido en un CFDI previo.
|
params.push(contribuyenteId);
|
||||||
|
whereContribuyente = `AND id IN (
|
||||||
|
SELECT rfc_receptor_id FROM cfdis WHERE contribuyente_id = $${params.length} AND rfc_receptor_id IS NOT NULL
|
||||||
|
UNION
|
||||||
|
SELECT rfc_emisor_id FROM cfdis WHERE contribuyente_id = $${params.length} AND rfc_emisor_id IS NOT NULL
|
||||||
|
)`;
|
||||||
|
}
|
||||||
|
|
||||||
const { rows } = await pool.query(`
|
const { rows } = await pool.query(`
|
||||||
SELECT id, rfc, razon_social as "razonSocial",
|
SELECT id, rfc, razon_social as "razonSocial",
|
||||||
regimen_fiscal as "regimenFiscal",
|
regimen_fiscal as "regimenFiscal",
|
||||||
@@ -672,9 +744,10 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
|
|||||||
FROM rfcs
|
FROM rfcs
|
||||||
WHERE rfc != $1
|
WHERE rfc != $1
|
||||||
AND (rfc ILIKE $2 OR razon_social ILIKE $2)
|
AND (rfc ILIKE $2 OR razon_social ILIKE $2)
|
||||||
|
${whereContribuyente}
|
||||||
ORDER BY razon_social
|
ORDER BY razon_social
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`, [tenantRfc, `%${q}%`]);
|
`, params);
|
||||||
|
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
} catch (error) { next(error); }
|
} 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);
|
||||||
|
|||||||
@@ -4,15 +4,10 @@ import { AppError } from '../middlewares/error.middleware.js';
|
|||||||
import * as papeleriaService from '../services/papeleria.service.js';
|
import * as papeleriaService from '../services/papeleria.service.js';
|
||||||
import { emailService } from '../services/email/email.service.js';
|
import { emailService } from '../services/email/email.service.js';
|
||||||
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
|
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
|
||||||
|
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
import { prisma } from '../config/database.js';
|
import { prisma } from '../config/database.js';
|
||||||
|
|
||||||
function rejectClienteRole(req: Request): void {
|
|
||||||
if (req.user?.role === 'cliente') {
|
|
||||||
throw new AppError(403, 'Papelería no disponible para usuarios cliente');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function effectiveTenantId(req: Request): string {
|
function effectiveTenantId(req: Request): string {
|
||||||
return req.viewingTenantId || req.user!.tenantId;
|
return req.viewingTenantId || req.user!.tenantId;
|
||||||
}
|
}
|
||||||
@@ -24,6 +19,7 @@ const uploadSchema = z.object({
|
|||||||
anio: z.number().int().min(2000).max(2100),
|
anio: z.number().int().min(2000).max(2100),
|
||||||
mes: z.number().int().min(1).max(12),
|
mes: z.number().int().min(1).max(12),
|
||||||
requiereAprobacion: z.boolean(),
|
requiereAprobacion: z.boolean(),
|
||||||
|
requiereAprobacionCliente: z.boolean(),
|
||||||
archivoBase64: z.string().min(1),
|
archivoBase64: z.string().min(1),
|
||||||
archivoFilename: z.string().min(1).max(255),
|
archivoFilename: z.string().min(1).max(255),
|
||||||
archivoMime: z.string().min(1).max(100),
|
archivoMime: z.string().min(1).max(100),
|
||||||
@@ -31,7 +27,9 @@ const uploadSchema = z.object({
|
|||||||
|
|
||||||
export async function upload(req: Request, res: Response, next: NextFunction) {
|
export async function upload(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
rejectClienteRole(req);
|
if (req.user?.role === 'cliente') {
|
||||||
|
throw new AppError(403, 'Los clientes no pueden subir documentos de papelería');
|
||||||
|
}
|
||||||
const data = uploadSchema.parse(req.body);
|
const data = uploadSchema.parse(req.body);
|
||||||
const archivo = Buffer.from(data.archivoBase64, 'base64');
|
const archivo = Buffer.from(data.archivoBase64, 'base64');
|
||||||
|
|
||||||
@@ -42,18 +40,23 @@ export async function upload(req: Request, res: Response, next: NextFunction) {
|
|||||||
anio: data.anio,
|
anio: data.anio,
|
||||||
mes: data.mes,
|
mes: data.mes,
|
||||||
requiereAprobacion: data.requiereAprobacion,
|
requiereAprobacion: data.requiereAprobacion,
|
||||||
|
requiereAprobacionCliente: data.requiereAprobacionCliente,
|
||||||
archivo,
|
archivo,
|
||||||
archivoFilename: data.archivoFilename,
|
archivoFilename: data.archivoFilename,
|
||||||
archivoMime: data.archivoMime,
|
archivoMime: data.archivoMime,
|
||||||
subidoPor: req.user!.userId,
|
subidoPor: req.user!.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notificación a aprobadores si la papelería requiere aprobación.
|
|
||||||
if (item.requiereAprobacion) {
|
if (item.requiereAprobacion) {
|
||||||
notifyAprobacionRequerida(req, item).catch(err =>
|
notifyAprobacionRequerida(req, item).catch(err =>
|
||||||
console.error('[papeleria.upload] notify aprobadores failed:', err?.message || err),
|
console.error('[papeleria.upload] notify aprobadores failed:', err?.message || err),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (item.requiereAprobacionCliente) {
|
||||||
|
notifyClienteAprobacionRequerida(req, item).catch(err =>
|
||||||
|
console.error('[papeleria.upload] notify clientes failed:', err?.message || err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
res.status(201).json(item);
|
res.status(201).json(item);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -74,13 +77,20 @@ const listSchema = z.object({
|
|||||||
|
|
||||||
export async function list(req: Request, res: Response, next: NextFunction) {
|
export async function list(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
rejectClienteRole(req);
|
|
||||||
const q = listSchema.parse(req.query);
|
const q = listSchema.parse(req.query);
|
||||||
|
const entidadIds = await getEntidadesVisibles(
|
||||||
|
req.tenantPool!, req.user!.userId, req.user!.role,
|
||||||
|
);
|
||||||
|
if (!entidadIds.includes(q.contribuyenteId)) {
|
||||||
|
return res.json([]);
|
||||||
|
}
|
||||||
const items = await papeleriaService.listPapeleria(req.tenantPool!, {
|
const items = await papeleriaService.listPapeleria(req.tenantPool!, {
|
||||||
contribuyenteId: q.contribuyenteId,
|
contribuyenteId: q.contribuyenteId,
|
||||||
anio: q.anio ? parseInt(q.anio, 10) : undefined,
|
anio: q.anio ? parseInt(q.anio, 10) : undefined,
|
||||||
mes: q.mes ? parseInt(q.mes, 10) : undefined,
|
mes: q.mes ? parseInt(q.mes, 10) : undefined,
|
||||||
estado: q.estado,
|
estado: q.estado,
|
||||||
|
entidadIds,
|
||||||
|
userRole: req.user!.role,
|
||||||
});
|
});
|
||||||
res.json(items);
|
res.json(items);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -91,9 +101,22 @@ export async function list(req: Request, res: Response, next: NextFunction) {
|
|||||||
|
|
||||||
export async function download(req: Request, res: Response, next: NextFunction) {
|
export async function download(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
rejectClienteRole(req);
|
|
||||||
const id = parseInt(String(req.params.id), 10);
|
const id = parseInt(String(req.params.id), 10);
|
||||||
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
||||||
|
|
||||||
|
const item = await papeleriaService.getById(req.tenantPool!, id);
|
||||||
|
if (!item) return next(new AppError(404, 'Documento no encontrado'));
|
||||||
|
|
||||||
|
const entidadIds = await getEntidadesVisibles(
|
||||||
|
req.tenantPool!, req.user!.userId, req.user!.role,
|
||||||
|
);
|
||||||
|
if (!entidadIds.includes(item.contribuyenteId)) {
|
||||||
|
return next(new AppError(403, 'No tienes acceso a este documento'));
|
||||||
|
}
|
||||||
|
if (req.user!.role === 'cliente' && !item.requiereAprobacionCliente) {
|
||||||
|
return next(new AppError(403, 'No tienes acceso a este documento'));
|
||||||
|
}
|
||||||
|
|
||||||
const file = await papeleriaService.downloadArchivo(req.tenantPool!, id);
|
const file = await papeleriaService.downloadArchivo(req.tenantPool!, id);
|
||||||
if (!file) return next(new AppError(404, 'Documento no encontrado'));
|
if (!file) return next(new AppError(404, 'Documento no encontrado'));
|
||||||
res.setHeader('Content-Type', file.mime);
|
res.setHeader('Content-Type', file.mime);
|
||||||
@@ -106,7 +129,9 @@ export async function download(req: Request, res: Response, next: NextFunction)
|
|||||||
|
|
||||||
export async function aprobar(req: Request, res: Response, next: NextFunction) {
|
export async function aprobar(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
rejectClienteRole(req);
|
if (req.user?.role === 'cliente') {
|
||||||
|
throw new AppError(403, 'Los clientes no pueden usar este endpoint');
|
||||||
|
}
|
||||||
const id = parseInt(String(req.params.id), 10);
|
const id = parseInt(String(req.params.id), 10);
|
||||||
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
||||||
const item = await papeleriaService.aprobar(
|
const item = await papeleriaService.aprobar(
|
||||||
@@ -127,7 +152,9 @@ const rechazarSchema = z.object({ comentario: z.string().max(2000).nullable().op
|
|||||||
|
|
||||||
export async function rechazar(req: Request, res: Response, next: NextFunction) {
|
export async function rechazar(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
rejectClienteRole(req);
|
if (req.user?.role === 'cliente') {
|
||||||
|
throw new AppError(403, 'Los clientes no pueden usar este endpoint');
|
||||||
|
}
|
||||||
const id = parseInt(String(req.params.id), 10);
|
const id = parseInt(String(req.params.id), 10);
|
||||||
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
||||||
const { comentario } = rechazarSchema.parse(req.body);
|
const { comentario } = rechazarSchema.parse(req.body);
|
||||||
@@ -146,9 +173,63 @@ export async function rechazar(req: Request, res: Response, next: NextFunction)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function aprobarCliente(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (req.user?.role !== 'cliente') {
|
||||||
|
throw new AppError(403, 'Solo clientes pueden usar este endpoint');
|
||||||
|
}
|
||||||
|
const id = parseInt(String(req.params.id), 10);
|
||||||
|
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
||||||
|
|
||||||
|
const entidadIds = await getEntidadesVisibles(
|
||||||
|
req.tenantPool!, req.user!.userId, req.user!.role,
|
||||||
|
);
|
||||||
|
const itemCheck = await papeleriaService.getById(req.tenantPool!, id);
|
||||||
|
if (!itemCheck || !entidadIds.includes(itemCheck.contribuyenteId) || !itemCheck.requiereAprobacionCliente) {
|
||||||
|
return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await papeleriaService.aprobarCliente(req.tenantPool!, id, req.user!.userId);
|
||||||
|
if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
|
||||||
|
res.json(item);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rechazarCliente(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (req.user?.role !== 'cliente') {
|
||||||
|
throw new AppError(403, 'Solo clientes pueden usar este endpoint');
|
||||||
|
}
|
||||||
|
const id = parseInt(String(req.params.id), 10);
|
||||||
|
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
||||||
|
const { comentario } = rechazarSchema.parse(req.body);
|
||||||
|
|
||||||
|
const entidadIds = await getEntidadesVisibles(
|
||||||
|
req.tenantPool!, req.user!.userId, req.user!.role,
|
||||||
|
);
|
||||||
|
const itemCheck = await papeleriaService.getById(req.tenantPool!, id);
|
||||||
|
if (!itemCheck || !entidadIds.includes(itemCheck.contribuyenteId) || !itemCheck.requiereAprobacionCliente) {
|
||||||
|
return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await papeleriaService.rechazarCliente(
|
||||||
|
req.tenantPool!, id, req.user!.userId, comentario ?? null,
|
||||||
|
);
|
||||||
|
if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
|
||||||
|
res.json(item);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function eliminar(req: Request, res: Response, next: NextFunction) {
|
export async function eliminar(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
rejectClienteRole(req);
|
if (req.user?.role === 'cliente') {
|
||||||
|
throw new AppError(403, 'Los clientes no pueden eliminar documentos');
|
||||||
|
}
|
||||||
const id = parseInt(String(req.params.id), 10);
|
const id = parseInt(String(req.params.id), 10);
|
||||||
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
||||||
const ok = await papeleriaService.eliminar(req.tenantPool!, id);
|
const ok = await papeleriaService.eliminar(req.tenantPool!, id);
|
||||||
@@ -161,22 +242,26 @@ export async function eliminar(req: Request, res: Response, next: NextFunction)
|
|||||||
|
|
||||||
// ─── Notificaciones ───
|
// ─── Notificaciones ───
|
||||||
|
|
||||||
|
async function getContribuyenteInfo(req: Request, contribuyenteId: string) {
|
||||||
|
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
|
||||||
|
`SELECT c.rfc, eg.nombre FROM contribuyentes c
|
||||||
|
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||||
|
WHERE c.entidad_id = $1`,
|
||||||
|
[contribuyenteId],
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notifica a owners y supervisores cuando una papelería requiere aprobación.
|
* Notifica a owners y supervisores cuando una papelería requiere aprobación.
|
||||||
* Owners se obtienen de tenant_memberships (BD central). Supervisores se
|
|
||||||
* resuelven leyendo carteras del tenant.
|
|
||||||
*/
|
*/
|
||||||
async function notifyAprobacionRequerida(
|
async function notifyAprobacionRequerida(
|
||||||
req: Request,
|
req: Request,
|
||||||
item: papeleriaService.PapeleriaItem,
|
item: papeleriaService.PapeleriaItem,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const tenantId = effectiveTenantId(req);
|
const tenantId = effectiveTenantId(req);
|
||||||
|
|
||||||
// Owners del despacho
|
|
||||||
const recipients = new Set<string>(await getTenantOwnerEmails(tenantId));
|
const recipients = new Set<string>(await getTenantOwnerEmails(tenantId));
|
||||||
|
|
||||||
// Supervisores: cualquier user con rol 'supervisor' o 'cfo' que pertenezca a este tenant.
|
|
||||||
// Buscamos vía tenant_memberships + roles.
|
|
||||||
const supervisores = await prisma.tenantMembership.findMany({
|
const supervisores = await prisma.tenantMembership.findMany({
|
||||||
where: { tenantId, active: true, rol: { nombre: { in: ['supervisor', 'cfo'] } } },
|
where: { tenantId, active: true, rol: { nombre: { in: ['supervisor', 'cfo'] } } },
|
||||||
include: { user: { select: { email: true, active: true } } },
|
include: { user: { select: { email: true, active: true } } },
|
||||||
@@ -185,23 +270,15 @@ async function notifyAprobacionRequerida(
|
|||||||
if (m.user.active && m.user.email) recipients.add(m.user.email);
|
if (m.user.active && m.user.email) recipients.add(m.user.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No notificarse a sí mismo
|
|
||||||
recipients.delete(req.user!.email);
|
recipients.delete(req.user!.email);
|
||||||
|
|
||||||
if (recipients.size === 0) return;
|
if (recipients.size === 0) return;
|
||||||
|
|
||||||
const tenant = await prisma.tenant.findUnique({
|
const tenant = await prisma.tenant.findUnique({
|
||||||
where: { id: tenantId },
|
where: { id: tenantId },
|
||||||
select: { nombre: true },
|
select: { nombre: true },
|
||||||
});
|
});
|
||||||
|
const info = await getContribuyenteInfo(req, item.contribuyenteId);
|
||||||
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
|
if (!info) return;
|
||||||
`SELECT c.rfc, eg.nombre FROM contribuyentes c
|
|
||||||
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
|
||||||
WHERE c.entidad_id = $1`,
|
|
||||||
[item.contribuyenteId],
|
|
||||||
);
|
|
||||||
if (rows.length === 0) return;
|
|
||||||
|
|
||||||
const link = `${env.FRONTEND_URL}/documentos`;
|
const link = `${env.FRONTEND_URL}/documentos`;
|
||||||
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
|
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
|
||||||
@@ -210,8 +287,8 @@ async function notifyAprobacionRequerida(
|
|||||||
for (const to of recipients) {
|
for (const to of recipients) {
|
||||||
try {
|
try {
|
||||||
await emailService.sendPapeleriaAprobacionRequerida(to, {
|
await emailService.sendPapeleriaAprobacionRequerida(to, {
|
||||||
contribuyenteRfc: rows[0].rfc,
|
contribuyenteRfc: info.rfc,
|
||||||
contribuyenteNombre: rows[0].nombre,
|
contribuyenteNombre: info.nombre,
|
||||||
despachoNombre: tenant?.nombre,
|
despachoNombre: tenant?.nombre,
|
||||||
nombreDocumento: item.nombre,
|
nombreDocumento: item.nombre,
|
||||||
descripcion: item.descripcion,
|
descripcion: item.descripcion,
|
||||||
@@ -226,9 +303,7 @@ async function notifyAprobacionRequerida(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notifica al uploader (auxiliar) cuando un documento que él subió fue
|
* Notifica al uploader cuando un documento fue aprobado o rechazado por owner/supervisor.
|
||||||
* aprobado o rechazado. Solo manda si quien aprobó/rechazó NO es el mismo
|
|
||||||
* uploader (caso edge: owner sube su propia papelería).
|
|
||||||
*/
|
*/
|
||||||
async function notifyDecisionAuxiliar(
|
async function notifyDecisionAuxiliar(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -238,21 +313,16 @@ async function notifyDecisionAuxiliar(
|
|||||||
const auxiliarEmail = await getUserEmailById(item.subidoPor);
|
const auxiliarEmail = await getUserEmailById(item.subidoPor);
|
||||||
if (!auxiliarEmail) return;
|
if (!auxiliarEmail) return;
|
||||||
|
|
||||||
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
|
const info = await getContribuyenteInfo(req, item.contribuyenteId);
|
||||||
`SELECT c.rfc, eg.nombre FROM contribuyentes c
|
if (!info) return;
|
||||||
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
|
||||||
WHERE c.entidad_id = $1`,
|
|
||||||
[item.contribuyenteId],
|
|
||||||
);
|
|
||||||
if (rows.length === 0) return;
|
|
||||||
|
|
||||||
const link = `${env.FRONTEND_URL}/documentos`;
|
const link = `${env.FRONTEND_URL}/documentos`;
|
||||||
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
|
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
|
||||||
const periodo = `${meses[item.mes - 1]} ${item.anio}`;
|
const periodo = `${meses[item.mes - 1]} ${item.anio}`;
|
||||||
|
|
||||||
await emailService.sendPapeleriaDecision(auxiliarEmail, {
|
await emailService.sendPapeleriaDecision(auxiliarEmail, {
|
||||||
contribuyenteRfc: rows[0].rfc,
|
contribuyenteRfc: info.rfc,
|
||||||
contribuyenteNombre: rows[0].nombre,
|
contribuyenteNombre: info.nombre,
|
||||||
nombreDocumento: item.nombre,
|
nombreDocumento: item.nombre,
|
||||||
estado: item.estado as 'aprobado' | 'rechazado',
|
estado: item.estado as 'aprobado' | 'rechazado',
|
||||||
revisor: req.user!.email,
|
revisor: req.user!.email,
|
||||||
@@ -261,3 +331,57 @@ async function notifyDecisionAuxiliar(
|
|||||||
link,
|
link,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifica a los usuarios cliente asociados al contribuyente cuando un documento
|
||||||
|
* requiere su aprobación.
|
||||||
|
*/
|
||||||
|
async function notifyClienteAprobacionRequerida(
|
||||||
|
req: Request,
|
||||||
|
item: papeleriaService.PapeleriaItem,
|
||||||
|
): Promise<void> {
|
||||||
|
const tenantId = effectiveTenantId(req);
|
||||||
|
|
||||||
|
// Obtener user_ids de clientes con acceso a este contribuyente
|
||||||
|
const { rows } = await req.tenantPool!.query<{ user_id: string }>(
|
||||||
|
`SELECT user_id FROM cliente_accesos WHERE entidad_id = $1`,
|
||||||
|
[item.contribuyenteId],
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
|
const userIds = rows.map(r => r.user_id);
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { id: { in: userIds }, active: true },
|
||||||
|
select: { email: true },
|
||||||
|
});
|
||||||
|
const recipients = users.map(u => u.email).filter(Boolean) as string[];
|
||||||
|
if (recipients.length === 0) return;
|
||||||
|
|
||||||
|
const tenant = await prisma.tenant.findUnique({
|
||||||
|
where: { id: tenantId },
|
||||||
|
select: { nombre: true },
|
||||||
|
});
|
||||||
|
const info = await getContribuyenteInfo(req, item.contribuyenteId);
|
||||||
|
if (!info) return;
|
||||||
|
|
||||||
|
const link = `${env.FRONTEND_URL}/documentos`;
|
||||||
|
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
|
||||||
|
const periodo = `${meses[item.mes - 1]} ${item.anio}`;
|
||||||
|
|
||||||
|
for (const to of recipients) {
|
||||||
|
try {
|
||||||
|
await emailService.sendPapeleriaAprobacionClienteRequerida(to, {
|
||||||
|
contribuyenteRfc: info.rfc,
|
||||||
|
contribuyenteNombre: info.nombre,
|
||||||
|
despachoNombre: tenant?.nombre,
|
||||||
|
nombreDocumento: item.nombre,
|
||||||
|
descripcion: item.descripcion,
|
||||||
|
periodo,
|
||||||
|
subidoPor: req.user!.email,
|
||||||
|
link,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[Email] papeleria-aprobacion-cliente a ${to}:`, err?.message || err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Request, Response, NextFunction } from 'express';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { AppError } from '../middlewares/error.middleware.js';
|
import { AppError } from '../middlewares/error.middleware.js';
|
||||||
import * as tareasService from '../services/tareas.service.js';
|
import * as tareasService from '../services/tareas.service.js';
|
||||||
|
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
|
||||||
import { emailService } from '../services/email/email.service.js';
|
import { emailService } from '../services/email/email.service.js';
|
||||||
import { getUserEmailById } from '../utils/memberships.js';
|
import { getUserEmailById } from '../utils/memberships.js';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
@@ -164,6 +165,17 @@ export async function descompletarPeriodo(req: Request, res: Response, next: Nex
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listMisTareas(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
rejectClienteRole(req);
|
||||||
|
const entidadIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
|
||||||
|
const tareas = await tareasService.listTareasConPeriodoPorContribuyentes(req.tenantPool!, entidadIds);
|
||||||
|
res.json(tareas);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function seedDefaults(req: Request, res: Response, next: NextFunction) {
|
export async function seedDefaults(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
rejectClienteRole(req);
|
rejectClienteRole(req);
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export async function createTenant(req: Request, res: Response, next: NextFuncti
|
|||||||
try {
|
try {
|
||||||
await requireGlobalAdmin(req);
|
await requireGlobalAdmin(req);
|
||||||
|
|
||||||
const { nombre, rfc, plan, adminEmail, adminNombre, amount, firstPaymentDueAt } = req.body;
|
const { nombre, rfc, plan, adminEmail, adminNombre, amount, firstPaymentDueAt, verticalProfile, codigoPostal } = req.body;
|
||||||
|
|
||||||
if (!nombre || !rfc || !adminEmail || !adminNombre) {
|
if (!nombre || !rfc || !adminEmail || !adminNombre) {
|
||||||
throw new AppError(400, 'Nombre, RFC, adminEmail y adminNombre son requeridos');
|
throw new AppError(400, 'Nombre, RFC, adminEmail y adminNombre son requeridos');
|
||||||
@@ -66,6 +66,8 @@ export async function createTenant(req: Request, res: Response, next: NextFuncti
|
|||||||
adminNombre,
|
adminNombre,
|
||||||
amount: amount || 0,
|
amount: amount || 0,
|
||||||
firstPaymentDueAt: firstPaymentDueAt || null,
|
firstPaymentDueAt: firstPaymentDueAt || null,
|
||||||
|
verticalProfile: verticalProfile || 'CONTABLE',
|
||||||
|
codigoPostal,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json(result);
|
res.status(201).json(result);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||||||
import * as usuariosService from '../services/usuarios.service.js';
|
import * as usuariosService from '../services/usuarios.service.js';
|
||||||
import { AppError } from '../middlewares/error.middleware.js';
|
import { AppError } from '../middlewares/error.middleware.js';
|
||||||
import { isGlobalAdmin as checkGlobalAdmin } from '../utils/global-admin.js';
|
import { isGlobalAdmin as checkGlobalAdmin } from '../utils/global-admin.js';
|
||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
|
||||||
const inviteSchema = z.object({
|
const inviteSchema = z.object({
|
||||||
email: z.string().email('email inválido'),
|
email: z.string().email('email inválido'),
|
||||||
@@ -26,6 +27,14 @@ const updateGlobalSchema = z.object({
|
|||||||
tenantId: z.string().uuid().optional(),
|
tenantId: z.string().uuid().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createGlobalSchema = z.object({
|
||||||
|
email: z.string().email('email inválido'),
|
||||||
|
nombre: z.string().min(2).max(100),
|
||||||
|
role: z.enum(['contador', 'visor', 'auxiliar', 'supervisor', 'cliente']),
|
||||||
|
tenantId: z.string().uuid('tenantId inválido'),
|
||||||
|
supervisorUserId: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
async function isGlobalAdmin(req: Request): Promise<boolean> {
|
async function isGlobalAdmin(req: Request): Promise<boolean> {
|
||||||
return checkGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
|
return checkGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
|
||||||
}
|
}
|
||||||
@@ -56,11 +65,16 @@ export async function getAllUsuarios(req: Request, res: Response, next: NextFunc
|
|||||||
|
|
||||||
export async function inviteUsuario(req: Request, res: Response, next: NextFunction) {
|
export async function inviteUsuario(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (req.user!.role !== 'owner') {
|
if (!['owner', 'cfo', 'supervisor'].includes(req.user!.role)) {
|
||||||
throw new AppError(403, 'Solo los dueños pueden invitar usuarios');
|
throw new AppError(403, 'No autorizado para invitar usuarios');
|
||||||
}
|
}
|
||||||
const data = inviteSchema.parse(req.body);
|
const data = inviteSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Los supervisores solo pueden invitar clientes
|
||||||
|
if (req.user!.role === 'supervisor' && data.role !== 'cliente') {
|
||||||
|
throw new AppError(403, 'Los supervisores solo pueden invitar clientes');
|
||||||
|
}
|
||||||
|
|
||||||
// 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');
|
throw new AppError(400, 'Debes asignar un supervisor al auxiliar');
|
||||||
@@ -131,7 +145,16 @@ export async function getSupervisor(req: Request, res: Response, next: NextFunct
|
|||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[userId],
|
[userId],
|
||||||
);
|
);
|
||||||
res.json({ supervisorUserId: rows[0]?.supervisor_user_id ?? null });
|
const supervisorUserId = rows[0]?.supervisor_user_id ?? null;
|
||||||
|
let supervisorNombre: string | null = null;
|
||||||
|
if (supervisorUserId) {
|
||||||
|
const u = await prisma.user.findUnique({
|
||||||
|
where: { id: supervisorUserId },
|
||||||
|
select: { nombre: true },
|
||||||
|
});
|
||||||
|
supervisorNombre = u?.nombre ?? null;
|
||||||
|
}
|
||||||
|
res.json({ supervisorUserId, supervisorNombre });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -190,6 +213,35 @@ export async function deleteUsuario(req: Request, res: Response, next: NextFunct
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un usuario globalmente (solo admin global)
|
||||||
|
*/
|
||||||
|
export async function createUsuarioGlobal(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!(await isGlobalAdmin(req))) {
|
||||||
|
throw new AppError(403, 'Solo el administrador global puede crear usuarios');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = createGlobalSchema.parse(req.body);
|
||||||
|
|
||||||
|
if (data.role === 'auxiliar' && !data.supervisorUserId) {
|
||||||
|
throw new AppError(400, 'Debes asignar un supervisor al auxiliar');
|
||||||
|
}
|
||||||
|
|
||||||
|
const usuario = await usuariosService.createUsuarioGlobal(data.tenantId, {
|
||||||
|
email: data.email,
|
||||||
|
nombre: data.nombre,
|
||||||
|
role: data.role,
|
||||||
|
supervisorUserId: data.supervisorUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(usuario);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Actualiza un usuario globalmente (puede cambiar de empresa)
|
* Actualiza un usuario globalmente (puede cambiar de empresa)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -10,6 +10,21 @@ import { despachoPlanTieneDualidadDb } from '../services/plan-catalogo.service.j
|
|||||||
import { emailService } from '../services/email/email.service.js';
|
import { emailService } from '../services/email/email.service.js';
|
||||||
import { getTenantOwnerEmail } from '../utils/memberships.js';
|
import { getTenantOwnerEmail } from '../utils/memberships.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula la siguiente fecha de fin de período según la frecuencia.
|
||||||
|
* Usa el mismo algoritmo que Mercado Pago: mismo día del mes siguiente,
|
||||||
|
* ajustando al último día si el mes destino tiene menos días.
|
||||||
|
*/
|
||||||
|
function computeNextPeriodEnd(date: Date, frequency: string): Date {
|
||||||
|
const d = new Date(date);
|
||||||
|
if (frequency === 'monthly') {
|
||||||
|
d.setMonth(d.getMonth() + 1);
|
||||||
|
} else if (frequency === 'annual' || frequency === 'yearly') {
|
||||||
|
d.setFullYear(d.getFullYear() + 1);
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleMercadoPagoWebhook(req: Request, res: Response, next: NextFunction) {
|
export async function handleMercadoPagoWebhook(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const { type, data } = req.body;
|
const { type, data } = req.body;
|
||||||
@@ -159,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({
|
||||||
@@ -187,9 +253,20 @@ 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' };
|
||||||
|
|
||||||
|
// Extender currentPeriodEnd para renovaciones recurrentes.
|
||||||
|
// El primer pago ya tiene currentPeriodEnd establecido al crear la suscripción;
|
||||||
|
// solo extendemos en pagos subsecuentes para reflejar el nuevo período cobrado.
|
||||||
|
if (!esPrimerPago && subscription.currentPeriodEnd) {
|
||||||
|
const nextPeriodEnd = computeNextPeriodEnd(subscription.currentPeriodEnd, subscription.frequency);
|
||||||
|
updateData.currentPeriodEnd = nextPeriodEnd;
|
||||||
|
console.log(`[WEBHOOK] Subscription ${subscription.id} extended to ${nextPeriodEnd.toISOString()} (${subscription.frequency})`);
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.subscription.update({
|
await prisma.subscription.update({
|
||||||
where: { id: subscription.id },
|
where: { id: subscription.id },
|
||||||
data: { status: 'authorized' },
|
data: updateData,
|
||||||
});
|
});
|
||||||
subscriptionService.invalidateSubscriptionCache(tenantId);
|
subscriptionService.invalidateSubscriptionCache(tenantId);
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import { resetExpiredMonthlyTimbres } from '../services/facturapi.service.js';
|
|||||||
import { purgeDeclaracionesAntiguas } from '../services/declaraciones.service.js';
|
import { purgeDeclaracionesAntiguas } from '../services/declaraciones.service.js';
|
||||||
import { consultarConstancia, purgeConstanciasAntiguas } from '../services/constancia.service.js';
|
import { consultarConstancia, purgeConstanciasAntiguas } from '../services/constancia.service.js';
|
||||||
import { tenantDb } from '../config/database.js';
|
import { tenantDb } from '../config/database.js';
|
||||||
|
import type { Pool } from 'pg';
|
||||||
|
|
||||||
const SYNC_CRON_SCHEDULE = '0 3 * * *'; // 3:00 AM todos los días
|
const SYNC_CRON_SCHEDULE = '0 3 * * *'; // 3: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 CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas
|
||||||
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)
|
||||||
@@ -20,6 +22,38 @@ const EXPIRY_REMINDERS_CRON = '0 9 * * *'; // 9:00 AM diario — avisos p
|
|||||||
|
|
||||||
let isRunning = false;
|
let isRunning = false;
|
||||||
let isIncrementalRunning = false;
|
let isIncrementalRunning = false;
|
||||||
|
let isRecoveryRunning = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si un tenant tiene FIEL a nivel tenant (legacy Horux 360)
|
||||||
|
* o a nivel contribuyente (modelo despacho).
|
||||||
|
*/
|
||||||
|
async function hasAnyFielConfigured(tenantId: string, databaseName?: string | null): Promise<boolean> {
|
||||||
|
// 1) FIEL legacy a nivel tenant
|
||||||
|
const hasLegacy = await hasFielConfigured(tenantId);
|
||||||
|
if (hasLegacy) return true;
|
||||||
|
|
||||||
|
// 2) FIEL por contribuyente (modelo despacho)
|
||||||
|
if (!databaseName) {
|
||||||
|
const tenant = await prisma.tenant.findUnique({
|
||||||
|
where: { id: tenantId },
|
||||||
|
select: { databaseName: true },
|
||||||
|
});
|
||||||
|
databaseName = tenant?.databaseName;
|
||||||
|
}
|
||||||
|
if (!databaseName) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pool = await tenantDb.getPool(tenantId, databaseName);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT 1 FROM fiel_contribuyente WHERE is_active = true LIMIT 1`
|
||||||
|
);
|
||||||
|
return rows.length > 0;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[SAT Cron] Error verificando FIEL contribuyente para tenant ${tenantId}:`, err.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obtiene los tenants que tienen FIEL configurada y activa
|
* Obtiene los tenants que tienen FIEL configurada y activa
|
||||||
@@ -27,13 +61,13 @@ let isIncrementalRunning = false;
|
|||||||
async function getTenantsWithFiel(): Promise<string[]> {
|
async function getTenantsWithFiel(): Promise<string[]> {
|
||||||
const tenants = await prisma.tenant.findMany({
|
const tenants = await prisma.tenant.findMany({
|
||||||
where: { active: true },
|
where: { active: true },
|
||||||
select: { id: true },
|
select: { id: true, databaseName: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const tenantsWithFiel: string[] = [];
|
const tenantsWithFiel: string[] = [];
|
||||||
|
|
||||||
for (const tenant of tenants) {
|
for (const tenant of tenants) {
|
||||||
const hasFiel = await hasFielConfigured(tenant.id);
|
const hasFiel = await hasAnyFielConfigured(tenant.id, tenant.databaseName);
|
||||||
if (hasFiel) {
|
if (hasFiel) {
|
||||||
tenantsWithFiel.push(tenant.id);
|
tenantsWithFiel.push(tenant.id);
|
||||||
}
|
}
|
||||||
@@ -43,16 +77,19 @@ async function getTenantsWithFiel(): Promise<string[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifica si un tenant necesita sincronización inicial
|
* Verifica si un tenant (o un contribuyente específico dentro del tenant)
|
||||||
|
* necesita sincronización inicial.
|
||||||
*/
|
*/
|
||||||
async function needsInitialSync(tenantId: string): Promise<boolean> {
|
async function needsInitialSync(tenantId: string, contribuyenteId?: string): Promise<boolean> {
|
||||||
const completedSync = await prisma.satSyncJob.findFirst({
|
const where: any = {
|
||||||
where: {
|
|
||||||
tenantId,
|
tenantId,
|
||||||
type: 'initial',
|
type: 'initial',
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
},
|
};
|
||||||
});
|
if (contribuyenteId) {
|
||||||
|
where.contribuyenteId = contribuyenteId;
|
||||||
|
}
|
||||||
|
const completedSync = await prisma.satSyncJob.findFirst({ where });
|
||||||
|
|
||||||
return !completedSync;
|
return !completedSync;
|
||||||
}
|
}
|
||||||
@@ -62,10 +99,6 @@ async function needsInitialSync(tenantId: string): Promise<boolean> {
|
|||||||
*/
|
*/
|
||||||
async function syncTenant(tenantId: string): Promise<void> {
|
async function syncTenant(tenantId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Determinar tipo de sync
|
|
||||||
const needsInitial = await needsInitialSync(tenantId);
|
|
||||||
const syncType = needsInitial ? 'initial' : 'daily';
|
|
||||||
|
|
||||||
// Obtener contribuyentes del tenant
|
// Obtener contribuyentes del tenant
|
||||||
const tenant = await prisma.tenant.findUnique({
|
const tenant = await prisma.tenant.findUnique({
|
||||||
where: { id: tenantId },
|
where: { id: tenantId },
|
||||||
@@ -81,6 +114,8 @@ async function syncTenant(tenantId: string): Promise<void> {
|
|||||||
|
|
||||||
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy Horux 360)
|
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy Horux 360)
|
||||||
if (contribuyenteIds.length === 0) {
|
if (contribuyenteIds.length === 0) {
|
||||||
|
const needsInitial = await needsInitialSync(tenantId);
|
||||||
|
const syncType = needsInitial ? 'initial' : 'daily';
|
||||||
const status = await getSyncStatus(tenantId);
|
const status = await getSyncStatus(tenantId);
|
||||||
if (status.hasActiveSync) {
|
if (status.hasActiveSync) {
|
||||||
console.log(`[SAT Cron] Tenant ${tenantId} ya tiene sync activo, omitiendo`);
|
console.log(`[SAT Cron] Tenant ${tenantId} ya tiene sync activo, omitiendo`);
|
||||||
@@ -92,7 +127,7 @@ async function syncTenant(tenantId: string): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sincronizar cada contribuyente
|
// Sincronizar cada contribuyente (cada uno puede necesitar su propio initial)
|
||||||
for (const contribuyenteId of contribuyenteIds) {
|
for (const contribuyenteId of contribuyenteIds) {
|
||||||
try {
|
try {
|
||||||
const status = await getSyncStatus(tenantId, contribuyenteId);
|
const status = await getSyncStatus(tenantId, contribuyenteId);
|
||||||
@@ -101,6 +136,8 @@ async function syncTenant(tenantId: string): Promise<void> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const needsInitial = await needsInitialSync(tenantId, contribuyenteId);
|
||||||
|
const syncType = needsInitial ? 'initial' : 'daily';
|
||||||
console.log(`[SAT Cron] Iniciando sync ${syncType} para tenant ${tenantId} contribuyente ${contribuyenteId}`);
|
console.log(`[SAT Cron] Iniciando sync ${syncType} para tenant ${tenantId} contribuyente ${contribuyenteId}`);
|
||||||
const jobId = await startSync(tenantId, syncType, undefined, undefined, contribuyenteId);
|
const jobId = await startSync(tenantId, syncType, undefined, undefined, contribuyenteId);
|
||||||
console.log(`[SAT Cron] Job ${jobId} iniciado para tenant ${tenantId} contribuyente ${contribuyenteId}`);
|
console.log(`[SAT Cron] Job ${jobId} iniciado para tenant ${tenantId} contribuyente ${contribuyenteId}`);
|
||||||
@@ -169,12 +206,12 @@ async function getTenantsConSatIncremental(): Promise<string[]> {
|
|||||||
|
|
||||||
const tenants = await prisma.tenant.findMany({
|
const tenants = await prisma.tenant.findMany({
|
||||||
where: { active: true, plan: { in: planNames as any } },
|
where: { active: true, plan: { in: planNames as any } },
|
||||||
select: { id: true },
|
select: { id: true, databaseName: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
for (const tenant of tenants) {
|
for (const tenant of tenants) {
|
||||||
if (await hasFielConfigured(tenant.id)) {
|
if (await hasAnyFielConfigured(tenant.id, tenant.databaseName)) {
|
||||||
result.push(tenant.id);
|
result.push(tenant.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,14 +224,6 @@ async function getTenantsConSatIncremental(): Promise<string[]> {
|
|||||||
*/
|
*/
|
||||||
async function incrementalSyncTenant(tenantId: string): Promise<void> {
|
async function incrementalSyncTenant(tenantId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const completedInitial = await prisma.satSyncJob.findFirst({
|
|
||||||
where: { tenantId, type: 'initial', status: 'completed' },
|
|
||||||
});
|
|
||||||
if (!completedInitial) {
|
|
||||||
console.log(`[SAT Cron Inc] Tenant ${tenantId} sin sync inicial completado, omitiendo incremental`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Obtener contribuyentes del tenant
|
// Obtener contribuyentes del tenant
|
||||||
const tenant = await prisma.tenant.findUnique({
|
const tenant = await prisma.tenant.findUnique({
|
||||||
where: { id: tenantId },
|
where: { id: tenantId },
|
||||||
@@ -210,6 +239,13 @@ async function incrementalSyncTenant(tenantId: string): Promise<void> {
|
|||||||
|
|
||||||
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy)
|
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy)
|
||||||
if (contribuyenteIds.length === 0) {
|
if (contribuyenteIds.length === 0) {
|
||||||
|
const completedInitial = await prisma.satSyncJob.findFirst({
|
||||||
|
where: { tenantId, type: 'initial', status: 'completed' },
|
||||||
|
});
|
||||||
|
if (!completedInitial) {
|
||||||
|
console.log(`[SAT Cron Inc] Tenant ${tenantId} sin sync inicial completado, omitiendo incremental`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const status = await getSyncStatus(tenantId);
|
const status = await getSyncStatus(tenantId);
|
||||||
if (status.hasActiveSync) {
|
if (status.hasActiveSync) {
|
||||||
console.log(`[SAT Cron Inc] Tenant ${tenantId} con sync activo, omitiendo`);
|
console.log(`[SAT Cron Inc] Tenant ${tenantId} con sync activo, omitiendo`);
|
||||||
@@ -221,9 +257,17 @@ async function incrementalSyncTenant(tenantId: string): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sincronizar cada contribuyente
|
// Sincronizar cada contribuyente solo si ya tiene su initial completado
|
||||||
for (const contribuyenteId of contribuyenteIds) {
|
for (const contribuyenteId of contribuyenteIds) {
|
||||||
try {
|
try {
|
||||||
|
const hasInitial = await prisma.satSyncJob.findFirst({
|
||||||
|
where: { tenantId, contribuyenteId, type: 'initial', status: 'completed' },
|
||||||
|
});
|
||||||
|
if (!hasInitial) {
|
||||||
|
console.log(`[SAT Cron Inc] Tenant ${tenantId} contribuyente ${contribuyenteId} sin sync inicial, omitiendo incremental`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const status = await getSyncStatus(tenantId, contribuyenteId);
|
const status = await getSyncStatus(tenantId, contribuyenteId);
|
||||||
if (status.hasActiveSync) {
|
if (status.hasActiveSync) {
|
||||||
console.log(`[SAT Cron Inc] Tenant ${tenantId} contribuyente ${contribuyenteId} con sync activo, omitiendo`);
|
console.log(`[SAT Cron Inc] Tenant ${tenantId} contribuyente ${contribuyenteId} con sync activo, omitiendo`);
|
||||||
@@ -341,12 +385,153 @@ async function runCsfJob(): Promise<void> {
|
|||||||
console.error(`[CSF Cron] Error para ${tenant.rfc}:`, error.message);
|
console.error(`[CSF Cron] Error para ${tenant.rfc}:`, error.message);
|
||||||
failed++;
|
failed++;
|
||||||
}
|
}
|
||||||
|
// Delay entre tenants para no saturar al SAT y reducir bloqueos por IP
|
||||||
|
await new Promise(r => setTimeout(r, 30_000));
|
||||||
}
|
}
|
||||||
console.log(`[CSF Cron] Completado — éxito: ${success}, fallidos: ${failed}, sin FIEL: ${skipped}`);
|
console.log(`[CSF Cron] Completado — éxito: ${success}, fallidos: ${failed}, sin FIEL: ${skipped}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getYesterdayEnd(): Date {
|
||||||
|
const now = new Date();
|
||||||
|
return new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 59, 59);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasIncompleteCfdis(pool: Pool, contribuyenteId: string): Promise<boolean> {
|
||||||
|
const { rows } = await pool.query<{ count: string }>(`
|
||||||
|
SELECT COUNT(*)::text as count
|
||||||
|
FROM cfdis
|
||||||
|
WHERE contribuyente_id = $1
|
||||||
|
AND status = 'Vigente'
|
||||||
|
AND tipo_comprobante IN ('I', 'E', 'P', 'N')
|
||||||
|
AND xml_original IS NULL
|
||||||
|
`, [contribuyenteId]);
|
||||||
|
return Number(rows[0]?.count || 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOldestIncompleteCfdiDate(pool: Pool, contribuyenteId: string): Promise<Date | null> {
|
||||||
|
const { rows } = await pool.query<{ fecha_emision: Date | null }>(`
|
||||||
|
SELECT MIN(fecha_emision) as fecha_emision
|
||||||
|
FROM cfdis
|
||||||
|
WHERE contribuyente_id = $1
|
||||||
|
AND status = 'Vigente'
|
||||||
|
AND tipo_comprobante IN ('I', 'E', 'P', 'N')
|
||||||
|
AND xml_original IS NULL
|
||||||
|
`, [contribuyenteId]);
|
||||||
|
return rows[0]?.fecha_emision || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRecoveryJob(jobId: string): Promise<void> {
|
||||||
|
while (true) {
|
||||||
|
const job = await prisma.satSyncJob.findUnique({ where: { id: jobId } });
|
||||||
|
if (!job || job.status === 'completed' || job.status === 'failed') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 60000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recoverContribuyente(tenantId: string, databaseName: string, contribuyenteId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const status = await getSyncStatus(tenantId, contribuyenteId);
|
||||||
|
if (status.hasActiveSync) {
|
||||||
|
console.log(`[SAT Recovery] ${contribuyenteId} tiene sync activo, omitiendo`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = await tenantDb.getPool(tenantId, databaseName);
|
||||||
|
const hasIncomplete = await hasIncompleteCfdis(pool, contribuyenteId);
|
||||||
|
|
||||||
|
const lastDaily = await prisma.satSyncJob.findFirst({
|
||||||
|
where: { tenantId, contribuyenteId, type: 'daily' },
|
||||||
|
orderBy: { startedAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasIncomplete && lastDaily?.status !== 'failed') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateTo = getYesterdayEnd();
|
||||||
|
let dateFrom = new Date(dateTo.getFullYear() - 1, dateTo.getMonth(), dateTo.getDate());
|
||||||
|
|
||||||
|
if (hasIncomplete) {
|
||||||
|
const oldest = await getOldestIncompleteCfdiDate(pool, contribuyenteId);
|
||||||
|
if (oldest) {
|
||||||
|
dateFrom = new Date(oldest.getFullYear(), oldest.getMonth(), 1);
|
||||||
|
dateFrom.setMonth(dateFrom.getMonth() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[SAT Recovery] Recuperando ${contribuyenteId}: ${dateFrom.toISOString()} → ${dateTo.toISOString()}`);
|
||||||
|
const jobId = await startSync(tenantId, 'initial', dateFrom, dateTo, contribuyenteId);
|
||||||
|
console.log(`[SAT Recovery] Job ${jobId} iniciado`);
|
||||||
|
|
||||||
|
await waitForRecoveryJob(jobId);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[SAT Recovery] Error recuperando ${contribuyenteId}:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recoverTenant(tenantId: string): Promise<void> {
|
||||||
|
const tenant = await prisma.tenant.findUnique({
|
||||||
|
where: { id: tenantId },
|
||||||
|
select: { databaseName: true },
|
||||||
|
});
|
||||||
|
if (!tenant?.databaseName) return;
|
||||||
|
|
||||||
|
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
||||||
|
const { rows } = await pool.query<{ entidad_id: string }>('SELECT entidad_id FROM contribuyentes');
|
||||||
|
const contribuyenteIds = rows.map(r => r.entidad_id);
|
||||||
|
|
||||||
|
if (contribuyenteIds.length === 0) {
|
||||||
|
const status = await getSyncStatus(tenantId);
|
||||||
|
if (status.hasActiveSync) return;
|
||||||
|
const lastDaily = await prisma.satSyncJob.findFirst({
|
||||||
|
where: { tenantId, contribuyenteId: null, type: 'daily' },
|
||||||
|
orderBy: { startedAt: 'desc' },
|
||||||
|
});
|
||||||
|
if (lastDaily?.status === 'failed') {
|
||||||
|
const dateTo = getYesterdayEnd();
|
||||||
|
const dateFrom = new Date(dateTo.getFullYear() - 1, dateTo.getMonth(), dateTo.getDate());
|
||||||
|
console.log(`[SAT Recovery] Recuperando tenant legacy ${tenantId}`);
|
||||||
|
const jobId = await startSync(tenantId, 'initial', dateFrom, dateTo);
|
||||||
|
await waitForRecoveryJob(jobId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const contribuyenteId of contribuyenteIds) {
|
||||||
|
await recoverContribuyente(tenantId, tenant.databaseName, contribuyenteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runRecoverySyncJob(): Promise<void> {
|
||||||
|
if (isRecoveryRunning) {
|
||||||
|
console.log('[SAT Recovery] Ya en ejecución, omitiendo');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRecoveryRunning = true;
|
||||||
|
console.log('[SAT Recovery] Iniciando job de recuperación');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tenantIds = await getTenantsWithFiel();
|
||||||
|
console.log(`[SAT Recovery] ${tenantIds.length} tenants con FIEL`);
|
||||||
|
|
||||||
|
for (const tenantId of tenantIds) {
|
||||||
|
await recoverTenant(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[SAT Recovery] Job de recuperación completado');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[SAT Recovery] Error:', error.message);
|
||||||
|
} finally {
|
||||||
|
isRecoveryRunning = 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 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;
|
||||||
@@ -387,6 +572,19 @@ export function startSatSyncJob(): void {
|
|||||||
timezone: 'America/Mexico_City',
|
timezone: 'America/Mexico_City',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cron de recuperación: 10:00 AM diario. Revisa si el sync diario falló o si
|
||||||
|
// hay CFDIs vigentes sin XML, y relanza un sync `initial` con rango extendido
|
||||||
|
// para completar los XML faltantes.
|
||||||
|
recoveryTask = cron.schedule(RECOVERY_CRON_SCHEDULE, async () => {
|
||||||
|
try {
|
||||||
|
await runRecoverySyncJob();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[SAT Recovery Cron] 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)
|
||||||
@@ -492,6 +690,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(`[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)`);
|
||||||
@@ -511,6 +710,10 @@ export function stopSatSyncJob(): void {
|
|||||||
retryTask.stop();
|
retryTask.stop();
|
||||||
retryTask = null;
|
retryTask = null;
|
||||||
}
|
}
|
||||||
|
if (recoveryTask) {
|
||||||
|
recoveryTask.stop();
|
||||||
|
recoveryTask = 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();
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,10 @@ CREATE TABLE IF NOT EXISTS cfdis (
|
|||||||
facturapi_id VARCHAR(50),
|
facturapi_id VARCHAR(50),
|
||||||
regimen_fiscal_emisor VARCHAR(3),
|
regimen_fiscal_emisor VARCHAR(3),
|
||||||
regimen_fiscal_receptor VARCHAR(3),
|
regimen_fiscal_receptor VARCHAR(3),
|
||||||
|
periodicidad VARCHAR(2),
|
||||||
|
meses_global VARCHAR(10),
|
||||||
|
año_global VARCHAR(4),
|
||||||
|
fecha_efectiva DATE,
|
||||||
creado_en TIMESTAMP DEFAULT NOW(),
|
creado_en TIMESTAMP DEFAULT NOW(),
|
||||||
actualizado_en TIMESTAMP DEFAULT NOW()
|
actualizado_en TIMESTAMP DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|||||||
11
apps/api/src/migrations/tenant/045_factura_global.sql
Normal file
11
apps/api/src/migrations/tenant/045_factura_global.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- Migration: 007_factura_global
|
||||||
|
-- Description: Agrega campos de InformacionGlobal y fecha_efectiva para facturas globales
|
||||||
|
|
||||||
|
ALTER TABLE cfdis
|
||||||
|
ADD COLUMN IF NOT EXISTS periodicidad VARCHAR(2),
|
||||||
|
ADD COLUMN IF NOT EXISTS meses_global VARCHAR(10),
|
||||||
|
ADD COLUMN IF NOT EXISTS año_global VARCHAR(4),
|
||||||
|
ADD COLUMN IF NOT EXISTS fecha_efectiva DATE;
|
||||||
|
|
||||||
|
-- Crear índice para acelerar métricas que filtran por fecha_efectiva
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cfdis_fecha_efectiva ON cfdis(fecha_efectiva);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS obligacion_asignaciones (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
obligacion_id uuid NOT NULL REFERENCES obligaciones_contribuyente(id) ON DELETE CASCADE,
|
||||||
|
auxiliar_user_id uuid NOT NULL,
|
||||||
|
asignado_por uuid NOT NULL,
|
||||||
|
asignado_at timestamptz DEFAULT now(),
|
||||||
|
UNIQUE (obligacion_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tarea_asignaciones (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tarea_id uuid NOT NULL REFERENCES tareas_catalogo(id) ON DELETE CASCADE,
|
||||||
|
auxiliar_user_id uuid NOT NULL,
|
||||||
|
asignado_por uuid NOT NULL,
|
||||||
|
asignado_at timestamptz DEFAULT now(),
|
||||||
|
UNIQUE (tarea_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_obligacion_asignaciones_auxiliar ON obligacion_asignaciones(auxiliar_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tarea_asignaciones_auxiliar ON tarea_asignaciones(auxiliar_user_id);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Migración 047: Renombrar SUELDOS → ISN en declaraciones existentes
|
||||||
|
-- Fecha: 2026-05-24
|
||||||
|
--
|
||||||
|
-- El campo impuestos es TEXT[]. Se usa array_replace para actualizar
|
||||||
|
-- declaraciones históricas que tenían 'SUELDOS' como impuesto cubierto.
|
||||||
|
|
||||||
|
UPDATE declaraciones_provisionales
|
||||||
|
SET impuestos = array_replace(impuestos, 'SUELDOS', 'ISN')
|
||||||
|
WHERE 'SUELDOS' = ANY(impuestos);
|
||||||
11
apps/api/src/migrations/tenant/048_cfdis_activos_indices.sql
Normal file
11
apps/api/src/migrations/tenant/048_cfdis_activos_indices.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- Índices para acelerar los filtros de "Considerar activos" en Impuestos.
|
||||||
|
|
||||||
|
-- Lookup rápido de facturas tipo I con uso de activo fijo
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cfdis_tipo_uso_activos
|
||||||
|
ON cfdis(tipo_comprobante, uso_cfdi)
|
||||||
|
WHERE tipo_comprobante = 'I' AND uso_cfdi IN ('I01','I02','I03','I04','I05','I06','I07','I08');
|
||||||
|
|
||||||
|
-- Filtrar E's que tienen relacionados (reduce el universo del anti-join)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cfdis_tipo_relacionados
|
||||||
|
ON cfdis(tipo_comprobante)
|
||||||
|
WHERE cfdis_relacionados IS NOT NULL;
|
||||||
11
apps/api/src/migrations/tenant/049_cfdis_relaciones_gin.sql
Normal file
11
apps/api/src/migrations/tenant/049_cfdis_relaciones_gin.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- Índices GIN para acelerar búsquedas de activos en cfdis_relacionados y uuid_relacionado.
|
||||||
|
-- El filtro "Considerar activos" usa string_to_array(..., '|') para buscar UUIDs
|
||||||
|
-- relacionados; el índice GIN permite búsquedas @> y ANY eficientes sobre arrays.
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cfdis_relacionados_gin
|
||||||
|
ON cfdis USING gin(string_to_array(LOWER(cfdis_relacionados), '|'))
|
||||||
|
WHERE cfdis_relacionados IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cfdis_uuid_relacionado_gin
|
||||||
|
ON cfdis USING gin(string_to_array(LOWER(uuid_relacionado), '|'))
|
||||||
|
WHERE uuid_relacionado IS NOT NULL;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Papelería de trabajo: aprobación independiente por cliente
|
||||||
|
|
||||||
|
ALTER TABLE papeleria_trabajo
|
||||||
|
ADD COLUMN IF NOT EXISTS requiere_aprobacion_cliente boolean NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS estado_cliente varchar(20)
|
||||||
|
CHECK (estado_cliente IS NULL OR estado_cliente IN ('pendiente','aprobado','rechazado')),
|
||||||
|
ADD COLUMN IF NOT EXISTS aprobado_por_cliente uuid,
|
||||||
|
ADD COLUMN IF NOT EXISTS aprobado_at_cliente timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS comentario_rechazo_cliente text;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_papeleria_estado_cliente
|
||||||
|
ON papeleria_trabajo(estado_cliente)
|
||||||
|
WHERE estado_cliente IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_papeleria_requiere_cliente
|
||||||
|
ON papeleria_trabajo(contribuyente_id, requiere_aprobacion_cliente)
|
||||||
|
WHERE requiere_aprobacion_cliente = true;
|
||||||
|
|
||||||
|
INSERT INTO tenant_migrations (scope, version, name)
|
||||||
|
VALUES ('vertical-contable', 50, '050_papeleria_aprobacion_cliente')
|
||||||
|
ON CONFLICT (scope, version) DO NOTHING;
|
||||||
@@ -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);
|
||||||
@@ -2,6 +2,7 @@ import { Router, type IRouter } from 'express';
|
|||||||
import { authenticate, authorize } from '../middlewares/auth.middleware.js';
|
import { authenticate, authorize } from '../middlewares/auth.middleware.js';
|
||||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||||
import * as ctrl from '../controllers/cartera.controller.js';
|
import * as ctrl from '../controllers/cartera.controller.js';
|
||||||
|
import * as asignacionesCtrl from '../controllers/asignaciones.controller.js';
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
@@ -11,6 +12,12 @@ router.use(tenantMiddleware);
|
|||||||
// Static routes first
|
// Static routes first
|
||||||
router.get('/supervisores', authorize('owner'), ctrl.getSupervisores);
|
router.get('/supervisores', authorize('owner'), ctrl.getSupervisores);
|
||||||
|
|
||||||
|
// 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/mias', authorize('auxiliar'), asignacionesCtrl.listPorAuxiliar);
|
||||||
|
router.get('/asignaciones/sin-asignar', authorize('owner', 'supervisor'), asignacionesCtrl.listSinAsignar);
|
||||||
|
router.get('/asignaciones/auxiliares-elegibles/:contribuyenteId', authorize('owner', 'supervisor'), asignacionesCtrl.listAuxiliaresElegibles);
|
||||||
|
|
||||||
// Read: owner + supervisor + auxiliar
|
// Read: owner + supervisor + auxiliar
|
||||||
router.get('/', authorize('owner', 'supervisor', 'auxiliar'), ctrl.list);
|
router.get('/', authorize('owner', 'supervisor', 'auxiliar'), ctrl.list);
|
||||||
router.get('/:id', authorize('owner', 'supervisor', 'auxiliar'), ctrl.getById);
|
router.get('/:id', authorize('owner', 'supervisor', 'auxiliar'), ctrl.getById);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ router.get('/conceptos', cfdiController.listConceptos);
|
|||||||
router.get('/:id', cfdiController.getCfdiById);
|
router.get('/:id', cfdiController.getCfdiById);
|
||||||
router.get('/:id/conceptos', cfdiController.getConceptos);
|
router.get('/:id/conceptos', cfdiController.getConceptos);
|
||||||
router.get('/:id/xml', cfdiController.getXml);
|
router.get('/:id/xml', cfdiController.getXml);
|
||||||
|
router.post('/download-xmls', cfdiController.downloadXmlsZip);
|
||||||
router.post('/', checkCfdiLimit, cfdiController.createCfdi);
|
router.post('/', checkCfdiLimit, cfdiController.createCfdi);
|
||||||
// Bulk upload: 10/hora — procesa hasta 50MB, pesado en parseo + inserts
|
// Bulk upload: 10/hora — procesa hasta 50MB, pesado en parseo + inserts
|
||||||
router.post('/bulk', strictLimit, express.json({ limit: '50mb' }), checkCfdiLimit, cfdiController.createManyCfdis);
|
router.post('/bulk', strictLimit, express.json({ limit: '50mb' }), checkCfdiLimit, cfdiController.createManyCfdis);
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { authenticate, authorize } from '../middlewares/auth.middleware.js';
|
|||||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||||
import * as ctrl from '../controllers/contribuyente.controller.js';
|
import * as ctrl from '../controllers/contribuyente.controller.js';
|
||||||
import * as configCtrl from '../controllers/contribuyente-config.controller.js';
|
import * as configCtrl from '../controllers/contribuyente-config.controller.js';
|
||||||
|
import * as facturacionCtrl from '../controllers/facturacion.controller.js';
|
||||||
import * as obligacionesCtrl from '../controllers/obligaciones.controller.js';
|
import * as obligacionesCtrl from '../controllers/obligaciones.controller.js';
|
||||||
|
import * as asignacionesCtrl from '../controllers/asignaciones.controller.js';
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
@@ -12,7 +14,7 @@ router.use(tenantMiddleware);
|
|||||||
|
|
||||||
// === Static routes FIRST (before /:id to avoid route conflict) ===
|
// === Static routes FIRST (before /:id to avoid route conflict) ===
|
||||||
router.get('/', ctrl.list);
|
router.get('/', ctrl.list);
|
||||||
router.post('/', authorize('owner', 'cfo'), ctrl.create);
|
router.post('/', authorize('owner', 'cfo', 'supervisor'), ctrl.create);
|
||||||
router.post('/backfill', authorize('owner'), ctrl.backfill);
|
router.post('/backfill', authorize('owner'), ctrl.backfill);
|
||||||
router.get('/catalogo-obligaciones', obligacionesCtrl.getCatalogo);
|
router.get('/catalogo-obligaciones', obligacionesCtrl.getCatalogo);
|
||||||
|
|
||||||
@@ -23,25 +25,34 @@ router.delete('/:id', authorize('owner'), ctrl.deactivate);
|
|||||||
router.post('/:id/cliente-acceso', authorize('owner', 'supervisor'), ctrl.addClienteAcceso);
|
router.post('/:id/cliente-acceso', authorize('owner', 'supervisor'), ctrl.addClienteAcceso);
|
||||||
|
|
||||||
// FIEL per contribuyente
|
// FIEL per contribuyente
|
||||||
router.post('/:id/fiel', authorize('owner', 'cfo'), configCtrl.uploadFiel);
|
router.post('/:id/fiel', authorize('owner', 'cfo', 'supervisor'), configCtrl.uploadFiel);
|
||||||
router.get('/:id/fiel/status', configCtrl.fielStatus);
|
router.get('/:id/fiel/status', configCtrl.fielStatus);
|
||||||
router.delete('/:id/fiel', authorize('owner', 'cfo'), configCtrl.deleteFiel);
|
router.delete('/:id/fiel', authorize('owner', 'cfo', 'supervisor'), configCtrl.deleteFiel);
|
||||||
|
|
||||||
// Facturapi per contribuyente
|
// Facturapi per contribuyente
|
||||||
router.post('/:id/facturapi/org', authorize('owner', 'cfo'), configCtrl.createOrg);
|
router.post('/:id/facturapi/org', authorize('owner', 'cfo'), configCtrl.createOrg);
|
||||||
router.get('/:id/facturapi/status', configCtrl.orgStatus);
|
router.get('/:id/facturapi/status', configCtrl.orgStatus);
|
||||||
router.post('/:id/facturapi/csd', authorize('owner', 'cfo'), configCtrl.uploadCsd);
|
router.post('/:id/facturapi/csd', authorize('owner', 'cfo', 'supervisor'), configCtrl.uploadCsd);
|
||||||
|
|
||||||
|
// Personalización per contribuyente
|
||||||
|
router.get('/:id/facturapi/customization', facturacionCtrl.getCustomizationContribuyenteCtrl);
|
||||||
|
router.post('/:id/facturapi/logo', authorize('owner', 'cfo'), facturacionCtrl.uploadLogoContribuyenteCtrl);
|
||||||
|
router.put('/:id/facturapi/color', authorize('owner', 'cfo'), facturacionCtrl.updateColorContribuyenteCtrl);
|
||||||
|
|
||||||
// Obligaciones fiscales per contribuyente
|
// Obligaciones fiscales per contribuyente
|
||||||
router.get('/:id/obligaciones/periodo', obligacionesCtrl.getObligacionesPorPeriodo);
|
router.get('/:id/obligaciones/periodo', obligacionesCtrl.getObligacionesPorPeriodo);
|
||||||
router.get('/:id/obligaciones', obligacionesCtrl.getObligaciones);
|
router.get('/:id/obligaciones', obligacionesCtrl.getObligaciones);
|
||||||
router.post('/:id/obligaciones/init', authorize('owner', 'cfo'), obligacionesCtrl.initRecomendaciones);
|
router.post('/:id/obligaciones/init', authorize('owner', 'cfo', 'supervisor'), obligacionesCtrl.initRecomendaciones);
|
||||||
router.post('/:id/obligaciones', authorize('owner', 'cfo'), obligacionesCtrl.addObligacion);
|
router.post('/:id/obligaciones', authorize('owner', 'cfo', 'supervisor'), obligacionesCtrl.addObligacion);
|
||||||
router.delete('/:id/obligaciones/:obligacionId', authorize('owner', 'cfo'), obligacionesCtrl.removeObligacion);
|
router.delete('/:id/obligaciones/:obligacionId', authorize('owner', 'cfo', 'supervisor'), obligacionesCtrl.removeObligacion);
|
||||||
router.post('/:id/obligaciones/:obligacionId/restore', authorize('owner', 'cfo'), obligacionesCtrl.restoreObligacion);
|
router.post('/:id/obligaciones/:obligacionId/restore', authorize('owner', 'cfo', 'supervisor'), obligacionesCtrl.restoreObligacion);
|
||||||
router.post('/:id/obligaciones/:obligacionId/complete', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.completeObligacion);
|
router.post('/:id/obligaciones/:obligacionId/complete', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.completeObligacion);
|
||||||
router.post('/:id/obligaciones/:obligacionId/uncomplete', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.uncompleteObligacion);
|
router.post('/:id/obligaciones/:obligacionId/uncomplete', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.uncompleteObligacion);
|
||||||
router.post('/:id/obligaciones/:obligacionId/complete-periodo', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.completePeriodo);
|
router.post('/:id/obligaciones/:obligacionId/complete-periodo', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.completePeriodo);
|
||||||
router.post('/:id/obligaciones/:obligacionId/uncomplete-periodo', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.uncompletePeriodo);
|
router.post('/:id/obligaciones/:obligacionId/uncomplete-periodo', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.uncompletePeriodo);
|
||||||
|
|
||||||
|
// Asignación de obligaciones a auxiliares (supervisor/owner)
|
||||||
|
router.post('/:id/obligaciones/:obligacionId/asignar', authorize('owner', 'supervisor'), asignacionesCtrl.asignarObligacion);
|
||||||
|
router.delete('/:id/obligaciones/:obligacionId/asignar', authorize('owner', 'supervisor'), asignacionesCtrl.desasignarObligacion);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ router.post('/', ctrl.upload);
|
|||||||
router.get('/:id/download', ctrl.download);
|
router.get('/:id/download', ctrl.download);
|
||||||
router.post('/:id/aprobar', ctrl.aprobar);
|
router.post('/:id/aprobar', ctrl.aprobar);
|
||||||
router.post('/:id/rechazar', ctrl.rechazar);
|
router.post('/:id/rechazar', ctrl.rechazar);
|
||||||
|
router.post('/:id/aprobar-cliente', ctrl.aprobarCliente);
|
||||||
|
router.post('/:id/rechazar-cliente', ctrl.rechazarCliente);
|
||||||
router.delete('/:id', ctrl.eliminar);
|
router.delete('/:id', ctrl.eliminar);
|
||||||
|
|
||||||
export { router as papeleriaRoutes };
|
export { router as papeleriaRoutes };
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { Router, type IRouter } from 'express';
|
import { Router, type IRouter } from 'express';
|
||||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
import { authenticate, authorize } from '../middlewares/auth.middleware.js';
|
||||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||||
import * as ctrl from '../controllers/tareas.controller.js';
|
import * as ctrl from '../controllers/tareas.controller.js';
|
||||||
|
import * as asignacionesCtrl from '../controllers/asignaciones.controller.js';
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(tenantMiddleware);
|
router.use(tenantMiddleware);
|
||||||
|
|
||||||
|
router.get('/mis-tareas', ctrl.listMisTareas);
|
||||||
router.get('/', ctrl.listTareas);
|
router.get('/', ctrl.listTareas);
|
||||||
router.post('/', ctrl.createTarea);
|
router.post('/', ctrl.createTarea);
|
||||||
router.post('/seed', ctrl.seedDefaults);
|
router.post('/seed', ctrl.seedDefaults);
|
||||||
@@ -17,4 +19,8 @@ router.delete('/:id', ctrl.deleteTarea);
|
|||||||
router.post('/periodo/:id/completar', ctrl.completarPeriodo);
|
router.post('/periodo/:id/completar', ctrl.completarPeriodo);
|
||||||
router.delete('/periodo/:id/completar', ctrl.descompletarPeriodo);
|
router.delete('/periodo/:id/completar', ctrl.descompletarPeriodo);
|
||||||
|
|
||||||
|
// Asignación de tareas a auxiliares (supervisor/owner)
|
||||||
|
router.post('/:id/asignar', authorize('owner', 'supervisor'), asignacionesCtrl.asignarTarea);
|
||||||
|
router.delete('/:id/asignar', authorize('owner', 'supervisor'), asignacionesCtrl.desasignarTarea);
|
||||||
|
|
||||||
export { router as tareasRoutes };
|
export { router as tareasRoutes };
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ router.put('/:id/supervisor', tenantMiddleware, usuariosController.updateSupervi
|
|||||||
|
|
||||||
// Rutas globales (solo admin global)
|
// Rutas globales (solo admin global)
|
||||||
router.get('/global/all', usuariosController.getAllUsuarios);
|
router.get('/global/all', usuariosController.getAllUsuarios);
|
||||||
|
router.post('/global', usuariosController.createUsuarioGlobal);
|
||||||
router.patch('/global/:id', usuariosController.updateUsuarioGlobal);
|
router.patch('/global/:id', usuariosController.updateUsuarioGlobal);
|
||||||
router.delete('/global/:id', usuariosController.deleteUsuarioGlobal);
|
router.delete('/global/:id', usuariosController.deleteUsuarioGlobal);
|
||||||
|
|
||||||
|
|||||||
23
apps/api/src/scripts/recalc-metricas.ts
Normal file
23
apps/api/src/scripts/recalc-metricas.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { tenantDb } from '../config/database.js';
|
||||||
|
import { computeMetricaMensual } from '../services/metricas-compute.service.js';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const tenantId = 'c52c2f5d-b1ae-45c6-8cc8-b11c9611618a';
|
||||||
|
const dbName = 'horux_hts240708lja';
|
||||||
|
const contribuyenteId = '4a1d6014-f705-424b-b185-7740be6a80c6';
|
||||||
|
const pool = await tenantDb.getPool(tenantId, dbName);
|
||||||
|
|
||||||
|
for (const mes of [1, 2, 3]) {
|
||||||
|
console.log(`Recalculando 2026-${String(mes).padStart(2, '0')}...`);
|
||||||
|
const r = await computeMetricaMensual(pool, tenantId, contribuyenteId, 2026, mes);
|
||||||
|
console.log(` Filas escritas: ${r.filasEscritas}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -24,44 +24,62 @@
|
|||||||
* el de activos aplica también pero algunos predicados son no-op funcional
|
* el de activos aplica también pero algunos predicados son no-op funcional
|
||||||
* en subqueries que filtran por tipo_comprobante específico (Postgres los
|
* en subqueries que filtran por tipo_comprobante específico (Postgres los
|
||||||
* optimiza away).
|
* optimiza away).
|
||||||
|
*
|
||||||
|
* OPTIMIZACIÓN: los subqueries de exclusiones de activos se reescribieron
|
||||||
|
* para usar subqueries NO-correlacionados donde sea posible (casos 1-3).
|
||||||
|
* Esto permite a PostgreSQL ejecutar el subquery una sola vez por query
|
||||||
|
* principal, en lugar de una vez por cada fila. Solo el caso 4 (anticipo
|
||||||
|
* referenciado por I07) requiere un correlated EXISTS.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ACTIVOS_USOS = "('I01','I02','I03','I04','I05','I06','I07','I08')";
|
const ACTIVOS_USOS = "('I01','I02','I03','I04','I05','I06','I07','I08')";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subquery no-correlacionado que devuelve todos los UUIDs de facturas tipo I
|
||||||
|
* con uso de activo. Usado para lookups P→I y E→I.
|
||||||
|
*/
|
||||||
|
const UUIDS_ACTIVOS = `SELECT LOWER(uuid) AS uuid FROM cfdis WHERE tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS_USOS}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subquery no-correlacionado que devuelve todos los UUIDs de E's que
|
||||||
|
* referencian un activo (directamente I-activo, o indirectamente P→I-activo).
|
||||||
|
*
|
||||||
|
* Usa JOIN + UNION en lugar de EXISTS + OR para que PostgreSQL pueda usar
|
||||||
|
* índices de forma más efectiva (especialmente el GIN en cfdis_relacionados).
|
||||||
|
*/
|
||||||
|
const UUIDS_E_DE_ACTIVOS = `
|
||||||
|
SELECT e.uuid
|
||||||
|
FROM cfdis e
|
||||||
|
JOIN cfdis r_act ON LOWER(r_act.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
||||||
|
WHERE e.tipo_comprobante = 'E'
|
||||||
|
AND e.cfdis_relacionados IS NOT NULL
|
||||||
|
AND r_act.tipo_comprobante = 'I'
|
||||||
|
AND r_act.uso_cfdi IN ${ACTIVOS_USOS}
|
||||||
|
UNION ALL
|
||||||
|
SELECT e.uuid
|
||||||
|
FROM cfdis e
|
||||||
|
JOIN cfdis r_act ON LOWER(r_act.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
||||||
|
JOIN cfdis pi_act ON LOWER(pi_act.uuid) = LOWER(r_act.uuid_relacionado)
|
||||||
|
WHERE e.tipo_comprobante = 'E'
|
||||||
|
AND e.cfdis_relacionados IS NOT NULL
|
||||||
|
AND r_act.tipo_comprobante = 'P'
|
||||||
|
AND pi_act.tipo_comprobante = 'I'
|
||||||
|
AND pi_act.uso_cfdi IN ${ACTIVOS_USOS}
|
||||||
|
`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Predicado SQL que detecta si el row actual (sin alias de tabla, asume
|
* Predicado SQL que detecta si el row actual (sin alias de tabla, asume
|
||||||
* `FROM cfdis`) referencia un activo directamente (I), indirectamente vía
|
* `FROM cfdis`) referencia un activo directamente (I), indirectamente vía
|
||||||
* pago (P→I), o transitivamente vía relación (E→I, E→P→I).
|
* pago (P→I), o transitivamente vía relación (E→I, E→P→I).
|
||||||
*
|
|
||||||
* IMPORTANTE — qualifying outer refs: dentro de los subqueries `cfdis i_act`
|
|
||||||
* y `cfdis r_act`, la tabla interna también tiene columnas `uuid_relacionado`
|
|
||||||
* y `cfdis_relacionados`. Una referencia no-qualificada las resolvería a las
|
|
||||||
* columnas internas (NO al row outer), volviendo el predicado a no-op.
|
|
||||||
* Por eso usamos `cfdis.uuid_relacionado` y `cfdis.cfdis_relacionados`
|
|
||||||
* explícitamente — fuerza la resolución al outer.
|
|
||||||
*/
|
*/
|
||||||
function activosExclusionNoAlias(): string {
|
function activosExclusionNoAlias(): string {
|
||||||
return `
|
return `
|
||||||
AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS_USOS})
|
AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS_USOS})
|
||||||
AND NOT (tipo_comprobante = 'P' AND EXISTS (
|
AND NOT (tipo_comprobante = 'P' AND EXISTS (
|
||||||
SELECT 1 FROM cfdis i_act
|
SELECT 1 FROM (${UUIDS_ACTIVOS}) ua
|
||||||
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
|
WHERE ua.uuid = ANY(string_to_array(LOWER(uuid_relacionado), '|'))
|
||||||
AND i_act.tipo_comprobante = 'I'
|
|
||||||
AND i_act.uso_cfdi IN ${ACTIVOS_USOS}
|
|
||||||
))
|
|
||||||
AND NOT (tipo_comprobante = 'E' AND cfdis.cfdis_relacionados IS NOT NULL AND EXISTS (
|
|
||||||
SELECT 1 FROM cfdis r_act
|
|
||||||
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(cfdis.cfdis_relacionados), '|'))
|
|
||||||
AND (
|
|
||||||
(r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS_USOS})
|
|
||||||
OR (r_act.tipo_comprobante = 'P' AND EXISTS (
|
|
||||||
SELECT 1 FROM cfdis pi_act
|
|
||||||
WHERE LOWER(pi_act.uuid) = LOWER(r_act.uuid_relacionado)
|
|
||||||
AND pi_act.tipo_comprobante = 'I'
|
|
||||||
AND pi_act.uso_cfdi IN ${ACTIVOS_USOS}
|
|
||||||
))
|
|
||||||
)
|
|
||||||
))
|
))
|
||||||
|
AND NOT (tipo_comprobante = 'E' AND uuid IN (${UUIDS_E_DE_ACTIVOS}))
|
||||||
AND NOT (tipo_comprobante = 'I' AND EXISTS (
|
AND NOT (tipo_comprobante = 'I' AND EXISTS (
|
||||||
-- Anticipo: CFDI tipo I (puede no tener uso_cfdi de activo) que es
|
-- Anticipo: CFDI tipo I (puede no tener uso_cfdi de activo) que es
|
||||||
-- referenciado por una I/07 PPD con uso_cfdi de activo. La I/07 PPD
|
-- referenciado por una I/07 PPD con uso_cfdi de activo. La I/07 PPD
|
||||||
@@ -87,24 +105,10 @@ function activosExclusionAlias(alias: string): string {
|
|||||||
return `
|
return `
|
||||||
AND NOT (${alias}.tipo_comprobante = 'I' AND ${alias}.uso_cfdi IN ${ACTIVOS_USOS})
|
AND NOT (${alias}.tipo_comprobante = 'I' AND ${alias}.uso_cfdi IN ${ACTIVOS_USOS})
|
||||||
AND NOT (${alias}.tipo_comprobante = 'P' AND EXISTS (
|
AND NOT (${alias}.tipo_comprobante = 'P' AND EXISTS (
|
||||||
SELECT 1 FROM cfdis i_act
|
SELECT 1 FROM (${UUIDS_ACTIVOS}) ua
|
||||||
WHERE LOWER(i_act.uuid) = LOWER(${alias}.uuid_relacionado)
|
WHERE ua.uuid = ANY(string_to_array(LOWER(${alias}.uuid_relacionado), '|'))
|
||||||
AND i_act.tipo_comprobante = 'I'
|
|
||||||
AND i_act.uso_cfdi IN ${ACTIVOS_USOS}
|
|
||||||
))
|
|
||||||
AND NOT (${alias}.tipo_comprobante = 'E' AND ${alias}.cfdis_relacionados IS NOT NULL AND EXISTS (
|
|
||||||
SELECT 1 FROM cfdis r_act
|
|
||||||
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(${alias}.cfdis_relacionados), '|'))
|
|
||||||
AND (
|
|
||||||
(r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS_USOS})
|
|
||||||
OR (r_act.tipo_comprobante = 'P' AND EXISTS (
|
|
||||||
SELECT 1 FROM cfdis pi_act
|
|
||||||
WHERE LOWER(pi_act.uuid) = LOWER(r_act.uuid_relacionado)
|
|
||||||
AND pi_act.tipo_comprobante = 'I'
|
|
||||||
AND pi_act.uso_cfdi IN ${ACTIVOS_USOS}
|
|
||||||
))
|
|
||||||
)
|
|
||||||
))
|
))
|
||||||
|
AND NOT (${alias}.tipo_comprobante = 'E' AND ${alias}.uuid IN (${UUIDS_E_DE_ACTIVOS}))
|
||||||
AND NOT (${alias}.tipo_comprobante = 'I' AND EXISTS (
|
AND NOT (${alias}.tipo_comprobante = 'I' AND EXISTS (
|
||||||
SELECT 1 FROM cfdis i07_act
|
SELECT 1 FROM cfdis i07_act
|
||||||
WHERE i07_act.tipo_comprobante = 'I'
|
WHERE i07_act.tipo_comprobante = 'I'
|
||||||
|
|||||||
@@ -66,11 +66,13 @@ export async function getClientesStats(range: ClientesStatsRange): Promise<Clien
|
|||||||
paymentsCount: payments._count,
|
paymentsCount: payments._count,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3) Clientes que NO renovaron: subs cuyo currentPeriodEnd cae en el rango
|
// 3) Clientes que NO renovaron:
|
||||||
// y que están en status terminal (cancelled, trial_expired, paused) o sin
|
// a) Subs cuyo currentPeriodEnd cae en el rango y están en status terminal
|
||||||
// payment posterior aprobado. Nota: un sub `authorized` con periodEnd
|
// (cancelled, trial_expired, paused).
|
||||||
// pasado es un "se renovó automáticamente" — para detectar no-renovaciones
|
// b) Tenants cuyo trialEndsAt ya pasó y NO tienen suscripción authorized
|
||||||
// miramos status efectivo + ausencia de payment en los siguientes 7 días.
|
// (incluye trials que nunca convirtieron o cuya sub fue borrada).
|
||||||
|
// c) Tenants con sub trial vencida (currentPeriodEnd < ahora) que nunca
|
||||||
|
// fue marcada trial_expired por el cron.
|
||||||
const subsExpiradas = await prisma.subscription.findMany({
|
const subsExpiradas = await prisma.subscription.findMany({
|
||||||
where: {
|
where: {
|
||||||
currentPeriodEnd: { gte: range.from, lte: range.to },
|
currentPeriodEnd: { gte: range.from, lte: range.to },
|
||||||
@@ -84,14 +86,99 @@ export async function getClientesStats(range: ClientesStatsRange): Promise<Clien
|
|||||||
tenant: { select: { id: true, nombre: true, rfc: true } },
|
tenant: { select: { id: true, nombre: true, rfc: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const noRenovaciones = subsExpiradas.map(s => ({
|
|
||||||
|
const noRenovacionesMap = new Map<string, ClientesStats['noRenovaciones'][number]>();
|
||||||
|
for (const s of subsExpiradas) {
|
||||||
|
noRenovacionesMap.set(s.tenantId, {
|
||||||
tenantId: s.tenantId,
|
tenantId: s.tenantId,
|
||||||
tenantNombre: s.tenant?.nombre ?? '',
|
tenantNombre: s.tenant?.nombre ?? '',
|
||||||
rfc: s.tenant?.rfc ?? '',
|
rfc: s.tenant?.rfc ?? '',
|
||||||
plan: String(s.plan),
|
plan: String(s.plan),
|
||||||
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '',
|
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '',
|
||||||
statusActual: s.status,
|
statusActual: s.status,
|
||||||
}));
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// b + c) Trials vencidos / sin suscripción activa / subs borradas
|
||||||
|
const now = new Date();
|
||||||
|
const tenantsConSubAutorizada = new Set(
|
||||||
|
(await prisma.subscription.findMany({
|
||||||
|
where: { status: 'authorized' },
|
||||||
|
select: { tenantId: true },
|
||||||
|
})).map(s => s.tenantId)
|
||||||
|
);
|
||||||
|
const excluded = Array.from(tenantsConSubAutorizada);
|
||||||
|
|
||||||
|
// Tenants con trialEndsAt pasado y sin sub authorized
|
||||||
|
const tenantsTrialsVencidos = await prisma.tenant.findMany({
|
||||||
|
where: {
|
||||||
|
trialEndsAt: { lt: now },
|
||||||
|
id: { notIn: excluded },
|
||||||
|
},
|
||||||
|
select: { id: true, nombre: true, rfc: true, plan: true, trialEndsAt: true },
|
||||||
|
});
|
||||||
|
for (const t of tenantsTrialsVencidos) {
|
||||||
|
if (noRenovacionesMap.has(t.id)) continue;
|
||||||
|
noRenovacionesMap.set(t.id, {
|
||||||
|
tenantId: t.id,
|
||||||
|
tenantNombre: t.nombre,
|
||||||
|
rfc: t.rfc ?? '',
|
||||||
|
plan: String(t.plan ?? 'trial'),
|
||||||
|
currentPeriodEnd: t.trialEndsAt?.toISOString() ?? '',
|
||||||
|
statusActual: 'trial_expired',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenants con sub trial vencida (currentPeriodEnd < ahora) que nunca fue
|
||||||
|
// marcada trial_expired por el cron, y no tienen otra sub authorized.
|
||||||
|
const subsTrialVencidas = await prisma.subscription.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'trial',
|
||||||
|
currentPeriodEnd: { lt: now },
|
||||||
|
tenantId: { notIn: excluded },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
tenantId: true,
|
||||||
|
plan: true,
|
||||||
|
currentPeriodEnd: true,
|
||||||
|
tenant: { select: { id: true, nombre: true, rfc: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const s of subsTrialVencidas) {
|
||||||
|
if (noRenovacionesMap.has(s.tenantId)) continue;
|
||||||
|
noRenovacionesMap.set(s.tenantId, {
|
||||||
|
tenantId: s.tenantId,
|
||||||
|
tenantNombre: s.tenant?.nombre ?? '',
|
||||||
|
rfc: s.tenant?.rfc ?? '',
|
||||||
|
plan: String(s.plan),
|
||||||
|
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '',
|
||||||
|
statusActual: 'trial_expired',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenants con plan de pago asignado manualmente (plan != 'trial') pero
|
||||||
|
// sin NINGUNA suscripción. Indica que nunca iniciaron el flujo de pago.
|
||||||
|
const tenantsConPlanPeroSinSub = await prisma.tenant.findMany({
|
||||||
|
where: {
|
||||||
|
plan: { not: 'trial' },
|
||||||
|
id: { notIn: excluded },
|
||||||
|
subscriptions: { none: {} },
|
||||||
|
},
|
||||||
|
select: { id: true, nombre: true, rfc: true, plan: true, createdAt: true },
|
||||||
|
});
|
||||||
|
for (const t of tenantsConPlanPeroSinSub) {
|
||||||
|
if (noRenovacionesMap.has(t.id)) continue;
|
||||||
|
noRenovacionesMap.set(t.id, {
|
||||||
|
tenantId: t.id,
|
||||||
|
tenantNombre: t.nombre,
|
||||||
|
rfc: t.rfc ?? '',
|
||||||
|
plan: String(t.plan),
|
||||||
|
currentPeriodEnd: t.createdAt.toISOString(),
|
||||||
|
statusActual: 'sin_suscripcion',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const noRenovaciones = Array.from(noRenovacionesMap.values());
|
||||||
|
|
||||||
// 4) Usuarios por cliente (memberships activos por tenant)
|
// 4) Usuarios por cliente (memberships activos por tenant)
|
||||||
const memberships = await prisma.tenantMembership.findMany({
|
const memberships = await prisma.tenantMembership.findMany({
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ async function alertaRiesgoCancelaciones(pool: Pool, contribuyenteId?: string |
|
|||||||
COUNT(*)::int as total,
|
COUNT(*)::int as total,
|
||||||
COUNT(CASE WHEN status IN ('Cancelado', '0') THEN 1 END)::int as cancelados
|
COUNT(CASE WHEN status IN ('Cancelado', '0') THEN 1 END)::int as cancelados
|
||||||
FROM cfdis
|
FROM cfdis
|
||||||
WHERE fecha_emision >= $1::date
|
WHERE (fecha_emision - interval '1 hour') >= $1::date
|
||||||
${cf}
|
${cf}
|
||||||
`, [fechaDesde]);
|
`, [fechaDesde]);
|
||||||
|
|
||||||
@@ -359,7 +359,7 @@ async function alertaCancelacionPeriodoAnterior(pool: Pool, contribuyenteId?: st
|
|||||||
FROM cfdis
|
FROM cfdis
|
||||||
WHERE status IN ('Cancelado', '0')
|
WHERE status IN ('Cancelado', '0')
|
||||||
AND fecha_cancelacion >= $1::date
|
AND fecha_cancelacion >= $1::date
|
||||||
AND fecha_emision < $1::date
|
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < $1::date
|
||||||
${cf}
|
${cf}
|
||||||
`, [inicioMes]);
|
`, [inicioMes]);
|
||||||
|
|
||||||
@@ -529,7 +529,7 @@ async function alertaResicoPfLimiteIngresos(
|
|||||||
FROM cfdis
|
FROM cfdis
|
||||||
WHERE type = 'EMITIDO'
|
WHERE type = 'EMITIDO'
|
||||||
AND status NOT IN ('Cancelado', '0')
|
AND status NOT IN ('Cancelado', '0')
|
||||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
AND EXTRACT(YEAR FROM COALESCE(fecha_efectiva, fecha_emision - interval '1 hour')) = $1
|
||||||
AND contribuyente_id = $2
|
AND contribuyente_id = $2
|
||||||
`, [año, safeId]);
|
`, [año, safeId]);
|
||||||
|
|
||||||
@@ -609,30 +609,46 @@ async function alertaOpinionCumplimiento(pool: Pool, contribuyenteId?: string |
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Genera todas las alertas automáticas para un tenant.
|
* Genera todas las alertas automáticas para un tenant.
|
||||||
|
* Cada alerta se envuelve en try/catch para que un fallo en una no
|
||||||
|
* bloquee el resto (robustez ante timeouts o errores transitorios).
|
||||||
*/
|
*/
|
||||||
export async function generarAlertasAutomaticas(
|
export async function generarAlertasAutomaticas(
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
contribuyenteId?: string | null,
|
contribuyenteId?: string | null,
|
||||||
): Promise<AlertaAuto[]> {
|
): Promise<AlertaAuto[]> {
|
||||||
const alertas = await Promise.all([
|
const generadores: { name: string; fn: () => Promise<AlertaAuto | null> }[] = [
|
||||||
alertaListaNegraPropia(pool, tenantId, contribuyenteId),
|
{ name: 'lista-negra-propia', fn: () => alertaListaNegraPropia(pool, tenantId, contribuyenteId) },
|
||||||
alertaClienteListaNegra(pool, contribuyenteId),
|
{ name: 'lista-negra-clientes', fn: () => alertaClienteListaNegra(pool, contribuyenteId) },
|
||||||
alertaProveedorListaNegra(pool, contribuyenteId),
|
{ name: 'lista-negra-proveedores', fn: () => alertaProveedorListaNegra(pool, contribuyenteId) },
|
||||||
alertaDiscrepanciaRegimen(pool, tenantId, contribuyenteId),
|
{ name: 'discrepancia-regimen', fn: () => alertaDiscrepanciaRegimen(pool, tenantId, contribuyenteId) },
|
||||||
alertaConcentracionClientes(pool, contribuyenteId),
|
{ name: 'concentracion-clientes', fn: () => alertaConcentracionClientes(pool, contribuyenteId) },
|
||||||
alertaConcentracionProveedores(pool, contribuyenteId),
|
{ name: 'concentracion-proveedores', fn: () => alertaConcentracionProveedores(pool, contribuyenteId) },
|
||||||
alertaRiesgoCambiario(pool, contribuyenteId),
|
{ name: 'riesgo-cambiario', fn: () => alertaRiesgoCambiario(pool, contribuyenteId) },
|
||||||
alertaRiesgoCancelaciones(pool, contribuyenteId),
|
{ name: 'riesgo-cancelaciones', fn: () => alertaRiesgoCancelaciones(pool, contribuyenteId) },
|
||||||
alertaRiesgoTransaccional(pool, contribuyenteId),
|
{ name: 'riesgo-transaccional', fn: () => alertaRiesgoTransaccional(pool, contribuyenteId) },
|
||||||
alertaCancelacionPeriodoAnterior(pool, contribuyenteId),
|
{ name: 'cancelacion-periodo-anterior', fn: () => alertaCancelacionPeriodoAnterior(pool, contribuyenteId) },
|
||||||
alertaOpinionCumplimiento(pool, contribuyenteId),
|
{ name: 'opinion-cumplimiento', fn: () => alertaOpinionCumplimiento(pool, contribuyenteId) },
|
||||||
alertaTipoRelacionSospechosa(pool, contribuyenteId),
|
{ name: 'tipo-relacion-sospechosa', fn: () => alertaTipoRelacionSospechosa(pool, contribuyenteId) },
|
||||||
alertaTareasProximasVencer(pool, contribuyenteId),
|
{ name: 'tareas-proximas-vencer', fn: () => alertaTareasProximasVencer(pool, contribuyenteId) },
|
||||||
alertaResicoPfLimiteIngresos(pool, contribuyenteId),
|
{ name: 'resico-pf-limite-ingresos', fn: () => alertaResicoPfLimiteIngresos(pool, contribuyenteId) },
|
||||||
]);
|
];
|
||||||
|
|
||||||
return alertas.filter((a): a is AlertaAuto => a !== null);
|
const alertas: AlertaAuto[] = [];
|
||||||
|
for (const g of generadores) {
|
||||||
|
try {
|
||||||
|
const a = await g.fn();
|
||||||
|
if (a) alertas.push(a);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[AlertasAuto] Fallo ${g.name} (tenant=${tenantId}, contribuyente=${contribuyenteId}):`, err.message || err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alertas.length > 0) {
|
||||||
|
console.log(`[AlertasAuto] tenant=${tenantId} contribuyente=${contribuyenteId || 'null'} generadas=${alertas.map(a => a.id).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return alertas;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -659,8 +675,8 @@ export async function getDiscrepanciasPorMes(
|
|||||||
|
|
||||||
const { rows } = await pool.query(`
|
const { rows } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
EXTRACT(YEAR FROM fecha_emision)::int as año,
|
EXTRACT(YEAR FROM COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'))::int as año,
|
||||||
EXTRACT(MONTH FROM fecha_emision)::int as mes,
|
EXTRACT(MONTH FROM COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'))::int as mes,
|
||||||
COUNT(*)::int as count
|
COUNT(*)::int as count
|
||||||
FROM cfdis
|
FROM cfdis
|
||||||
WHERE type = 'RECIBIDO' AND ${VIGENTE}
|
WHERE type = 'RECIBIDO' AND ${VIGENTE}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
343
apps/api/src/services/asignaciones.service.ts
Normal file
343
apps/api/src/services/asignaciones.service.ts
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
import type { Pool } from 'pg';
|
||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
|
||||||
|
// ── Asignación de obligaciones ──
|
||||||
|
|
||||||
|
export async function asignarObligacion(
|
||||||
|
pool: Pool,
|
||||||
|
obligacionId: string,
|
||||||
|
auxiliarUserId: string,
|
||||||
|
asignadoPor: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO obligacion_asignaciones (obligacion_id, auxiliar_user_id, asignado_por)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (obligacion_id)
|
||||||
|
DO UPDATE SET auxiliar_user_id = $2, asignado_por = $3, asignado_at = now()`,
|
||||||
|
[obligacionId, auxiliarUserId, asignadoPor],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function desasignarObligacion(pool: Pool, obligacionId: string): Promise<void> {
|
||||||
|
await pool.query('DELETE FROM obligacion_asignaciones WHERE obligacion_id = $1', [obligacionId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Asignación de tareas ──
|
||||||
|
|
||||||
|
export async function asignarTarea(
|
||||||
|
pool: Pool,
|
||||||
|
tareaId: string,
|
||||||
|
auxiliarUserId: string,
|
||||||
|
asignadoPor: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO tarea_asignaciones (tarea_id, auxiliar_user_id, asignado_por)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (tarea_id)
|
||||||
|
DO UPDATE SET auxiliar_user_id = $2, asignado_por = $3, asignado_at = now()`,
|
||||||
|
[tareaId, auxiliarUserId, asignadoPor],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function desasignarTarea(pool: Pool, tareaId: string): Promise<void> {
|
||||||
|
await pool.query('DELETE FROM tarea_asignaciones WHERE tarea_id = $1', [tareaId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Listados ──
|
||||||
|
|
||||||
|
export interface AsignacionObligacion {
|
||||||
|
id: string;
|
||||||
|
obligacionId: string;
|
||||||
|
obligacionNombre: string;
|
||||||
|
contribuyenteId: string;
|
||||||
|
contribuyenteRfc: string;
|
||||||
|
contribuyenteRazonSocial: string;
|
||||||
|
auxiliarUserId: string;
|
||||||
|
auxiliarNombre: string | null;
|
||||||
|
asignadoPor: string;
|
||||||
|
asignadoAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AsignacionTarea {
|
||||||
|
id: string;
|
||||||
|
tareaId: string;
|
||||||
|
tareaNombre: string;
|
||||||
|
contribuyenteId: string;
|
||||||
|
contribuyenteRfc: string;
|
||||||
|
contribuyenteRazonSocial: string;
|
||||||
|
auxiliarUserId: string;
|
||||||
|
auxiliarNombre: string | null;
|
||||||
|
asignadoPor: string;
|
||||||
|
asignadoAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveUserNames(userIds: string[]): Promise<Map<string, string>> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
if (userIds.length === 0) return map;
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { id: { in: userIds } },
|
||||||
|
select: { id: true, nombre: true },
|
||||||
|
});
|
||||||
|
for (const u of users) {
|
||||||
|
map.set(u.id, u.nombre);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devuelve todas las asignaciones de obligaciones y tareas de los auxiliares
|
||||||
|
* que pertenecen al supervisor indicado (vía auxiliar_supervisores).
|
||||||
|
* Owner ve todas las asignaciones del tenant.
|
||||||
|
*/
|
||||||
|
export async function getAsignacionesPorSupervisor(
|
||||||
|
pool: Pool,
|
||||||
|
supervisorUserId: string,
|
||||||
|
role: string,
|
||||||
|
): Promise<{ obligaciones: AsignacionObligacion[]; tareas: AsignacionTarea[] }> {
|
||||||
|
const isOwner = role === 'owner' || role === 'cfo' || role === 'contador';
|
||||||
|
|
||||||
|
// Relación supervisor → auxiliar se infiere desde carteras (directas y
|
||||||
|
// subcarteras) con fallback a la tabla legacy auxiliar_supervisores.
|
||||||
|
const supervisorFilter = isOwner
|
||||||
|
? ''
|
||||||
|
: `AND EXISTS (
|
||||||
|
SELECT 1 FROM (
|
||||||
|
SELECT c.auxiliar_user_id
|
||||||
|
FROM carteras c
|
||||||
|
WHERE c.supervisor_user_id = $1
|
||||||
|
AND c.auxiliar_user_id IS NOT NULL
|
||||||
|
UNION
|
||||||
|
SELECT sub.auxiliar_user_id
|
||||||
|
FROM carteras sub
|
||||||
|
JOIN carteras p ON p.id = sub.parent_id
|
||||||
|
WHERE p.supervisor_user_id = $1
|
||||||
|
AND sub.auxiliar_user_id IS NOT NULL
|
||||||
|
UNION
|
||||||
|
SELECT auxiliar_user_id FROM auxiliar_supervisores WHERE supervisor_user_id = $1
|
||||||
|
) sup_aux WHERE sup_aux.auxiliar_user_id = __AUX_COL__
|
||||||
|
)`;
|
||||||
|
const whereObl = isOwner
|
||||||
|
? 'WHERE 1=1'
|
||||||
|
: `WHERE 1=1 ${supervisorFilter.replace(/__AUX_COL__/g, 'oa.auxiliar_user_id')}`;
|
||||||
|
const whereTarea = isOwner
|
||||||
|
? 'WHERE 1=1'
|
||||||
|
: `WHERE 1=1 ${supervisorFilter.replace(/__AUX_COL__/g, 'ta.auxiliar_user_id')}`;
|
||||||
|
const params = isOwner ? [] : [supervisorUserId];
|
||||||
|
|
||||||
|
const { rows: obligaciones } = await pool.query<AsignacionObligacion>(
|
||||||
|
`SELECT
|
||||||
|
oa.id,
|
||||||
|
oa.obligacion_id AS "obligacionId",
|
||||||
|
oc.nombre AS "obligacionNombre",
|
||||||
|
oc.contribuyente_id AS "contribuyenteId",
|
||||||
|
c.rfc AS "contribuyenteRfc",
|
||||||
|
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial",
|
||||||
|
oa.auxiliar_user_id AS "auxiliarUserId",
|
||||||
|
oa.asignado_por AS "asignadoPor",
|
||||||
|
oa.asignado_at AS "asignadoAt"
|
||||||
|
FROM obligacion_asignaciones oa
|
||||||
|
JOIN obligaciones_contribuyente oc ON oc.id = oa.obligacion_id
|
||||||
|
JOIN contribuyentes c ON c.entidad_id = oc.contribuyente_id
|
||||||
|
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
|
||||||
|
${whereObl}
|
||||||
|
ORDER BY oa.asignado_at DESC`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows: tareas } = await pool.query<AsignacionTarea>(
|
||||||
|
`SELECT
|
||||||
|
ta.id,
|
||||||
|
ta.tarea_id AS "tareaId",
|
||||||
|
tc.nombre AS "tareaNombre",
|
||||||
|
tc.contribuyente_id AS "contribuyenteId",
|
||||||
|
c.rfc AS "contribuyenteRfc",
|
||||||
|
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial",
|
||||||
|
ta.auxiliar_user_id AS "auxiliarUserId",
|
||||||
|
ta.asignado_por AS "asignadoPor",
|
||||||
|
ta.asignado_at AS "asignadoAt"
|
||||||
|
FROM tarea_asignaciones ta
|
||||||
|
JOIN tareas_catalogo tc ON tc.id = ta.tarea_id
|
||||||
|
JOIN contribuyentes c ON c.entidad_id = tc.contribuyente_id
|
||||||
|
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
|
||||||
|
${whereTarea}
|
||||||
|
ORDER BY ta.asignado_at DESC`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
const allAuxIds = [...new Set([
|
||||||
|
...obligaciones.map(o => o.auxiliarUserId),
|
||||||
|
...tareas.map(t => t.auxiliarUserId),
|
||||||
|
])];
|
||||||
|
const names = await resolveUserNames(allAuxIds);
|
||||||
|
|
||||||
|
return {
|
||||||
|
obligaciones: obligaciones.map(o => ({ ...o, auxiliarNombre: names.get(o.auxiliarUserId) ?? null })),
|
||||||
|
tareas: tareas.map(t => ({ ...t, auxiliarNombre: names.get(t.auxiliarUserId) ?? null })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devuelve las asignaciones del auxiliar logueado.
|
||||||
|
*/
|
||||||
|
export async function getAsignacionesPorAuxiliar(
|
||||||
|
pool: Pool,
|
||||||
|
auxiliarUserId: string,
|
||||||
|
): Promise<{ obligaciones: AsignacionObligacion[]; tareas: AsignacionTarea[] }> {
|
||||||
|
const { rows: obligaciones } = await pool.query<AsignacionObligacion>(
|
||||||
|
`SELECT
|
||||||
|
oa.id,
|
||||||
|
oa.obligacion_id AS "obligacionId",
|
||||||
|
oc.nombre AS "obligacionNombre",
|
||||||
|
oc.contribuyente_id AS "contribuyenteId",
|
||||||
|
c.rfc AS "contribuyenteRfc",
|
||||||
|
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial",
|
||||||
|
oa.auxiliar_user_id AS "auxiliarUserId",
|
||||||
|
oa.asignado_por AS "asignadoPor",
|
||||||
|
oa.asignado_at AS "asignadoAt"
|
||||||
|
FROM obligacion_asignaciones oa
|
||||||
|
JOIN obligaciones_contribuyente oc ON oc.id = oa.obligacion_id
|
||||||
|
JOIN contribuyentes c ON c.entidad_id = oc.contribuyente_id
|
||||||
|
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
|
||||||
|
WHERE oa.auxiliar_user_id = $1
|
||||||
|
ORDER BY oa.asignado_at DESC`,
|
||||||
|
[auxiliarUserId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows: tareas } = await pool.query<AsignacionTarea>(
|
||||||
|
`SELECT
|
||||||
|
ta.id,
|
||||||
|
ta.tarea_id AS "tareaId",
|
||||||
|
tc.nombre AS "tareaNombre",
|
||||||
|
tc.contribuyente_id AS "contribuyenteId",
|
||||||
|
c.rfc AS "contribuyenteRfc",
|
||||||
|
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial",
|
||||||
|
ta.auxiliar_user_id AS "auxiliarUserId",
|
||||||
|
ta.asignado_por AS "asignadoPor",
|
||||||
|
ta.asignado_at AS "asignadoAt"
|
||||||
|
FROM tarea_asignaciones ta
|
||||||
|
JOIN tareas_catalogo tc ON tc.id = ta.tarea_id
|
||||||
|
JOIN contribuyentes c ON c.entidad_id = tc.contribuyente_id
|
||||||
|
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
|
||||||
|
WHERE ta.auxiliar_user_id = $1
|
||||||
|
ORDER BY ta.asignado_at DESC`,
|
||||||
|
[auxiliarUserId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const names = await resolveUserNames([auxiliarUserId]);
|
||||||
|
const auxName = names.get(auxiliarUserId) ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
obligaciones: obligaciones.map(o => ({ ...o, auxiliarNombre: auxName })),
|
||||||
|
tareas: tareas.map(t => ({ ...t, auxiliarNombre: auxName })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devuelve obligaciones activas sin asignar para los contribuyentes indicados.
|
||||||
|
*/
|
||||||
|
export async function getObligacionesSinAsignar(
|
||||||
|
pool: Pool,
|
||||||
|
entidadIds: string[],
|
||||||
|
): Promise<Omit<AsignacionObligacion, 'id' | 'auxiliarUserId' | 'auxiliarNombre' | 'asignadoPor' | 'asignadoAt'>[]> {
|
||||||
|
if (entidadIds.length === 0) return [];
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
oc.id AS "obligacionId",
|
||||||
|
oc.nombre AS "obligacionNombre",
|
||||||
|
oc.contribuyente_id AS "contribuyenteId",
|
||||||
|
c.rfc AS "contribuyenteRfc",
|
||||||
|
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial"
|
||||||
|
FROM obligaciones_contribuyente oc
|
||||||
|
JOIN contribuyentes c ON c.entidad_id = oc.contribuyente_id
|
||||||
|
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
|
||||||
|
LEFT JOIN obligacion_asignaciones oa ON oa.obligacion_id = oc.id
|
||||||
|
WHERE oc.activa = true AND oa.id IS NULL AND oc.contribuyente_id = ANY($1)
|
||||||
|
ORDER BY c.rfc, oc.nombre`,
|
||||||
|
[entidadIds],
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devuelve tareas activas sin asignar para los contribuyentes indicados.
|
||||||
|
*/
|
||||||
|
export async function getTareasSinAsignar(
|
||||||
|
pool: Pool,
|
||||||
|
entidadIds: string[],
|
||||||
|
): Promise<Omit<AsignacionTarea, 'id' | 'auxiliarUserId' | 'auxiliarNombre' | 'asignadoPor' | 'asignadoAt'>[]> {
|
||||||
|
if (entidadIds.length === 0) return [];
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
tc.id AS "tareaId",
|
||||||
|
tc.nombre AS "tareaNombre",
|
||||||
|
tc.contribuyente_id AS "contribuyenteId",
|
||||||
|
c.rfc AS "contribuyenteRfc",
|
||||||
|
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial"
|
||||||
|
FROM tareas_catalogo tc
|
||||||
|
JOIN contribuyentes c ON c.entidad_id = tc.contribuyente_id
|
||||||
|
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
|
||||||
|
LEFT JOIN tarea_asignaciones ta ON ta.tarea_id = tc.id
|
||||||
|
WHERE tc.active = true AND ta.id IS NULL AND tc.contribuyente_id = ANY($1)
|
||||||
|
ORDER BY c.rfc, tc.nombre`,
|
||||||
|
[entidadIds],
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resuelve el auxiliar asignado a una obligación (o null).
|
||||||
|
*/
|
||||||
|
export async function getAuxiliarAsignadoObligacion(
|
||||||
|
pool: Pool,
|
||||||
|
obligacionId: string,
|
||||||
|
): Promise<{ auxiliarUserId: string; auxiliarNombre: string | null } | null> {
|
||||||
|
const { rows } = await pool.query<{ auxiliar_user_id: string }>(
|
||||||
|
`SELECT oa.auxiliar_user_id
|
||||||
|
FROM obligacion_asignaciones oa
|
||||||
|
WHERE oa.obligacion_id = $1`,
|
||||||
|
[obligacionId],
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
const auxId = rows[0].auxiliar_user_id;
|
||||||
|
const names = await resolveUserNames([auxId]);
|
||||||
|
return { auxiliarUserId: auxId, auxiliarNombre: names.get(auxId) ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resuelve el auxiliar asignado a una tarea (o null).
|
||||||
|
*/
|
||||||
|
export async function getAuxiliarAsignadoTarea(
|
||||||
|
pool: Pool,
|
||||||
|
tareaId: string,
|
||||||
|
): Promise<{ auxiliarUserId: string; auxiliarNombre: string | null } | null> {
|
||||||
|
const { rows } = await pool.query<{ auxiliar_user_id: string }>(
|
||||||
|
`SELECT ta.auxiliar_user_id
|
||||||
|
FROM tarea_asignaciones ta
|
||||||
|
WHERE ta.tarea_id = $1`,
|
||||||
|
[tareaId],
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
const auxId = rows[0].auxiliar_user_id;
|
||||||
|
const names = await resolveUserNames([auxId]);
|
||||||
|
return { auxiliarUserId: auxId, auxiliarNombre: names.get(auxId) ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devuelve los userIds de auxiliares que tienen al contribuyente en alguna
|
||||||
|
* de sus subcarteras (carteras con auxiliar_user_id no nulo que contienen
|
||||||
|
* al contribuyente en cartera_entidades).
|
||||||
|
*/
|
||||||
|
export async function getAuxiliaresElegibles(
|
||||||
|
pool: Pool,
|
||||||
|
contribuyenteId: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const { rows } = await pool.query<{ auxiliar_user_id: string }>(
|
||||||
|
`SELECT DISTINCT c.auxiliar_user_id
|
||||||
|
FROM carteras c
|
||||||
|
JOIN cartera_entidades ce ON ce.cartera_id = c.id
|
||||||
|
WHERE ce.entidad_id = $1
|
||||||
|
AND c.auxiliar_user_id IS NOT NULL`,
|
||||||
|
[contribuyenteId],
|
||||||
|
);
|
||||||
|
return rows.map(r => r.auxiliar_user_id);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
// refresh token (delete + create) para evitar race conditions con requests
|
||||||
|
// concurrentes que intenten refrescar con el token anterior.
|
||||||
|
const previousTenantId = user.lastTenantId;
|
||||||
|
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: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
token: refreshToken,
|
token: refreshToken,
|
||||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,17 @@ export async function deleteCartera(pool: Pool, id: string): Promise<boolean> {
|
|||||||
|
|
||||||
// Entidades in cartera
|
// Entidades in cartera
|
||||||
export async function addEntidadToCartera(pool: Pool, carteraId: string, entidadId: string): Promise<void> {
|
export async function addEntidadToCartera(pool: Pool, carteraId: string, entidadId: string): Promise<void> {
|
||||||
|
// Si es subcartera, validar que la entidad pertenezca a la cartera padre
|
||||||
|
const cartera = await getCarteraById(pool, carteraId);
|
||||||
|
if (cartera?.parentId) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'SELECT 1 FROM cartera_entidades WHERE cartera_id = $1 AND entidad_id = $2',
|
||||||
|
[cartera.parentId, entidadId],
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new Error('La entidad no pertenece a la cartera padre de esta subcartera');
|
||||||
|
}
|
||||||
|
}
|
||||||
await pool.query('INSERT INTO cartera_entidades (cartera_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [carteraId, entidadId]);
|
await pool.query('INSERT INTO cartera_entidades (cartera_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [carteraId, entidadId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,12 +102,12 @@ export async function getCfdis(pool: Pool, filters: CfdiFilters): Promise<CfdiLi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (filters.fechaInicio) {
|
if (filters.fechaInicio) {
|
||||||
whereClause += ` AND fecha_emision >= $${paramIndex++}::date`;
|
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $${paramIndex++}::date`;
|
||||||
params.push(filters.fechaInicio);
|
params.push(filters.fechaInicio);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.fechaFin) {
|
if (filters.fechaFin) {
|
||||||
whereClause += ` AND fecha_emision <= ($${paramIndex++}::date + interval '1 day')`;
|
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`;
|
||||||
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 c.fecha_emision >= $${paramIndex++}::date`;
|
whereClause += ` AND COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') >= $${paramIndex++}::date`;
|
||||||
params.push(filters.fechaInicio);
|
params.push(filters.fechaInicio);
|
||||||
}
|
}
|
||||||
if (filters.fechaFin) {
|
if (filters.fechaFin) {
|
||||||
whereClause += ` AND c.fecha_emision <= ($${paramIndex++}::date + interval '1 day')`;
|
whereClause += ` AND COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`;
|
||||||
params.push(filters.fechaFin);
|
params.push(filters.fechaFin);
|
||||||
}
|
}
|
||||||
if (filters.rfc) {
|
if (filters.rfc) {
|
||||||
@@ -357,6 +357,81 @@ export async function getXmlById(pool: Pool, id: string): Promise<string | null>
|
|||||||
return rows[0]?.xml_original || null;
|
return rows[0]?.xml_original || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getXmlsByIds(pool: Pool, ids: number[]): Promise<{ id: number; uuid: string; xml: string | null }[]> {
|
||||||
|
const { rows } = await pool.query(`
|
||||||
|
SELECT id, uuid, xml_original FROM cfdis WHERE id = ANY($1)
|
||||||
|
`, [ids]);
|
||||||
|
return rows.map((r: any) => ({ id: r.id, uuid: r.uuid, xml: r.xml_original || null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCfdiXmlsForZip(
|
||||||
|
pool: Pool,
|
||||||
|
filters: CfdiFilters
|
||||||
|
): Promise<{ uuid: string; xml: string | null }[]> {
|
||||||
|
let whereClause = 'WHERE xml_original IS NOT NULL';
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (filters.tipo && !filters.contribuyenteId) {
|
||||||
|
whereClause += ` AND type = $${paramIndex++}`;
|
||||||
|
params.push(filters.tipo);
|
||||||
|
}
|
||||||
|
if (filters.tipoComprobante) {
|
||||||
|
whereClause += ` AND tipo_comprobante = $${paramIndex++}`;
|
||||||
|
params.push(filters.tipoComprobante);
|
||||||
|
}
|
||||||
|
if (filters.estado) {
|
||||||
|
whereClause += ` AND status = $${paramIndex++}`;
|
||||||
|
params.push(filters.estado);
|
||||||
|
}
|
||||||
|
if (filters.fechaInicio) {
|
||||||
|
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $${paramIndex++}::date`;
|
||||||
|
params.push(filters.fechaInicio);
|
||||||
|
}
|
||||||
|
if (filters.fechaFin) {
|
||||||
|
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`;
|
||||||
|
params.push(filters.fechaFin);
|
||||||
|
}
|
||||||
|
if (filters.rfc) {
|
||||||
|
whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR rfc_receptor ILIKE $${paramIndex++})`;
|
||||||
|
params.push(`%${filters.rfc}%`);
|
||||||
|
}
|
||||||
|
if (filters.emisor) {
|
||||||
|
whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex++})`;
|
||||||
|
params.push(`%${filters.emisor}%`);
|
||||||
|
}
|
||||||
|
if (filters.receptor) {
|
||||||
|
whereClause += ` AND (rfc_receptor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex++})`;
|
||||||
|
params.push(`%${filters.receptor}%`);
|
||||||
|
}
|
||||||
|
if (filters.search) {
|
||||||
|
whereClause += ` AND (uuid ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex} OR rfc_emisor ILIKE $${paramIndex} OR rfc_receptor ILIKE $${paramIndex++})`;
|
||||||
|
params.push(`%${filters.search}%`);
|
||||||
|
}
|
||||||
|
if (filters.contribuyenteId) {
|
||||||
|
if (filters.tipo === 'EMITIDO') {
|
||||||
|
whereClause += ` AND rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`;
|
||||||
|
params.push(filters.contribuyenteId);
|
||||||
|
} else if (filters.tipo === 'RECIBIDO') {
|
||||||
|
whereClause += ` AND rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`;
|
||||||
|
params.push(filters.contribuyenteId);
|
||||||
|
} else {
|
||||||
|
whereClause += ` AND (contribuyente_id = $${paramIndex} OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex}) OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++}))`;
|
||||||
|
params.push(filters.contribuyenteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(1000);
|
||||||
|
const { rows } = await pool.query(`
|
||||||
|
SELECT uuid, xml_original FROM cfdis
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY fecha_emision DESC
|
||||||
|
LIMIT $${paramIndex++}
|
||||||
|
`, params);
|
||||||
|
|
||||||
|
return rows.map((r: any) => ({ uuid: r.uuid, xml: r.xml_original || null }));
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateCfdiData {
|
export interface CreateCfdiData {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
type: 'EMITIDO' | 'RECIBIDO';
|
type: 'EMITIDO' | 'RECIBIDO';
|
||||||
@@ -746,7 +821,10 @@ export async function getReceptores(pool: Pool, search: string, limit: number =
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getResumenCfdis(pool: Pool, año: number, mes: number, contribuyenteId?: string) {
|
export async function getResumenCfdis(pool: Pool, año: number, mes: number, contribuyenteId?: string) {
|
||||||
let whereClause = `WHERE status NOT IN ('Cancelado', '0') AND year = $1 AND month = $2`;
|
const fi = `${año}-${String(mes).padStart(2, '0')}-01`;
|
||||||
|
const lastDay = new Date(año, mes, 0).getDate();
|
||||||
|
const ff = `${año}-${String(mes).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||||
|
let whereClause = `WHERE status NOT IN ('Cancelado', '0') AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= $2::date`;
|
||||||
if (contribuyenteId) {
|
if (contribuyenteId) {
|
||||||
const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, '');
|
const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, '');
|
||||||
whereClause += ` AND (contribuyente_id = '${safeId}' OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}') OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}'))`;
|
whereClause += ` AND (contribuyente_id = '${safeId}' OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}') OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}'))`;
|
||||||
@@ -761,7 +839,7 @@ export async function getResumenCfdis(pool: Pool, año: number, mes: number, con
|
|||||||
COALESCE(SUM(CASE WHEN type = 'RECIBIDO' THEN iva_traslado_mxn ELSE 0 END), 0) as iva_acreditable
|
COALESCE(SUM(CASE WHEN type = 'RECIBIDO' THEN iva_traslado_mxn ELSE 0 END), 0) as iva_acreditable
|
||||||
FROM cfdis
|
FROM cfdis
|
||||||
${whereClause}
|
${whereClause}
|
||||||
`, [String(año), String(mes).padStart(2, '0')]);
|
`, [fi, ff]);
|
||||||
|
|
||||||
const r = rows[0];
|
const r = rows[0];
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface ConciliacionCfdi {
|
|||||||
nombreEmisor: string;
|
nombreEmisor: string;
|
||||||
rfcReceptor: string;
|
rfcReceptor: string;
|
||||||
nombreReceptor: string;
|
nombreReceptor: string;
|
||||||
|
regimenFiscalEmisor: string | null;
|
||||||
|
regimenFiscalReceptor: string | null;
|
||||||
total: number;
|
total: number;
|
||||||
totalMxn: number;
|
totalMxn: number;
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
@@ -68,11 +70,11 @@ export async function getCfdisConConciliacion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (filters.fechaInicio) {
|
if (filters.fechaInicio) {
|
||||||
where += ` AND COALESCE(c.fecha_pago_p, c.fecha_emision) >= $${idx++}::date`;
|
where += ` AND CASE WHEN c.tipo_comprobante = 'P' THEN c.fecha_pago_p - interval '1 hour' ELSE COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') END >= $${idx++}::date`;
|
||||||
params.push(filters.fechaInicio);
|
params.push(filters.fechaInicio);
|
||||||
}
|
}
|
||||||
if (filters.fechaFin) {
|
if (filters.fechaFin) {
|
||||||
where += ` AND COALESCE(c.fecha_pago_p, c.fecha_emision) <= ($${idx++}::date + interval '1 day')`;
|
where += ` AND CASE WHEN c.tipo_comprobante = 'P' THEN c.fecha_pago_p - interval '1 hour' ELSE COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') END <= ($${idx++}::date + interval '1 day')`;
|
||||||
params.push(filters.fechaFin);
|
params.push(filters.fechaFin);
|
||||||
}
|
}
|
||||||
if (filters.regimen) {
|
if (filters.regimen) {
|
||||||
@@ -98,6 +100,7 @@ export async function getCfdisConConciliacion(
|
|||||||
c.fecha_emision as "fechaEmision",
|
c.fecha_emision as "fechaEmision",
|
||||||
c.rfc_emisor as "rfcEmisor", c.nombre_emisor as "nombreEmisor",
|
c.rfc_emisor as "rfcEmisor", c.nombre_emisor as "nombreEmisor",
|
||||||
c.rfc_receptor as "rfcReceptor", c.nombre_receptor as "nombreReceptor",
|
c.rfc_receptor as "rfcReceptor", c.nombre_receptor as "nombreReceptor",
|
||||||
|
c.regimen_fiscal_emisor as "regimenFiscalEmisor", c.regimen_fiscal_receptor as "regimenFiscalReceptor",
|
||||||
c.total, c.total_mxn as "totalMxn",
|
c.total, c.total_mxn as "totalMxn",
|
||||||
c.subtotal, c.descuento,
|
c.subtotal, c.descuento,
|
||||||
c.moneda, c.tipo_cambio as "tipoCambio",
|
c.moneda, c.tipo_cambio as "tipoCambio",
|
||||||
@@ -136,6 +139,8 @@ export async function getCfdisConConciliacion(
|
|||||||
nombreEmisor: r.nombreEmisor,
|
nombreEmisor: r.nombreEmisor,
|
||||||
rfcReceptor: r.rfcReceptor,
|
rfcReceptor: r.rfcReceptor,
|
||||||
nombreReceptor: r.nombreReceptor,
|
nombreReceptor: r.nombreReceptor,
|
||||||
|
regimenFiscalEmisor: r.regimenFiscalEmisor,
|
||||||
|
regimenFiscalReceptor: r.regimenFiscalReceptor,
|
||||||
total: Number(r.total),
|
total: Number(r.total),
|
||||||
totalMxn: Number(r.totalMxn),
|
totalMxn: Number(r.totalMxn),
|
||||||
subtotal: Number(r.subtotal || 0),
|
subtotal: Number(r.subtotal || 0),
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ function rowToConstancia(r: any): ConstanciaRow {
|
|||||||
* sincroniza automáticamente domicilio + regímenes activos con lo que reporta
|
* sincroniza automáticamente domicilio + regímenes activos con lo que reporta
|
||||||
* el SAT. El auto-fill NO es destructivo para datos custom del usuario:
|
* el SAT. El auto-fill NO es destructivo para datos custom del usuario:
|
||||||
* solo sobreescribe campos si la CSF tiene un valor no-vacío.
|
* solo sobreescribe campos si la CSF tiene un valor no-vacío.
|
||||||
|
*
|
||||||
|
* Incluye retry con backoff (3 intentos) para robustez ante timeouts
|
||||||
|
* transitorios del portal SAT (mantenimiento nocturno, congestión, etc.).
|
||||||
*/
|
*/
|
||||||
export async function consultarConstancia(tenantId: string): Promise<ConstanciaRow> {
|
export async function consultarConstancia(tenantId: string): Promise<ConstanciaRow> {
|
||||||
const fiel = await getDecryptedFiel(tenantId);
|
const fiel = await getDecryptedFiel(tenantId);
|
||||||
@@ -55,6 +58,10 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
|
|||||||
});
|
});
|
||||||
if (!tenant) throw new Error('Tenant no encontrado');
|
if (!tenant) throw new Error('Tenant no encontrado');
|
||||||
|
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
const RETRY_DELAYS = [5_000, 15_000, 30_000]; // backoff
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||||
const tempId = randomUUID();
|
const tempId = randomUUID();
|
||||||
const tempDir = join(tmpdir(), `horux-csf-${tempId}`);
|
const tempDir = join(tmpdir(), `horux-csf-${tempId}`);
|
||||||
mkdirSync(tempDir, { recursive: true, mode: 0o700 });
|
mkdirSync(tempDir, { recursive: true, mode: 0o700 });
|
||||||
@@ -65,9 +72,6 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
|
|||||||
writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 });
|
writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 });
|
||||||
writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
|
writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
|
||||||
|
|
||||||
// Headless por default. El fix de dispatchEvent en sat-csf-login cubre el
|
|
||||||
// caso donde el click sintético no dispara el handler del SAT. Si algún
|
|
||||||
// ambiente necesita ver el browser (debug), setear SAT_HEADLESS=false.
|
|
||||||
const headless = process.env.SAT_HEADLESS !== 'false';
|
const headless = process.env.SAT_HEADLESS !== 'false';
|
||||||
const browser = await chromium.launch({
|
const browser = await chromium.launch({
|
||||||
headless,
|
headless,
|
||||||
@@ -76,11 +80,11 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||||
setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 3 minutos')), PROCESS_TIMEOUT),
|
setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 5 minutos')), 300_000),
|
||||||
);
|
);
|
||||||
|
|
||||||
const resultPromise = (async () => {
|
const resultPromise = (async () => {
|
||||||
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password);
|
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password, fiel.rfc);
|
||||||
const pdfBuffer = await extractCsfPdf(session);
|
const pdfBuffer = await extractCsfPdf(session);
|
||||||
const csf = await parseCsfPdf(pdfBuffer);
|
const csf = await parseCsfPdf(pdfBuffer);
|
||||||
|
|
||||||
@@ -102,9 +106,6 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-fill domicilio del tenant + regímenes activos desde el CSF.
|
|
||||||
// Se hace después del INSERT para que si algo falla en la sincronización
|
|
||||||
// la CSF ya quedó guardada y el usuario puede verla.
|
|
||||||
await sincronizarDatosFiscales(tenantId, csf).catch(err => {
|
await sincronizarDatosFiscales(tenantId, csf).catch(err => {
|
||||||
console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err);
|
console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err);
|
||||||
});
|
});
|
||||||
@@ -116,6 +117,11 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
|
|||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
}
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const willRetry = attempt < MAX_RETRIES - 1;
|
||||||
|
console.error(`[CSF] Intento ${attempt + 1}/${MAX_RETRIES} falló para tenant ${tenantId}: ${err.message}${willRetry ? ` — reintentando en ${RETRY_DELAYS[attempt]}ms...` : ''}`);
|
||||||
|
if (!willRetry) throw err;
|
||||||
|
await new Promise(r => setTimeout(r, RETRY_DELAYS[attempt]));
|
||||||
} finally {
|
} finally {
|
||||||
try { unlinkSync(cerPath); } catch { /* ok */ }
|
try { unlinkSync(cerPath); } catch { /* ok */ }
|
||||||
try { unlinkSync(keyPath); } catch { /* ok */ }
|
try { unlinkSync(keyPath); } catch { /* ok */ }
|
||||||
@@ -123,6 +129,9 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw new Error('No debería llegar aquí');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convierte el domicilio del CSF a los campos de `tenants` (calle compuesta
|
* Convierte el domicilio del CSF a los campos de `tenants` (calle compuesta
|
||||||
* por tipoVialidad + nombreVialidad). Solo actualiza campos cuando el CSF
|
* por tipoVialidad + nombreVialidad). Solo actualiza campos cuando el CSF
|
||||||
@@ -297,7 +306,7 @@ export async function consultarConstanciaContribuyente(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const resultPromise = (async () => {
|
const resultPromise = (async () => {
|
||||||
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password);
|
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password, fiel.rfc);
|
||||||
const pdfBuffer = await extractCsfPdf(session);
|
const pdfBuffer = await extractCsfPdf(session);
|
||||||
const csf = await parseCsfPdf(pdfBuffer);
|
const csf = await parseCsfPdf(pdfBuffer);
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ export async function createOrgContribuyente(
|
|||||||
// Idempotente: si existe en ambos lados, asegurar que la live key está
|
// Idempotente: si existe en ambos lados, asegurar que la live key está
|
||||||
// cacheada (puede faltar en orgs legacy creadas antes del refactor live).
|
// cacheada (puede faltar en orgs legacy creadas antes del refactor live).
|
||||||
await ensureLiveKeyCached(pool, existingId);
|
await ensureLiveKeyCached(pool, existingId);
|
||||||
|
// Backfill: si la org fue creada antes de este fix, sincronizar datos fiscales.
|
||||||
|
await updateOrgLegalOnCreate(pool, existingId, contribuyenteId);
|
||||||
return { orgId: existingId, reused: true };
|
return { orgId: existingId, reused: true };
|
||||||
} catch {
|
} catch {
|
||||||
const org = await client.organizations.create({ name: nombre });
|
const org = await client.organizations.create({ name: nombre });
|
||||||
@@ -101,6 +103,7 @@ export async function createOrgContribuyente(
|
|||||||
);
|
);
|
||||||
// Eager: generar y cachear live key para que la org quede lista para emitir.
|
// Eager: generar y cachear live key para que la org quede lista para emitir.
|
||||||
await ensureLiveKeyCached(pool, org.id);
|
await ensureLiveKeyCached(pool, org.id);
|
||||||
|
await updateOrgLegalOnCreate(pool, org.id, contribuyenteId);
|
||||||
return { orgId: org.id, recreated: true };
|
return { orgId: org.id, recreated: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,6 +116,7 @@ export async function createOrgContribuyente(
|
|||||||
);
|
);
|
||||||
// Eager: generar y cachear live key inmediatamente tras crear la org.
|
// Eager: generar y cachear live key inmediatamente tras crear la org.
|
||||||
await ensureLiveKeyCached(pool, org.id);
|
await ensureLiveKeyCached(pool, org.id);
|
||||||
|
await updateOrgLegalOnCreate(pool, org.id, contribuyenteId);
|
||||||
return { orgId: org.id };
|
return { orgId: org.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +136,98 @@ async function ensureLiveKeyCached(pool: Pool, orgId: string): Promise<void> {
|
|||||||
await persistEncryptedKey(pool, orgId, apiKey);
|
await persistEncryptedKey(pool, orgId, apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ContribuyenteFiscalData {
|
||||||
|
rfc: string;
|
||||||
|
razon_social: string | null;
|
||||||
|
regimen_fiscal: string | null;
|
||||||
|
codigo_postal: string | null;
|
||||||
|
domicilio: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchContribuyenteFiscalData(
|
||||||
|
pool: Pool,
|
||||||
|
contribuyenteId: string,
|
||||||
|
): Promise<ContribuyenteFiscalData> {
|
||||||
|
const { rows } = await pool.query<ContribuyenteFiscalData>(
|
||||||
|
`SELECT c.rfc, r.razon_social, c.regimen_fiscal, c.codigo_postal, c.domicilio
|
||||||
|
FROM contribuyentes c
|
||||||
|
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
|
||||||
|
WHERE c.entidad_id = $1`,
|
||||||
|
[contribuyenteId],
|
||||||
|
);
|
||||||
|
if (rows.length === 0) throw new Error('Contribuyente no encontrado');
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildLegalPayload(
|
||||||
|
contrib: ContribuyenteFiscalData,
|
||||||
|
chosenTaxSystem: string,
|
||||||
|
currentLegal?: any,
|
||||||
|
) {
|
||||||
|
const domicilio = (contrib.domicilio || {}) as any;
|
||||||
|
return {
|
||||||
|
name: contrib.razon_social || currentLegal?.name || '',
|
||||||
|
legal_name: contrib.razon_social || currentLegal?.legal_name || '',
|
||||||
|
tax_system: chosenTaxSystem,
|
||||||
|
address: {
|
||||||
|
street: domicilio.calle || currentLegal?.address?.street || '',
|
||||||
|
exterior: domicilio.numExterior || currentLegal?.address?.exterior || '',
|
||||||
|
interior: domicilio.numInterior || currentLegal?.address?.interior || '',
|
||||||
|
neighborhood: domicilio.colonia || currentLegal?.address?.neighborhood || '',
|
||||||
|
city: domicilio.ciudad || currentLegal?.address?.city || '',
|
||||||
|
municipality: domicilio.municipio || currentLegal?.address?.municipality || '',
|
||||||
|
state: domicilio.estado || currentLegal?.address?.state || '',
|
||||||
|
zip: contrib.codigo_postal || domicilio.codigoPostal || currentLegal?.address?.zip || '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putOrgLegal(orgId: string, payload: any): Promise<void> {
|
||||||
|
const userKey = env.FACTURAPI_USER_KEY;
|
||||||
|
if (!userKey) throw new Error('FACTURAPI_USER_KEY no configurada');
|
||||||
|
const putRes = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/legal`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${userKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!putRes.ok) {
|
||||||
|
const errText = await putRes.text();
|
||||||
|
throw new Error(
|
||||||
|
`Error actualizando datos fiscales de la organización Facturapi (${putRes.status}): ${errText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualiza los datos fiscales de una organización Facturapi recién creada
|
||||||
|
* usando la información del contribuyente. Se usa el primer régimen fiscal
|
||||||
|
* registrado. No-op si no hay régimen fiscal o razón social que setear.
|
||||||
|
*/
|
||||||
|
async function updateOrgLegalOnCreate(
|
||||||
|
pool: Pool,
|
||||||
|
orgId: string,
|
||||||
|
contribuyenteId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const contrib = await fetchContribuyenteFiscalData(pool, contribuyenteId);
|
||||||
|
const allowed = (contrib.regimen_fiscal || '')
|
||||||
|
.split(',')
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!allowed.length || !contrib.razon_social) {
|
||||||
|
// Datos incompletos: no fallar la creación de la org, solo loguear silenciosamente.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = await buildLegalPayload(contrib, allowed[0]);
|
||||||
|
await putOrgLegal(orgId, payload);
|
||||||
|
} catch {
|
||||||
|
// No bloquear la creación de la org si el update legal falla.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getOrgStatusContribuyente(
|
export async function getOrgStatusContribuyente(
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
contribuyenteId: string
|
contribuyenteId: string
|
||||||
@@ -339,7 +435,7 @@ export async function createInvoiceContribuyente(
|
|||||||
unit_key: item.unitKey || 'E48',
|
unit_key: item.unitKey || 'E48',
|
||||||
unit_name: item.unitName || 'Servicio',
|
unit_name: item.unitName || 'Servicio',
|
||||||
price: item.price,
|
price: item.price,
|
||||||
tax_included: item.taxIncluded ?? true,
|
tax_included: item.taxIncluded ?? false,
|
||||||
taxes: item.taxes?.map((t: any) => ({
|
taxes: item.taxes?.map((t: any) => ({
|
||||||
type: t.type,
|
type: t.type,
|
||||||
rate: t.rate,
|
rate: t.rate,
|
||||||
@@ -347,6 +443,7 @@ export async function createInvoiceContribuyente(
|
|||||||
...(t.withholding ? { withholding: true } : {}),
|
...(t.withholding ? { withholding: true } : {}),
|
||||||
})) || [{ type: 'IVA', rate: 0.16 }],
|
})) || [{ type: 'IVA', rate: 0.16 }],
|
||||||
},
|
},
|
||||||
|
...(data.cuentaPredial ? { property_tax_account: data.cuentaPredial } : {}),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +458,7 @@ export async function createInvoiceContribuyente(
|
|||||||
|
|
||||||
if (data.series) invoicePayload.series = data.series;
|
if (data.series) invoicePayload.series = data.series;
|
||||||
if (data.folioNumber) invoicePayload.folio_number = data.folioNumber;
|
if (data.folioNumber) invoicePayload.folio_number = data.folioNumber;
|
||||||
|
if (data.fechaEmision) invoicePayload.date = data.fechaEmision;
|
||||||
|
|
||||||
if (data.relatedDocuments?.length) {
|
if (data.relatedDocuments?.length) {
|
||||||
// Estructura SAT 4.0: agrupa N uuids por tipo de relación. Acepta tanto
|
// Estructura SAT 4.0: agrupa N uuids por tipo de relación. Acepta tanto
|
||||||
@@ -409,22 +507,7 @@ async function ensureOrgLegalForEmit(
|
|||||||
const userKey = env.FACTURAPI_USER_KEY;
|
const userKey = env.FACTURAPI_USER_KEY;
|
||||||
if (!userKey) throw new Error('FACTURAPI_USER_KEY no configurada');
|
if (!userKey) throw new Error('FACTURAPI_USER_KEY no configurada');
|
||||||
|
|
||||||
// Datos fiscales del contribuyente (razón social + domicilio)
|
const contrib = await fetchContribuyenteFiscalData(pool, contribuyenteId);
|
||||||
const { rows } = await pool.query<{
|
|
||||||
rfc: string;
|
|
||||||
razon_social: string | null;
|
|
||||||
regimen_fiscal: string | null;
|
|
||||||
codigo_postal: string | null;
|
|
||||||
domicilio: any;
|
|
||||||
}>(
|
|
||||||
`SELECT c.rfc, r.razon_social, c.regimen_fiscal, c.codigo_postal, c.domicilio
|
|
||||||
FROM contribuyentes c
|
|
||||||
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
|
|
||||||
WHERE c.entidad_id = $1`,
|
|
||||||
[contribuyenteId],
|
|
||||||
);
|
|
||||||
if (rows.length === 0) throw new Error('Contribuyente no encontrado');
|
|
||||||
const contrib = rows[0];
|
|
||||||
|
|
||||||
// Validar que el régimen elegido esté entre los registrados del contrib
|
// Validar que el régimen elegido esté entre los registrados del contrib
|
||||||
const allowed = (contrib.regimen_fiscal || '')
|
const allowed = (contrib.regimen_fiscal || '')
|
||||||
@@ -458,36 +541,69 @@ async function ensureOrgLegalForEmit(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const domicilio = (contrib.domicilio || {}) as any;
|
const payload = await buildLegalPayload(contrib, chosenTaxSystem, currentLegal);
|
||||||
const legalPayload = {
|
await putOrgLegal(orgId, payload);
|
||||||
name: contrib.razon_social || currentLegal.name || '',
|
}
|
||||||
legal_name: contrib.razon_social || currentLegal.legal_name || '',
|
|
||||||
tax_system: chosenTaxSystem,
|
|
||||||
address: {
|
|
||||||
street: domicilio.calle || currentLegal.address?.street || '',
|
|
||||||
exterior: domicilio.numExterior || currentLegal.address?.exterior || '',
|
|
||||||
interior: domicilio.numInterior || currentLegal.address?.interior || '',
|
|
||||||
neighborhood: domicilio.colonia || currentLegal.address?.neighborhood || '',
|
|
||||||
city: domicilio.ciudad || currentLegal.address?.city || '',
|
|
||||||
municipality: domicilio.municipio || currentLegal.address?.municipality || '',
|
|
||||||
state: domicilio.estado || currentLegal.address?.state || '',
|
|
||||||
zip: contrib.codigo_postal || domicilio.codigoPostal || currentLegal.address?.zip || '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const putRes = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/legal`, {
|
// ── Personalización (logo, color) per-contribuyente ──
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${userKey}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(legalPayload),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!putRes.ok) {
|
export async function getCustomizationContribuyente(
|
||||||
const errText = await putRes.text();
|
pool: Pool,
|
||||||
throw new Error(
|
contribuyenteId: string,
|
||||||
`Error actualizando datos fiscales de la organización Facturapi (${putRes.status}): ${errText}`,
|
): Promise<{ logoUrl?: string; color?: string } | null> {
|
||||||
|
const { rows } = await pool.query<{ facturapi_org_id: string }>(
|
||||||
|
'SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true',
|
||||||
|
[contribuyenteId],
|
||||||
);
|
);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const userClient = getUserClient();
|
||||||
|
try {
|
||||||
|
const org = await userClient.organizations.retrieve(rows[0].facturapi_org_id);
|
||||||
|
return {
|
||||||
|
logoUrl: org.customization?.has_logo ? (org.logo_url ?? undefined) : undefined,
|
||||||
|
color: org.customization?.color || undefined,
|
||||||
|
};
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadLogoContribuyente(
|
||||||
|
pool: Pool,
|
||||||
|
contribuyenteId: string,
|
||||||
|
logoBase64: string,
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
const { rows } = await pool.query<{ facturapi_org_id: string }>(
|
||||||
|
'SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true',
|
||||||
|
[contribuyenteId],
|
||||||
|
);
|
||||||
|
if (rows.length === 0) throw new Error('Organización no configurada');
|
||||||
|
|
||||||
|
const userClient = getUserClient();
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.from(logoBase64, 'base64');
|
||||||
|
await userClient.organizations.uploadLogo(rows[0].facturapi_org_id, buffer);
|
||||||
|
return { success: true, message: 'Logo subido correctamente' };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, message: error.message || 'Error al subir logo' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateColorContribuyente(
|
||||||
|
pool: Pool,
|
||||||
|
contribuyenteId: string,
|
||||||
|
color: string,
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
const { rows } = await pool.query<{ facturapi_org_id: string }>(
|
||||||
|
'SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true',
|
||||||
|
[contribuyenteId],
|
||||||
|
);
|
||||||
|
if (rows.length === 0) throw new Error('Organización no configurada');
|
||||||
|
|
||||||
|
const userClient = getUserClient();
|
||||||
|
try {
|
||||||
|
await userClient.organizations.updateCustomization(rows[0].facturapi_org_id, { color });
|
||||||
|
return { success: true, message: 'Color actualizado correctamente' };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, message: error.message || 'Error al actualizar color' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
|
||||||
export interface CreateContribuyenteData {
|
export interface CreateContribuyenteData {
|
||||||
rfc: string;
|
rfc: string;
|
||||||
@@ -23,7 +24,61 @@ export interface ContribuyenteRow {
|
|||||||
domicilio: Record<string, unknown> | null;
|
domicilio: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listContribuyentes(pool: Pool, entidadIds?: string[]): Promise<ContribuyenteRow[]> {
|
async function fetchTenantFiscalData(tenantId: string) {
|
||||||
|
const tenant = await prisma.tenant.findUnique({
|
||||||
|
where: { id: tenantId },
|
||||||
|
select: {
|
||||||
|
rfc: true,
|
||||||
|
codigoPostal: true,
|
||||||
|
calle: true,
|
||||||
|
numExterior: true,
|
||||||
|
numInterior: true,
|
||||||
|
colonia: true,
|
||||||
|
ciudad: true,
|
||||||
|
municipio: true,
|
||||||
|
estado: true,
|
||||||
|
telefono: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!tenant) return null;
|
||||||
|
|
||||||
|
const regimenes = await prisma.tenantRegimenActivo.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
select: { regimen: { select: { clave: true } } },
|
||||||
|
});
|
||||||
|
const regimenFiscal = regimenes.map(r => r.regimen.clave).join(',') || null;
|
||||||
|
|
||||||
|
const hasAnyAddress = tenant.calle || tenant.colonia || tenant.ciudad || tenant.municipio || tenant.estado || tenant.codigoPostal;
|
||||||
|
const domicilio = hasAnyAddress
|
||||||
|
? {
|
||||||
|
calle: tenant.calle || '',
|
||||||
|
numExterior: tenant.numExterior || '',
|
||||||
|
numInterior: tenant.numInterior || '',
|
||||||
|
colonia: tenant.colonia || '',
|
||||||
|
ciudad: tenant.ciudad || '',
|
||||||
|
municipio: tenant.municipio || '',
|
||||||
|
estado: tenant.estado || '',
|
||||||
|
codigoPostal: tenant.codigoPostal || '',
|
||||||
|
telefono: tenant.telefono || '',
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { tenantRfc: tenant.rfc, regimenFiscal, codigoPostal: tenant.codigoPostal, domicilio };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeContribuyenteWithTenant(
|
||||||
|
row: ContribuyenteRow,
|
||||||
|
tenantData: NonNullable<Awaited<ReturnType<typeof fetchTenantFiscalData>>>
|
||||||
|
): ContribuyenteRow {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
regimenFiscal: row.regimenFiscal || tenantData.regimenFiscal,
|
||||||
|
codigoPostal: row.codigoPostal || tenantData.codigoPostal,
|
||||||
|
domicilio: row.domicilio || tenantData.domicilio,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listContribuyentes(pool: Pool, entidadIds?: string[], tenantId?: string): Promise<ContribuyenteRow[]> {
|
||||||
let query = `
|
let query = `
|
||||||
SELECT
|
SELECT
|
||||||
e.id, e.tipo, e.nombre, e.identificador,
|
e.id, e.tipo, e.nombre, e.identificador,
|
||||||
@@ -45,10 +100,20 @@ export async function listContribuyentes(pool: Pool, entidadIds?: string[]): Pro
|
|||||||
|
|
||||||
query += ' ORDER BY e.created_at DESC';
|
query += ' ORDER BY e.created_at DESC';
|
||||||
const { rows } = await pool.query(query, params);
|
const { rows } = await pool.query(query, params);
|
||||||
return rows;
|
|
||||||
|
if (!tenantId) return rows;
|
||||||
|
|
||||||
|
const tenantData = await fetchTenantFiscalData(tenantId);
|
||||||
|
if (!tenantData) return rows;
|
||||||
|
|
||||||
|
return rows.map((r: ContribuyenteRow) => {
|
||||||
|
if (r.rfc !== tenantData.tenantRfc) return r;
|
||||||
|
if (r.regimenFiscal && r.codigoPostal && r.domicilio) return r;
|
||||||
|
return mergeContribuyenteWithTenant(r, tenantData);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getContribuyenteById(pool: Pool, id: string): Promise<ContribuyenteRow | null> {
|
export async function getContribuyenteById(pool: Pool, id: string, tenantId?: string): Promise<ContribuyenteRow | null> {
|
||||||
const { rows } = await pool.query(`
|
const { rows } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
e.id, e.tipo, e.nombre, e.identificador,
|
e.id, e.tipo, e.nombre, e.identificador,
|
||||||
@@ -60,7 +125,14 @@ export async function getContribuyenteById(pool: Pool, id: string): Promise<Cont
|
|||||||
JOIN contribuyentes c ON c.entidad_id = e.id
|
JOIN contribuyentes c ON c.entidad_id = e.id
|
||||||
WHERE e.id = $1
|
WHERE e.id = $1
|
||||||
`, [id]);
|
`, [id]);
|
||||||
return rows[0] ?? null;
|
const row = rows[0] ?? null;
|
||||||
|
if (!row || !tenantId) return row;
|
||||||
|
|
||||||
|
const tenantData = await fetchTenantFiscalData(tenantId);
|
||||||
|
if (!tenantData || row.rfc !== tenantData.tenantRfc) return row;
|
||||||
|
if (row.regimenFiscal && row.codigoPostal && row.domicilio) return row;
|
||||||
|
|
||||||
|
return mergeContribuyenteWithTenant(row, tenantData);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createContribuyente(pool: Pool, data: CreateContribuyenteData): Promise<ContribuyenteRow> {
|
export async function createContribuyente(pool: Pool, data: CreateContribuyenteData): Promise<ContribuyenteRow> {
|
||||||
|
|||||||
@@ -109,13 +109,13 @@ export const GRUPO_PM_OTROS = ['601', '603', '607', '608', '610', '611', '614',
|
|||||||
const TODOS_REGIMENES = [...GRUPO_PF_EMPRESARIAL, ...GRUPO_SUELDOS, ...GRUPO_PM_OTROS];
|
const TODOS_REGIMENES = [...GRUPO_PF_EMPRESARIAL, ...GRUPO_SUELDOS, ...GRUPO_PM_OTROS];
|
||||||
|
|
||||||
// Filtro de fecha por rango — normal o conciliación
|
// Filtro de fecha por rango — normal o conciliación
|
||||||
const FECHA_RANGO = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`;
|
const FECHA_RANGO = `COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')`;
|
||||||
// Para CFDIs tipo P (complementos de pago): el ingreso/gasto se reconoce en la
|
// Para CFDIs tipo P (complementos de pago): el ingreso/gasto se reconoce en la
|
||||||
// fecha_pago_p (cuándo el cliente realmente pagó), no cuando se emitió el
|
// fecha_pago_p (cuándo el cliente realmente pagó), no cuando se emitió el
|
||||||
// complemento — el CFDI P puede emitirse hasta el día 5 del mes siguiente al
|
// complemento — el CFDI P puede emitirse hasta el día 5 del mes siguiente al
|
||||||
// pago, o incluso después, y cruzar meses (ej. pago de noviembre 2024 con
|
// pago, o incluso después, y cruzar meses (ej. pago de noviembre 2024 con
|
||||||
// complemento emitido en mayo 2025).
|
// complemento emitido en mayo 2025).
|
||||||
const FECHA_PAGO_RANGO = `fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')`;
|
const FECHA_PAGO_RANGO = `(fecha_pago_p - interval '1 hour') >= $1::date AND (fecha_pago_p - interval '1 hour') < ($2::date + interval '1 day')`;
|
||||||
const FECHA_RANGO_CONCILIACION = `id_conciliacion IS NOT NULL AND id_conciliacion IN (
|
const FECHA_RANGO_CONCILIACION = `id_conciliacion IS NOT NULL AND id_conciliacion IN (
|
||||||
SELECT id FROM conciliaciones WHERE fecha_de_pago >= $1::date AND fecha_de_pago < ($2::date + interval '1 day')
|
SELECT id FROM conciliaciones WHERE fecha_de_pago >= $1::date AND fecha_de_pago < ($2::date + interval '1 day')
|
||||||
)`;
|
)`;
|
||||||
@@ -989,14 +989,14 @@ export async function calcularIvaBalancePorRegimen(
|
|||||||
AND e.status NOT IN ('Cancelado', '0')
|
AND e.status NOT IN ('Cancelado', '0')
|
||||||
AND ${esEmisor.replace(/\brfc_emisor\b/g, 'e.rfc_emisor')}
|
AND ${esEmisor.replace(/\brfc_emisor\b/g, 'e.rfc_emisor')}
|
||||||
AND LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
AND LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
||||||
AND date_trunc('month', e.fecha_emision) = date_trunc('month', i.fecha_emision)
|
AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour')) = date_trunc('month', COALESCE(i.fecha_efectiva, i.fecha_emision - interval '1 hour'))
|
||||||
)), 0) as monto
|
)), 0) as monto
|
||||||
FROM cfdis i
|
FROM cfdis i
|
||||||
WHERE ${esEmisor.replace(/\brfc_emisor\b/g, 'i.rfc_emisor')}
|
WHERE ${esEmisor.replace(/\brfc_emisor\b/g, 'i.rfc_emisor')}
|
||||||
AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD'
|
AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD'
|
||||||
AND COALESCE(i.cfdi_tipo_relacion, '') = '07'
|
AND COALESCE(i.cfdi_tipo_relacion, '') = '07'
|
||||||
AND i.status NOT IN ('Cancelado','0')
|
AND i.status NOT IN ('Cancelado','0')
|
||||||
AND ${FR.replace(/\bfecha_emision\b/g, 'i.fecha_emision')}
|
AND ${FR.replace(/\bfecha_efectiva\b/g, 'i.fecha_efectiva').replace(/\bfecha_emision\b/g, 'i.fecha_emision')}
|
||||||
AND i.regimen_fiscal_emisor = ANY($3)
|
AND i.regimen_fiscal_emisor = ANY($3)
|
||||||
GROUP BY i.regimen_fiscal_emisor
|
GROUP BY i.regimen_fiscal_emisor
|
||||||
`, [fechaInicio, fechaFin, TODOS_REGIMENES]);
|
`, [fechaInicio, fechaFin, TODOS_REGIMENES]);
|
||||||
@@ -1012,14 +1012,14 @@ export async function calcularIvaBalancePorRegimen(
|
|||||||
AND e.status NOT IN ('Cancelado', '0')
|
AND e.status NOT IN ('Cancelado', '0')
|
||||||
AND ${esReceptor.replace(/\brfc_receptor\b/g, 'e.rfc_receptor')}
|
AND ${esReceptor.replace(/\brfc_receptor\b/g, 'e.rfc_receptor')}
|
||||||
AND LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
AND LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
||||||
AND date_trunc('month', e.fecha_emision) = date_trunc('month', i.fecha_emision)
|
AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour')) = date_trunc('month', COALESCE(i.fecha_efectiva, i.fecha_emision - interval '1 hour'))
|
||||||
)), 0) as monto
|
)), 0) as monto
|
||||||
FROM cfdis i
|
FROM cfdis i
|
||||||
WHERE ${esReceptor.replace(/\brfc_receptor\b/g, 'i.rfc_receptor')}
|
WHERE ${esReceptor.replace(/\brfc_receptor\b/g, 'i.rfc_receptor')}
|
||||||
AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD'
|
AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD'
|
||||||
AND COALESCE(i.cfdi_tipo_relacion, '') = '07'
|
AND COALESCE(i.cfdi_tipo_relacion, '') = '07'
|
||||||
AND i.status NOT IN ('Cancelado','0')
|
AND i.status NOT IN ('Cancelado','0')
|
||||||
AND ${FR.replace(/\bfecha_emision\b/g, 'i.fecha_emision')}
|
AND ${FR.replace(/\bfecha_efectiva\b/g, 'i.fecha_efectiva').replace(/\bfecha_emision\b/g, 'i.fecha_emision')}
|
||||||
AND i.regimen_fiscal_receptor = ANY($3)
|
AND i.regimen_fiscal_receptor = ANY($3)
|
||||||
GROUP BY i.regimen_fiscal_receptor
|
GROUP BY i.regimen_fiscal_receptor
|
||||||
`, [fechaInicio, fechaFin, TODOS_REGIMENES]);
|
`, [fechaInicio, fechaFin, TODOS_REGIMENES]);
|
||||||
@@ -1107,10 +1107,21 @@ export async function getKpis(
|
|||||||
const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId);
|
const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId);
|
||||||
const esEmisor = ctx.esEmisor;
|
const esEmisor = ctx.esEmisor;
|
||||||
const esReceptor = ctx.esReceptor;
|
const esReceptor = ctx.esReceptor;
|
||||||
const ingresosData = await calcularIngresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId);
|
const [
|
||||||
const egresosData = await calcularEgresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId);
|
ingresosData,
|
||||||
const adquisicionData = await calcularAdquisicionesMercancias(pool, tenantId, fechaInicio, fechaFin, conciliacion, contribuyenteId);
|
egresosData,
|
||||||
const ivaData = await calcularIvaBalancePorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId);
|
adquisicionData,
|
||||||
|
ivaData,
|
||||||
|
ncsEmitidasData,
|
||||||
|
ncsRecibidasData,
|
||||||
|
] = await Promise.all([
|
||||||
|
calcularIngresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
|
||||||
|
calcularEgresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
|
||||||
|
calcularAdquisicionesMercancias(pool, tenantId, fechaInicio, fechaFin, conciliacion, contribuyenteId),
|
||||||
|
calcularIvaBalancePorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
|
||||||
|
calcularNcsEmitidasPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
|
||||||
|
calcularNcsRecibidasPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
|
||||||
|
]);
|
||||||
|
|
||||||
// IVA a favor año actual: desde enero del año en curso
|
// IVA a favor año actual: desde enero del año en curso
|
||||||
const ivaAFavorAcumulado = await calcularIvaAFavorAcumulado(pool, tenantId, fechaFin, undefined, conciliacion, contribuyenteId);
|
const ivaAFavorAcumulado = await calcularIvaAFavorAcumulado(pool, tenantId, fechaFin, undefined, conciliacion, contribuyenteId);
|
||||||
@@ -1163,6 +1174,10 @@ export async function getKpis(
|
|||||||
cfdisEmitidosPorRegimen: emitidosPorRegimen,
|
cfdisEmitidosPorRegimen: emitidosPorRegimen,
|
||||||
cfdisRecibidos: recibidosPorRegimen.reduce((s: number, r: any) => s + r.total, 0),
|
cfdisRecibidos: recibidosPorRegimen.reduce((s: number, r: any) => s + r.total, 0),
|
||||||
cfdisRecibidosPorRegimen: recibidosPorRegimen,
|
cfdisRecibidosPorRegimen: recibidosPorRegimen,
|
||||||
|
ncsEmitidas: ncsEmitidasData.total,
|
||||||
|
ncsEmitidasPorRegimen: ncsEmitidasData.porRegimen,
|
||||||
|
ncsRecibidas: ncsRecibidasData.total,
|
||||||
|
ncsRecibidasPorRegimen: ncsRecibidasData.porRegimen,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -9,7 +43,8 @@ const IMPUESTO_A_OBLIGACION_KEYWORDS: Record<string, { include: string[]; exclud
|
|||||||
IVA: { include: ['iva'], exclude: ['diot', 'proveedores de iva', 'informativa'] },
|
IVA: { include: ['iva'], exclude: ['diot', 'proveedores de iva', 'informativa'] },
|
||||||
ISR: { include: ['isr'], exclude: ['retenciones', 'asimilados a salarios'] },
|
ISR: { include: ['isr'], exclude: ['retenciones', 'asimilados a salarios'] },
|
||||||
IEPS: { include: ['ieps'], exclude: [] },
|
IEPS: { include: ['ieps'], exclude: [] },
|
||||||
SUELDOS: { include: ['sueldos', 'salarios', 'nómina'], exclude: [] },
|
ISN: { include: ['isn', 'sueldos', 'salarios', 'nómina'], exclude: [] },
|
||||||
|
ISH: { include: [], exclude: [] },
|
||||||
DIOT: { include: ['diot', 'proveedores de iva'], exclude: [] },
|
DIOT: { include: ['diot', 'proveedores de iva'], exclude: [] },
|
||||||
OTRO: { include: [], exclude: [] },
|
OTRO: { include: [], exclude: [] },
|
||||||
};
|
};
|
||||||
@@ -24,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`,
|
||||||
@@ -42,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];
|
||||||
@@ -54,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 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,9 +216,9 @@ async function completarObligacionesPorDeclaracion(
|
|||||||
* adicional, no reemplaza.
|
* adicional, no reemplaza.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type Impuesto = 'IVA' | 'ISR' | 'IEPS' | 'SUELDOS' | 'DIOT' | 'OTRO';
|
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;
|
||||||
@@ -123,17 +246,19 @@ const IMPUESTO_A_PREFIJO_DECL: Record<string, string[]> = {
|
|||||||
IVA: ['decl-iva'],
|
IVA: ['decl-iva'],
|
||||||
ISR: ['decl-isr'],
|
ISR: ['decl-isr'],
|
||||||
IEPS: ['decl-ieps'],
|
IEPS: ['decl-ieps'],
|
||||||
SUELDOS: ['decl-sueldos'],
|
ISN: ['decl-isn'],
|
||||||
DIOT: ['diot'],
|
DIOT: ['diot'],
|
||||||
OTRO: [],
|
OTRO: [],
|
||||||
|
ISH: [],
|
||||||
};
|
};
|
||||||
const IMPUESTO_A_PREFIJO_PAGO: Record<string, string[]> = {
|
const IMPUESTO_A_PREFIJO_PAGO: Record<string, string[]> = {
|
||||||
IVA: ['pago-iva'],
|
IVA: ['pago-iva'],
|
||||||
ISR: ['pago-isr'],
|
ISR: ['pago-isr'],
|
||||||
IEPS: ['pago-ieps'],
|
IEPS: ['pago-ieps'],
|
||||||
SUELDOS: [], // sueldos solo es declaración informativa, no tiene pago provisional
|
ISN: [], // ISN solo es declaración informativa, no tiene pago provisional
|
||||||
DIOT: [],
|
DIOT: [],
|
||||||
OTRO: [],
|
OTRO: [],
|
||||||
|
ISH: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -229,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;
|
||||||
@@ -250,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
|
||||||
@@ -259,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-*`
|
|
||||||
// visibles hasta que realmente se cierre el ciclo.
|
|
||||||
const cubrePago = data.tipo === 'complementaria' || montoPago === 0;
|
|
||||||
if (data.contribuyenteId && cubrePago) {
|
|
||||||
if (!data.creadoPorUserId) {
|
|
||||||
console.warn('[createDeclaracion] Sin creadoPorUserId — no se auto-completan obligaciones del contribuyente');
|
|
||||||
} else {
|
|
||||||
const periodo = `${data.año}-${String(data.mes).padStart(2, '0')}`;
|
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,
|
if (obligacionesSeleccionadas.length > 0) {
|
||||||
|
await registrarEvidenciasPorObligaciones(
|
||||||
|
pool, obligacionesSeleccionadas, data.contribuyenteId, periodo, data.creadoPorUserId,
|
||||||
|
data.pdfBase64, data.pdfFilename, 'declaracion', data.notas,
|
||||||
);
|
);
|
||||||
|
} 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,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,5 +1,6 @@
|
|||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
import { prisma } from '../config/database.js';
|
import { prisma } from '../config/database.js';
|
||||||
|
import { materializarPeriodos } from './tareas.service.js';
|
||||||
|
|
||||||
export interface ContribuyentesStats {
|
export interface ContribuyentesStats {
|
||||||
totalContribuyentes: number;
|
totalContribuyentes: number;
|
||||||
@@ -210,26 +211,58 @@ export async function getMisAsignados(
|
|||||||
const inicioMes = `${_año}-${String(_mes).padStart(2, '0')}-01`;
|
const inicioMes = `${_año}-${String(_mes).padStart(2, '0')}-01`;
|
||||||
const finMes = new Date(_año, _mes, 0).toISOString().split('T')[0];
|
const finMes = new Date(_año, _mes, 0).toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Materializar periodos de tareas antes de contar (evita que tareas sin
|
||||||
|
// registro en tarea_periodos aparezcan como 0).
|
||||||
|
await Promise.all(ids.map(id => materializarPeriodos(pool, id).catch(() => {})));
|
||||||
|
|
||||||
const { rows: stats } = await pool.query(
|
const { rows: stats } = await pool.query(
|
||||||
`WITH obl AS (
|
`WITH obligaciones_activas AS (
|
||||||
SELECT oc.contribuyente_id,
|
SELECT id, contribuyente_id FROM obligaciones_contribuyente
|
||||||
COUNT(*) FILTER (WHERE op.completada = false AND op.periodo = $1)::int AS pendientes,
|
WHERE contribuyente_id = ANY($4::uuid[]) AND activa = true
|
||||||
COUNT(*) FILTER (WHERE op.completada = false AND op.periodo < $1)::int AS atrasadas,
|
),
|
||||||
COUNT(*) FILTER (WHERE op.completada = true AND op.periodo = $1)::int AS completadas
|
op_actual AS (
|
||||||
FROM obligaciones_contribuyente oc
|
SELECT obligacion_id, completada FROM obligacion_periodos
|
||||||
LEFT JOIN obligacion_periodos op ON op.obligacion_id = oc.id
|
WHERE obligacion_id IN (SELECT id FROM obligaciones_activas) AND periodo = $1
|
||||||
WHERE oc.contribuyente_id = ANY($4::uuid[]) AND oc.activa = true
|
),
|
||||||
GROUP BY oc.contribuyente_id
|
op_atrasadas AS (
|
||||||
|
SELECT obligacion_id, COUNT(*) as atrasadas FROM obligacion_periodos
|
||||||
|
WHERE obligacion_id IN (SELECT id FROM obligaciones_activas) AND periodo < $1 AND completada = false
|
||||||
|
GROUP BY obligacion_id
|
||||||
|
),
|
||||||
|
obl AS (
|
||||||
|
SELECT oa.contribuyente_id,
|
||||||
|
COUNT(*) FILTER (WHERE op_a.completada IS NULL OR op_a.completada = false)::int AS pendientes,
|
||||||
|
COALESCE(SUM(op_atr.atrasadas), 0)::int AS atrasadas,
|
||||||
|
COUNT(*) FILTER (WHERE op_a.completada = true)::int AS completadas
|
||||||
|
FROM obligaciones_activas oa
|
||||||
|
LEFT JOIN op_actual op_a ON op_a.obligacion_id = oa.id
|
||||||
|
LEFT JOIN op_atrasadas op_atr ON op_atr.obligacion_id = oa.id
|
||||||
|
GROUP BY oa.contribuyente_id
|
||||||
|
),
|
||||||
|
tareas_activas AS (
|
||||||
|
SELECT id, contribuyente_id FROM tareas_catalogo
|
||||||
|
WHERE contribuyente_id = ANY($4::uuid[]) AND active = true
|
||||||
|
),
|
||||||
|
tar_actual AS (
|
||||||
|
SELECT tarea_id, completada FROM tarea_periodos
|
||||||
|
WHERE tarea_id IN (SELECT id FROM tareas_activas)
|
||||||
|
AND fecha_limite BETWEEN $2::date AND $3::date
|
||||||
|
),
|
||||||
|
tar_atrasadas AS (
|
||||||
|
SELECT tarea_id, COUNT(*) as atrasadas FROM tarea_periodos
|
||||||
|
WHERE tarea_id IN (SELECT id FROM tareas_activas)
|
||||||
|
AND fecha_limite < $2::date AND completada = false
|
||||||
|
GROUP BY tarea_id
|
||||||
),
|
),
|
||||||
tar AS (
|
tar AS (
|
||||||
SELECT tc.contribuyente_id,
|
SELECT ta.contribuyente_id,
|
||||||
COUNT(*) FILTER (WHERE tp.completada = false AND tp.fecha_limite BETWEEN $2::date AND $3::date)::int AS pendientes,
|
COUNT(*) FILTER (WHERE tar_a.completada IS NULL OR tar_a.completada = false)::int AS pendientes,
|
||||||
COUNT(*) FILTER (WHERE tp.completada = false AND tp.fecha_limite < $2::date)::int AS atrasadas,
|
COALESCE(SUM(tar_atr.atrasadas), 0)::int AS atrasadas,
|
||||||
COUNT(*) FILTER (WHERE tp.completada = true AND tp.fecha_limite BETWEEN $2::date AND $3::date)::int AS completadas
|
COUNT(*) FILTER (WHERE tar_a.completada = true)::int AS completadas
|
||||||
FROM tareas_catalogo tc
|
FROM tareas_activas ta
|
||||||
LEFT JOIN tarea_periodos tp ON tp.tarea_id = tc.id
|
LEFT JOIN tar_actual tar_a ON tar_a.tarea_id = ta.id
|
||||||
WHERE tc.contribuyente_id = ANY($4::uuid[]) AND tc.active = true
|
LEFT JOIN tar_atrasadas tar_atr ON tar_atr.tarea_id = ta.id
|
||||||
GROUP BY tc.contribuyente_id
|
GROUP BY ta.contribuyente_id
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
obl.contribuyente_id AS obl_id, obl.pendientes AS obl_pen, obl.atrasadas AS obl_atr, obl.completadas AS obl_com,
|
obl.contribuyente_id AS obl_id, obl.pendientes AS obl_pen, obl.atrasadas AS obl_atr, obl.completadas AS obl_com,
|
||||||
|
|||||||
@@ -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,12 +13,12 @@ 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 = {
|
||||||
sendWelcome: async (to: string, data: { nombre: string; email: string; tempPassword: string }) => {
|
sendWelcome: async (to: string, data: { nombre: string; email: string; tempPassword: string | null | undefined }) => {
|
||||||
const { welcomeEmail } = await import('./templates/welcome.js');
|
const { welcomeEmail } = await import('./templates/welcome.js');
|
||||||
await sendEmail(to, 'Bienvenido a Horux360', welcomeEmail(data));
|
await sendEmail(to, 'Bienvenido a Horux360', welcomeEmail(data));
|
||||||
},
|
},
|
||||||
@@ -60,7 +60,7 @@ export const emailService = {
|
|||||||
clienteRfc: string;
|
clienteRfc: string;
|
||||||
adminEmail: string;
|
adminEmail: string;
|
||||||
adminNombre: string;
|
adminNombre: string;
|
||||||
tempPassword: string;
|
tempPassword: string | null | undefined;
|
||||||
databaseName: string;
|
databaseName: string;
|
||||||
plan: string;
|
plan: string;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -128,10 +128,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 +147,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);
|
||||||
}
|
}
|
||||||
@@ -193,6 +197,19 @@ export const emailService = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Clientes reciben aviso cuando se sube papelería que requiere su aprobación. */
|
||||||
|
sendPapeleriaAprobacionClienteRequerida: async (
|
||||||
|
to: string,
|
||||||
|
data: import('./templates/papeleria.js').PapeleriaAprobacionClienteRequeridaData,
|
||||||
|
) => {
|
||||||
|
const { papeleriaAprobacionClienteRequeridaEmail } = await import('./templates/papeleria.js');
|
||||||
|
await sendEmail(
|
||||||
|
to,
|
||||||
|
`📋 Documento pendiente de tu aprobación — ${data.contribuyenteRfc}`,
|
||||||
|
papeleriaAprobacionClienteRequeridaEmail(data),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cron 8:30 AM — alertas fiscales nuevas activadas hoy. Envía un solo
|
* Cron 8:30 AM — alertas fiscales nuevas activadas hoy. Envía un solo
|
||||||
* correo por destinatario con el batch completo. Caller debe deduplicar
|
* correo por destinatario con el batch completo. Caller debe deduplicar
|
||||||
|
|||||||
@@ -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,17 +24,30 @@ 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'
|
||||||
|
: data.kind === 'obligacion_evidencia'
|
||||||
|
? 'Nueva evidencia de obligación fiscal'
|
||||||
: 'Nuevo documento subido';
|
: 'Nuevo documento subido';
|
||||||
|
|
||||||
const contenidoEspecifico = data.kind === 'declaracion' && data.declaracion
|
const contenidoEspecifico = data.kind === 'declaracion' && data.declaracion
|
||||||
? declaracionBlock(data.declaracion)
|
? declaracionBlock(data.declaracion)
|
||||||
|
: data.kind === 'obligacion_evidencia' && data.evidencia
|
||||||
|
? evidenciaBlock(data.evidencia)
|
||||||
: data.extra
|
: data.extra
|
||||||
? extraBlock(data.extra)
|
? extraBlock(data.extra)
|
||||||
: '';
|
: '';
|
||||||
@@ -42,7 +55,7 @@ export function documentoSubidoEmail(data: DocumentoSubidoData): string {
|
|||||||
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>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function newClientAdminEmail(data: {
|
|||||||
clienteRfc: string;
|
clienteRfc: string;
|
||||||
adminEmail: string;
|
adminEmail: string;
|
||||||
adminNombre: string;
|
adminNombre: string;
|
||||||
tempPassword: string;
|
tempPassword: string | null | undefined;
|
||||||
databaseName: string;
|
databaseName: string;
|
||||||
plan: string;
|
plan: string;
|
||||||
}): string {
|
}): string {
|
||||||
@@ -46,7 +46,7 @@ export function newClientAdminEmail(data: {
|
|||||||
${sectionHeader('Credenciales del usuario', C.secondary)}
|
${sectionHeader('Credenciales del usuario', C.secondary)}
|
||||||
${row('Nombre', escapeHtml(data.adminNombre))}
|
${row('Nombre', escapeHtml(data.adminNombre))}
|
||||||
${row('Email', `<span style="font-family:monospace;">${escapeHtml(data.adminEmail)}</span>`)}
|
${row('Email', `<span style="font-family:monospace;">${escapeHtml(data.adminEmail)}</span>`)}
|
||||||
${row('Contraseña temporal', `<code style="background-color:${C.bgLight};padding:4px 10px;border-radius:6px;font-size:13px;color:#dc2626;border:1px solid ${C.border};">${escapeHtml(data.tempPassword)}</code>`, true)}
|
${row('Contraseña temporal', `<code style="background-color:${C.bgLight};padding:4px 10px;border-radius:6px;font-size:13px;color:#dc2626;border:1px solid ${C.border};">${escapeHtml(data.tempPassword || 'N/A - usuario ya existía')}</code>`, true)}
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div style="background-color:#fef2f2;border-left:4px solid #ef4444;border-radius:8px;padding:12px 16px;margin:0 0 16px;">
|
<div style="background-color:#fef2f2;border-left:4px solid #ef4444;border-radius:8px;padding:12px 16px;margin:0 0 16px;">
|
||||||
|
|||||||
@@ -55,3 +55,32 @@ export function papeleriaDecisionEmail(d: PapeleriaDecisionData): string {
|
|||||||
`;
|
`;
|
||||||
return baseTemplate(body);
|
return baseTemplate(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PapeleriaAprobacionClienteRequeridaData {
|
||||||
|
contribuyenteRfc: string;
|
||||||
|
contribuyenteNombre: string;
|
||||||
|
despachoNombre?: string;
|
||||||
|
nombreDocumento: string;
|
||||||
|
descripcion: string | null;
|
||||||
|
periodo: string;
|
||||||
|
subidoPor: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function papeleriaAprobacionClienteRequeridaEmail(d: PapeleriaAprobacionClienteRequeridaData): string {
|
||||||
|
const body = `
|
||||||
|
${heading('Documento pendiente de tu aprobación')}
|
||||||
|
<p>${d.subidoPor} subió un documento que requiere tu aprobación como cliente:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Documento:</strong> ${d.nombreDocumento}</li>
|
||||||
|
<li><strong>Contribuyente:</strong> ${d.contribuyenteNombre} (${d.contribuyenteRfc})</li>
|
||||||
|
<li><strong>Periodo:</strong> ${d.periodo}</li>
|
||||||
|
${d.descripcion ? `<li><strong>Descripción:</strong> ${d.descripcion}</li>` : ''}
|
||||||
|
</ul>
|
||||||
|
${infoBox('Revisa el documento y márcalo como aprobado o rechazado desde la sección de Documentos del despacho.')}
|
||||||
|
<div style="margin-top: 24px;">
|
||||||
|
${primaryButton('Ver documento', d.link)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return baseTemplate(body);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { baseTemplate, heading, primaryButton, infoBox, BRAND_COLORS as C } from './base.js';
|
import { baseTemplate, heading, primaryButton, infoBox, BRAND_COLORS as C } from './base.js';
|
||||||
|
|
||||||
export function welcomeEmail(data: { nombre: string; email: string; tempPassword: string }): string {
|
export function welcomeEmail(data: { nombre: string; email: string; tempPassword: string | null | undefined }): string {
|
||||||
return baseTemplate(`
|
return baseTemplate(`
|
||||||
${heading('Bienvenido a Horux 360')}
|
${heading('Bienvenido a Horux 360')}
|
||||||
<p style="color:${C.textPrimary};margin:0 0 16px;">Hola <strong>${data.nombre}</strong>,</p>
|
<p style="color:${C.textPrimary};margin:0 0 16px;">Hola <strong>${data.nombre}</strong>,</p>
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ export async function exportCfdisToExcel(
|
|||||||
params.push(filters.estado);
|
params.push(filters.estado);
|
||||||
}
|
}
|
||||||
if (filters.fechaInicio) {
|
if (filters.fechaInicio) {
|
||||||
whereClause += ` AND fecha_emision >= $${paramIndex++}`;
|
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $${paramIndex++}`;
|
||||||
params.push(filters.fechaInicio);
|
params.push(filters.fechaInicio);
|
||||||
}
|
}
|
||||||
if (filters.fechaFin) {
|
if (filters.fechaFin) {
|
||||||
whereClause += ` AND fecha_emision <= $${paramIndex++}`;
|
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= $${paramIndex++}`;
|
||||||
params.push(filters.fechaFin);
|
params.push(filters.fechaFin);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ export async function exportCfdisToExcel(
|
|||||||
cfdis.forEach((cfdi: any) => {
|
cfdis.forEach((cfdi: any) => {
|
||||||
sheet.addRow({
|
sheet.addRow({
|
||||||
...cfdi,
|
...cfdi,
|
||||||
fecha_emision: new Date(cfdi.fecha_emision).toLocaleDateString('es-MX'),
|
fecha_emision: new Date(new Date(cfdi.fecha_emision).getTime() - 60*60*1000).toLocaleDateString('es-MX'),
|
||||||
subtotal: Number(cfdi.subtotal),
|
subtotal: Number(cfdi.subtotal),
|
||||||
subtotal_mxn: Number(cfdi.subtotal_mxn),
|
subtotal_mxn: Number(cfdi.subtotal_mxn),
|
||||||
iva_traslado: Number(cfdi.iva_traslado),
|
iva_traslado: Number(cfdi.iva_traslado),
|
||||||
@@ -103,7 +103,7 @@ export async function exportReporteToExcel(
|
|||||||
COALESCE(SUM(CASE WHEN type = 'EMITIDO' AND tipo_comprobante = 'I' THEN subtotal_mxn ELSE 0 END), 0) as ingresos,
|
COALESCE(SUM(CASE WHEN type = 'EMITIDO' AND tipo_comprobante = 'I' THEN subtotal_mxn ELSE 0 END), 0) as ingresos,
|
||||||
COALESCE(SUM(CASE WHEN type = 'RECIBIDO' AND tipo_comprobante = 'I' THEN subtotal_mxn ELSE 0 END), 0) as egresos
|
COALESCE(SUM(CASE WHEN type = 'RECIBIDO' AND tipo_comprobante = 'I' THEN subtotal_mxn ELSE 0 END), 0) as egresos
|
||||||
FROM cfdis
|
FROM cfdis
|
||||||
WHERE status NOT IN ('Cancelado', '0') AND fecha_emision BETWEEN $1 AND $2
|
WHERE status NOT IN ('Cancelado', '0') AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') BETWEEN $1 AND $2
|
||||||
`, [fechaInicio, fechaFin]);
|
`, [fechaInicio, fechaFin]);
|
||||||
|
|
||||||
sheet.columns = [
|
sheet.columns = [
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ export async function createInvoice(
|
|||||||
unit_key: item.unitKey || 'E48',
|
unit_key: item.unitKey || 'E48',
|
||||||
unit_name: item.unitName || 'Servicio',
|
unit_name: item.unitName || 'Servicio',
|
||||||
price: item.price,
|
price: item.price,
|
||||||
tax_included: item.taxIncluded ?? true,
|
tax_included: item.taxIncluded ?? false,
|
||||||
taxes: item.taxes?.map(t => ({
|
taxes: item.taxes?.map(t => ({
|
||||||
type: t.type,
|
type: t.type,
|
||||||
rate: t.rate,
|
rate: t.rate,
|
||||||
@@ -323,6 +323,7 @@ export async function createInvoice(
|
|||||||
...(t.withholding ? { withholding: true } : {}),
|
...(t.withholding ? { withholding: true } : {}),
|
||||||
})) || [{ type: 'IVA', rate: 0.16 }],
|
})) || [{ type: 'IVA', rate: 0.16 }],
|
||||||
},
|
},
|
||||||
|
...((data as any).cuentaPredial ? { property_tax_account: (data as any).cuentaPredial } : {}),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +341,7 @@ export async function createInvoice(
|
|||||||
|
|
||||||
if (data.series) invoiceData.series = data.series;
|
if (data.series) invoiceData.series = data.series;
|
||||||
if (data.folioNumber) invoiceData.folio_number = data.folioNumber;
|
if (data.folioNumber) invoiceData.folio_number = data.folioNumber;
|
||||||
|
if ((data as any).fechaEmision) invoiceData.date = (data as any).fechaEmision;
|
||||||
|
|
||||||
// Documentos relacionados (Ingreso / Egreso / Pago / Traslado).
|
// Documentos relacionados (Ingreso / Egreso / Pago / Traslado).
|
||||||
if (data.relatedDocuments?.length) {
|
if (data.relatedDocuments?.length) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Pool } from 'pg';
|
import type { Pool, PoolClient } from 'pg';
|
||||||
import type { IvaMensual, IsrMensual, ResumenIva, IvaRegimenDetalle, ResumenIsr } from '@horux/shared';
|
import type { IvaMensual, IsrMensual, ResumenIva, IvaRegimenDetalle, ResumenIsr } from '@horux/shared';
|
||||||
import { getRegimenesIgnoradosClaves } from './regimen.service.js';
|
import { getRegimenesIgnoradosClaves } from './regimen.service.js';
|
||||||
import {
|
import {
|
||||||
@@ -22,7 +22,7 @@ const VIGENTE = `status NOT IN ('Cancelado', '0')`;
|
|||||||
// - otros tipos (I, E, T, N): fecha_emision del comprobante
|
// - otros tipos (I, E, T, N): fecha_emision del comprobante
|
||||||
// El CASE se evalúa por fila, garantizando que un P emitido en mayo por un pago
|
// El CASE se evalúa por fila, garantizando que un P emitido en mayo por un pago
|
||||||
// real de noviembre quede contabilizado en noviembre.
|
// real de noviembre quede contabilizado en noviembre.
|
||||||
const FECHA_EFECTIVA = `CASE WHEN tipo_comprobante = 'P' THEN fecha_pago_p ELSE fecha_emision END`;
|
const FECHA_EFECTIVA = `CASE WHEN tipo_comprobante = 'P' THEN (fecha_pago_p - interval '1 hour') ELSE COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') END`;
|
||||||
const FECHA_RANGO = `${FECHA_EFECTIVA} >= $1::date AND ${FECHA_EFECTIVA} < ($2::date + interval '1 day')`;
|
const FECHA_RANGO = `${FECHA_EFECTIVA} >= $1::date AND ${FECHA_EFECTIVA} < ($2::date + interval '1 day')`;
|
||||||
const FECHA_RANGO_CONCILIACION = `id_conciliacion IS NOT NULL AND id_conciliacion IN (
|
const FECHA_RANGO_CONCILIACION = `id_conciliacion IS NOT NULL AND id_conciliacion IN (
|
||||||
SELECT id FROM conciliaciones WHERE fecha_de_pago >= $1::date AND fecha_de_pago < ($2::date + interval '1 day')
|
SELECT id FROM conciliaciones WHERE fecha_de_pago >= $1::date AND fecha_de_pago < ($2::date + interval '1 day')
|
||||||
@@ -106,32 +106,40 @@ const SUM_E_REFERENCING_TRAS = (
|
|||||||
esLadoE: string,
|
esLadoE: string,
|
||||||
considerarActivos: boolean,
|
considerarActivos: boolean,
|
||||||
considerarNCs: boolean,
|
considerarNCs: boolean,
|
||||||
) => `COALESCE((
|
) => {
|
||||||
|
if (!considerarNCs) return '0';
|
||||||
|
return `COALESCE((
|
||||||
SELECT SUM(${IVA_TRAS_EXPR_ALIAS('e')})
|
SELECT SUM(${IVA_TRAS_EXPR_ALIAS('e')})
|
||||||
FROM cfdis e
|
FROM cfdis e
|
||||||
WHERE e.tipo_comprobante = 'E'
|
WHERE e.tipo_comprobante = 'E'
|
||||||
AND e.metodo_pago = 'PUE'
|
AND e.metodo_pago = 'PUE'
|
||||||
AND e.status NOT IN ('Cancelado', '0')
|
AND e.status NOT IN ('Cancelado', '0')
|
||||||
AND ${esLadoE}
|
AND ${esLadoE}
|
||||||
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
AND e.cfdis_relacionados IS NOT NULL
|
||||||
AND date_trunc('month', e.fecha_emision)
|
AND string_to_array(LOWER(e.cfdis_relacionados), '|') @> ARRAY[LOWER(cfdis.uuid)]
|
||||||
= date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
|
AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour'))
|
||||||
|
= date_trunc('month', COALESCE(cfdis.fecha_efectiva, cfdis.fecha_emision - interval '1 hour'))${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
|
||||||
), 0)`;
|
), 0)`;
|
||||||
|
};
|
||||||
const SUM_E_REFERENCING_RET = (
|
const SUM_E_REFERENCING_RET = (
|
||||||
esLadoE: string,
|
esLadoE: string,
|
||||||
considerarActivos: boolean,
|
considerarActivos: boolean,
|
||||||
considerarNCs: boolean,
|
considerarNCs: boolean,
|
||||||
) => `COALESCE((
|
) => {
|
||||||
|
if (!considerarNCs) return '0';
|
||||||
|
return `COALESCE((
|
||||||
SELECT SUM(${IVA_RET_EXPR_ALIAS('e')})
|
SELECT SUM(${IVA_RET_EXPR_ALIAS('e')})
|
||||||
FROM cfdis e
|
FROM cfdis e
|
||||||
WHERE e.tipo_comprobante = 'E'
|
WHERE e.tipo_comprobante = 'E'
|
||||||
AND e.metodo_pago = 'PUE'
|
AND e.metodo_pago = 'PUE'
|
||||||
AND e.status NOT IN ('Cancelado', '0')
|
AND e.status NOT IN ('Cancelado', '0')
|
||||||
AND ${esLadoE}
|
AND ${esLadoE}
|
||||||
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
AND e.cfdis_relacionados IS NOT NULL
|
||||||
AND date_trunc('month', e.fecha_emision)
|
AND string_to_array(LOWER(e.cfdis_relacionados), '|') @> ARRAY[LOWER(cfdis.uuid)]
|
||||||
= date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
|
AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour'))
|
||||||
|
= date_trunc('month', COALESCE(cfdis.fecha_efectiva, cfdis.fecha_emision - interval '1 hour'))${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
|
||||||
), 0)`;
|
), 0)`;
|
||||||
|
};
|
||||||
// Régimen del contribuyente según su lado: emisor/receptor del CFDI.
|
// Régimen del contribuyente según su lado: emisor/receptor del CFDI.
|
||||||
// Usa el RFC del contribuyente (via `ctx.esEmisor`/`ctx.esReceptor`) para
|
// Usa el RFC del contribuyente (via `ctx.esEmisor`/`ctx.esReceptor`) para
|
||||||
// determinar el lado, no el `type` de BD.
|
// determinar el lado, no el `type` de BD.
|
||||||
@@ -152,16 +160,20 @@ const HAS_E_REFERENCING_MISMO_MES = (
|
|||||||
esLadoE: string,
|
esLadoE: string,
|
||||||
considerarActivos: boolean,
|
considerarActivos: boolean,
|
||||||
considerarNCs: boolean,
|
considerarNCs: boolean,
|
||||||
) => `EXISTS (
|
) => {
|
||||||
|
if (!considerarNCs) return 'FALSE';
|
||||||
|
return `EXISTS (
|
||||||
SELECT 1 FROM cfdis e
|
SELECT 1 FROM cfdis e
|
||||||
WHERE e.tipo_comprobante = 'E'
|
WHERE e.tipo_comprobante = 'E'
|
||||||
AND e.metodo_pago = 'PUE'
|
AND e.metodo_pago = 'PUE'
|
||||||
AND e.status NOT IN ('Cancelado', '0')
|
AND e.status NOT IN ('Cancelado', '0')
|
||||||
AND ${esLadoE}
|
AND ${esLadoE}
|
||||||
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
AND e.cfdis_relacionados IS NOT NULL
|
||||||
|
AND string_to_array(LOWER(e.cfdis_relacionados), '|') @> ARRAY[LOWER(cfdis.uuid)]
|
||||||
AND date_trunc('month', e.fecha_emision)
|
AND date_trunc('month', e.fecha_emision)
|
||||||
= date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
|
= date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
|
||||||
)`;
|
)`;
|
||||||
|
};
|
||||||
|
|
||||||
// Atribución por lado usando RFC en lugar de `type`. Los buckets son
|
// Atribución por lado usando RFC en lugar de `type`. Los buckets son
|
||||||
// factories que reciben el context del contribuyente:
|
// factories que reciben el context del contribuyente:
|
||||||
@@ -397,8 +409,8 @@ export async function getIvaMensual(
|
|||||||
const añoEnd = `${año}-12-31`;
|
const añoEnd = `${año}-12-31`;
|
||||||
const extra = buildExtraFilters(considerarActivos, considerarNCs);
|
const extra = buildExtraFilters(considerarActivos, considerarNCs);
|
||||||
|
|
||||||
const [{ rows: causadoRows }, { rows: acreditableRows }] = await Promise.all([
|
const { rows: causadoRows } = await withJitOff(pool, (client) =>
|
||||||
pool.query<{ mes: number; trasladado: string; retencion: string }>(`
|
client.query<{ mes: number; trasladado: string; retencion: string }>(`
|
||||||
SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes,
|
SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes,
|
||||||
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
|
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
|
||||||
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
|
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
|
||||||
@@ -407,8 +419,10 @@ export async function getIvaMensual(
|
|||||||
AND ${VIGENTE} AND ${FR}${extra}
|
AND ${VIGENTE} AND ${FR}${extra}
|
||||||
AND (${REGIMEN_TENANT}) = ANY($3)
|
AND (${REGIMEN_TENANT}) = ANY($3)
|
||||||
GROUP BY mes
|
GROUP BY mes
|
||||||
`, [añoStart, añoEnd, TODOS_REGIMENES]),
|
`, [añoStart, añoEnd, TODOS_REGIMENES])
|
||||||
pool.query<{ mes: number; trasladado: string; retencion: string }>(`
|
);
|
||||||
|
const { rows: acreditableRows } = await withJitOff(pool, (client) =>
|
||||||
|
client.query<{ mes: number; trasladado: string; retencion: string }>(`
|
||||||
SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes,
|
SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes,
|
||||||
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
|
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
|
||||||
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
|
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
|
||||||
@@ -417,8 +431,8 @@ export async function getIvaMensual(
|
|||||||
AND ${VIGENTE} AND ${FR}${extra}
|
AND ${VIGENTE} AND ${FR}${extra}
|
||||||
AND (${REGIMEN_TENANT}) = ANY($3)
|
AND (${REGIMEN_TENANT}) = ANY($3)
|
||||||
GROUP BY mes
|
GROUP BY mes
|
||||||
`, [añoStart, añoEnd, TODOS_REGIMENES]),
|
`, [añoStart, añoEnd, TODOS_REGIMENES])
|
||||||
]);
|
);
|
||||||
|
|
||||||
perMes = new Map();
|
perMes = new Map();
|
||||||
for (const row of causadoRows) {
|
for (const row of causadoRows) {
|
||||||
@@ -648,7 +662,8 @@ async function readResumenIvaFromCache(
|
|||||||
const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear();
|
const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear();
|
||||||
const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO;
|
const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO;
|
||||||
const REGIMEN_TENANT = regimenTenantExpr(ctx);
|
const REGIMEN_TENANT = regimenTenantExpr(ctx);
|
||||||
const acumRow = (await pool.query(`
|
const acumRow = (await withJitOff(pool, (client) =>
|
||||||
|
client.query(`
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) -
|
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) -
|
||||||
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) -
|
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) -
|
||||||
@@ -661,7 +676,8 @@ async function readResumenIvaFromCache(
|
|||||||
AND (${REGIMEN_TENANT}) = ANY($3)
|
AND (${REGIMEN_TENANT}) = ANY($3)
|
||||||
AND ${acumFR}
|
AND ${acumFR}
|
||||||
AND (${ctx.esEmisor} OR ${ctx.esReceptor})
|
AND (${ctx.esEmisor} OR ${ctx.esReceptor})
|
||||||
`, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES])).rows[0];
|
`, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES])
|
||||||
|
)).rows[0];
|
||||||
|
|
||||||
// Cache hit retorna 0/empty para los surface IVA No Acreditable. El cache
|
// Cache hit retorna 0/empty para los surface IVA No Acreditable. El cache
|
||||||
// aún no persiste esos campos — si se hace crítico para BI, agregar columna
|
// aún no persiste esos campos — si se hace crítico para BI, agregar columna
|
||||||
@@ -698,6 +714,29 @@ async function readResumenIvaFromCache(
|
|||||||
*
|
*
|
||||||
* Algebraicamente: T − A − R == dashboard.balance, céntimo por céntimo.
|
* Algebraicamente: T − A − R == dashboard.balance, céntimo por céntimo.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Ejecuta un callback con un client de pool con JIT desactivado (SET LOCAL jit = off).
|
||||||
|
* Usa una transacción implícita para que el SET LOCAL se restaure automáticamente
|
||||||
|
* al liberar la conexión. Esto evita que PostgreSQL compile JIT para queries con
|
||||||
|
* muchos subplans (correlacionados), lo cual puede tardar >15s en queries con
|
||||||
|
* costo estimado muy alto aunque la ejecución real sea rápida.
|
||||||
|
*/
|
||||||
|
async function withJitOff<T>(pool: Pool, fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
await client.query('SET LOCAL jit = off');
|
||||||
|
const result = await fn(client);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK').catch(() => {});
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getResumenIva(
|
export async function getResumenIva(
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
fechaInicio: string,
|
fechaInicio: string,
|
||||||
@@ -725,10 +764,10 @@ export async function getResumenIva(
|
|||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Una query por lado (causado / acreditable). Filtro por RFC via
|
// Queries con JIT off: evitan compilación JIT de >15s en queries con muchos
|
||||||
// ctx.esEmisor/esReceptor (embedded en buckets/signed exprs).
|
// subplans correlacionados (activado por costo estimado >100k).
|
||||||
const [{ rows: causadoRows }, { rows: acreditableRows }] = await Promise.all([
|
const { rows: causadoRows } = await withJitOff(pool, (client) =>
|
||||||
pool.query<{ regimen: string | null; trasladado: string; retencion: string }>(`
|
client.query<{ regimen: string | null; trasladado: string; retencion: string }>(`
|
||||||
SELECT ${REGIMEN_TENANT} as regimen,
|
SELECT ${REGIMEN_TENANT} as regimen,
|
||||||
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
|
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
|
||||||
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
|
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
|
||||||
@@ -737,8 +776,10 @@ export async function getResumenIva(
|
|||||||
AND ${VIGENTE} AND ${FR}${extra}
|
AND ${VIGENTE} AND ${FR}${extra}
|
||||||
AND (${REGIMEN_TENANT}) = ANY($3)
|
AND (${REGIMEN_TENANT}) = ANY($3)
|
||||||
GROUP BY ${REGIMEN_TENANT}
|
GROUP BY ${REGIMEN_TENANT}
|
||||||
`, [fechaInicio, fechaFin, TODOS_REGIMENES]),
|
`, [fechaInicio, fechaFin, TODOS_REGIMENES])
|
||||||
pool.query<{ regimen: string | null; trasladado: string; retencion: string }>(`
|
);
|
||||||
|
const { rows: acreditableRows } = await withJitOff(pool, (client) =>
|
||||||
|
client.query<{ regimen: string | null; trasladado: string; retencion: string }>(`
|
||||||
SELECT ${REGIMEN_TENANT} as regimen,
|
SELECT ${REGIMEN_TENANT} as regimen,
|
||||||
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
|
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
|
||||||
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
|
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
|
||||||
@@ -747,8 +788,8 @@ export async function getResumenIva(
|
|||||||
AND ${VIGENTE} AND ${FR}${extra}
|
AND ${VIGENTE} AND ${FR}${extra}
|
||||||
AND (${REGIMEN_TENANT}) = ANY($3)
|
AND (${REGIMEN_TENANT}) = ANY($3)
|
||||||
GROUP BY ${REGIMEN_TENANT}
|
GROUP BY ${REGIMEN_TENANT}
|
||||||
`, [fechaInicio, fechaFin, TODOS_REGIMENES]),
|
`, [fechaInicio, fechaFin, TODOS_REGIMENES])
|
||||||
]);
|
);
|
||||||
|
|
||||||
// Combinar por régimen: el set de régimenes posibles es la unión de ambos lados.
|
// Combinar por régimen: el set de régimenes posibles es la unión de ambos lados.
|
||||||
type Acc = { trasCausado: number; retCausado: number; trasAcreditable: number; retAcreditable: number };
|
type Acc = { trasCausado: number; retCausado: number; trasAcreditable: number; retAcreditable: number };
|
||||||
@@ -799,7 +840,8 @@ export async function getResumenIva(
|
|||||||
// Acumulado anual (misma fórmula T − A − R, pero rango = enero → fechaFin).
|
// Acumulado anual (misma fórmula T − A − R, pero rango = enero → fechaFin).
|
||||||
const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear();
|
const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear();
|
||||||
const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO;
|
const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO;
|
||||||
const { rows: [acumRow] } = await pool.query(`
|
const { rows: [acumRow] } = await withJitOff(pool, (client) =>
|
||||||
|
client.query(`
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) -
|
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) -
|
||||||
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) -
|
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) -
|
||||||
@@ -812,7 +854,8 @@ export async function getResumenIva(
|
|||||||
AND (${REGIMEN_TENANT}) = ANY($3)
|
AND (${REGIMEN_TENANT}) = ANY($3)
|
||||||
AND ${acumFR}${extra}
|
AND ${acumFR}${extra}
|
||||||
AND (${ctx.esEmisor} OR ${ctx.esReceptor})
|
AND (${ctx.esEmisor} OR ${ctx.esReceptor})
|
||||||
`, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES]);
|
`, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES])
|
||||||
|
);
|
||||||
|
|
||||||
// IVA No Acreditable surface (Art. 5 LIVA fracción I + Art. 27 fracción III LISR).
|
// IVA No Acreditable surface (Art. 5 LIVA fracción I + Art. 27 fracción III LISR).
|
||||||
// No participa en `resultado` — ya excluido del `acreditable` arriba via filtro
|
// No participa en `resultado` — ya excluido del `acreditable` arriba via filtro
|
||||||
|
|||||||
@@ -92,8 +92,8 @@ export async function computeMetricaMensual(
|
|||||||
COUNT(*) FILTER (WHERE status = 'Vigente') AS vigentes,
|
COUNT(*) FILTER (WHERE status = 'Vigente') AS vigentes,
|
||||||
COUNT(*) FILTER (WHERE status IN ('Cancelado','0')) AS cancelados
|
COUNT(*) FILTER (WHERE status IN ('Cancelado','0')) AS cancelados
|
||||||
FROM cfdis
|
FROM cfdis
|
||||||
WHERE EXTRACT(YEAR FROM (CASE WHEN tipo_comprobante='P' THEN fecha_pago_p ELSE fecha_emision END)) = $1
|
WHERE EXTRACT(YEAR FROM (CASE WHEN tipo_comprobante='P' THEN (fecha_pago_p - interval '1 hour') ELSE COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') END)) = $1
|
||||||
AND EXTRACT(MONTH FROM (CASE WHEN tipo_comprobante='P' THEN fecha_pago_p ELSE fecha_emision END)) = $2
|
AND EXTRACT(MONTH FROM (CASE WHEN tipo_comprobante='P' THEN (fecha_pago_p - interval '1 hour') ELSE COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') END)) = $2
|
||||||
AND contribuyente_id = $3
|
AND contribuyente_id = $3
|
||||||
GROUP BY 1, 2
|
GROUP BY 1, 2
|
||||||
`, [anio, mes, safeContrib]);
|
`, [anio, mes, safeContrib]);
|
||||||
@@ -227,7 +227,7 @@ export async function backfillTenant(
|
|||||||
|
|
||||||
for (const c of contribs) {
|
for (const c of contribs) {
|
||||||
const { rows: [rango] } = await pool.query<{ min_anio: number | null }>(
|
const { rows: [rango] } = await pool.query<{ min_anio: number | null }>(
|
||||||
`SELECT EXTRACT(YEAR FROM MIN(fecha_emision))::int AS min_anio
|
`SELECT EXTRACT(YEAR FROM MIN(fecha_emision - interval '1 hour'))::int AS min_anio
|
||||||
FROM cfdis WHERE contribuyente_id = $1`,
|
FROM cfdis WHERE contribuyente_id = $1`,
|
||||||
[c.entidad_id],
|
[c.entidad_id],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -361,10 +382,11 @@ export async function processProximosRecordatorios(
|
|||||||
|
|
||||||
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).
|
||||||
@@ -138,6 +143,8 @@ export interface ObligacionContribuyente {
|
|||||||
completadaPor: string | null;
|
completadaPor: string | null;
|
||||||
periodoCompletado: string | null;
|
periodoCompletado: string | null;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
auxiliarAsignadoId?: string | null;
|
||||||
|
auxiliarAsignadoNombre?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCatalogo(): ObligacionFiscal[] {
|
export function getCatalogo(): ObligacionFiscal[] {
|
||||||
@@ -146,15 +153,18 @@ export function getCatalogo(): ObligacionFiscal[] {
|
|||||||
|
|
||||||
export async function getObligaciones(pool: Pool, contribuyenteId: string): Promise<ObligacionContribuyente[]> {
|
export async function getObligaciones(pool: Pool, contribuyenteId: string): Promise<ObligacionContribuyente[]> {
|
||||||
const { rows } = await pool.query(`
|
const { rows } = await pool.query(`
|
||||||
SELECT id, contribuyente_id AS "contribuyenteId", catalogo_id AS "catalogoId",
|
SELECT
|
||||||
nombre, fundamento, frecuencia, fecha_limite AS "fechaLimite", categoria,
|
oc.id, oc.contribuyente_id AS "contribuyenteId", oc.catalogo_id AS "catalogoId",
|
||||||
activa, es_recomendada AS "esRecomendada", es_custom AS "esCustom",
|
oc.nombre, oc.fundamento, oc.frecuencia, oc.fecha_limite AS "fechaLimite", oc.categoria,
|
||||||
completada, completada_at AS "completadaAt", completada_por AS "completadaPor",
|
oc.activa, oc.es_recomendada AS "esRecomendada", oc.es_custom AS "esCustom",
|
||||||
periodo_completado AS "periodoCompletado",
|
oc.completada, oc.completada_at AS "completadaAt", oc.completada_por AS "completadaPor",
|
||||||
created_at AS "createdAt"
|
oc.periodo_completado AS "periodoCompletado",
|
||||||
FROM obligaciones_contribuyente
|
oc.created_at AS "createdAt",
|
||||||
WHERE contribuyente_id = $1
|
oa.auxiliar_user_id AS "auxiliarAsignadoId"
|
||||||
ORDER BY categoria, nombre
|
FROM obligaciones_contribuyente oc
|
||||||
|
LEFT JOIN obligacion_asignaciones oa ON oa.obligacion_id = oc.id
|
||||||
|
WHERE oc.contribuyente_id = $1
|
||||||
|
ORDER BY oc.categoria, oc.nombre
|
||||||
`, [contribuyenteId]);
|
`, [contribuyenteId]);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
@@ -250,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';
|
||||||
@@ -346,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;
|
||||||
@@ -360,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,
|
||||||
@@ -372,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,
|
||||||
@@ -402,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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -443,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 {
|
||||||
@@ -452,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;
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ export interface PapeleriaItem {
|
|||||||
aprobadoPor: string | null;
|
aprobadoPor: string | null;
|
||||||
aprobadoAt: Date | null;
|
aprobadoAt: Date | null;
|
||||||
comentarioRechazo: string | null;
|
comentarioRechazo: string | null;
|
||||||
|
requiereAprobacionCliente: boolean;
|
||||||
|
estadoCliente: EstadoPapeleria | null;
|
||||||
|
aprobadoPorCliente: string | null;
|
||||||
|
aprobadoAtCliente: Date | null;
|
||||||
|
comentarioRechazoCliente: string | null;
|
||||||
subidoPor: string;
|
subidoPor: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
@@ -36,6 +41,7 @@ const SELECT = `
|
|||||||
archivo_filename, archivo_mime, archivo_size,
|
archivo_filename, archivo_mime, archivo_size,
|
||||||
anio, mes,
|
anio, mes,
|
||||||
requiere_aprobacion, estado, aprobado_por, aprobado_at, comentario_rechazo,
|
requiere_aprobacion, estado, aprobado_por, aprobado_at, comentario_rechazo,
|
||||||
|
requiere_aprobacion_cliente, estado_cliente, aprobado_por_cliente, aprobado_at_cliente, comentario_rechazo_cliente,
|
||||||
subido_por, created_at
|
subido_por, created_at
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -54,6 +60,11 @@ const ROW = (r: any): PapeleriaItem => ({
|
|||||||
aprobadoPor: r.aprobado_por,
|
aprobadoPor: r.aprobado_por,
|
||||||
aprobadoAt: r.aprobado_at,
|
aprobadoAt: r.aprobado_at,
|
||||||
comentarioRechazo: r.comentario_rechazo,
|
comentarioRechazo: r.comentario_rechazo,
|
||||||
|
requiereAprobacionCliente: r.requiere_aprobacion_cliente,
|
||||||
|
estadoCliente: r.estado_cliente,
|
||||||
|
aprobadoPorCliente: r.aprobado_por_cliente,
|
||||||
|
aprobadoAtCliente: r.aprobado_at_cliente,
|
||||||
|
comentarioRechazoCliente: r.comentario_rechazo_cliente,
|
||||||
subidoPor: r.subido_por,
|
subidoPor: r.subido_por,
|
||||||
createdAt: r.created_at,
|
createdAt: r.created_at,
|
||||||
});
|
});
|
||||||
@@ -69,6 +80,7 @@ export interface UploadInput {
|
|||||||
anio: number;
|
anio: number;
|
||||||
mes: number;
|
mes: number;
|
||||||
requiereAprobacion: boolean;
|
requiereAprobacion: boolean;
|
||||||
|
requiereAprobacionCliente: boolean;
|
||||||
archivo: Buffer;
|
archivo: Buffer;
|
||||||
archivoFilename: string;
|
archivoFilename: string;
|
||||||
archivoMime: string;
|
archivoMime: string;
|
||||||
@@ -87,12 +99,13 @@ export async function uploadPapeleria(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const estadoInicial = input.requiereAprobacion ? 'pendiente' : null;
|
const estadoInicial = input.requiereAprobacion ? 'pendiente' : null;
|
||||||
|
const estadoClienteInicial = input.requiereAprobacionCliente ? 'pendiente' : null;
|
||||||
|
|
||||||
const { rows: [r] } = await pool.query(
|
const { rows: [r] } = await pool.query(
|
||||||
`INSERT INTO papeleria_trabajo
|
`INSERT INTO papeleria_trabajo
|
||||||
(contribuyente_id, nombre, descripcion, archivo, archivo_filename, archivo_mime, archivo_size,
|
(contribuyente_id, nombre, descripcion, archivo, archivo_filename, archivo_mime, archivo_size,
|
||||||
anio, mes, requiere_aprobacion, estado, subido_por)
|
anio, mes, requiere_aprobacion, estado, requiere_aprobacion_cliente, estado_cliente, subido_por)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||||
RETURNING ${SELECT}`,
|
RETURNING ${SELECT}`,
|
||||||
[
|
[
|
||||||
sanitizeUuid(input.contribuyenteId),
|
sanitizeUuid(input.contribuyenteId),
|
||||||
@@ -106,6 +119,8 @@ export async function uploadPapeleria(
|
|||||||
input.mes,
|
input.mes,
|
||||||
input.requiereAprobacion,
|
input.requiereAprobacion,
|
||||||
estadoInicial,
|
estadoInicial,
|
||||||
|
input.requiereAprobacionCliente,
|
||||||
|
estadoClienteInicial,
|
||||||
input.subidoPor,
|
input.subidoPor,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -117,6 +132,8 @@ export interface ListFilters {
|
|||||||
anio?: number;
|
anio?: number;
|
||||||
mes?: number;
|
mes?: number;
|
||||||
estado?: EstadoPapeleria | 'sin_aprobacion';
|
estado?: EstadoPapeleria | 'sin_aprobacion';
|
||||||
|
entidadIds?: string[];
|
||||||
|
userRole?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listPapeleria(pool: Pool, f: ListFilters): Promise<PapeleriaItem[]> {
|
export async function listPapeleria(pool: Pool, f: ListFilters): Promise<PapeleriaItem[]> {
|
||||||
@@ -126,10 +143,17 @@ export async function listPapeleria(pool: Pool, f: ListFilters): Promise<Papeler
|
|||||||
if (f.anio) { conds.push(`anio = $${i++}`); vals.push(f.anio); }
|
if (f.anio) { conds.push(`anio = $${i++}`); vals.push(f.anio); }
|
||||||
if (f.mes) { conds.push(`mes = $${i++}`); vals.push(f.mes); }
|
if (f.mes) { conds.push(`mes = $${i++}`); vals.push(f.mes); }
|
||||||
if (f.estado === 'sin_aprobacion') {
|
if (f.estado === 'sin_aprobacion') {
|
||||||
conds.push('requiere_aprobacion = false');
|
conds.push('requiere_aprobacion = false AND requiere_aprobacion_cliente = false');
|
||||||
} else if (f.estado) {
|
} else if (f.estado) {
|
||||||
conds.push(`estado = $${i++}`); vals.push(f.estado);
|
conds.push(`estado = $${i++}`); vals.push(f.estado);
|
||||||
}
|
}
|
||||||
|
if (f.entidadIds && f.entidadIds.length > 0) {
|
||||||
|
conds.push(`contribuyente_id = ANY($${i++})`);
|
||||||
|
vals.push(f.entidadIds);
|
||||||
|
}
|
||||||
|
if (f.userRole === 'cliente') {
|
||||||
|
conds.push('requiere_aprobacion_cliente = true');
|
||||||
|
}
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT ${SELECT} FROM papeleria_trabajo
|
`SELECT ${SELECT} FROM papeleria_trabajo
|
||||||
WHERE ${conds.join(' AND ')}
|
WHERE ${conds.join(' AND ')}
|
||||||
@@ -202,6 +226,39 @@ export async function rechazar(
|
|||||||
return r ? ROW(r) : null;
|
return r ? ROW(r) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function aprobarCliente(
|
||||||
|
pool: Pool,
|
||||||
|
id: number,
|
||||||
|
userId: string,
|
||||||
|
): Promise<PapeleriaItem | null> {
|
||||||
|
const { rows: [r] } = await pool.query(
|
||||||
|
`UPDATE papeleria_trabajo
|
||||||
|
SET estado_cliente = 'aprobado', aprobado_por_cliente = $2, aprobado_at_cliente = NOW(),
|
||||||
|
comentario_rechazo_cliente = NULL
|
||||||
|
WHERE id = $1 AND requiere_aprobacion_cliente = true
|
||||||
|
RETURNING ${SELECT}`,
|
||||||
|
[id, userId],
|
||||||
|
);
|
||||||
|
return r ? ROW(r) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rechazarCliente(
|
||||||
|
pool: Pool,
|
||||||
|
id: number,
|
||||||
|
userId: string,
|
||||||
|
comentario: string | null,
|
||||||
|
): Promise<PapeleriaItem | null> {
|
||||||
|
const { rows: [r] } = await pool.query(
|
||||||
|
`UPDATE papeleria_trabajo
|
||||||
|
SET estado_cliente = 'rechazado', aprobado_por_cliente = $2, aprobado_at_cliente = NOW(),
|
||||||
|
comentario_rechazo_cliente = $3
|
||||||
|
WHERE id = $1 AND requiere_aprobacion_cliente = true
|
||||||
|
RETURNING ${SELECT}`,
|
||||||
|
[id, userId, comentario],
|
||||||
|
);
|
||||||
|
return r ? ROW(r) : null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function eliminar(pool: Pool, id: number): Promise<boolean> {
|
export async function eliminar(pool: Pool, id: number): Promise<boolean> {
|
||||||
const { rowCount } = await pool.query(
|
const { rowCount } = await pool.query(
|
||||||
`DELETE FROM papeleria_trabajo WHERE id = $1`,
|
`DELETE FROM papeleria_trabajo WHERE id = $1`,
|
||||||
@@ -209,3 +266,30 @@ export async function eliminar(pool: Pool, id: number): Promise<boolean> {
|
|||||||
);
|
);
|
||||||
return (rowCount ?? 0) > 0;
|
return (rowCount ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula el estado visual combinado considerando ambas aprobaciones.
|
||||||
|
*/
|
||||||
|
export function estadoGlobal(item: PapeleriaItem): 'pendiente' | 'aprobado' | 'rechazado' | null {
|
||||||
|
const reqOwner = item.requiereAprobacion;
|
||||||
|
const reqCliente = item.requiereAprobacionCliente;
|
||||||
|
const estOwner = item.estado;
|
||||||
|
const estCliente = item.estadoCliente;
|
||||||
|
|
||||||
|
if (!reqOwner && !reqCliente) return null;
|
||||||
|
|
||||||
|
// Si cualquiera está rechazado, el documento está rechazado
|
||||||
|
if (estOwner === 'rechazado' || estCliente === 'rechazado') return 'rechazado';
|
||||||
|
|
||||||
|
// Si ambos requieren aprobación
|
||||||
|
if (reqOwner && reqCliente) {
|
||||||
|
if (estOwner === 'aprobado' && estCliente === 'aprobado') return 'aprobado';
|
||||||
|
return 'pendiente';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo owner
|
||||||
|
if (reqOwner) return estOwner;
|
||||||
|
|
||||||
|
// Solo cliente
|
||||||
|
return estCliente;
|
||||||
|
}
|
||||||
|
|||||||
@@ -348,6 +348,17 @@ export async function emitInvoiceIfApplicable(paymentId: string): Promise<void>
|
|||||||
data: { facturapiInvoiceId: invoice.id },
|
data: { facturapiInvoiceId: invoice.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Enviar factura por email al cliente cuando se factura con datos reales
|
||||||
|
// (no público en general). Fail-soft: si el envío falla, no bloquea.
|
||||||
|
if (customer?.email) {
|
||||||
|
try {
|
||||||
|
await facturapiService.sendInvoiceByEmail(emitter.id, invoice.id, customer.email);
|
||||||
|
console.log(`[Invoicing] Factura ${invoice.id} enviada a ${customer.email}`);
|
||||||
|
} catch (emailErr: any) {
|
||||||
|
console.error(`[Invoicing] Error enviando factura ${invoice.id} a ${customer.email}:`, emailErr.message || emailErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
auditLog({
|
auditLog({
|
||||||
tenantId: payment.tenantId,
|
tenantId: payment.tenantId,
|
||||||
action: 'invoice.emitted_auto',
|
action: 'invoice.emitted_auto',
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -227,6 +230,51 @@ export async function createProrationPreference(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
preferenceId: response.id!,
|
||||||
|
checkoutUrl: response.init_point!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Crea una Preference (checkout de pago único) para comprar un paquete de
|
* Crea una Preference (checkout de pago único) para comprar un paquete de
|
||||||
* timbres adicionales. external_reference = `timbres-pack:${paymentId}` para
|
* timbres adicionales. external_reference = `timbres-pack:${paymentId}` para
|
||||||
|
|||||||
@@ -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,8 +737,14 @@ 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) {
|
||||||
|
if (newAmount > mpService.MP_PREAPPROVAL_MAX_AMOUNT) {
|
||||||
|
await mpService.cancelPreapproval(sub.mpPreapprovalId);
|
||||||
|
console.log(`[Upgrade] Preapproval ${sub.mpPreapprovalId} cancelado porque el nuevo monto $${newAmount} supera el límite de MP`);
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
await mpService.updatePreapprovalAmount(sub.mpPreapprovalId, newAmount);
|
await mpService.updatePreapprovalAmount(sub.mpPreapprovalId, newAmount);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -646,6 +752,7 @@ export async function applyApprovedUpgrade(subscriptionId: string): Promise<void
|
|||||||
throw error; // Re-lanza para que MP reintente el webhook
|
throw error; // Re-lanza para que MP reintente el webhook
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
prisma.subscription.update({
|
prisma.subscription.update({
|
||||||
@@ -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,13 +1236,27 @@ export async function sendExpiryReminders(): Promise<{ sent: number; resetOnly:
|
|||||||
|
|
||||||
// Hay algo que avisar.
|
// Hay algo que avisar.
|
||||||
try {
|
try {
|
||||||
|
// Para suscripciones de pago, respeta preferencia 'subscription_expiring' del rol owner.
|
||||||
|
// 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);
|
const ownerEmail = await getTenantOwnerEmail(sub.tenantId);
|
||||||
if (!ownerEmail) {
|
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, {
|
||||||
@@ -1157,6 +1278,7 @@ export async function sendExpiryReminders(): Promise<{ sent: number; resetOnly:
|
|||||||
expiresAt: sub.currentPeriodEnd.toLocaleDateString('es-MX', { dateStyle: 'long' }),
|
expiresAt: sub.currentPeriodEnd.toLocaleDateString('es-MX', { dateStyle: 'long' }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.subscription.update({
|
await prisma.subscription.update({
|
||||||
where: { id: sub.id },
|
where: { id: sub.id },
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ export async function getRegimenesActivosClaves(tenantId: string): Promise<strin
|
|||||||
/**
|
/**
|
||||||
* Resuelve las claves de regímenes activos para la alerta de discrepancia.
|
* Resuelve las claves de regímenes activos para la alerta de discrepancia.
|
||||||
* Si hay contribuyenteId, lee de contribuyentes.regimen_fiscal (comma-separated).
|
* Si hay contribuyenteId, lee de contribuyentes.regimen_fiscal (comma-separated).
|
||||||
* Si no, fallback a TenantRegimenActivo (tabla central).
|
* Si no, combina TenantRegimenActivo (tabla central) con los regímenes de
|
||||||
|
* todos los contribuyentes activos del tenant. Esto evita que la alerta
|
||||||
|
* aparezca en el correo por-contribuyente pero desaparezca en el dashboard
|
||||||
|
* cuando no hay un contribuyente seleccionado.
|
||||||
*/
|
*/
|
||||||
export async function getRegimenesActivosClavesEfectivos(
|
export async function getRegimenesActivosClavesEfectivos(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
@@ -61,9 +64,49 @@ export async function getRegimenesActivosClavesEfectivos(
|
|||||||
if (rows.length > 0 && rows[0].regimen_fiscal) {
|
if (rows.length > 0 && rows[0].regimen_fiscal) {
|
||||||
return rows[0].regimen_fiscal.split(',').map((c: string) => c.trim()).filter(Boolean);
|
return rows[0].regimen_fiscal.split(',').map((c: string) => c.trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
return [];
|
// Fallback: si el contribuyente no tiene regimen_fiscal, usamos los del tenant
|
||||||
|
// para no perder la alerta si el campo quedó vacío accidentalmente.
|
||||||
|
const tenantRegimenes = await getRegimenesActivosClaves(tenantId);
|
||||||
|
if (tenantRegimenes.length > 0) return tenantRegimenes;
|
||||||
|
|
||||||
|
const { rows: allRows } = await pool.query(
|
||||||
|
`SELECT DISTINCT regimen_fiscal FROM contribuyentes WHERE regimen_fiscal IS NOT NULL AND regimen_fiscal <> ''`,
|
||||||
|
);
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const row of allRows) {
|
||||||
|
if (row.regimen_fiscal) {
|
||||||
|
for (const clave of row.regimen_fiscal.split(',')) {
|
||||||
|
const trimmed = clave.trim();
|
||||||
|
if (trimmed) set.add(trimmed);
|
||||||
}
|
}
|
||||||
return getRegimenesActivosClaves(tenantId);
|
}
|
||||||
|
}
|
||||||
|
return Array.from(set);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantRegimenes = await getRegimenesActivosClaves(tenantId);
|
||||||
|
|
||||||
|
// Fallback: si no hay regímenes configurados a nivel tenant, usamos los
|
||||||
|
// regímenes de todos los contribuyentes activos del tenant.
|
||||||
|
if (tenantRegimenes.length > 0) {
|
||||||
|
return tenantRegimenes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT DISTINCT regimen_fiscal FROM contribuyentes WHERE regimen_fiscal IS NOT NULL AND regimen_fiscal <> ''`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.regimen_fiscal) {
|
||||||
|
for (const clave of row.regimen_fiscal.split(',')) {
|
||||||
|
const trimmed = clave.trim();
|
||||||
|
if (trimmed) set.add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(set);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setRegimenesActivos(tenantId: string, regimenIds: number[]) {
|
export async function setRegimenesActivos(tenantId: string, regimenIds: number[]) {
|
||||||
|
|||||||
@@ -94,12 +94,12 @@ export async function getFlujoEfectivo(
|
|||||||
contribuyenteId?: string | null,
|
contribuyenteId?: string | null,
|
||||||
): Promise<FlujoEfectivo> {
|
): Promise<FlujoEfectivo> {
|
||||||
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
|
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
|
||||||
const RANGO = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`;
|
const RANGO = `COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')`;
|
||||||
const RANGO_PAGO = `fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')`;
|
const RANGO_PAGO = `(fecha_pago_p - interval '1 hour') >= $1::date AND (fecha_pago_p - interval '1 hour') < ($2::date + interval '1 day')`;
|
||||||
const { esEmisor, esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId);
|
const { esEmisor, esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId);
|
||||||
|
|
||||||
const { rows: entradasPUE } = await pool.query(`
|
const { rows: entradasPUE } = await pool.query(`
|
||||||
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
|
SELECT TO_CHAR(COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'), 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
|
||||||
FROM cfdis
|
FROM cfdis
|
||||||
WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE'
|
WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE'
|
||||||
AND ${VIGENTE} AND ${RANGO}
|
AND ${VIGENTE} AND ${RANGO}
|
||||||
@@ -107,7 +107,7 @@ export async function getFlujoEfectivo(
|
|||||||
`, [fechaInicio, fechaFin]);
|
`, [fechaInicio, fechaFin]);
|
||||||
|
|
||||||
const { rows: entradasPago } = await pool.query(`
|
const { rows: entradasPago } = await pool.query(`
|
||||||
SELECT TO_CHAR(fecha_pago_p, 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total
|
SELECT TO_CHAR(fecha_pago_p - interval '1 hour', 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total
|
||||||
FROM cfdis
|
FROM cfdis
|
||||||
WHERE ${esEmisor} AND tipo_comprobante = 'P'
|
WHERE ${esEmisor} AND tipo_comprobante = 'P'
|
||||||
AND ${VIGENTE} AND ${RANGO_PAGO}
|
AND ${VIGENTE} AND ${RANGO_PAGO}
|
||||||
@@ -115,7 +115,7 @@ export async function getFlujoEfectivo(
|
|||||||
`, [fechaInicio, fechaFin]);
|
`, [fechaInicio, fechaFin]);
|
||||||
|
|
||||||
const { rows: entradasNC } = await pool.query(`
|
const { rows: entradasNC } = await pool.query(`
|
||||||
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
|
SELECT TO_CHAR(COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'), 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
|
||||||
FROM cfdis
|
FROM cfdis
|
||||||
WHERE ${esEmisor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
|
WHERE ${esEmisor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
|
||||||
AND COALESCE(cfdi_tipo_relacion, '') <> '07'
|
AND COALESCE(cfdi_tipo_relacion, '') <> '07'
|
||||||
@@ -124,7 +124,7 @@ export async function getFlujoEfectivo(
|
|||||||
`, [fechaInicio, fechaFin]);
|
`, [fechaInicio, fechaFin]);
|
||||||
|
|
||||||
const { rows: salidasPUE } = await pool.query(`
|
const { rows: salidasPUE } = await pool.query(`
|
||||||
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
|
SELECT TO_CHAR(COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'), 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
|
||||||
FROM cfdis
|
FROM cfdis
|
||||||
WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE'
|
WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE'
|
||||||
AND ${VIGENTE} AND ${RANGO}
|
AND ${VIGENTE} AND ${RANGO}
|
||||||
@@ -132,7 +132,7 @@ export async function getFlujoEfectivo(
|
|||||||
`, [fechaInicio, fechaFin]);
|
`, [fechaInicio, fechaFin]);
|
||||||
|
|
||||||
const { rows: salidasPago } = await pool.query(`
|
const { rows: salidasPago } = await pool.query(`
|
||||||
SELECT TO_CHAR(fecha_pago_p, 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total
|
SELECT TO_CHAR(fecha_pago_p - interval '1 hour', 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total
|
||||||
FROM cfdis
|
FROM cfdis
|
||||||
WHERE ${esReceptor} AND tipo_comprobante = 'P'
|
WHERE ${esReceptor} AND tipo_comprobante = 'P'
|
||||||
AND ${VIGENTE} AND ${RANGO_PAGO}
|
AND ${VIGENTE} AND ${RANGO_PAGO}
|
||||||
@@ -140,7 +140,7 @@ export async function getFlujoEfectivo(
|
|||||||
`, [fechaInicio, fechaFin]);
|
`, [fechaInicio, fechaFin]);
|
||||||
|
|
||||||
const { rows: salidasNC } = await pool.query(`
|
const { rows: salidasNC } = await pool.query(`
|
||||||
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
|
SELECT TO_CHAR(COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'), 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
|
||||||
FROM cfdis
|
FROM cfdis
|
||||||
WHERE ${esReceptor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
|
WHERE ${esReceptor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
|
||||||
AND COALESCE(cfdi_tipo_relacion, '') <> '07'
|
AND COALESCE(cfdi_tipo_relacion, '') <> '07'
|
||||||
@@ -187,8 +187,8 @@ async function calcularFlujoPorMes(pool: Pool, año: number, contribuyenteId?: s
|
|||||||
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
|
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
|
||||||
const fi = `${año}-01-01`;
|
const fi = `${año}-01-01`;
|
||||||
const ff = `${año}-12-31`;
|
const ff = `${año}-12-31`;
|
||||||
const RANGO = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`;
|
const RANGO = `COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')`;
|
||||||
const RANGO_PAGO = `fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')`;
|
const RANGO_PAGO = `(fecha_pago_p - interval '1 hour') >= $1::date AND (fecha_pago_p - interval '1 hour') < ($2::date + interval '1 day')`;
|
||||||
const { esEmisor, esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId);
|
const { esEmisor, esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId);
|
||||||
|
|
||||||
const q = async (lado: 'EMITIDO' | 'RECIBIDO', tc: string, campo: string, mp?: string) => {
|
const q = async (lado: 'EMITIDO' | 'RECIBIDO', tc: string, campo: string, mp?: string) => {
|
||||||
@@ -198,7 +198,7 @@ async function calcularFlujoPorMes(pool: Pool, año: number, contribuyenteId?: s
|
|||||||
const noAnticipo = tc === 'E' ? `AND COALESCE(cfdi_tipo_relacion, '') <> '07'` : '';
|
const noAnticipo = tc === 'E' ? `AND COALESCE(cfdi_tipo_relacion, '') <> '07'` : '';
|
||||||
const ladoCond = lado === 'EMITIDO' ? esEmisor : esReceptor;
|
const ladoCond = lado === 'EMITIDO' ? esEmisor : esReceptor;
|
||||||
const { rows } = await pool.query(`
|
const { rows } = await pool.query(`
|
||||||
SELECT EXTRACT(MONTH FROM ${fechaCol})::int as mes, COALESCE(SUM(${campo}), 0) as total
|
SELECT EXTRACT(MONTH FROM COALESCE(fecha_efectiva, ${fechaCol} - interval '1 hour'))::int as mes, COALESCE(SUM(${campo}), 0) as total
|
||||||
FROM cfdis
|
FROM cfdis
|
||||||
WHERE ${ladoCond} AND tipo_comprobante = '${tc}' ${mpF} ${noAnticipo} AND ${VIGENTE} AND ${rango}
|
WHERE ${ladoCond} AND tipo_comprobante = '${tc}' ${mpF} ${noAnticipo} AND ${VIGENTE} AND ${rango}
|
||||||
GROUP BY mes
|
GROUP BY mes
|
||||||
@@ -277,7 +277,7 @@ export async function getConcentradoRfc(
|
|||||||
COUNT(*)::int as "cantidadCfdis"
|
COUNT(*)::int as "cantidadCfdis"
|
||||||
FROM cfdis
|
FROM cfdis
|
||||||
WHERE ${esEmisor} AND tipo_comprobante = 'I' AND status NOT IN ('Cancelado', '0')
|
WHERE ${esEmisor} AND tipo_comprobante = 'I' AND status NOT IN ('Cancelado', '0')
|
||||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') BETWEEN $1::date AND $2::date
|
||||||
GROUP BY rfc_receptor, nombre_receptor
|
GROUP BY rfc_receptor, nombre_receptor
|
||||||
ORDER BY "totalFacturado" DESC
|
ORDER BY "totalFacturado" DESC
|
||||||
`, [fechaInicio, fechaFin]);
|
`, [fechaInicio, fechaFin]);
|
||||||
@@ -298,7 +298,7 @@ export async function getConcentradoRfc(
|
|||||||
COUNT(*)::int as "cantidadCfdis"
|
COUNT(*)::int as "cantidadCfdis"
|
||||||
FROM cfdis
|
FROM cfdis
|
||||||
WHERE ${esReceptor} AND tipo_comprobante = 'I' AND status NOT IN ('Cancelado', '0')
|
WHERE ${esReceptor} AND tipo_comprobante = 'I' AND status NOT IN ('Cancelado', '0')
|
||||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') BETWEEN $1::date AND $2::date
|
||||||
GROUP BY rfc_emisor, nombre_emisor
|
GROUP BY rfc_emisor, nombre_emisor
|
||||||
ORDER BY "totalFacturado" DESC
|
ORDER BY "totalFacturado" DESC
|
||||||
`, [fechaInicio, fechaFin]);
|
`, [fechaInicio, fechaFin]);
|
||||||
@@ -338,8 +338,8 @@ export async function getCuentasXPagar(
|
|||||||
FROM cfdis
|
FROM cfdis
|
||||||
WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PPD'
|
WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PPD'
|
||||||
AND status NOT IN ('Cancelado', '0')
|
AND status NOT IN ('Cancelado', '0')
|
||||||
AND fecha_emision >= $1::date
|
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date
|
||||||
AND fecha_emision < ($2::date + interval '1 day')
|
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')
|
||||||
AND COALESCE(saldo_pendiente_mxn, total_mxn) > 0.01
|
AND COALESCE(saldo_pendiente_mxn, total_mxn) > 0.01
|
||||||
${regimenFilter}
|
${regimenFilter}
|
||||||
GROUP BY rfc_emisor, nombre_emisor
|
GROUP BY rfc_emisor, nombre_emisor
|
||||||
@@ -365,7 +365,7 @@ export async function getCuentasXPagar(
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const VIGENTE_ER = `status NOT IN ('Cancelado', '0')`;
|
const VIGENTE_ER = `status NOT IN ('Cancelado', '0')`;
|
||||||
const RANGO_FECHA = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`;
|
const RANGO_FECHA = `COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')`;
|
||||||
const BASE_MONTO = `COALESCE(subtotal_mxn, 0) - COALESCE(descuento_mxn, 0)`;
|
const BASE_MONTO = `COALESCE(subtotal_mxn, 0) - COALESCE(descuento_mxn, 0)`;
|
||||||
|
|
||||||
function sameDateLastYear(dateStr: string): string {
|
function sameDateLastYear(dateStr: string): string {
|
||||||
@@ -842,8 +842,8 @@ export async function getCuentasXCobrar(
|
|||||||
FROM cfdis
|
FROM cfdis
|
||||||
WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago = 'PPD'
|
WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago = 'PPD'
|
||||||
AND status NOT IN ('Cancelado', '0')
|
AND status NOT IN ('Cancelado', '0')
|
||||||
AND fecha_emision >= $1::date
|
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date
|
||||||
AND fecha_emision < ($2::date + interval '1 day')
|
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')
|
||||||
AND COALESCE(saldo_pendiente_mxn, total_mxn) > 0.01
|
AND COALESCE(saldo_pendiente_mxn, total_mxn) > 0.01
|
||||||
${regimenFilter}
|
${regimenFilter}
|
||||||
GROUP BY rfc_receptor, nombre_receptor
|
GROUP BY rfc_receptor, nombre_receptor
|
||||||
|
|||||||
@@ -72,9 +72,17 @@ export async function querySat(
|
|||||||
requestType: 'metadata' | 'cfdi' = 'cfdi'
|
requestType: 'metadata' | 'cfdi' = 'cfdi'
|
||||||
): Promise<QueryResult> {
|
): Promise<QueryResult> {
|
||||||
try {
|
try {
|
||||||
|
// El SAT rechaza fechaInicial >= fechaFinal. Como formatDateForSat trunca
|
||||||
|
// a medianoche, dos fechas dentro del mismo día calendario resultan iguales.
|
||||||
|
// Ajustamos fechaFin al día siguiente para evitar el error.
|
||||||
|
let adjustedFechaFin = fechaFin;
|
||||||
|
if (formatDateForSat(fechaInicio) === formatDateForSat(fechaFin)) {
|
||||||
|
adjustedFechaFin = new Date(fechaFin.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
const period = DateTimePeriod.createFromValues(
|
const period = DateTimePeriod.createFromValues(
|
||||||
formatDateForSat(fechaInicio),
|
formatDateForSat(fechaInicio),
|
||||||
formatDateForSat(fechaFin)
|
formatDateForSat(adjustedFechaFin)
|
||||||
);
|
);
|
||||||
|
|
||||||
const downloadType = new DownloadType(tipo === 'emitidos' ? 'issued' : 'received');
|
const downloadType = new DownloadType(tipo === 'emitidos' ? 'issued' : 'received');
|
||||||
@@ -239,10 +247,11 @@ 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
|
||||||
|
* "Fecha final invalida" / "Fecha inicial invalida".
|
||||||
*/
|
*/
|
||||||
function formatDateForSat(date: Date): string {
|
function formatDateForSat(date: Date): string {
|
||||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} 00:00:00`;
|
||||||
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export async function loginSatCsf(
|
|||||||
cerPath: string,
|
cerPath: string,
|
||||||
keyPath: string,
|
keyPath: string,
|
||||||
password: string,
|
password: string,
|
||||||
|
knownRfc?: string,
|
||||||
): Promise<CsfLoginSession> {
|
): Promise<CsfLoginSession> {
|
||||||
const context = await browser.newContext({
|
const context = await browser.newContext({
|
||||||
acceptDownloads: true,
|
acceptDownloads: true,
|
||||||
@@ -29,20 +30,20 @@ export async function loginSatCsf(
|
|||||||
const publicPage = await context.newPage();
|
const publicPage = await context.newPage();
|
||||||
publicPage.setDefaultTimeout(60_000);
|
publicPage.setDefaultTimeout(60_000);
|
||||||
|
|
||||||
await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle' });
|
await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle', timeout: 120_000 });
|
||||||
await publicPage.waitForTimeout(2000);
|
await publicPage.waitForTimeout(3000);
|
||||||
|
|
||||||
// Click acordeón "Obtén tu constancia" / "Obtener constancia"
|
// Click acordeón "Obtén tu constancia" / "Obtener constancia"
|
||||||
const obtenerLocator = publicPage.locator(
|
const obtenerLocator = publicPage.locator(
|
||||||
'text=/Obt[eé]n\\s+la\\s+constancia|Obt[eé]n\\s+tu\\s+constancia|Obtener\\s+constancia|Obtener\\s+la\\s+constancia/i',
|
'text=/Obt[eé]n\\s+la\\s+constancia|Obt[eé]n\\s+tu\\s+constancia|Obtener\\s+constancia|Obtener\\s+la\\s+constancia/i',
|
||||||
).first();
|
).first();
|
||||||
await obtenerLocator.waitFor({ state: 'visible', timeout: 60_000 });
|
await obtenerLocator.waitFor({ state: 'visible', timeout: 120_000 });
|
||||||
await obtenerLocator.scrollIntoViewIfNeeded();
|
await obtenerLocator.scrollIntoViewIfNeeded();
|
||||||
await obtenerLocator.click();
|
await obtenerLocator.click();
|
||||||
await publicPage.waitForTimeout(1500);
|
await publicPage.waitForTimeout(1500);
|
||||||
|
|
||||||
// Click "SERVICIO" → popup
|
// Click "SERVICIO" → popup
|
||||||
const popupPromise = context.waitForEvent('page', { timeout: 60_000 });
|
const popupPromise = context.waitForEvent('page', { timeout: 120_000 });
|
||||||
await publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first().click();
|
await publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first().click();
|
||||||
const loginPage = await popupPromise;
|
const loginPage = await popupPromise;
|
||||||
await loginPage.waitForLoadState('domcontentloaded');
|
await loginPage.waitForLoadState('domcontentloaded');
|
||||||
@@ -55,7 +56,7 @@ export async function loginSatCsf(
|
|||||||
const efirmaBtn = loginPage
|
const efirmaBtn = loginPage
|
||||||
.locator('button:has-text("e.firma"):not(:has-text("portable")), input[type="button"][value="e.firma" i], input[type="submit"][value="e.firma" i]')
|
.locator('button:has-text("e.firma"):not(:has-text("portable")), input[type="button"][value="e.firma" i], input[type="submit"][value="e.firma" i]')
|
||||||
.first();
|
.first();
|
||||||
await efirmaBtn.waitFor({ state: 'visible', timeout: 30_000 });
|
await efirmaBtn.waitFor({ state: 'visible', timeout: 60_000 });
|
||||||
await efirmaBtn.scrollIntoViewIfNeeded();
|
await efirmaBtn.scrollIntoViewIfNeeded();
|
||||||
await efirmaBtn.click();
|
await efirmaBtn.click();
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ export async function loginSatCsf(
|
|||||||
await fileInputs.nth(1).setInputFiles(keyPath);
|
await fileInputs.nth(1).setInputFiles(keyPath);
|
||||||
|
|
||||||
// Esperar a que el cert async parsing termine (RFC auto-populado por SAT).
|
// Esperar a que el cert async parsing termine (RFC auto-populado por SAT).
|
||||||
|
let rfcPopulated = false;
|
||||||
try {
|
try {
|
||||||
await loginPage.waitForFunction(
|
await loginPage.waitForFunction(
|
||||||
() => {
|
() => {
|
||||||
@@ -80,15 +82,36 @@ export async function loginSatCsf(
|
|||||||
return rfc !== null && rfc.value.length >= 12;
|
return rfc !== null && rfc.value.length >= 12;
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
{ timeout: 30_000 },
|
{ timeout: 120_000 },
|
||||||
);
|
);
|
||||||
} catch (err) {
|
rfcPopulated = true;
|
||||||
|
} catch {
|
||||||
|
// Fallback: si tenemos el RFC conocido, intentar llenarlo manualmente
|
||||||
|
// (el SAT a veces no auto-popula en tiempo esperado pero acepta el envío igual).
|
||||||
|
if (knownRfc && knownRfc.length >= 12) {
|
||||||
|
try {
|
||||||
|
const rfcInput = loginPage.locator('#rfc').first();
|
||||||
|
await rfcInput.evaluate((el: HTMLElement, val: string) => {
|
||||||
|
(el as HTMLInputElement).disabled = false;
|
||||||
|
(el as HTMLInputElement).value = val;
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}, knownRfc);
|
||||||
|
await loginPage.waitForTimeout(500);
|
||||||
|
rfcPopulated = true;
|
||||||
|
} catch {
|
||||||
|
// Manual fill failed, will debug-dump below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rfcPopulated) {
|
||||||
const html = await loginPage.content();
|
const html = await loginPage.content();
|
||||||
const { writeFileSync, mkdirSync } = await import('node:fs');
|
const { writeFileSync, mkdirSync } = await import('node:fs');
|
||||||
const debugDir = '/tmp/horux-csf-debug';
|
const debugDir = '/tmp/horux-csf-debug';
|
||||||
try { mkdirSync(debugDir, { recursive: true }); } catch { /* ok */ }
|
try { mkdirSync(debugDir, { recursive: true }); } catch { /* ok */ }
|
||||||
writeFileSync(`${debugDir}/04c-rfc-timeout-html.html`, html);
|
writeFileSync(`${debugDir}/04c-rfc-timeout-html-${Date.now()}.html`, html);
|
||||||
throw err;
|
throw new Error('El SAT no auto-populó el RFC tras subir el certificado y no se pudo llenar manualmente');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Password + Enviar
|
// Password + Enviar
|
||||||
@@ -98,7 +121,7 @@ export async function loginSatCsf(
|
|||||||
// Esperar a que salga del dominio de login y aterrice en el portal SAT
|
// Esperar a que salga del dominio de login y aterrice en el portal SAT
|
||||||
await loginPage.waitForURL(
|
await loginPage.waitForURL(
|
||||||
url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'),
|
url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'),
|
||||||
{ timeout: 60_000 },
|
{ timeout: 120_000 },
|
||||||
);
|
);
|
||||||
await loginPage.waitForLoadState('networkidle').catch(() => undefined);
|
await loginPage.waitForLoadState('networkidle').catch(() => undefined);
|
||||||
await loginPage.waitForTimeout(2000);
|
await loginPage.waitForTimeout(2000);
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ interface CfdiParsed {
|
|||||||
cfdisRelacionados: string | null;
|
cfdisRelacionados: string | null;
|
||||||
conceptos: ConceptoParsed[];
|
conceptos: ConceptoParsed[];
|
||||||
xmlOriginal: string;
|
xmlOriginal: string;
|
||||||
|
|
||||||
|
// Factura global (InformacionGlobal)
|
||||||
|
periodicidad: string | null;
|
||||||
|
mesesGlobal: string | null;
|
||||||
|
añoGlobal: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConceptoParsed {
|
interface ConceptoParsed {
|
||||||
@@ -569,6 +574,9 @@ export function parseXml(xmlContent: string, downloadType: 'emitidos' | 'recibid
|
|||||||
...nominaData,
|
...nominaData,
|
||||||
conceptos: extractConceptos(comprobante),
|
conceptos: extractConceptos(comprobante),
|
||||||
xmlOriginal: xmlContent,
|
xmlOriginal: xmlContent,
|
||||||
|
periodicidad: comprobante.InformacionGlobal?.['@_Periodicidad'] || null,
|
||||||
|
mesesGlobal: comprobante.InformacionGlobal?.['@_Meses'] || null,
|
||||||
|
añoGlobal: comprobante.InformacionGlobal?.['@_Año'] || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!cfdi.uuid) {
|
if (!cfdi.uuid) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 60000; // 60 segundos
|
const POLL_INTERVAL_MS = 60000; // 60 segundos
|
||||||
const MAX_POLL_ATTEMPTS = 45; // 45 minutos máximo (45 × 60s)
|
const MAX_POLL_ATTEMPTS = 500; // ~8 horas máximo para syncs iniciales grandes
|
||||||
const YEARS_TO_SYNC = 6; // SAT solo permite descargar últimos 6 años
|
const YEARS_TO_SYNC = 6; // SAT solo permite descargar últimos 6 años
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -121,6 +121,35 @@ async function getOrCreateRfc(pool: Pool, rfc: string, razonSocial: string | nul
|
|||||||
return rows[0].id;
|
return rows[0].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula la fecha efectiva de un CFDI para métricas.
|
||||||
|
* Si tiene InformacionGlobal, usa el año/mes declarado.
|
||||||
|
* Para bimestral (periodicidad 05), convierte el código 13-18 a mes 2-12.
|
||||||
|
*/
|
||||||
|
function calcFechaEfectiva(cfdi: CfdiParsed): Date | null {
|
||||||
|
if (!cfdi.añoGlobal || !cfdi.mesesGlobal) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const anio = parseInt(cfdi.añoGlobal, 10);
|
||||||
|
if (isNaN(anio)) return null;
|
||||||
|
|
||||||
|
const mesesStr = cfdi.mesesGlobal;
|
||||||
|
const mesesParts = mesesStr.split(',').map((s: string) => s.trim());
|
||||||
|
const ultimoMesStr = mesesParts[mesesParts.length - 1];
|
||||||
|
let mes = parseInt(ultimoMesStr, 10);
|
||||||
|
if (isNaN(mes)) return null;
|
||||||
|
|
||||||
|
// Bimestral: códigos 13-18 → meses 2,4,6,8,10,12
|
||||||
|
if (cfdi.periodicidad === '05') {
|
||||||
|
if (mes >= 13 && mes <= 18) {
|
||||||
|
mes = (mes - 12) * 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mes < 1 || mes > 12) return null;
|
||||||
|
return new Date(anio, mes - 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Guarda los XMLs extraídos del ZIP en disco para respaldo
|
* Guarda los XMLs extraídos del ZIP en disco para respaldo
|
||||||
*/
|
*/
|
||||||
@@ -212,6 +241,10 @@ async function saveCfdis(
|
|||||||
cfdi.subsidioCausado, m(cfdi.subsidioCausado),
|
cfdi.subsidioCausado, m(cfdi.subsidioCausado),
|
||||||
cfdi.regimenFiscalEmisor, cfdi.regimenFiscalReceptor,
|
cfdi.regimenFiscalEmisor, cfdi.regimenFiscalReceptor,
|
||||||
cfdi.codigoPostalReceptor,
|
cfdi.codigoPostalReceptor,
|
||||||
|
cfdi.periodicidad,
|
||||||
|
cfdi.mesesGlobal,
|
||||||
|
cfdi.añoGlobal,
|
||||||
|
calcFechaEfectiva(cfdi),
|
||||||
cfdi.xmlOriginal,
|
cfdi.xmlOriginal,
|
||||||
cfdi.cfdiTipoRelacion, cfdi.cfdisRelacionados,
|
cfdi.cfdiTipoRelacion, cfdi.cfdisRelacionados,
|
||||||
jobId,
|
jobId,
|
||||||
@@ -261,16 +294,17 @@ async function saveCfdis(
|
|||||||
subsidio_causado=$78, subsidio_causado_mxn=$79,
|
subsidio_causado=$78, subsidio_causado_mxn=$79,
|
||||||
regimen_fiscal_emisor=$80, regimen_fiscal_receptor=$81,
|
regimen_fiscal_emisor=$80, regimen_fiscal_receptor=$81,
|
||||||
codigo_postal_receptor=$82,
|
codigo_postal_receptor=$82,
|
||||||
xml_original=$83,
|
periodicidad=$83, meses_global=$84, año_global=$85, fecha_efectiva=$86,
|
||||||
cfdi_tipo_relacion=$84, cfdis_relacionados=$85,
|
xml_original=$87,
|
||||||
last_sat_sync=NOW(), sat_sync_job_id=$86::uuid,
|
cfdi_tipo_relacion=$88, cfdis_relacionados=$89,
|
||||||
|
last_sat_sync=NOW(), sat_sync_job_id=$90::uuid,
|
||||||
actualizado_en=NOW()
|
actualizado_en=NOW()
|
||||||
WHERE uuid = $1`,
|
WHERE LOWER(uuid) = LOWER($1)`,
|
||||||
[cfdi.uuid, ...vals]
|
[cfdi.uuid, ...vals]
|
||||||
);
|
);
|
||||||
// Re-insert conceptos for updated CFDI
|
// Re-insert conceptos for updated CFDI
|
||||||
await pool.query(`DELETE FROM cfdi_conceptos WHERE cfdi_id = $1`, [existing[0].id]);
|
await pool.query(`DELETE FROM cfdi_conceptos WHERE cfdi_id = $1`, [existing[0].id]);
|
||||||
await saveConceptos(pool, existing[0].id, cfdi);
|
await saveConceptosWithRetry(pool, existing[0].id, cfdi);
|
||||||
updated++;
|
updated++;
|
||||||
} else {
|
} else {
|
||||||
// $1-$83 = data fields (year..cfdis_relacionados), $84 = jobId, $85 = contribuyente_id
|
// $1-$83 = data fields (year..cfdis_relacionados), $84 = jobId, $85 = contribuyente_id
|
||||||
@@ -310,6 +344,7 @@ async function saveCfdis(
|
|||||||
subsidio_causado, subsidio_causado_mxn,
|
subsidio_causado, subsidio_causado_mxn,
|
||||||
regimen_fiscal_emisor, regimen_fiscal_receptor,
|
regimen_fiscal_emisor, regimen_fiscal_receptor,
|
||||||
codigo_postal_receptor,
|
codigo_postal_receptor,
|
||||||
|
periodicidad, meses_global, año_global, fecha_efectiva,
|
||||||
xml_original,
|
xml_original,
|
||||||
cfdi_tipo_relacion, cfdis_relacionados,
|
cfdi_tipo_relacion, cfdis_relacionados,
|
||||||
source, sat_sync_job_id, last_sat_sync, contribuyente_id
|
source, sat_sync_job_id, last_sat_sync, contribuyente_id
|
||||||
@@ -320,8 +355,8 @@ async function saveCfdis(
|
|||||||
[...vals, contribuyenteId]
|
[...vals, contribuyenteId]
|
||||||
);
|
);
|
||||||
// Get the inserted cfdi id and save conceptos
|
// Get the inserted cfdi id and save conceptos
|
||||||
const { rows: [newRow] } = await pool.query(`SELECT id FROM cfdis WHERE uuid = $1`, [cfdi.uuid]);
|
const { rows: [newRow] } = await pool.query(`SELECT id FROM cfdis WHERE LOWER(uuid) = LOWER($1)`, [cfdi.uuid]);
|
||||||
if (newRow) await saveConceptos(pool, newRow.id, cfdi);
|
if (newRow) await saveConceptosWithRetry(pool, newRow.id, cfdi);
|
||||||
inserted++;
|
inserted++;
|
||||||
}
|
}
|
||||||
// Marcar el mes para recompute de métricas pre-calculadas. Para tipo P
|
// Marcar el mes para recompute de métricas pre-calculadas. Para tipo P
|
||||||
@@ -404,6 +439,26 @@ async function saveConceptos(pool: Pool, cfdiId: number, cfdi: CfdiParsed): Prom
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reintenta saveConceptos con backoff exponencial para tolerar errores transitorios. */
|
||||||
|
async function saveConceptosWithRetry(pool: Pool, cfdiId: number, cfdi: CfdiParsed, maxRetries = 3): Promise<void> {
|
||||||
|
let lastError: any;
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
await saveConceptos(pool, cfdiId, cfdi);
|
||||||
|
return;
|
||||||
|
} catch (err: any) {
|
||||||
|
lastError = err;
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const delay = 500 * attempt;
|
||||||
|
console.warn(`[SAT] saveConceptos falló (intento ${attempt}/${maxRetries}) para CFDI ${cfdi.uuid}, reintentando en ${delay}ms...`);
|
||||||
|
await new Promise(r => setTimeout(r, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error(`[SAT] saveConceptos falló definitivamente después de ${maxRetries} intentos para CFDI ${cfdi.uuid}:`, lastError?.message || lastError);
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Guarda/actualiza CFDIs desde metadata del SAT.
|
* Guarda/actualiza CFDIs desde metadata del SAT.
|
||||||
* - Si el CFDI no existe: inserta con datos básicos de metadata (sin XML).
|
* - Si el CFDI no existe: inserta con datos básicos de metadata (sin XML).
|
||||||
@@ -554,30 +609,35 @@ async function requestAndDownload(
|
|||||||
});
|
});
|
||||||
let existingMap = (jobRow?.satRequestIds as Record<string, string> | null) || {};
|
let existingMap = (jobRow?.satRequestIds as Record<string, string> | null) || {};
|
||||||
|
|
||||||
|
// NOTA: se desactivó la reutilización de requestIds de jobs previos porque el SAT
|
||||||
|
// limita las descargas por solicitud. Reusar un requestId de un job anterior puede
|
||||||
|
// agotar el límite y devolver "Máximo de descargas permitidas", dejando el recovery
|
||||||
|
// sin poder descargar. Cada job nuevo crea sus propias solicitudes.
|
||||||
|
//
|
||||||
// Si no existe en el job actual, buscar en el job más reciente del mismo tenant/contribuyente
|
// Si no existe en el job actual, buscar en el job más reciente del mismo tenant/contribuyente
|
||||||
// SOLO si el rango de fechas es idéntico (mismo dateFrom/dateTo).
|
// SOLO si el rango de fechas es idéntico (mismo dateFrom/dateTo).
|
||||||
if (!existingMap[kindKey]) {
|
// if (!existingMap[kindKey]) {
|
||||||
const previousJob = await prisma.satSyncJob.findFirst({
|
// const previousJob = await prisma.satSyncJob.findFirst({
|
||||||
where: {
|
// where: {
|
||||||
tenantId: jobRow?.tenantId,
|
// tenantId: jobRow?.tenantId,
|
||||||
contribuyenteId: jobRow?.contribuyenteId ?? null,
|
// contribuyenteId: jobRow?.contribuyenteId ?? null,
|
||||||
id: { not: jobId },
|
// id: { not: jobId },
|
||||||
dateFrom: jobRow?.dateFrom,
|
// dateFrom: jobRow?.dateFrom,
|
||||||
dateTo: jobRow?.dateTo,
|
// dateTo: jobRow?.dateTo,
|
||||||
},
|
// },
|
||||||
orderBy: { createdAt: 'desc' },
|
// orderBy: { createdAt: 'desc' },
|
||||||
select: { satRequestIds: true },
|
// select: { satRequestIds: true },
|
||||||
});
|
// });
|
||||||
if (previousJob?.satRequestIds) {
|
// if (previousJob?.satRequestIds) {
|
||||||
const prevMap = previousJob.satRequestIds as Record<string, string>;
|
// const prevMap = previousJob.satRequestIds as Record<string, string>;
|
||||||
if (prevMap[kindKey]) {
|
// if (prevMap[kindKey]) {
|
||||||
console.log(`[SAT] Reutilizando requestId de job previo (${label}): ${prevMap[kindKey]}`);
|
// console.log(`[SAT] Reutilizando requestId de job previo (${label}): ${prevMap[kindKey]}`);
|
||||||
// Copiar al job actual para futuros usos
|
// // Copiar al job actual para futuros usos
|
||||||
await persistSatRequestId(jobId, kindKey, prevMap[kindKey]);
|
// await persistSatRequestId(jobId, kindKey, prevMap[kindKey]);
|
||||||
existingMap = { ...existingMap, [kindKey]: prevMap[kindKey] };
|
// existingMap = { ...existingMap, [kindKey]: prevMap[kindKey] };
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
let requestId: string | null = existingMap[kindKey] || null;
|
let requestId: string | null = existingMap[kindKey] || null;
|
||||||
let verifyResult: Awaited<ReturnType<typeof verifySatRequest>> | undefined;
|
let verifyResult: Awaited<ReturnType<typeof verifySatRequest>> | undefined;
|
||||||
@@ -770,6 +830,26 @@ async function determineChunkMonths(
|
|||||||
fechaInicio: Date,
|
fechaInicio: Date,
|
||||||
fechaFin: Date,
|
fechaFin: Date,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
|
// Si el job previo del mismo tenant/contribuyente ya tenía chunks,
|
||||||
|
// inferimos que el volumen es alto y usamos 6 meses directamente
|
||||||
|
// para evitar el sondeo lento del SAT.
|
||||||
|
const previousJob = await prisma.satSyncJob.findFirst({
|
||||||
|
where: {
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
contribuyenteId: ctx.contribuyenteId ?? null,
|
||||||
|
id: { not: jobId },
|
||||||
|
status: 'completed',
|
||||||
|
cfdisFound: { gt: 0 },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: { satRequestIds: true, cfdisFound: true },
|
||||||
|
});
|
||||||
|
if (previousJob?.satRequestIds && Object.keys(previousJob.satRequestIds as Record<string, string>).length > 0) {
|
||||||
|
const chunkMonths = (previousJob.cfdisFound || 0) > 15_000 ? 3 : 6;
|
||||||
|
console.log(`[SAT] Reutilizando estrategia de job previo (${previousJob.cfdisFound} CFDIs) → bloques de ${chunkMonths} meses`);
|
||||||
|
return chunkMonths;
|
||||||
|
}
|
||||||
|
|
||||||
const THRESHOLD = 15_000;
|
const THRESHOLD = 15_000;
|
||||||
let totalCfdis = 0;
|
let totalCfdis = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ export interface SweepResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
|
const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
|
||||||
initial: 8,
|
initial: 24,
|
||||||
daily: 4,
|
daily: 4,
|
||||||
incremental: 2,
|
incremental: 2,
|
||||||
custom: 4,
|
custom: 24,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface TareaCatalogo {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
orden: number;
|
orden: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
auxiliarAsignadoId?: string | null;
|
||||||
|
auxiliarAsignadoNombre?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TareaPeriodo {
|
export interface TareaPeriodo {
|
||||||
@@ -47,6 +49,8 @@ const ROW_TO_TAREA = (r: any): TareaCatalogo => ({
|
|||||||
active: r.active,
|
active: r.active,
|
||||||
orden: r.orden,
|
orden: r.orden,
|
||||||
createdAt: r.created_at,
|
createdAt: r.created_at,
|
||||||
|
auxiliarAsignadoId: r.auxiliarAsignadoId ?? null,
|
||||||
|
auxiliarAsignadoNombre: r.auxiliarAsignadoNombre ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ROW_TO_PERIODO = (r: any): TareaPeriodo => ({
|
const ROW_TO_PERIODO = (r: any): TareaPeriodo => ({
|
||||||
@@ -68,9 +72,13 @@ function sanitizeUuid(id: string): string {
|
|||||||
|
|
||||||
export async function listTareas(pool: Pool, contribuyenteId: string): Promise<TareaCatalogo[]> {
|
export async function listTareas(pool: Pool, contribuyenteId: string): Promise<TareaCatalogo[]> {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT * FROM tareas_catalogo
|
`SELECT
|
||||||
WHERE contribuyente_id = $1 AND active = true
|
tc.*,
|
||||||
ORDER BY orden, nombre`,
|
ta.auxiliar_user_id AS "auxiliarAsignadoId"
|
||||||
|
FROM tareas_catalogo tc
|
||||||
|
LEFT JOIN tarea_asignaciones ta ON ta.tarea_id = tc.id
|
||||||
|
WHERE tc.contribuyente_id = $1 AND tc.active = true
|
||||||
|
ORDER BY tc.orden, tc.nombre`,
|
||||||
[sanitizeUuid(contribuyenteId)],
|
[sanitizeUuid(contribuyenteId)],
|
||||||
);
|
);
|
||||||
return rows.map(ROW_TO_TAREA);
|
return rows.map(ROW_TO_TAREA);
|
||||||
@@ -272,6 +280,59 @@ export async function listTareasConPeriodoActual(
|
|||||||
return tareas.map(t => ({ ...t, periodoActual: periodos.get(t.id) ?? null }));
|
return tareas.map(t => ({ ...t, periodoActual: periodos.get(t.id) ?? null }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TareaConContribuyente extends TareaConPeriodo {
|
||||||
|
contribuyenteRfc: string;
|
||||||
|
contribuyenteRazonSocial: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lee tareas activas con periodo actual para una lista de contribuyentes.
|
||||||
|
* Útil para la vista "Mis Tareas".
|
||||||
|
*/
|
||||||
|
export async function listTareasConPeriodoPorContribuyentes(
|
||||||
|
pool: Pool,
|
||||||
|
contribuyenteIds: string[],
|
||||||
|
): Promise<TareaConContribuyente[]> {
|
||||||
|
if (contribuyenteIds.length === 0) return [];
|
||||||
|
|
||||||
|
// Materializar periodos para cada contribuyente en paralelo
|
||||||
|
await Promise.all(contribuyenteIds.map(id => materializarPeriodos(pool, id)));
|
||||||
|
|
||||||
|
const { rows: tareasRows } = await pool.query(
|
||||||
|
`SELECT tc.*, c.entidad_id AS "contribuyenteId",
|
||||||
|
c.rfc AS "contribuyenteRfc",
|
||||||
|
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial"
|
||||||
|
FROM tareas_catalogo tc
|
||||||
|
JOIN contribuyentes c ON c.entidad_id = tc.contribuyente_id
|
||||||
|
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
|
||||||
|
WHERE tc.contribuyente_id = ANY($1::uuid[]) AND tc.active = true
|
||||||
|
ORDER BY c.rfc, tc.orden, tc.nombre`,
|
||||||
|
[contribuyenteIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tareasRows.length === 0) return [];
|
||||||
|
|
||||||
|
const tareaIds = tareasRows.map((r: any) => r.id);
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const { rows: periodoRows } = await pool.query(
|
||||||
|
`SELECT DISTINCT ON (tarea_id) *
|
||||||
|
FROM tarea_periodos
|
||||||
|
WHERE tarea_id = ANY($1::uuid[])
|
||||||
|
AND (completada = false OR fecha_limite >= $2::date)
|
||||||
|
ORDER BY tarea_id, fecha_limite ASC`,
|
||||||
|
[tareaIds, today],
|
||||||
|
);
|
||||||
|
const periodos = new Map(periodoRows.map((r: any) => [r.tarea_id, ROW_TO_PERIODO(r)]));
|
||||||
|
|
||||||
|
return tareasRows.map((r: any) => ({
|
||||||
|
...ROW_TO_TAREA(r),
|
||||||
|
contribuyenteId: r.contribuyenteId,
|
||||||
|
contribuyenteRfc: r.contribuyenteRfc,
|
||||||
|
contribuyenteRazonSocial: r.contribuyenteRazonSocial,
|
||||||
|
periodoActual: periodos.get(r.id) ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Completar / descompletar periodo ───
|
// ─── Completar / descompletar periodo ───
|
||||||
|
|
||||||
const ROLES_SUPERVISOR = new Set(['owner', 'cfo', 'supervisor']);
|
const ROLES_SUPERVISOR = new Set(['owner', 'cfo', 'supervisor']);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export async function getAllTenants() {
|
|||||||
plan: true,
|
plan: true,
|
||||||
databaseName: true,
|
databaseName: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
verticalProfile: true,
|
||||||
|
codigoPostal: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: { memberships: { where: { active: true } } as any }
|
select: { memberships: { where: { active: true } } as any }
|
||||||
},
|
},
|
||||||
@@ -43,6 +45,8 @@ export async function getTenantById(id: string) {
|
|||||||
plan: true,
|
plan: true,
|
||||||
databaseName: true,
|
databaseName: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
verticalProfile: true,
|
||||||
|
codigoPostal: true,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -57,6 +61,8 @@ export async function createTenant(data: {
|
|||||||
/** Solo plan custom: primera fecha de pago (deadline para que el cliente
|
/** Solo plan custom: primera fecha de pago (deadline para que el cliente
|
||||||
* complete su primer cobro). Se persiste como Subscription.currentPeriodEnd. */
|
* complete su primer cobro). Se persiste como Subscription.currentPeriodEnd. */
|
||||||
firstPaymentDueAt?: string | null;
|
firstPaymentDueAt?: string | null;
|
||||||
|
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
|
||||||
|
codigoPostal?: string;
|
||||||
}) {
|
}) {
|
||||||
const plan = data.plan || 'trial';
|
const plan = data.plan || 'trial';
|
||||||
|
|
||||||
@@ -70,24 +76,32 @@ export async function createTenant(data: {
|
|||||||
}).catch(err => console.error('[METABASE] Register failed:', err));
|
}).catch(err => console.error('[METABASE] Register failed:', err));
|
||||||
|
|
||||||
// 2. Create tenant record
|
// 2. Create tenant record
|
||||||
|
const isTrial = plan === 'trial';
|
||||||
|
|
||||||
const tenant = await prisma.tenant.create({
|
const tenant = await prisma.tenant.create({
|
||||||
data: {
|
data: {
|
||||||
nombre: data.nombre,
|
nombre: data.nombre,
|
||||||
rfc: data.rfc.toUpperCase(),
|
rfc: data.rfc.toUpperCase(),
|
||||||
plan,
|
plan,
|
||||||
databaseName,
|
databaseName,
|
||||||
|
dbMode: 'MANAGED',
|
||||||
|
verticalProfile: data.verticalProfile || 'CONTABLE',
|
||||||
|
codigoPostal: data.codigoPostal || undefined,
|
||||||
|
trialEndsAt: isTrial ? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) : undefined,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Create admin user with temp password
|
// 3. Create or reuse admin user
|
||||||
const tempPassword = randomBytes(4).toString('hex'); // 8-char random
|
|
||||||
const hashedPassword = await bcrypt.hash(tempPassword, 10);
|
|
||||||
|
|
||||||
// Get owner role ID from roles table (rol que asignamos al dueño del tenant al crearlo)
|
|
||||||
const ownerRol = await prisma.rol.findUnique({ where: { nombre: 'owner' } });
|
const ownerRol = await prisma.rol.findUnique({ where: { nombre: 'owner' } });
|
||||||
if (!ownerRol) throw new Error('Rol owner no encontrado en la base de datos');
|
if (!ownerRol) throw new Error('Rol owner no encontrado en la base de datos');
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
let user = await prisma.user.findUnique({ where: { email: data.adminEmail } });
|
||||||
|
let tempPassword: string | null = null;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
tempPassword = randomBytes(4).toString('hex'); // 8-char random
|
||||||
|
const hashedPassword = await bcrypt.hash(tempPassword, 10);
|
||||||
|
user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: data.adminEmail,
|
email: data.adminEmail,
|
||||||
passwordHash: hashedPassword,
|
passwordHash: hashedPassword,
|
||||||
@@ -95,10 +109,23 @@ export async function createTenant(data: {
|
|||||||
lastTenantId: tenant.id,
|
lastTenantId: tenant.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
// Crea membership owner del nuevo user en su tenant (fase 4 multi-tenant)
|
// User ya existe: actualizar lastTenantId y nombre si cambió
|
||||||
await prisma.tenantMembership.create({
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
|
lastTenantId: tenant.id,
|
||||||
|
...(data.adminNombre && data.adminNombre !== user.nombre ? { nombre: data.adminNombre } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crea membership owner del user en su tenant (fase 4 multi-tenant).
|
||||||
|
// Si ya existía (re-invite a otro tenant), reactivar.
|
||||||
|
await prisma.tenantMembership.upsert({
|
||||||
|
where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } },
|
||||||
|
update: { rolId: ownerRol.id, isOwner: true, active: true },
|
||||||
|
create: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
tenantId: tenant.id,
|
tenantId: tenant.id,
|
||||||
rolId: ownerRol.id,
|
rolId: ownerRol.id,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { prisma } from '../config/database.js';
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
import { getDespachoPlanLimits } from './plan-catalogo.service.js';
|
import { getDespachoPlanLimits } from './plan-catalogo.service.js';
|
||||||
|
import { emailService } from './email/email.service.js';
|
||||||
import type { UserListItem, UserInvite, UserUpdate, Role } from '@horux/shared';
|
import type { UserListItem, UserInvite, UserUpdate, Role } from '@horux/shared';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,6 +100,13 @@ export async function inviteUsuario(tenantId: string, data: UserInvite): Promise
|
|||||||
lastTenantId: tenantId,
|
lastTenantId: tenantId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Enviar correo de bienvenida con credenciales (non-blocking)
|
||||||
|
emailService.sendWelcome(data.email, {
|
||||||
|
nombre: data.nombre,
|
||||||
|
email: data.email,
|
||||||
|
tempPassword,
|
||||||
|
}).catch(err => console.error('[EMAIL] Welcome email failed:', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
const rolId = await getRolId(data.role);
|
const rolId = await getRolId(data.role);
|
||||||
@@ -200,6 +208,73 @@ export async function getAllUsuarios(): Promise<UserListItem[]> {
|
|||||||
return memberships.map(m => mapMembershipRow(m, true));
|
return memberships.map(m => mapMembershipRow(m, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createUsuarioGlobal(
|
||||||
|
tenantId: string,
|
||||||
|
data: UserInvite
|
||||||
|
): Promise<UserListItem> {
|
||||||
|
const tenant = await prisma.tenant.findUnique({
|
||||||
|
where: { id: tenantId },
|
||||||
|
select: { plan: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Límite del catálogo despacho desde BD (con cache). -1 = ilimitado.
|
||||||
|
const planLimits = tenant ? await getDespachoPlanLimits(tenant.plan) : null;
|
||||||
|
const maxUsers = planLimits?.maxUsers ?? 1;
|
||||||
|
|
||||||
|
const currentCount = await prisma.tenantMembership.count({
|
||||||
|
where: { tenantId, active: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (maxUsers !== -1 && currentCount >= maxUsers) {
|
||||||
|
throw new Error('Límite de usuarios alcanzado para este plan');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si el email ya existe como user global, agregamos membership en este tenant
|
||||||
|
let user = await prisma.user.findUnique({ where: { email: data.email } });
|
||||||
|
|
||||||
|
let tempPassword: string | null = null;
|
||||||
|
if (!user) {
|
||||||
|
tempPassword = randomBytes(4).toString('hex');
|
||||||
|
const passwordHash = await bcrypt.hash(tempPassword, 12);
|
||||||
|
user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: data.email,
|
||||||
|
passwordHash,
|
||||||
|
nombre: data.nombre,
|
||||||
|
lastTenantId: tenantId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enviar correo de bienvenida con credenciales (non-blocking)
|
||||||
|
emailService.sendWelcome(data.email, {
|
||||||
|
nombre: data.nombre,
|
||||||
|
email: data.email,
|
||||||
|
tempPassword,
|
||||||
|
}).catch(err => console.error('[EMAIL] Welcome email failed:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rolId = await getRolId(data.role);
|
||||||
|
|
||||||
|
await prisma.tenantMembership.upsert({
|
||||||
|
where: { userId_tenantId: { userId: user.id, tenantId } },
|
||||||
|
update: { rolId, isOwner: false, active: true },
|
||||||
|
create: {
|
||||||
|
userId: user.id,
|
||||||
|
tenantId,
|
||||||
|
rolId,
|
||||||
|
isOwner: false,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const membership = await prisma.tenantMembership.findUnique({
|
||||||
|
where: { userId_tenantId: { userId: user.id, tenantId } },
|
||||||
|
include: MEMBERSHIP_INCLUDE,
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapMembershipRow(membership!);
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateUsuarioGlobal(
|
export async function updateUsuarioGlobal(
|
||||||
userId: string,
|
userId: string,
|
||||||
data: UserUpdate & { tenantId?: string }
|
data: UserUpdate & { tenantId?: string }
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user