Compare commits
57 Commits
0c7580aa44
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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")
|
||||
plan Plan
|
||||
mpPreapprovalId String? @map("mp_preapproval_id")
|
||||
mpPreferenceId String? @map("mp_preference_id")
|
||||
status String @default("pending")
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
frequency String @default("monthly")
|
||||
|
||||
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();
|
||||
});
|
||||
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();
|
||||
});
|
||||
@@ -71,9 +71,9 @@ class TenantConnectionManager {
|
||||
user: connectionOverride?.user ?? this.dbConfig.user,
|
||||
password: connectionOverride?.password ?? this.dbConfig.password,
|
||||
database: databaseName,
|
||||
max: 3,
|
||||
max: 10,
|
||||
idleTimeoutMillis: 300_000,
|
||||
connectionTimeoutMillis: 10_000,
|
||||
connectionTimeoutMillis: 30_000,
|
||||
};
|
||||
|
||||
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 {
|
||||
const now = Date.now();
|
||||
const maxIdle = 5 * 60 * 1000;
|
||||
const maxIdle = 12 * 60 * 60 * 1000;
|
||||
|
||||
for (const [tenantId, entry] of this.pools.entries()) {
|
||||
if (now - entry.lastAccess.getTime() > maxIdle) {
|
||||
|
||||
@@ -2,53 +2,67 @@ export interface ObligacionFiscal {
|
||||
id: string;
|
||||
nombre: string;
|
||||
fundamento: string;
|
||||
frecuencia: 'mensual' | 'bimestral' | 'trimestral' | 'anual' | 'eventual';
|
||||
frecuencia: 'mensual' | 'bimestral' | 'trimestral' | 'cuatrimestral' | 'anual' | 'eventual';
|
||||
fechaLimite: string;
|
||||
aplica: 'PM' | 'PF' | 'ambos';
|
||||
regimenes: string[] | null; // null = all regimes
|
||||
condicion: string | null;
|
||||
categoria: string;
|
||||
recomendadaPorDefecto: boolean;
|
||||
/** Si true, la obligación requiere comprobante de pago para cerrarse. */
|
||||
requierePago: boolean;
|
||||
}
|
||||
|
||||
export const OBLIGACIONES_CATALOGO: ObligacionFiscal[] = [
|
||||
// === FEDERALES MENSUALES (día 17) ===
|
||||
{ id: 'isr-provisional', nombre: 'Pago provisional de ISR', fundamento: 'Art. 14 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', recomendadaPorDefecto: true },
|
||||
{ id: 'iva-mensual', nombre: 'Pago mensual definitivo de IVA', fundamento: 'Art. 5-D LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', recomendadaPorDefecto: true },
|
||||
{ id: 'ret-isr-sueldos', nombre: 'Retenciones de ISR por sueldos y salarios', fundamento: 'Art. 96 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', recomendadaPorDefecto: false },
|
||||
{ id: 'ret-isr-asimilados', nombre: 'Retenciones de ISR por asimilados a salarios', fundamento: 'Art. 94 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', recomendadaPorDefecto: false },
|
||||
{ id: 'ret-isr-honorarios', nombre: 'Retenciones de ISR por honorarios y arrendamiento a PF', fundamento: 'Art. 106/116 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'PM que contrate PF', categoria: 'Federal mensual', recomendadaPorDefecto: false },
|
||||
{ id: 'ret-iva', nombre: 'Retenciones de IVA (servicios, fletes, outsourcing)', fundamento: 'Art. 1-A LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Según supuesto', categoria: 'Federal mensual', recomendadaPorDefecto: false },
|
||||
{ id: 'ieps', nombre: 'Pago definitivo de IEPS', fundamento: 'Art. 5 LIEPS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Productores/importadores', categoria: 'Federal mensual', recomendadaPorDefecto: false },
|
||||
{ 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', requierePago: true, recomendadaPorDefecto: true },
|
||||
{ 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-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-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-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: '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 ===
|
||||
{ id: 'diot', nombre: 'DIOT (Declaración Informativa de Operaciones con Terceros)', fundamento: 'Art. 32 LIVA', frecuencia: 'mensual', fechaLimite: 'Último día del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false },
|
||||
{ id: 'cont-balanza', nombre: 'Contabilidad Electrónica - Balanza de comprobación', fundamento: 'CFF Art. 28', frecuencia: 'mensual', fechaLimite: 'Día 3 del segundo mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false },
|
||||
{ id: 'cont-catalogo', nombre: 'Contabilidad Electrónica - Catálogo de cuentas', fundamento: 'CFF Art. 28', frecuencia: 'eventual', fechaLimite: 'Cuando haya modificación', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false },
|
||||
{ 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', 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', 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 ===
|
||||
{ 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 ===
|
||||
{ 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 ===
|
||||
{ id: 'anual-isr-pm', nombre: 'Declaración Anual de ISR PM', fundamento: 'Art. 76 LISR', frecuencia: 'anual', fechaLimite: '31 de marzo', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: true },
|
||||
{ id: 'issif', nombre: 'ISSIF (Información sobre Situación Fiscal)', fundamento: 'CFF Art. 32-H', frecuencia: 'anual', fechaLimite: 'Con la declaración anual', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: false },
|
||||
{ id: 'dictamen-fiscal', nombre: 'Dictamen Fiscal', fundamento: 'CFF Art. 32-A', frecuencia: 'anual', fechaLimite: '15 de mayo', aplica: 'PM', regimenes: null, condicion: 'Ingresos > $1,855M o grupos', categoria: 'Anual', recomendadaPorDefecto: false },
|
||||
{ id: 'dim', nombre: 'DIM - Declaraciones Informativas Múltiples', fundamento: 'CFF', frecuencia: 'anual', fechaLimite: '15 de febrero', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: false },
|
||||
{ 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: '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: '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: '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 ===
|
||||
{ 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 ===
|
||||
{ id: 'imss-cuotas', nombre: 'Cuotas obrero-patronales IMSS', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
|
||||
{ id: 'infonavit', nombre: 'Aportaciones INFONAVIT + amortizaciones', fundamento: 'LINFONAVIT', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
|
||||
{ id: 'sar-retiro', nombre: 'SAR / Retiro', fundamento: 'LSS', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
|
||||
{ id: 'prima-riesgo', nombre: 'Determinación Prima de Riesgo de Trabajo', fundamento: 'LSS Art. 74', frecuencia: 'anual', fechaLimite: 'Febrero', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
|
||||
{ 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: '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: '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: '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 ===
|
||||
{ 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) {
|
||||
try {
|
||||
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);
|
||||
console.log(`[AlertasCtrl] GET /automaticas devuelve ${alertas.length} alertas: ${alertas.map(a => a.id).join(', ') || 'ninguna'}`);
|
||||
res.json(alertas);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
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); }
|
||||
}
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export async function searchClaveProdServ(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
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
|
||||
// Primero buscar por clave, luego por texto
|
||||
const data = await prisma.catClaveProdServ.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ clave: { startsWith: q } },
|
||||
{ clave: { startsWith: 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);
|
||||
}
|
||||
|
||||
// Buscar con variantes comunes de acentos
|
||||
const withAccents = normalized
|
||||
// Buscar con variantes comunes de acentos, escapando caracteres regex primero
|
||||
const withAccents = escapeRegex(normalized)
|
||||
.replace(/a/gi, '[aá]').replace(/e/gi, '[eé]')
|
||||
.replace(/i/gi, '[ií]').replace(/o/gi, '[oó]').replace(/u/gi, '[uú]')
|
||||
.replace(/n/gi, '[nñ]');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as cfdiService from '../services/cfdi.service.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 { getRegimenesIgnoradosClaves } from '../services/regimen.service.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) {
|
||||
try {
|
||||
if (!req.tenantPool) return next(new AppError(400, 'Tenant no configurado'));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import * as contribuyenteService from '../services/contribuyente.service.js';
|
||||
import * as carteraService from '../services/cartera.service.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
|
||||
import { adjustDespachoOverage } from '../services/payment/addon.service.js';
|
||||
@@ -41,7 +42,24 @@ export async function list(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
|
||||
const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds, 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); }
|
||||
}
|
||||
|
||||
@@ -77,6 +95,19 @@ export async function create(req: Request, res: Response, next: NextFunction) {
|
||||
|
||||
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
|
||||
// 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.
|
||||
@@ -139,6 +170,15 @@ export async function addClienteAcceso(req: Request, res: Response, next: NextFu
|
||||
const { userId } = req.body;
|
||||
if (!userId || typeof userId !== 'string') return next(new AppError(400, 'userId requerido'));
|
||||
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(
|
||||
'INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[userId, entidadId],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||
import { signupDespacho } from '../services/despacho.service.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { getPlanPrice } from '../services/payment/subscription.service.js';
|
||||
|
||||
const signupSchema = 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
|
||||
// para que el feature-gate y los límites funcionen correctamente.
|
||||
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' },
|
||||
select: {
|
||||
status: true, amount: true, plan: true,
|
||||
@@ -64,6 +65,18 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
|
||||
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
|
||||
// recurrente actual, fecha de próxima renovación y si el primer pago
|
||||
// (cuando aplica dualidad firstYear) ya fue completado.
|
||||
@@ -72,6 +85,7 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
|
||||
dbMode: tenant.dbMode,
|
||||
trialEndsAt: tenant.trialEndsAt?.toISOString() ?? null,
|
||||
isTrialActive,
|
||||
planPrice,
|
||||
subscription: subscription
|
||||
? {
|
||||
status: subscription.status,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getOpiniones, getOpinionPdf, consultarOpinion, consultarOpinionContribu
|
||||
import * as declaracionesService from '../services/declaraciones.service.js';
|
||||
import * as constanciaService from '../services/constancia.service.js';
|
||||
import * as extrasService from '../services/documentos-extras.service.js';
|
||||
import * as obligacionEvidenciasService from '../services/obligacion-evidencias.service.js';
|
||||
import { notifyDocumentoSubido } from '../services/notify-upload.service.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
|
||||
@@ -71,7 +72,7 @@ export async function consultarManual(req: Request, res: Response, next: NextFun
|
||||
// Declaraciones provisionales
|
||||
// ============================================================================
|
||||
|
||||
const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar'];
|
||||
const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'];
|
||||
|
||||
function canUpload(req: Request): boolean {
|
||||
return ROLES_UPLOAD.includes(req.user!.role);
|
||||
@@ -81,8 +82,9 @@ const createDeclaracionSchema = z.object({
|
||||
año: z.number().int().min(2020).max(2100),
|
||||
mes: z.number().int().min(1).max(12),
|
||||
tipo: z.enum(['normal', 'complementaria']),
|
||||
periodicidad: z.enum(['mensual', 'bimestral', 'trimestral', 'semestral', 'anual']).optional(),
|
||||
impuestos: z.array(z.enum(['IVA', 'ISR', 'IEPS', 'SUELDOS', 'DIOT', 'OTRO'])).min(1, 'Selecciona al menos un impuesto'),
|
||||
periodicidad: z.enum(['mensual', 'bimestral', 'trimestral', 'cuatrimestral', 'semestral', 'anual']).optional(),
|
||||
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(),
|
||||
pdfBase64: z.string().min(100),
|
||||
pdfFilename: z.string().min(1).max(255),
|
||||
@@ -92,6 +94,9 @@ const createDeclaracionSchema = z.object({
|
||||
}).refine(
|
||||
d => !d.ligaPagoBase64 || !!d.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) {
|
||||
@@ -119,6 +124,7 @@ export async function crearDeclaracion(req: Request, res: Response, next: NextFu
|
||||
});
|
||||
|
||||
// Notificación fire-and-forget a owners del despacho + supervisor del RFC.
|
||||
// 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.
|
||||
notifyDocumentoSubido({
|
||||
pool: req.tenantPool!,
|
||||
@@ -126,6 +132,7 @@ export async function crearDeclaracion(req: Request, res: Response, next: NextFu
|
||||
contribuyenteId: contribuyenteId ?? null,
|
||||
subidoPor: req.user!.email,
|
||||
kind: 'declaracion',
|
||||
declaracionId: result.declaracion.id,
|
||||
declaracion: {
|
||||
periodo: `${MESES[data.mes - 1]} ${data.año}`,
|
||||
tipo: data.tipo,
|
||||
@@ -334,3 +341,91 @@ export async function listarCategoriasExtras(req: Request, res: Response, next:
|
||||
res.json(data);
|
||||
} 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); }
|
||||
}
|
||||
|
||||
@@ -580,7 +580,13 @@ export async function searchConceptos(req: Request, res: Response, next: NextFun
|
||||
const params: any[] = [];
|
||||
if (q.length >= 2) {
|
||||
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(`
|
||||
@@ -605,6 +611,7 @@ export async function searchConceptos(req: Request, res: Response, next: NextFun
|
||||
WHERE c.status NOT IN ('Cancelado', '0')
|
||||
${whereType}
|
||||
${whereSearch}
|
||||
${whereContribuyente}
|
||||
ORDER BY cc.clave_prod_serv, cc.descripcion, c.fecha_emision DESC
|
||||
LIMIT 30
|
||||
`, params);
|
||||
@@ -708,7 +715,7 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
|
||||
const q = (req.query.q as string || '').trim();
|
||||
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!;
|
||||
|
||||
// RFC del tenant despacho para excluirlo (no se factura a sí mismo)
|
||||
@@ -719,10 +726,17 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
|
||||
});
|
||||
const tenantRfc = tenant?.rfc || '';
|
||||
|
||||
// Búsqueda en el catálogo completo de RFCs. El contribuyente activo solo
|
||||
// filtra CFDIs relacionados / PPD, no el autocompleto de RFCs — de lo
|
||||
// contrario no se podría facturar a un cliente nuevo que nunca haya
|
||||
// aparecido en un CFDI previo.
|
||||
const params: any[] = [tenantRfc, `%${q}%`];
|
||||
let whereContribuyente = '';
|
||||
if (contribuyenteId) {
|
||||
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(`
|
||||
SELECT id, rfc, razon_social as "razonSocial",
|
||||
regimen_fiscal as "regimenFiscal",
|
||||
@@ -730,9 +744,10 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
|
||||
FROM rfcs
|
||||
WHERE rfc != $1
|
||||
AND (rfc ILIKE $2 OR razon_social ILIKE $2)
|
||||
${whereContribuyente}
|
||||
ORDER BY razon_social
|
||||
LIMIT 10
|
||||
`, [tenantRfc, `%${q}%`]);
|
||||
`, params);
|
||||
|
||||
res.json(rows);
|
||||
} catch (error) { next(error); }
|
||||
|
||||
@@ -3,29 +3,42 @@ import { z } from 'zod';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import {
|
||||
EMAIL_TYPES,
|
||||
getEmailPreferencesPorContribuyente,
|
||||
setContribuyenteEmailPreferences,
|
||||
NOTIFICATION_ROLES,
|
||||
getRoleEmailPreferences,
|
||||
setRoleEmailPreference,
|
||||
type EmailType,
|
||||
type NotificationRole,
|
||||
} from '../services/notification-preferences.service.js';
|
||||
|
||||
export async function listPreferences(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = await getEmailPreferencesPorContribuyente(req.tenantPool!);
|
||||
res.json({ emailTypes: EMAIL_TYPES, data });
|
||||
const preferences = await getRoleEmailPreferences(req.tenantPool!);
|
||||
res.json({
|
||||
emailTypes: EMAIL_TYPES,
|
||||
roles: NOTIFICATION_ROLES,
|
||||
preferences,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
const updateSchema = z.object({
|
||||
contribuyenteId: z.string().uuid(),
|
||||
preferences: z.record(z.string(), z.boolean()),
|
||||
emailType: z.enum([...EMAIL_TYPES] as [string, ...string[]]),
|
||||
role: z.enum([...NOTIFICATION_ROLES] as [string, ...string[]]),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
export async function updatePreferences(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { contribuyenteId, preferences } = updateSchema.parse(req.body);
|
||||
const updated = await setContribuyenteEmailPreferences(req.tenantPool!, contribuyenteId, preferences);
|
||||
res.json({ contribuyenteId, preferences: updated });
|
||||
const { emailType, role, enabled } = updateSchema.parse(req.body);
|
||||
const preferences = await setRoleEmailPreference(
|
||||
req.tenantPool!,
|
||||
emailType as EmailType,
|
||||
role as NotificationRole,
|
||||
enabled,
|
||||
);
|
||||
res.json({ preferences });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
|
||||
@@ -4,15 +4,10 @@ import { AppError } from '../middlewares/error.middleware.js';
|
||||
import * as papeleriaService from '../services/papeleria.service.js';
|
||||
import { emailService } from '../services/email/email.service.js';
|
||||
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
|
||||
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
function rejectClienteRole(req: Request): void {
|
||||
if (req.user?.role === 'cliente') {
|
||||
throw new AppError(403, 'Papelería no disponible para usuarios cliente');
|
||||
}
|
||||
}
|
||||
|
||||
function effectiveTenantId(req: Request): string {
|
||||
return req.viewingTenantId || req.user!.tenantId;
|
||||
}
|
||||
@@ -24,6 +19,7 @@ const uploadSchema = z.object({
|
||||
anio: z.number().int().min(2000).max(2100),
|
||||
mes: z.number().int().min(1).max(12),
|
||||
requiereAprobacion: z.boolean(),
|
||||
requiereAprobacionCliente: z.boolean(),
|
||||
archivoBase64: z.string().min(1),
|
||||
archivoFilename: z.string().min(1).max(255),
|
||||
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) {
|
||||
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 archivo = Buffer.from(data.archivoBase64, 'base64');
|
||||
|
||||
@@ -42,18 +40,23 @@ export async function upload(req: Request, res: Response, next: NextFunction) {
|
||||
anio: data.anio,
|
||||
mes: data.mes,
|
||||
requiereAprobacion: data.requiereAprobacion,
|
||||
requiereAprobacionCliente: data.requiereAprobacionCliente,
|
||||
archivo,
|
||||
archivoFilename: data.archivoFilename,
|
||||
archivoMime: data.archivoMime,
|
||||
subidoPor: req.user!.userId,
|
||||
});
|
||||
|
||||
// Notificación a aprobadores si la papelería requiere aprobación.
|
||||
if (item.requiereAprobacion) {
|
||||
notifyAprobacionRequerida(req, item).catch(err =>
|
||||
console.error('[papeleria.upload] notify aprobadores failed:', err?.message || err),
|
||||
);
|
||||
}
|
||||
if (item.requiereAprobacionCliente) {
|
||||
notifyClienteAprobacionRequerida(req, item).catch(err =>
|
||||
console.error('[papeleria.upload] notify clientes failed:', err?.message || err),
|
||||
);
|
||||
}
|
||||
|
||||
res.status(201).json(item);
|
||||
} catch (error: any) {
|
||||
@@ -74,13 +77,20 @@ const listSchema = z.object({
|
||||
|
||||
export async function list(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
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!, {
|
||||
contribuyenteId: q.contribuyenteId,
|
||||
anio: q.anio ? parseInt(q.anio, 10) : undefined,
|
||||
mes: q.mes ? parseInt(q.mes, 10) : undefined,
|
||||
estado: q.estado,
|
||||
entidadIds,
|
||||
userRole: req.user!.role,
|
||||
});
|
||||
res.json(items);
|
||||
} 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) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
const id = parseInt(String(req.params.id), 10);
|
||||
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
||||
|
||||
const item = await papeleriaService.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);
|
||||
if (!file) return next(new AppError(404, 'Documento no encontrado'));
|
||||
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) {
|
||||
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);
|
||||
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
||||
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) {
|
||||
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);
|
||||
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
||||
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) {
|
||||
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);
|
||||
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
|
||||
const ok = await papeleriaService.eliminar(req.tenantPool!, id);
|
||||
@@ -161,22 +242,26 @@ export async function eliminar(req: Request, res: Response, next: NextFunction)
|
||||
|
||||
// ─── 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.
|
||||
* Owners se obtienen de tenant_memberships (BD central). Supervisores se
|
||||
* resuelven leyendo carteras del tenant.
|
||||
*/
|
||||
async function notifyAprobacionRequerida(
|
||||
req: Request,
|
||||
item: papeleriaService.PapeleriaItem,
|
||||
): Promise<void> {
|
||||
const tenantId = effectiveTenantId(req);
|
||||
|
||||
// Owners del despacho
|
||||
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({
|
||||
where: { tenantId, active: true, rol: { nombre: { in: ['supervisor', 'cfo'] } } },
|
||||
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);
|
||||
}
|
||||
|
||||
// No notificarse a sí mismo
|
||||
recipients.delete(req.user!.email);
|
||||
|
||||
if (recipients.size === 0) return;
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { nombre: true },
|
||||
});
|
||||
|
||||
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
|
||||
`SELECT c.rfc, eg.nombre FROM contribuyentes c
|
||||
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||
WHERE c.entidad_id = $1`,
|
||||
[item.contribuyenteId],
|
||||
);
|
||||
if (rows.length === 0) return;
|
||||
const 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'];
|
||||
@@ -210,8 +287,8 @@ async function notifyAprobacionRequerida(
|
||||
for (const to of recipients) {
|
||||
try {
|
||||
await emailService.sendPapeleriaAprobacionRequerida(to, {
|
||||
contribuyenteRfc: rows[0].rfc,
|
||||
contribuyenteNombre: rows[0].nombre,
|
||||
contribuyenteRfc: info.rfc,
|
||||
contribuyenteNombre: info.nombre,
|
||||
despachoNombre: tenant?.nombre,
|
||||
nombreDocumento: item.nombre,
|
||||
descripcion: item.descripcion,
|
||||
@@ -226,9 +303,7 @@ async function notifyAprobacionRequerida(
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifica al uploader (auxiliar) cuando un documento que él subió fue
|
||||
* aprobado o rechazado. Solo manda si quien aprobó/rechazó NO es el mismo
|
||||
* uploader (caso edge: owner sube su propia papelería).
|
||||
* Notifica al uploader cuando un documento fue aprobado o rechazado por owner/supervisor.
|
||||
*/
|
||||
async function notifyDecisionAuxiliar(
|
||||
req: Request,
|
||||
@@ -238,21 +313,16 @@ async function notifyDecisionAuxiliar(
|
||||
const auxiliarEmail = await getUserEmailById(item.subidoPor);
|
||||
if (!auxiliarEmail) return;
|
||||
|
||||
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
|
||||
`SELECT c.rfc, eg.nombre FROM contribuyentes c
|
||||
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||
WHERE c.entidad_id = $1`,
|
||||
[item.contribuyenteId],
|
||||
);
|
||||
if (rows.length === 0) return;
|
||||
const 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}`;
|
||||
|
||||
await emailService.sendPapeleriaDecision(auxiliarEmail, {
|
||||
contribuyenteRfc: rows[0].rfc,
|
||||
contribuyenteNombre: rows[0].nombre,
|
||||
contribuyenteRfc: info.rfc,
|
||||
contribuyenteNombre: info.nombre,
|
||||
nombreDocumento: item.nombre,
|
||||
estado: item.estado as 'aprobado' | 'rechazado',
|
||||
revisor: req.user!.email,
|
||||
@@ -261,3 +331,57 @@ async function notifyDecisionAuxiliar(
|
||||
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')) {
|
||||
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) {
|
||||
return res.status(503).json({
|
||||
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 { AppError } from '../middlewares/error.middleware.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 { getUserEmailById } from '../utils/memberships.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) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||
import * as usuariosService from '../services/usuarios.service.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import { isGlobalAdmin as checkGlobalAdmin } from '../utils/global-admin.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
const inviteSchema = z.object({
|
||||
email: z.string().email('email inválido'),
|
||||
@@ -64,11 +65,16 @@ export async function getAllUsuarios(req: Request, res: Response, next: NextFunc
|
||||
|
||||
export async function inviteUsuario(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user!.role !== 'owner') {
|
||||
throw new AppError(403, 'Solo los dueños pueden invitar usuarios');
|
||||
if (!['owner', 'cfo', 'supervisor'].includes(req.user!.role)) {
|
||||
throw new AppError(403, 'No autorizado para invitar usuarios');
|
||||
}
|
||||
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
|
||||
if (data.role === 'auxiliar' && !data.supervisorUserId) {
|
||||
throw new AppError(400, 'Debes asignar un supervisor al auxiliar');
|
||||
@@ -139,7 +145,16 @@ export async function getSupervisor(req: Request, res: Response, next: NextFunct
|
||||
LIMIT 1`,
|
||||
[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) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,21 @@ import { despachoPlanTieneDualidadDb } from '../services/plan-catalogo.service.j
|
||||
import { emailService } from '../services/email/email.service.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) {
|
||||
try {
|
||||
const { type, data } = req.body;
|
||||
@@ -159,6 +174,57 @@ async function handlePaymentNotification(paymentId: string) {
|
||||
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
|
||||
const tenantId = payment.externalReference;
|
||||
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
|
||||
// `getPlanPrice(phase='firstYear')` devolvería para este plan.
|
||||
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({
|
||||
where: { id: subscription.id },
|
||||
data: { status: 'authorized' },
|
||||
data: updateData,
|
||||
});
|
||||
subscriptionService.invalidateSubscriptionCache(tenantId);
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ import { resetExpiredMonthlyTimbres } from '../services/facturapi.service.js';
|
||||
import { purgeDeclaracionesAntiguas } from '../services/declaraciones.service.js';
|
||||
import { consultarConstancia, purgeConstanciasAntiguas } from '../services/constancia.service.js';
|
||||
import { tenantDb } from '../config/database.js';
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
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 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)
|
||||
@@ -20,6 +22,38 @@ const EXPIRY_REMINDERS_CRON = '0 9 * * *'; // 9:00 AM diario — avisos p
|
||||
|
||||
let isRunning = 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
|
||||
@@ -27,13 +61,13 @@ let isIncrementalRunning = false;
|
||||
async function getTenantsWithFiel(): Promise<string[]> {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true },
|
||||
select: { id: true, databaseName: true },
|
||||
});
|
||||
|
||||
const tenantsWithFiel: string[] = [];
|
||||
|
||||
for (const tenant of tenants) {
|
||||
const hasFiel = await hasFielConfigured(tenant.id);
|
||||
const hasFiel = await hasAnyFielConfigured(tenant.id, tenant.databaseName);
|
||||
if (hasFiel) {
|
||||
tenantsWithFiel.push(tenant.id);
|
||||
}
|
||||
@@ -172,12 +206,12 @@ async function getTenantsConSatIncremental(): Promise<string[]> {
|
||||
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true, plan: { in: planNames as any } },
|
||||
select: { id: true },
|
||||
select: { id: true, databaseName: true },
|
||||
});
|
||||
|
||||
const result: string[] = [];
|
||||
for (const tenant of tenants) {
|
||||
if (await hasFielConfigured(tenant.id)) {
|
||||
if (await hasAnyFielConfigured(tenant.id, tenant.databaseName)) {
|
||||
result.push(tenant.id);
|
||||
}
|
||||
}
|
||||
@@ -351,12 +385,153 @@ async function runCsfJob(): Promise<void> {
|
||||
console.error(`[CSF Cron] Error para ${tenant.rfc}:`, error.message);
|
||||
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}`);
|
||||
}
|
||||
|
||||
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 retryTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
let recoveryTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
let opinionTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
let csfTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
let incrementalTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
@@ -397,6 +572,19 @@ export function startSatSyncJob(): void {
|
||||
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
|
||||
// (pending con nextRetryAt > 12h atrás, running con startedAt > 4h atrás).
|
||||
// Thresholds sobreescribibles vía env (STALE_PENDING_HOURS / STALE_RUNNING_HOURS)
|
||||
@@ -502,6 +690,7 @@ export function startSatSyncJob(): void {
|
||||
|
||||
console.log(`[SAT Cron] Job programado para: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||
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(`[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)`);
|
||||
@@ -521,6 +710,10 @@ export function stopSatSyncJob(): void {
|
||||
retryTask.stop();
|
||||
retryTask = null;
|
||||
}
|
||||
if (recoveryTask) {
|
||||
recoveryTask.stop();
|
||||
recoveryTask = null;
|
||||
}
|
||||
if (opinionTask) {
|
||||
opinionTask.stop();
|
||||
opinionTask = null;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { tenantDb } from '../config/database.js';
|
||||
import { getKpis } from '../services/dashboard.service.js';
|
||||
import { generarAlertasAutomaticas, getDiscrepanciasPorMes } from '../services/alertas-auto.service.js';
|
||||
import { emailService } from '../services/email/email.service.js';
|
||||
import { filterRecipientsByRole } from '../services/notification-preferences.service.js';
|
||||
|
||||
const SCHEDULE = '0 8 * * 1'; // Lunes 8:00 AM
|
||||
|
||||
@@ -45,19 +46,27 @@ export async function sendWeeklyUpdateForTenant(tenantId: string): Promise<{ sen
|
||||
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({
|
||||
where: { tenantId, isOwner: true, active: true },
|
||||
include: { user: { select: { email: true, nombre: true, active: true } } },
|
||||
});
|
||||
const recipients = owners.filter(o => o.user.active);
|
||||
if (recipients.length === 0) {
|
||||
const activeOwners = owners.filter(o => o.user.active);
|
||||
if (activeOwners.length === 0) {
|
||||
console.log(`[Weekly] Tenant ${tenant.rfc} sin owners activos, skip`);
|
||||
return { sent: 0 };
|
||||
}
|
||||
|
||||
// Pool del tenant para queries de CFDI
|
||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
||||
const recipientsWithRole = activeOwners.map(o => ({ email: o.user.email, role: 'owner' as const }));
|
||||
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();
|
||||
|
||||
|
||||
@@ -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 { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import * as ctrl from '../controllers/cartera.controller.js';
|
||||
import * as asignacionesCtrl from '../controllers/asignaciones.controller.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -11,6 +12,12 @@ router.use(tenantMiddleware);
|
||||
// Static routes first
|
||||
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
|
||||
router.get('/', authorize('owner', 'supervisor', 'auxiliar'), ctrl.list);
|
||||
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/conceptos', cfdiController.getConceptos);
|
||||
router.get('/:id/xml', cfdiController.getXml);
|
||||
router.post('/download-xmls', cfdiController.downloadXmlsZip);
|
||||
router.post('/', checkCfdiLimit, cfdiController.createCfdi);
|
||||
// Bulk upload: 10/hora — procesa hasta 50MB, pesado en parseo + inserts
|
||||
router.post('/bulk', strictLimit, express.json({ limit: '50mb' }), checkCfdiLimit, cfdiController.createManyCfdis);
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as ctrl from '../controllers/contribuyente.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 asignacionesCtrl from '../controllers/asignaciones.controller.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -13,7 +14,7 @@ router.use(tenantMiddleware);
|
||||
|
||||
// === Static routes FIRST (before /:id to avoid route conflict) ===
|
||||
router.get('/', ctrl.list);
|
||||
router.post('/', authorize('owner', 'cfo'), ctrl.create);
|
||||
router.post('/', authorize('owner', 'cfo', 'supervisor'), ctrl.create);
|
||||
router.post('/backfill', authorize('owner'), ctrl.backfill);
|
||||
router.get('/catalogo-obligaciones', obligacionesCtrl.getCatalogo);
|
||||
|
||||
@@ -24,14 +25,14 @@ router.delete('/:id', authorize('owner'), ctrl.deactivate);
|
||||
router.post('/:id/cliente-acceso', authorize('owner', 'supervisor'), ctrl.addClienteAcceso);
|
||||
|
||||
// FIEL per contribuyente
|
||||
router.post('/:id/fiel', authorize('owner', 'cfo'), configCtrl.uploadFiel);
|
||||
router.post('/:id/fiel', authorize('owner', 'cfo', 'supervisor'), configCtrl.uploadFiel);
|
||||
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
|
||||
router.post('/:id/facturapi/org', authorize('owner', 'cfo'), configCtrl.createOrg);
|
||||
router.get('/:id/facturapi/status', configCtrl.orgStatus);
|
||||
router.post('/:id/facturapi/csd', authorize('owner', 'cfo'), configCtrl.uploadCsd);
|
||||
router.post('/:id/facturapi/csd', authorize('owner', 'cfo', 'supervisor'), configCtrl.uploadCsd);
|
||||
|
||||
// Personalización per contribuyente
|
||||
router.get('/:id/facturapi/customization', facturacionCtrl.getCustomizationContribuyenteCtrl);
|
||||
@@ -41,13 +42,17 @@ router.put('/:id/facturapi/color', authorize('owner', 'cfo'), facturacionCtrl.up
|
||||
// Obligaciones fiscales per contribuyente
|
||||
router.get('/:id/obligaciones/periodo', obligacionesCtrl.getObligacionesPorPeriodo);
|
||||
router.get('/:id/obligaciones', obligacionesCtrl.getObligaciones);
|
||||
router.post('/:id/obligaciones/init', authorize('owner', 'cfo'), obligacionesCtrl.initRecomendaciones);
|
||||
router.post('/:id/obligaciones', authorize('owner', 'cfo'), obligacionesCtrl.addObligacion);
|
||||
router.delete('/:id/obligaciones/:obligacionId', authorize('owner', 'cfo'), obligacionesCtrl.removeObligacion);
|
||||
router.post('/:id/obligaciones/:obligacionId/restore', authorize('owner', 'cfo'), obligacionesCtrl.restoreObligacion);
|
||||
router.post('/:id/obligaciones/init', authorize('owner', 'cfo', 'supervisor'), obligacionesCtrl.initRecomendaciones);
|
||||
router.post('/:id/obligaciones', authorize('owner', 'cfo', 'supervisor'), obligacionesCtrl.addObligacion);
|
||||
router.delete('/:id/obligaciones/:obligacionId', authorize('owner', 'cfo', 'supervisor'), obligacionesCtrl.removeObligacion);
|
||||
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/uncomplete', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.uncompleteObligacion);
|
||||
router.post('/:id/obligaciones/:obligacionId/complete-periodo', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.completePeriodo);
|
||||
router.post('/:id/obligaciones/:obligacionId/uncomplete-periodo', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.uncompletePeriodo);
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -35,4 +35,10 @@ router.post('/extras', documentosController.crearExtra);
|
||||
router.get('/extras/:id/pdf', documentosController.descargarExtraPdf);
|
||||
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 };
|
||||
|
||||
@@ -13,6 +13,8 @@ router.post('/', ctrl.upload);
|
||||
router.get('/:id/download', ctrl.download);
|
||||
router.post('/:id/aprobar', ctrl.aprobar);
|
||||
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);
|
||||
|
||||
export { router as papeleriaRoutes };
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
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 * as ctrl from '../controllers/tareas.controller.js';
|
||||
import * as asignacionesCtrl from '../controllers/asignaciones.controller.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
|
||||
router.get('/mis-tareas', ctrl.listMisTareas);
|
||||
router.get('/', ctrl.listTareas);
|
||||
router.post('/', ctrl.createTarea);
|
||||
router.post('/seed', ctrl.seedDefaults);
|
||||
@@ -17,4 +19,8 @@ router.delete('/:id', ctrl.deleteTarea);
|
||||
router.post('/periodo/:id/completar', ctrl.completarPeriodo);
|
||||
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 };
|
||||
|
||||
@@ -24,44 +24,62 @@
|
||||
* el de activos aplica también pero algunos predicados son no-op funcional
|
||||
* en subqueries que filtran por tipo_comprobante específico (Postgres los
|
||||
* optimiza away).
|
||||
*
|
||||
* 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')";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* `FROM cfdis`) referencia un activo directamente (I), indirectamente vía
|
||||
* pago (P→I), o transitivamente vía relación (E→I, E→P→I).
|
||||
*
|
||||
* IMPORTANTE — qualifying outer refs: dentro de los subqueries `cfdis i_act`
|
||||
* y `cfdis r_act`, la tabla interna también tiene columnas `uuid_relacionado`
|
||||
* y `cfdis_relacionados`. Una referencia no-qualificada las resolvería a las
|
||||
* columnas internas (NO al row outer), volviendo el predicado a no-op.
|
||||
* Por eso usamos `cfdis.uuid_relacionado` y `cfdis.cfdis_relacionados`
|
||||
* explícitamente — fuerza la resolución al outer.
|
||||
*/
|
||||
function activosExclusionNoAlias(): string {
|
||||
return `
|
||||
AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS_USOS})
|
||||
AND NOT (tipo_comprobante = 'P' AND EXISTS (
|
||||
SELECT 1 FROM cfdis i_act
|
||||
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
|
||||
AND i_act.tipo_comprobante = 'I'
|
||||
AND i_act.uso_cfdi IN ${ACTIVOS_USOS}
|
||||
))
|
||||
AND NOT (tipo_comprobante = 'E' AND cfdis.cfdis_relacionados IS NOT NULL AND EXISTS (
|
||||
SELECT 1 FROM cfdis r_act
|
||||
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(cfdis.cfdis_relacionados), '|'))
|
||||
AND (
|
||||
(r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS_USOS})
|
||||
OR (r_act.tipo_comprobante = 'P' AND EXISTS (
|
||||
SELECT 1 FROM cfdis pi_act
|
||||
WHERE LOWER(pi_act.uuid) = LOWER(r_act.uuid_relacionado)
|
||||
AND pi_act.tipo_comprobante = 'I'
|
||||
AND pi_act.uso_cfdi IN ${ACTIVOS_USOS}
|
||||
))
|
||||
)
|
||||
SELECT 1 FROM (${UUIDS_ACTIVOS}) ua
|
||||
WHERE ua.uuid = ANY(string_to_array(LOWER(uuid_relacionado), '|'))
|
||||
))
|
||||
AND NOT (tipo_comprobante = 'E' AND uuid IN (${UUIDS_E_DE_ACTIVOS}))
|
||||
AND NOT (tipo_comprobante = 'I' AND EXISTS (
|
||||
-- Anticipo: CFDI tipo I (puede no tener uso_cfdi de activo) que es
|
||||
-- referenciado por una I/07 PPD con uso_cfdi de activo. La I/07 PPD
|
||||
@@ -87,24 +105,10 @@ function activosExclusionAlias(alias: string): string {
|
||||
return `
|
||||
AND NOT (${alias}.tipo_comprobante = 'I' AND ${alias}.uso_cfdi IN ${ACTIVOS_USOS})
|
||||
AND NOT (${alias}.tipo_comprobante = 'P' AND EXISTS (
|
||||
SELECT 1 FROM cfdis i_act
|
||||
WHERE LOWER(i_act.uuid) = LOWER(${alias}.uuid_relacionado)
|
||||
AND i_act.tipo_comprobante = 'I'
|
||||
AND i_act.uso_cfdi IN ${ACTIVOS_USOS}
|
||||
))
|
||||
AND NOT (${alias}.tipo_comprobante = 'E' AND ${alias}.cfdis_relacionados IS NOT NULL AND EXISTS (
|
||||
SELECT 1 FROM cfdis r_act
|
||||
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(${alias}.cfdis_relacionados), '|'))
|
||||
AND (
|
||||
(r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS_USOS})
|
||||
OR (r_act.tipo_comprobante = 'P' AND EXISTS (
|
||||
SELECT 1 FROM cfdis pi_act
|
||||
WHERE LOWER(pi_act.uuid) = LOWER(r_act.uuid_relacionado)
|
||||
AND pi_act.tipo_comprobante = 'I'
|
||||
AND pi_act.uso_cfdi IN ${ACTIVOS_USOS}
|
||||
))
|
||||
)
|
||||
SELECT 1 FROM (${UUIDS_ACTIVOS}) ua
|
||||
WHERE ua.uuid = ANY(string_to_array(LOWER(${alias}.uuid_relacionado), '|'))
|
||||
))
|
||||
AND NOT (${alias}.tipo_comprobante = 'E' AND ${alias}.uuid IN (${UUIDS_E_DE_ACTIVOS}))
|
||||
AND NOT (${alias}.tipo_comprobante = 'I' AND EXISTS (
|
||||
SELECT 1 FROM cfdis i07_act
|
||||
WHERE i07_act.tipo_comprobante = 'I'
|
||||
|
||||
@@ -66,11 +66,13 @@ export async function getClientesStats(range: ClientesStatsRange): Promise<Clien
|
||||
paymentsCount: payments._count,
|
||||
};
|
||||
|
||||
// 3) Clientes que NO renovaron: subs cuyo currentPeriodEnd cae en el rango
|
||||
// y que están en status terminal (cancelled, trial_expired, paused) o sin
|
||||
// payment posterior aprobado. Nota: un sub `authorized` con periodEnd
|
||||
// pasado es un "se renovó automáticamente" — para detectar no-renovaciones
|
||||
// miramos status efectivo + ausencia de payment en los siguientes 7 días.
|
||||
// 3) Clientes que NO renovaron:
|
||||
// a) Subs cuyo currentPeriodEnd cae en el rango y están en status terminal
|
||||
// (cancelled, trial_expired, paused).
|
||||
// b) Tenants cuyo trialEndsAt ya pasó y NO tienen suscripción authorized
|
||||
// (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({
|
||||
where: {
|
||||
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 } },
|
||||
},
|
||||
});
|
||||
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,
|
||||
tenantNombre: s.tenant?.nombre ?? '',
|
||||
rfc: s.tenant?.rfc ?? '',
|
||||
plan: String(s.plan),
|
||||
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '',
|
||||
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)
|
||||
const memberships = await prisma.tenantMembership.findMany({
|
||||
|
||||
@@ -609,30 +609,46 @@ async function alertaOpinionCumplimiento(pool: Pool, contribuyenteId?: string |
|
||||
|
||||
/**
|
||||
* 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(
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<AlertaAuto[]> {
|
||||
const alertas = await Promise.all([
|
||||
alertaListaNegraPropia(pool, tenantId, contribuyenteId),
|
||||
alertaClienteListaNegra(pool, contribuyenteId),
|
||||
alertaProveedorListaNegra(pool, contribuyenteId),
|
||||
alertaDiscrepanciaRegimen(pool, tenantId, contribuyenteId),
|
||||
alertaConcentracionClientes(pool, contribuyenteId),
|
||||
alertaConcentracionProveedores(pool, contribuyenteId),
|
||||
alertaRiesgoCambiario(pool, contribuyenteId),
|
||||
alertaRiesgoCancelaciones(pool, contribuyenteId),
|
||||
alertaRiesgoTransaccional(pool, contribuyenteId),
|
||||
alertaCancelacionPeriodoAnterior(pool, contribuyenteId),
|
||||
alertaOpinionCumplimiento(pool, contribuyenteId),
|
||||
alertaTipoRelacionSospechosa(pool, contribuyenteId),
|
||||
alertaTareasProximasVencer(pool, contribuyenteId),
|
||||
alertaResicoPfLimiteIngresos(pool, contribuyenteId),
|
||||
]);
|
||||
const generadores: { name: string; fn: () => Promise<AlertaAuto | null> }[] = [
|
||||
{ name: 'lista-negra-propia', fn: () => alertaListaNegraPropia(pool, tenantId, contribuyenteId) },
|
||||
{ name: 'lista-negra-clientes', fn: () => alertaClienteListaNegra(pool, contribuyenteId) },
|
||||
{ name: 'lista-negra-proveedores', fn: () => alertaProveedorListaNegra(pool, contribuyenteId) },
|
||||
{ name: 'discrepancia-regimen', fn: () => alertaDiscrepanciaRegimen(pool, tenantId, contribuyenteId) },
|
||||
{ name: 'concentracion-clientes', fn: () => alertaConcentracionClientes(pool, contribuyenteId) },
|
||||
{ name: 'concentracion-proveedores', fn: () => alertaConcentracionProveedores(pool, contribuyenteId) },
|
||||
{ name: 'riesgo-cambiario', fn: () => alertaRiesgoCambiario(pool, contribuyenteId) },
|
||||
{ name: 'riesgo-cancelaciones', fn: () => alertaRiesgoCancelaciones(pool, contribuyenteId) },
|
||||
{ name: 'riesgo-transaccional', fn: () => alertaRiesgoTransaccional(pool, contribuyenteId) },
|
||||
{ name: 'cancelacion-periodo-anterior', fn: () => alertaCancelacionPeriodoAnterior(pool, contribuyenteId) },
|
||||
{ name: 'opinion-cumplimiento', fn: () => alertaOpinionCumplimiento(pool, contribuyenteId) },
|
||||
{ name: 'tipo-relacion-sospechosa', fn: () => alertaTipoRelacionSospechosa(pool, contribuyenteId) },
|
||||
{ name: 'tareas-proximas-vencer', fn: () => alertaTareasProximasVencer(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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -119,6 +119,7 @@ function appliesToPeriod(frecuencia: string | null, periodo: string): boolean {
|
||||
case 'mensual': return true;
|
||||
case 'bimestral': return month % 2 === 1;
|
||||
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 'eventual': return false;
|
||||
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);
|
||||
}
|
||||
@@ -214,6 +214,7 @@ export async function generarEventosDesdeObligaciones(
|
||||
if (freq === 'mensual') monthsToGenerate.push(m);
|
||||
else if (freq === 'bimestral' && m % 2 === 1) monthsToGenerate.push(m);
|
||||
else if (freq === 'trimestral' && [1, 4, 7, 10].includes(m)) monthsToGenerate.push(m);
|
||||
else if (freq === 'cuatrimestral' && [1, 5, 9].includes(m)) monthsToGenerate.push(m);
|
||||
else if (freq === 'anual' && (m === 3 || m === 4)) monthsToGenerate.push(m);
|
||||
// 'eventual' and unknown: skip auto-generation
|
||||
}
|
||||
|
||||
@@ -110,6 +110,17 @@ export async function deleteCartera(pool: Pool, id: string): Promise<boolean> {
|
||||
|
||||
// Entidades in cartera
|
||||
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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -357,6 +357,81 @@ export async function getXmlById(pool: Pool, id: string): Promise<string | 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 {
|
||||
uuid: string;
|
||||
type: 'EMITIDO' | 'RECIBIDO';
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface ConciliacionCfdi {
|
||||
nombreEmisor: string;
|
||||
rfcReceptor: string;
|
||||
nombreReceptor: string;
|
||||
regimenFiscalEmisor: string | null;
|
||||
regimenFiscalReceptor: string | null;
|
||||
total: number;
|
||||
totalMxn: number;
|
||||
subtotal: number;
|
||||
@@ -98,6 +100,7 @@ export async function getCfdisConConciliacion(
|
||||
c.fecha_emision as "fechaEmision",
|
||||
c.rfc_emisor as "rfcEmisor", c.nombre_emisor as "nombreEmisor",
|
||||
c.rfc_receptor as "rfcReceptor", c.nombre_receptor as "nombreReceptor",
|
||||
c.regimen_fiscal_emisor as "regimenFiscalEmisor", c.regimen_fiscal_receptor as "regimenFiscalReceptor",
|
||||
c.total, c.total_mxn as "totalMxn",
|
||||
c.subtotal, c.descuento,
|
||||
c.moneda, c.tipo_cambio as "tipoCambio",
|
||||
@@ -136,6 +139,8 @@ export async function getCfdisConConciliacion(
|
||||
nombreEmisor: r.nombreEmisor,
|
||||
rfcReceptor: r.rfcReceptor,
|
||||
nombreReceptor: r.nombreReceptor,
|
||||
regimenFiscalEmisor: r.regimenFiscalEmisor,
|
||||
regimenFiscalReceptor: r.regimenFiscalReceptor,
|
||||
total: Number(r.total),
|
||||
totalMxn: Number(r.totalMxn),
|
||||
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
|
||||
* 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.
|
||||
*
|
||||
* 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> {
|
||||
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');
|
||||
|
||||
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 tempDir = join(tmpdir(), `horux-csf-${tempId}`);
|
||||
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(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
|
||||
|
||||
// Headless por default. El fix de dispatchEvent en sat-csf-login cubre el
|
||||
// caso donde el click sintético no dispara el handler del SAT. Si algún
|
||||
// ambiente necesita ver el browser (debug), setear SAT_HEADLESS=false.
|
||||
const headless = process.env.SAT_HEADLESS !== 'false';
|
||||
const browser = await chromium.launch({
|
||||
headless,
|
||||
@@ -76,7 +80,7 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
|
||||
});
|
||||
try {
|
||||
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 () => {
|
||||
@@ -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 => {
|
||||
console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err);
|
||||
});
|
||||
@@ -116,11 +117,19 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
|
||||
} finally {
|
||||
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 {
|
||||
try { unlinkSync(cerPath); } catch { /* ok */ }
|
||||
try { unlinkSync(keyPath); } catch { /* ok */ }
|
||||
try { rmdirSync(tempDir); } catch { /* ok */ }
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('No debería llegar aquí');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1107,10 +1107,21 @@ export async function getKpis(
|
||||
const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId);
|
||||
const esEmisor = ctx.esEmisor;
|
||||
const esReceptor = ctx.esReceptor;
|
||||
const ingresosData = await calcularIngresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId);
|
||||
const egresosData = await calcularEgresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId);
|
||||
const adquisicionData = await calcularAdquisicionesMercancias(pool, tenantId, fechaInicio, fechaFin, conciliacion, contribuyenteId);
|
||||
const ivaData = await calcularIvaBalancePorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId);
|
||||
const [
|
||||
ingresosData,
|
||||
egresosData,
|
||||
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
|
||||
const ivaAFavorAcumulado = await calcularIvaAFavorAcumulado(pool, tenantId, fechaFin, undefined, conciliacion, contribuyenteId);
|
||||
@@ -1163,6 +1174,10 @@ export async function getKpis(
|
||||
cfdisEmitidosPorRegimen: emitidosPorRegimen,
|
||||
cfdisRecibidos: recibidosPorRegimen.reduce((s: number, r: any) => s + r.total, 0),
|
||||
cfdisRecibidosPorRegimen: recibidosPorRegimen,
|
||||
ncsEmitidas: ncsEmitidasData.total,
|
||||
ncsEmitidasPorRegimen: ncsEmitidasData.porRegimen,
|
||||
ncsRecibidas: ncsRecibidasData.total,
|
||||
ncsRecibidasPorRegimen: ncsRecibidasData.porRegimen,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,38 @@
|
||||
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
|
||||
// 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'] },
|
||||
ISR: { include: ['isr'], exclude: ['retenciones', 'asimilados a salarios'] },
|
||||
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: [] },
|
||||
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
|
||||
* 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,
|
||||
contribuyenteId: string,
|
||||
impuestos: string[],
|
||||
periodo: string,
|
||||
/** UUID del usuario que subió la declaración (obligacion_periodos.completada_por es uuid). */
|
||||
completadaPor: string,
|
||||
declaracionId: number,
|
||||
/** Periodicidad de la declaración. Si no se provee, se asume 'mensual'. */
|
||||
/** UUID del usuario que subió el documento. */
|
||||
subidoPor: string,
|
||||
pdfBase64: string,
|
||||
pdfFilename: string,
|
||||
tipoDocumento: 'declaracion' | 'pago',
|
||||
/** Periodicidad de la declaración. Si no se provee, asume 'mensual'. */
|
||||
periodicidad: string = 'mensual',
|
||||
): Promise<number> {
|
||||
): Promise<{ count: number; obligacionesAfectadas: string[] }> {
|
||||
// Get active obligations for this contribuyente (incluye frecuencia para filtrar)
|
||||
const { rows: obligaciones } = await pool.query<{ id: string; nombre: string; frecuencia: string | null }>(
|
||||
`SELECT id, nombre, frecuencia FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND activa = true`,
|
||||
@@ -42,6 +88,7 @@ async function completarObligacionesPorDeclaracion(
|
||||
);
|
||||
|
||||
let count = 0;
|
||||
const obligacionesAfectadas: string[] = [];
|
||||
|
||||
for (const impuesto of impuestos) {
|
||||
const rules = IMPUESTO_A_OBLIGACION_KEYWORDS[impuesto];
|
||||
@@ -54,33 +101,109 @@ async function completarObligacionesPorDeclaracion(
|
||||
if (!matches) continue;
|
||||
|
||||
// Filtro por periodicidad/frecuencia: una declaración mensual no debe
|
||||
// cerrar obligaciones anuales del mismo impuesto (ej. ISR mensual no
|
||||
// cubre "Declaración anual de ISR"). Si la obligación tiene frecuencia
|
||||
// explícita y no coincide con la periodicidad de la declaración, skip.
|
||||
// `eventual` obligaciones no se tocan automáticamente.
|
||||
// cerrar obligaciones anuales del mismo impuesto.
|
||||
const obFrec = (ob.frecuencia || '').toLowerCase();
|
||||
if (obFrec === 'eventual') continue;
|
||||
if (obFrec && obFrec !== periodicidad.toLowerCase()) continue;
|
||||
|
||||
// Mark obligation as completed for this period, with FK a la declaración
|
||||
await pool.query(`
|
||||
INSERT INTO obligacion_periodos (obligacion_id, periodo, completada, completada_at, completada_por, notas, declaracion_id)
|
||||
VALUES ($1, $2, true, now(), $3, $4, $5)
|
||||
ON CONFLICT (obligacion_id, periodo)
|
||||
DO UPDATE SET completada = true, completada_at = now(), completada_por = $3, declaracion_id = $5
|
||||
`, [ob.id, periodo, completadaPor, `Declaración ${impuesto} subida`, declaracionId]);
|
||||
|
||||
// Resolve the ob-* alert for this obligation+period
|
||||
await pool.query(
|
||||
`UPDATE alertas SET resuelta = true WHERE tipo = $1 AND resuelta = false`,
|
||||
[`ob-${ob.id}-${periodo}`],
|
||||
);
|
||||
await createEvidencia(pool, {
|
||||
obligacionId: ob.id,
|
||||
periodo,
|
||||
contribuyenteId,
|
||||
tipoDocumento,
|
||||
pdfBase64,
|
||||
pdfFilename,
|
||||
notas: `${tipoDocumento === 'pago' ? 'Pago' : 'Declaración'} ${impuesto}`,
|
||||
subidoPor,
|
||||
});
|
||||
|
||||
if (!obligacionesAfectadas.includes(ob.id)) obligacionesAfectadas.push(ob.id);
|
||||
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.
|
||||
*/
|
||||
|
||||
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 {
|
||||
id: number;
|
||||
@@ -123,17 +246,19 @@ const IMPUESTO_A_PREFIJO_DECL: Record<string, string[]> = {
|
||||
IVA: ['decl-iva'],
|
||||
ISR: ['decl-isr'],
|
||||
IEPS: ['decl-ieps'],
|
||||
SUELDOS: ['decl-sueldos'],
|
||||
ISN: ['decl-isn'],
|
||||
DIOT: ['diot'],
|
||||
OTRO: [],
|
||||
ISH: [],
|
||||
};
|
||||
const IMPUESTO_A_PREFIJO_PAGO: Record<string, string[]> = {
|
||||
IVA: ['pago-iva'],
|
||||
ISR: ['pago-isr'],
|
||||
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: [],
|
||||
OTRO: [],
|
||||
ISH: [],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -229,7 +354,10 @@ export async function createDeclaracion(
|
||||
mes: number;
|
||||
tipo: 'normal' | 'complementaria';
|
||||
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;
|
||||
pdfBase64: string; // PDF de la declaración (base64)
|
||||
pdfFilename: string;
|
||||
@@ -250,6 +378,16 @@ export async function createDeclaracion(
|
||||
// If monto_pago is exactly 0, auto-mark as paid (no payment receipt needed)
|
||||
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 {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO declaraciones_provisionales
|
||||
@@ -259,46 +397,55 @@ export async function createDeclaracion(
|
||||
RETURNING id, año, mes, tipo, periodicidad, impuestos, monto_pago, pdf_filename,
|
||||
pdf_liga_pago_filename, pdf_pago_filename, pagado_at, creado_por, notas,
|
||||
created_at, updated_at`,
|
||||
[data.año, data.mes, data.tipo, periodicidad, data.impuestos, montoPago,
|
||||
[data.año, data.mes, data.tipo, periodicidad, impuestos, montoPago,
|
||||
buf, data.pdfFilename, ligaBuf, data.ligaPagoFilename ?? null,
|
||||
data.notas ?? null, data.creadoPor, pagadoAt, data.contribuyenteId ?? null],
|
||||
);
|
||||
|
||||
const declaracion = rowToDeclaracion(rows[0]);
|
||||
|
||||
// Auto-resolver alertas. Reglas:
|
||||
// - tipo='normal': resuelve alertas de declaración (decl-*) del mes.
|
||||
// El pago se resuelve por separado al subir comprobante.
|
||||
// - tipo='complementaria': sustituye a la normal en términos de
|
||||
// obligación de pago — al subirla se resuelven AMBAS (decl-* y
|
||||
// pago-*) porque el cliente pagará usando la complementaria,
|
||||
// no la normal. La alerta de declaración ya estaría resuelta
|
||||
// si la normal se subió antes; el resolver es idempotente.
|
||||
const prefijosDecl = data.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_DECL[i] || []);
|
||||
// Guardar relación con obligaciones para que el comprobante de pago
|
||||
// posterior se aplique a las mismas obligaciones.
|
||||
if (obligacionesSeleccionadas.length > 0) {
|
||||
const values = obligacionesSeleccionadas.map((_, i) => `($1, $${i + 2})`).join(',');
|
||||
await pool.query(
|
||||
`INSERT INTO declaracion_obligaciones (declaracion_id, obligacion_id) VALUES ${values}`,
|
||||
[declaracion.id, ...obligacionesSeleccionadas.map(o => o.id)],
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (data.tipo === 'complementaria' || montoPago === 0) {
|
||||
// complementaria: sustituye normal para pago → resolver ambas
|
||||
// monto 0: nada que pagar → resolver alertas de pago también
|
||||
const prefijosPago = data.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []);
|
||||
const prefijosPago = impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []);
|
||||
alertasResueltas += await resolverAlertasPorPeriodo(pool, prefijosPago, data.año, data.mes);
|
||||
}
|
||||
|
||||
// Auto-complete obligaciones del contribuyente SOLO si la declaración
|
||||
// también cubre el pago (complementaria sustituye a la normal para el
|
||||
// pago; monto=0 significa "nada que pagar"). Una declaración normal con
|
||||
// monto>0 solo presenta el acuse — la obligación de pago sigue abierta
|
||||
// y se marca completada hasta que se suba el comprobante via
|
||||
// `uploadComprobantePago`. Esto mantiene las alertas `pago-*` y `ob-*`
|
||||
// visibles hasta que realmente se cierre el ciclo.
|
||||
const cubrePago = data.tipo === 'complementaria' || montoPago === 0;
|
||||
if (data.contribuyenteId && cubrePago) {
|
||||
if (!data.creadoPorUserId) {
|
||||
console.warn('[createDeclaracion] Sin creadoPorUserId — no se auto-completan obligaciones del contribuyente');
|
||||
} else {
|
||||
// Registrar evidencias de declaración en las obligaciones seleccionadas.
|
||||
// Fallback legacy: si no se enviaron obligaciones, se usa el keyword matching
|
||||
// anterior a partir de impuestos.
|
||||
let obligacionesAfectadas: string[] = obligacionesSeleccionadas.map(o => o.id);
|
||||
if (data.contribuyenteId && data.creadoPorUserId) {
|
||||
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 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] || []);
|
||||
let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosPago, declaracion.año, declaracion.mes);
|
||||
|
||||
// Al subirse el comprobante de pago, la obligación ahora SÍ está completada
|
||||
// (declaración + pago). Marcar `obligacion_periodos.completada=true` y
|
||||
// resolver los `ob-*` alerts. Requires contribuyenteId (guardado en la
|
||||
// declaración) y userId (del caller).
|
||||
// Registrar evidencias de pago en las obligaciones vinculadas a esta declaración.
|
||||
// Fallback legacy: si no hay relaciones, se usa keyword matching por impuestos.
|
||||
if (row.contribuyente_id && data.uploadedByUserId) {
|
||||
const periodo = `${declaracion.año}-${String(declaracion.mes).padStart(2, '0')}`;
|
||||
const periodicidad = row.periodicidad || 'mensual';
|
||||
alertasResueltas += await completarObligacionesPorDeclaracion(
|
||||
pool, row.contribuyente_id, declaracion.impuestos, periodo, data.uploadedByUserId, declaracion.id, periodicidad,
|
||||
|
||||
const { rows: relaciones } = await pool.query<{ obligacion_id: string }>(
|
||||
`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 };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { materializarPeriodos } from './tareas.service.js';
|
||||
|
||||
export interface ContribuyentesStats {
|
||||
totalContribuyentes: number;
|
||||
@@ -210,26 +211,58 @@ export async function getMisAsignados(
|
||||
const inicioMes = `${_año}-${String(_mes).padStart(2, '0')}-01`;
|
||||
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(
|
||||
`WITH obl AS (
|
||||
SELECT oc.contribuyente_id,
|
||||
COUNT(*) FILTER (WHERE op.completada = false AND op.periodo = $1)::int AS pendientes,
|
||||
COUNT(*) FILTER (WHERE op.completada = false AND op.periodo < $1)::int AS atrasadas,
|
||||
COUNT(*) FILTER (WHERE op.completada = true AND op.periodo = $1)::int AS completadas
|
||||
FROM obligaciones_contribuyente oc
|
||||
LEFT JOIN obligacion_periodos op ON op.obligacion_id = oc.id
|
||||
WHERE oc.contribuyente_id = ANY($4::uuid[]) AND oc.activa = true
|
||||
GROUP BY oc.contribuyente_id
|
||||
`WITH obligaciones_activas AS (
|
||||
SELECT id, contribuyente_id FROM obligaciones_contribuyente
|
||||
WHERE contribuyente_id = ANY($4::uuid[]) AND activa = true
|
||||
),
|
||||
op_actual AS (
|
||||
SELECT obligacion_id, completada FROM obligacion_periodos
|
||||
WHERE obligacion_id IN (SELECT id FROM obligaciones_activas) AND periodo = $1
|
||||
),
|
||||
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 (
|
||||
SELECT tc.contribuyente_id,
|
||||
COUNT(*) FILTER (WHERE tp.completada = false AND tp.fecha_limite BETWEEN $2::date AND $3::date)::int AS pendientes,
|
||||
COUNT(*) FILTER (WHERE tp.completada = false AND tp.fecha_limite < $2::date)::int AS atrasadas,
|
||||
COUNT(*) FILTER (WHERE tp.completada = true AND tp.fecha_limite BETWEEN $2::date AND $3::date)::int AS completadas
|
||||
FROM tareas_catalogo tc
|
||||
LEFT JOIN tarea_periodos tp ON tp.tarea_id = tc.id
|
||||
WHERE tc.contribuyente_id = ANY($4::uuid[]) AND tc.active = true
|
||||
GROUP BY tc.contribuyente_id
|
||||
SELECT ta.contribuyente_id,
|
||||
COUNT(*) FILTER (WHERE tar_a.completada IS NULL OR tar_a.completada = false)::int AS pendientes,
|
||||
COALESCE(SUM(tar_atr.atrasadas), 0)::int AS atrasadas,
|
||||
COUNT(*) FILTER (WHERE tar_a.completada = true)::int AS completadas
|
||||
FROM tareas_activas ta
|
||||
LEFT JOIN tar_actual tar_a ON tar_a.tarea_id = ta.id
|
||||
LEFT JOIN tar_atrasadas tar_atr ON tar_atr.tarea_id = ta.id
|
||||
GROUP BY ta.contribuyente_id
|
||||
)
|
||||
SELECT
|
||||
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';
|
||||
|
||||
const transport = createEmailTransport(
|
||||
@@ -13,8 +13,8 @@ const transport = createEmailTransport(
|
||||
: null
|
||||
);
|
||||
|
||||
async function sendEmail(to: string, subject: string, html: string) {
|
||||
await transport.send(to, subject, html);
|
||||
async function sendEmail(to: string, subject: string, html: string, attachments?: EmailAttachment[]) {
|
||||
await transport.send(to, subject, html, attachments);
|
||||
}
|
||||
|
||||
export const emailService = {
|
||||
@@ -128,10 +128,14 @@ export const emailService = {
|
||||
* Notifica la subida de una declaración o documento extra al despacho.
|
||||
* `recipients` debe venir deduplicado por el caller. El subject se
|
||||
* genera a partir del kind y RFC del contribuyente.
|
||||
*
|
||||
* Para declaraciones, `attachments` puede contener los PDFs subidos
|
||||
* (acuse + liga de pago) para enviarlos adjuntos al correo.
|
||||
*/
|
||||
sendDocumentoSubido: async (
|
||||
recipients: string[],
|
||||
data: import('./templates/documento-subido.js').DocumentoSubidoData,
|
||||
attachments?: EmailAttachment[],
|
||||
) => {
|
||||
if (recipients.length === 0) return;
|
||||
const { documentoSubidoEmail } = await import('./templates/documento-subido.js');
|
||||
@@ -143,7 +147,7 @@ export const emailService = {
|
||||
// destinatario NO debe impedir enviar al siguiente.
|
||||
for (const to of recipients) {
|
||||
try {
|
||||
await sendEmail(to, subject, html);
|
||||
await sendEmail(to, subject, html, attachments);
|
||||
} catch (err: any) {
|
||||
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
|
||||
* 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 {
|
||||
/** Kind: para el título/subject. */
|
||||
kind: 'declaracion' | 'extra';
|
||||
kind: 'declaracion' | 'extra' | 'obligacion_evidencia';
|
||||
/** Quién subió el documento (email). */
|
||||
subidoPor: string;
|
||||
/** RFC del contribuyente. */
|
||||
@@ -24,17 +24,30 @@ export interface DocumentoSubidoData {
|
||||
descripcion?: 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). */
|
||||
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 {
|
||||
const titulo = data.kind === 'declaracion'
|
||||
? 'Nueva declaración subida'
|
||||
: data.kind === 'obligacion_evidencia'
|
||||
? 'Nueva evidencia de obligación fiscal'
|
||||
: 'Nuevo documento subido';
|
||||
|
||||
const contenidoEspecifico = data.kind === 'declaracion' && data.declaracion
|
||||
? declaracionBlock(data.declaracion)
|
||||
: data.kind === 'obligacion_evidencia' && data.evidencia
|
||||
? evidenciaBlock(data.evidencia)
|
||||
: data.extra
|
||||
? extraBlock(data.extra)
|
||||
: '';
|
||||
@@ -42,7 +55,7 @@ export function documentoSubidoEmail(data: DocumentoSubidoData): string {
|
||||
return baseTemplate(`
|
||||
${heading(titulo)}
|
||||
<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>.
|
||||
</p>
|
||||
${infoBox(`
|
||||
@@ -57,6 +70,12 @@ export function documentoSubidoEmail(data: DocumentoSubidoData): string {
|
||||
<div style="margin-top:24px;">
|
||||
${primaryButton('Ver en el sistema', data.link)}
|
||||
</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 {
|
||||
return `
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Documento</p>
|
||||
|
||||
@@ -55,3 +55,32 @@ export function papeleriaDecisionEmail(d: PapeleriaDecisionData): string {
|
||||
`;
|
||||
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,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 { getRegimenesIgnoradosClaves } from './regimen.service.js';
|
||||
import {
|
||||
@@ -106,32 +106,40 @@ const SUM_E_REFERENCING_TRAS = (
|
||||
esLadoE: string,
|
||||
considerarActivos: boolean,
|
||||
considerarNCs: boolean,
|
||||
) => `COALESCE((
|
||||
) => {
|
||||
if (!considerarNCs) return '0';
|
||||
return `COALESCE((
|
||||
SELECT SUM(${IVA_TRAS_EXPR_ALIAS('e')})
|
||||
FROM cfdis e
|
||||
WHERE e.tipo_comprobante = 'E'
|
||||
AND e.metodo_pago = 'PUE'
|
||||
AND e.status NOT IN ('Cancelado', '0')
|
||||
AND ${esLadoE}
|
||||
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
||||
AND e.cfdis_relacionados IS NOT NULL
|
||||
AND string_to_array(LOWER(e.cfdis_relacionados), '|') @> ARRAY[LOWER(cfdis.uuid)]
|
||||
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 = (
|
||||
esLadoE: string,
|
||||
considerarActivos: boolean,
|
||||
considerarNCs: boolean,
|
||||
) => `COALESCE((
|
||||
) => {
|
||||
if (!considerarNCs) return '0';
|
||||
return `COALESCE((
|
||||
SELECT SUM(${IVA_RET_EXPR_ALIAS('e')})
|
||||
FROM cfdis e
|
||||
WHERE e.tipo_comprobante = 'E'
|
||||
AND e.metodo_pago = 'PUE'
|
||||
AND e.status NOT IN ('Cancelado', '0')
|
||||
AND ${esLadoE}
|
||||
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
||||
AND e.cfdis_relacionados IS NOT NULL
|
||||
AND string_to_array(LOWER(e.cfdis_relacionados), '|') @> ARRAY[LOWER(cfdis.uuid)]
|
||||
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.
|
||||
// Usa el RFC del contribuyente (via `ctx.esEmisor`/`ctx.esReceptor`) para
|
||||
// determinar el lado, no el `type` de BD.
|
||||
@@ -152,16 +160,20 @@ const HAS_E_REFERENCING_MISMO_MES = (
|
||||
esLadoE: string,
|
||||
considerarActivos: boolean,
|
||||
considerarNCs: boolean,
|
||||
) => `EXISTS (
|
||||
) => {
|
||||
if (!considerarNCs) return 'FALSE';
|
||||
return `EXISTS (
|
||||
SELECT 1 FROM cfdis e
|
||||
WHERE e.tipo_comprobante = 'E'
|
||||
AND e.metodo_pago = 'PUE'
|
||||
AND e.status NOT IN ('Cancelado', '0')
|
||||
AND ${esLadoE}
|
||||
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
||||
AND 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)
|
||||
= date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
|
||||
)`;
|
||||
)`;
|
||||
};
|
||||
|
||||
// Atribución por lado usando RFC en lugar de `type`. Los buckets son
|
||||
// factories que reciben el context del contribuyente:
|
||||
@@ -397,8 +409,8 @@ export async function getIvaMensual(
|
||||
const añoEnd = `${año}-12-31`;
|
||||
const extra = buildExtraFilters(considerarActivos, considerarNCs);
|
||||
|
||||
const [{ rows: causadoRows }, { rows: acreditableRows }] = await Promise.all([
|
||||
pool.query<{ mes: number; trasladado: string; retencion: string }>(`
|
||||
const { rows: causadoRows } = await withJitOff(pool, (client) =>
|
||||
client.query<{ mes: number; trasladado: string; retencion: string }>(`
|
||||
SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes,
|
||||
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
|
||||
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
|
||||
@@ -407,8 +419,10 @@ export async function getIvaMensual(
|
||||
AND ${VIGENTE} AND ${FR}${extra}
|
||||
AND (${REGIMEN_TENANT}) = ANY($3)
|
||||
GROUP BY mes
|
||||
`, [añoStart, añoEnd, TODOS_REGIMENES]),
|
||||
pool.query<{ mes: number; trasladado: string; retencion: string }>(`
|
||||
`, [añoStart, añoEnd, TODOS_REGIMENES])
|
||||
);
|
||||
const { rows: acreditableRows } = await withJitOff(pool, (client) =>
|
||||
client.query<{ mes: number; trasladado: string; retencion: string }>(`
|
||||
SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes,
|
||||
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
|
||||
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
|
||||
@@ -417,8 +431,8 @@ export async function getIvaMensual(
|
||||
AND ${VIGENTE} AND ${FR}${extra}
|
||||
AND (${REGIMEN_TENANT}) = ANY($3)
|
||||
GROUP BY mes
|
||||
`, [añoStart, añoEnd, TODOS_REGIMENES]),
|
||||
]);
|
||||
`, [añoStart, añoEnd, TODOS_REGIMENES])
|
||||
);
|
||||
|
||||
perMes = new Map();
|
||||
for (const row of causadoRows) {
|
||||
@@ -648,7 +662,8 @@ async function readResumenIvaFromCache(
|
||||
const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear();
|
||||
const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO;
|
||||
const REGIMEN_TENANT = regimenTenantExpr(ctx);
|
||||
const acumRow = (await pool.query(`
|
||||
const acumRow = (await withJitOff(pool, (client) =>
|
||||
client.query(`
|
||||
SELECT
|
||||
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) -
|
||||
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) -
|
||||
@@ -661,7 +676,8 @@ async function readResumenIvaFromCache(
|
||||
AND (${REGIMEN_TENANT}) = ANY($3)
|
||||
AND ${acumFR}
|
||||
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
|
||||
// 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.
|
||||
*/
|
||||
/**
|
||||
* 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(
|
||||
pool: Pool,
|
||||
fechaInicio: string,
|
||||
@@ -725,10 +764,10 @@ export async function getResumenIva(
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
// Una query por lado (causado / acreditable). Filtro por RFC via
|
||||
// ctx.esEmisor/esReceptor (embedded en buckets/signed exprs).
|
||||
const [{ rows: causadoRows }, { rows: acreditableRows }] = await Promise.all([
|
||||
pool.query<{ regimen: string | null; trasladado: string; retencion: string }>(`
|
||||
// Queries con JIT off: evitan compilación JIT de >15s en queries con muchos
|
||||
// subplans correlacionados (activado por costo estimado >100k).
|
||||
const { rows: causadoRows } = await withJitOff(pool, (client) =>
|
||||
client.query<{ regimen: string | null; trasladado: string; retencion: string }>(`
|
||||
SELECT ${REGIMEN_TENANT} as regimen,
|
||||
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
|
||||
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
|
||||
@@ -737,8 +776,10 @@ export async function getResumenIva(
|
||||
AND ${VIGENTE} AND ${FR}${extra}
|
||||
AND (${REGIMEN_TENANT}) = ANY($3)
|
||||
GROUP BY ${REGIMEN_TENANT}
|
||||
`, [fechaInicio, fechaFin, TODOS_REGIMENES]),
|
||||
pool.query<{ regimen: string | null; trasladado: string; retencion: string }>(`
|
||||
`, [fechaInicio, fechaFin, TODOS_REGIMENES])
|
||||
);
|
||||
const { rows: acreditableRows } = await withJitOff(pool, (client) =>
|
||||
client.query<{ regimen: string | null; trasladado: string; retencion: string }>(`
|
||||
SELECT ${REGIMEN_TENANT} as regimen,
|
||||
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
|
||||
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
|
||||
@@ -747,8 +788,8 @@ export async function getResumenIva(
|
||||
AND ${VIGENTE} AND ${FR}${extra}
|
||||
AND (${REGIMEN_TENANT}) = ANY($3)
|
||||
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.
|
||||
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).
|
||||
const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear();
|
||||
const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO;
|
||||
const { rows: [acumRow] } = await pool.query(`
|
||||
const { rows: [acumRow] } = await withJitOff(pool, (client) =>
|
||||
client.query(`
|
||||
SELECT
|
||||
COALESCE(SUM(${signedCausadoTras(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 ${acumFR}${extra}
|
||||
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).
|
||||
// No participa en `resultado` — ya excluido del `acreditable` arriba via filtro
|
||||
|
||||
@@ -1,30 +1,49 @@
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
/**
|
||||
* Tipos de correos informativos cuyo envío puede desactivarse por
|
||||
* contribuyente. NO incluye correos transaccionales críticos
|
||||
* (welcome, password-reset, payment-*) — esos siempre se envían.
|
||||
* Tipos de correos informativos cuyo envío puede desactivarse por rol.
|
||||
* NO incluye correos transaccionales críticos (welcome, password-reset,
|
||||
* payment-*, invitaciones) — esos siempre se envían.
|
||||
*
|
||||
* Estado de implementación:
|
||||
* - documento_subido: ✅ implementado (notify-upload.service.ts)
|
||||
* - weekly_update: ⏳ pendiente (job es tenant-wide hoy)
|
||||
* - subscription_expiring: ⏳ pendiente (no es per-contribuyente hoy)
|
||||
* - documento_subido: ✅ implementado (owner + supervisor del contribuyente)
|
||||
* - weekly_update: ✅ implementado (job tenant-wide, owners)
|
||||
* - subscription_expiring: ✅ implementado (aviso a owner)
|
||||
* - recordatorio_fiscal: ⏳ placeholder para futuras alertas
|
||||
* - alertas_nuevas: ✅ implementado (supervisor + auxiliares + clientes)
|
||||
* - recordatorio_proximo: ✅ implementado (auxiliar/supervisor/cliente/owner)
|
||||
*/
|
||||
export const EMAIL_TYPES = [
|
||||
'documento_subido',
|
||||
'weekly_update',
|
||||
'subscription_expiring',
|
||||
'recordatorio_fiscal',
|
||||
'alertas_nuevas',
|
||||
'recordatorio_proximo',
|
||||
] as const;
|
||||
|
||||
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 RoleEmailPreferences = Record<EmailType, Record<NotificationRole, boolean>>;
|
||||
|
||||
/**
|
||||
* Default: todo activado. Si el JSONB en BD viene vacío o falta una
|
||||
* key, asumimos `true` para preservar el comportamiento previo.
|
||||
* Default legacy (por contribuyente). Se mantiene por compatibilidad con la
|
||||
* columna `contribuyentes.email_preferences`; la UI nueva ya no lo usa.
|
||||
*/
|
||||
function applyDefaults(raw: Partial<Record<string, unknown>>): EmailPreferences {
|
||||
const out = {} as EmailPreferences;
|
||||
@@ -38,10 +57,10 @@ function sanitizeUuid(id: string): string {
|
||||
return id.replace(/[^a-f0-9-]/gi, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lee las preferencias de un contribuyente. Devuelve defaults (todo
|
||||
* activado) si no hay fila o la columna está vacía.
|
||||
*/
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Preferencias por contribuyente (legacy — conservado por compatibilidad)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function getContribuyenteEmailPreferences(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
@@ -55,11 +74,6 @@ export async function getContribuyenteEmailPreferences(
|
||||
return applyDefaults(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza las preferencias de un contribuyente. Solo persiste las
|
||||
* keys conocidas (filtra extras maliciosos). Merge sobre la columna
|
||||
* existente (no sobreescribe keys no enviadas).
|
||||
*/
|
||||
export async function setContribuyenteEmailPreferences(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
@@ -81,10 +95,6 @@ export async function setContribuyenteEmailPreferences(
|
||||
return getContribuyenteEmailPreferences(pool, contribuyenteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lee preferencias para múltiples contribuyentes en una sola query.
|
||||
* Útil para la UI de `/configuracion/notificaciones` que lista todos.
|
||||
*/
|
||||
export async function getEmailPreferencesPorContribuyente(
|
||||
pool: Pool,
|
||||
): Promise<Array<{ contribuyenteId: string; rfc: string; nombre: string; preferences: EmailPreferences }>> {
|
||||
@@ -108,3 +118,89 @@ export async function getEmailPreferencesPorContribuyente(
|
||||
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 type { AlertaItem } from './email/templates/alertas-nuevas.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';
|
||||
|
||||
@@ -100,39 +106,60 @@ async function getUserContacts(userIds: string[]): Promise<UserContact[]> {
|
||||
|
||||
/**
|
||||
* Destinatarios de una alerta: supervisor + auxiliares + clientes del
|
||||
* contribuyente. Si el owner del tenant es supervisor, ya queda incluido
|
||||
* (no se duplica).
|
||||
* contribuyente. Retorna emails con su rol para poder filtrar por
|
||||
* preferencias de notificación.
|
||||
*/
|
||||
async function recipientsForAlerta(
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
contribuyenteId: string,
|
||||
): Promise<string[]> {
|
||||
): Promise<RecipientWithRole[]> {
|
||||
const ids = await getUserIdsContribuyente(pool, contribuyenteId);
|
||||
const userIds = new Set<string>();
|
||||
if (ids.supervisor) userIds.add(ids.supervisor);
|
||||
ids.auxiliares.forEach(id => userIds.add(id));
|
||||
ids.clientes.forEach(id => userIds.add(id));
|
||||
const contacts = await getUserContacts([...userIds]);
|
||||
return [...new Set(contacts.map(c => c.email))];
|
||||
const byRole = new Map<string, NotificationRole>();
|
||||
if (ids.supervisor) byRole.set(ids.supervisor, 'supervisor');
|
||||
ids.auxiliares.forEach(id => byRole.set(id, 'auxiliar'));
|
||||
ids.clientes.forEach(id => byRole.set(id, 'cliente'));
|
||||
|
||||
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
|
||||
* tenant-level (no atados a contribuyente). Para públicos: clientes con
|
||||
* algún acceso + auxiliares de cualquier cartera; si no hay auxiliares,
|
||||
* supervisores; si owner aparece como supervisor, también recibe.
|
||||
* tenant-level (no atados a contribuyente). Retorna emails con rol para
|
||||
* filtrado por preferencias.
|
||||
*
|
||||
* Públicos: clientes + auxiliares + supervisores + owners.
|
||||
* Privados: solo el creador.
|
||||
*/
|
||||
async function recipientsForRecordatorio(
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
recordatorio: { creadoPor: string; privado: boolean },
|
||||
): Promise<string[]> {
|
||||
): Promise<RecipientWithRole[]> {
|
||||
if (recordatorio.privado) {
|
||||
const role = await getUserRole(tenantId, recordatorio.creadoPor);
|
||||
if (!role) return [];
|
||||
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.
|
||||
@@ -158,27 +185,19 @@ async function recipientsForRecordatorio(
|
||||
), ARRAY[]::uuid[]) AS cliente_user_ids
|
||||
`);
|
||||
|
||||
const auxiliares = r?.auxiliar_user_ids ?? [];
|
||||
const supervisores = r?.supervisor_user_ids ?? [];
|
||||
const clientes = r?.cliente_user_ids ?? [];
|
||||
const byRole = new Map<string, NotificationRole>();
|
||||
(r?.auxiliar_user_ids ?? []).forEach(id => byRole.set(id, 'auxiliar'));
|
||||
(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);
|
||||
owners.forEach(id => byRole.set(id, 'owner'));
|
||||
|
||||
// Regla del owner: clientes y auxiliares siempre. Si no hay auxiliares,
|
||||
// agregar supervisores. Si owner es supervisor y no hay auxiliares,
|
||||
// owner queda incluido vía la lista de supervisores.
|
||||
const userIds = new Set<string>();
|
||||
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))];
|
||||
const contacts = await getUserContacts([...byRole.keys()]);
|
||||
return contacts
|
||||
.filter(c => byRole.has(c.userId))
|
||||
.map(c => ({ email: c.email, role: byRole.get(c.userId)! }));
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
@@ -276,8 +295,10 @@ async function processAlertasContribuyente(
|
||||
return { nuevas: 0, resueltas };
|
||||
}
|
||||
|
||||
// Envía email batched a los responsables del contribuyente.
|
||||
const recipients = await recipientsForAlerta(pool, tenantId, contribuyente.entidadId);
|
||||
// Envía email batched a los responsables del contribuyente, filtrando por
|
||||
// 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) {
|
||||
console.warn(`[Notifications] Sin destinatarios para alertas de ${contribuyente.rfc} (tenant ${tenant.rfc})`);
|
||||
return { nuevas: nuevas.length, resueltas };
|
||||
@@ -361,10 +382,11 @@ export async function processProximosRecordatorios(
|
||||
|
||||
for (const r of rows) {
|
||||
try {
|
||||
const recipients = await recipientsForRecordatorio(pool, tenantId, {
|
||||
const recipientsWithRole = await recipientsForRecordatorio(pool, tenantId, {
|
||||
creadoPor: r.creado_por,
|
||||
privado: r.privado,
|
||||
});
|
||||
const recipients = await filterRecipientsByRole(pool, 'recordatorio_proximo', recipientsWithRole);
|
||||
if (recipients.length === 0) {
|
||||
console.warn(`[Notifications] Recordatorio ${r.id} (${tenant.rfc}) sin destinatarios — skip ${ventana}`);
|
||||
continue;
|
||||
|
||||
@@ -3,8 +3,12 @@ import { prisma } from '../config/database.js';
|
||||
import { emailService } from './email/email.service.js';
|
||||
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { getContribuyenteEmailPreferences } from './notification-preferences.service.js';
|
||||
import { filterRecipientsByRole, type RecipientWithRole } from './notification-preferences.service.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
|
||||
@@ -26,7 +30,11 @@ export async function notifyDocumentoSubido(params: {
|
||||
subidoPor: string;
|
||||
kind: DocumentoSubidoData['kind'];
|
||||
declaracion?: DocumentoSubidoData['declaracion'];
|
||||
declaracionId?: number;
|
||||
extra?: DocumentoSubidoData['extra'];
|
||||
evidencia?: DocumentoSubidoData['evidencia'];
|
||||
/** PDF en base64 para adjuntar en notificaciones de evidencia de obligación. */
|
||||
pdfBase64?: string;
|
||||
}): Promise<void> {
|
||||
const { pool, tenantId, contribuyenteId, subidoPor } = params;
|
||||
|
||||
@@ -34,10 +42,7 @@ export async function notifyDocumentoSubido(params: {
|
||||
// subject informativo ni supervisor — skip.
|
||||
if (!contribuyenteId) return;
|
||||
|
||||
// Respeta preferencias de notificación del contribuyente. Si el user
|
||||
// desactivó `documento_subido` para este contribuyente, no enviar.
|
||||
const prefs = await getContribuyenteEmailPreferences(pool, contribuyenteId);
|
||||
if (!prefs.documento_subido) return;
|
||||
|
||||
|
||||
const { rows } = await pool.query<{
|
||||
rfc: string;
|
||||
@@ -54,14 +59,17 @@ export async function notifyDocumentoSubido(params: {
|
||||
const contrib = rows[0];
|
||||
|
||||
// 2. Recipients. Owners primero; luego supervisor si aplica.
|
||||
const owners = await getTenantOwnerEmails(tenantId);
|
||||
const recipients = new Set<string>(owners);
|
||||
const ownerEmails = await getTenantOwnerEmails(tenantId);
|
||||
const recipientsWithRole: RecipientWithRole[] = ownerEmails.map(email => ({ email, role: 'owner' }));
|
||||
|
||||
if (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.
|
||||
recipients.delete(subidoPor.toLowerCase());
|
||||
recipients.delete(subidoPor);
|
||||
@@ -77,6 +85,23 @@ export async function notifyDocumentoSubido(params: {
|
||||
// 4. Link al sistema. Usa FRONTEND_URL del env.
|
||||
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), {
|
||||
kind: params.kind,
|
||||
subidoPor,
|
||||
@@ -85,6 +110,46 @@ export async function notifyDocumentoSubido(params: {
|
||||
despachoNombre: tenant?.nombre,
|
||||
declaracion: params.declaracion,
|
||||
extra: params.extra,
|
||||
evidencia: params.evidencia,
|
||||
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 { 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
|
||||
* that must ALL appear in the SAT description (normalized, lowercase, no accents).
|
||||
@@ -138,6 +143,8 @@ export interface ObligacionContribuyente {
|
||||
completadaPor: string | null;
|
||||
periodoCompletado: string | null;
|
||||
createdAt?: string;
|
||||
auxiliarAsignadoId?: string | null;
|
||||
auxiliarAsignadoNombre?: string | null;
|
||||
}
|
||||
|
||||
export function getCatalogo(): ObligacionFiscal[] {
|
||||
@@ -146,15 +153,18 @@ export function getCatalogo(): ObligacionFiscal[] {
|
||||
|
||||
export async function getObligaciones(pool: Pool, contribuyenteId: string): Promise<ObligacionContribuyente[]> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, contribuyente_id AS "contribuyenteId", catalogo_id AS "catalogoId",
|
||||
nombre, fundamento, frecuencia, fecha_limite AS "fechaLimite", categoria,
|
||||
activa, es_recomendada AS "esRecomendada", es_custom AS "esCustom",
|
||||
completada, completada_at AS "completadaAt", completada_por AS "completadaPor",
|
||||
periodo_completado AS "periodoCompletado",
|
||||
created_at AS "createdAt"
|
||||
FROM obligaciones_contribuyente
|
||||
WHERE contribuyente_id = $1
|
||||
ORDER BY categoria, nombre
|
||||
SELECT
|
||||
oc.id, oc.contribuyente_id AS "contribuyenteId", oc.catalogo_id AS "catalogoId",
|
||||
oc.nombre, oc.fundamento, oc.frecuencia, oc.fecha_limite AS "fechaLimite", oc.categoria,
|
||||
oc.activa, oc.es_recomendada AS "esRecomendada", oc.es_custom AS "esCustom",
|
||||
oc.completada, oc.completada_at AS "completadaAt", oc.completada_por AS "completadaPor",
|
||||
oc.periodo_completado AS "periodoCompletado",
|
||||
oc.created_at AS "createdAt",
|
||||
oa.auxiliar_user_id AS "auxiliarAsignadoId"
|
||||
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]);
|
||||
return rows;
|
||||
}
|
||||
@@ -250,6 +260,7 @@ export async function initRecomendaciones(
|
||||
function inferirFrecuencia(vencimiento: string): string {
|
||||
const lower = vencimiento.toLowerCase();
|
||||
if (lower.includes('mensual') || lower.includes('mes')) return 'mensual';
|
||||
if (lower.includes('cuatrimest')) return 'cuatrimestral';
|
||||
if (lower.includes('bimest')) return 'bimestral';
|
||||
if (lower.includes('trimest')) return 'trimestral';
|
||||
if (lower.includes('anual') || lower.includes('ejercicio') || lower.includes('tres meses siguientes')) return 'anual';
|
||||
@@ -346,13 +357,22 @@ export async function getObligacionesPorPeriodo(
|
||||
|
||||
const [year, month] = periodo.split('-').map(Number);
|
||||
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
|
||||
const { rows: completions } = await pool.query<{
|
||||
obligacion_id: string;
|
||||
periodo: string;
|
||||
completada: boolean;
|
||||
declaracion_presentada: boolean;
|
||||
pago_presentado: boolean;
|
||||
declaracion_id: number | null;
|
||||
decl_año: number | null;
|
||||
decl_mes: number | null;
|
||||
@@ -360,6 +380,7 @@ export async function getObligacionesPorPeriodo(
|
||||
decl_pdf_filename: string | null;
|
||||
}>(`
|
||||
SELECT op.obligacion_id, op.periodo, op.completada,
|
||||
op.declaracion_presentada, op.pago_presentado,
|
||||
op.declaracion_id,
|
||||
dp.año AS decl_año,
|
||||
dp.mes AS decl_mes,
|
||||
@@ -372,10 +393,14 @@ export async function getObligacionesPorPeriodo(
|
||||
`, [contribuyenteId]);
|
||||
|
||||
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>();
|
||||
for (const c of completions) {
|
||||
const key = `${c.obligacion_id}:${c.periodo}`;
|
||||
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) {
|
||||
declaracionMap.set(key, {
|
||||
id: c.declaracion_id,
|
||||
@@ -402,6 +427,9 @@ export async function getObligacionesPorPeriodo(
|
||||
periodStatus: isCompleted ? 'completada' : 'pendiente',
|
||||
periodoAplica: periodo,
|
||||
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',
|
||||
periodoAplica: pastPeriodo,
|
||||
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 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 {
|
||||
@@ -452,6 +490,7 @@ function appliesTo(frecuencia: string | null, periodo: string): boolean {
|
||||
case 'mensual': return true;
|
||||
case 'bimestral': return month % 2 === 1; // Jan, Mar, May...
|
||||
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 'eventual': return false; // Don't auto-show
|
||||
default: return true;
|
||||
|
||||
@@ -27,6 +27,11 @@ export interface PapeleriaItem {
|
||||
aprobadoPor: string | null;
|
||||
aprobadoAt: Date | null;
|
||||
comentarioRechazo: string | null;
|
||||
requiereAprobacionCliente: boolean;
|
||||
estadoCliente: EstadoPapeleria | null;
|
||||
aprobadoPorCliente: string | null;
|
||||
aprobadoAtCliente: Date | null;
|
||||
comentarioRechazoCliente: string | null;
|
||||
subidoPor: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -36,6 +41,7 @@ const SELECT = `
|
||||
archivo_filename, archivo_mime, archivo_size,
|
||||
anio, mes,
|
||||
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
|
||||
`;
|
||||
|
||||
@@ -54,6 +60,11 @@ const ROW = (r: any): PapeleriaItem => ({
|
||||
aprobadoPor: r.aprobado_por,
|
||||
aprobadoAt: r.aprobado_at,
|
||||
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,
|
||||
createdAt: r.created_at,
|
||||
});
|
||||
@@ -69,6 +80,7 @@ export interface UploadInput {
|
||||
anio: number;
|
||||
mes: number;
|
||||
requiereAprobacion: boolean;
|
||||
requiereAprobacionCliente: boolean;
|
||||
archivo: Buffer;
|
||||
archivoFilename: string;
|
||||
archivoMime: string;
|
||||
@@ -87,12 +99,13 @@ export async function uploadPapeleria(
|
||||
}
|
||||
|
||||
const estadoInicial = input.requiereAprobacion ? 'pendiente' : null;
|
||||
const estadoClienteInicial = input.requiereAprobacionCliente ? 'pendiente' : null;
|
||||
|
||||
const { rows: [r] } = await pool.query(
|
||||
`INSERT INTO papeleria_trabajo
|
||||
(contribuyente_id, nombre, descripcion, archivo, archivo_filename, archivo_mime, archivo_size,
|
||||
anio, mes, requiere_aprobacion, estado, subido_por)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
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, $13, $14)
|
||||
RETURNING ${SELECT}`,
|
||||
[
|
||||
sanitizeUuid(input.contribuyenteId),
|
||||
@@ -106,6 +119,8 @@ export async function uploadPapeleria(
|
||||
input.mes,
|
||||
input.requiereAprobacion,
|
||||
estadoInicial,
|
||||
input.requiereAprobacionCliente,
|
||||
estadoClienteInicial,
|
||||
input.subidoPor,
|
||||
],
|
||||
);
|
||||
@@ -117,6 +132,8 @@ export interface ListFilters {
|
||||
anio?: number;
|
||||
mes?: number;
|
||||
estado?: EstadoPapeleria | 'sin_aprobacion';
|
||||
entidadIds?: string[];
|
||||
userRole?: string;
|
||||
}
|
||||
|
||||
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.mes) { conds.push(`mes = $${i++}`); vals.push(f.mes); }
|
||||
if (f.estado === 'sin_aprobacion') {
|
||||
conds.push('requiere_aprobacion = false');
|
||||
conds.push('requiere_aprobacion = false AND requiere_aprobacion_cliente = false');
|
||||
} else if (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(
|
||||
`SELECT ${SELECT} FROM papeleria_trabajo
|
||||
WHERE ${conds.join(' AND ')}
|
||||
@@ -202,6 +226,39 @@ export async function rechazar(
|
||||
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> {
|
||||
const { rowCount } = await pool.query(
|
||||
`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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 },
|
||||
});
|
||||
|
||||
// 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({
|
||||
tenantId: payment.tenantId,
|
||||
action: 'invoice.emitted_auto',
|
||||
|
||||
@@ -25,6 +25,9 @@ const preApprovalClient = new PreApproval(config);
|
||||
const paymentClient = new MPPayment(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.
|
||||
* 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
|
||||
* 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 { emailService } from '../email/email.service.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 { despachoPlanTieneDualidadDb, getPrecioDespachoDb } from '../plan-catalogo.service.js';
|
||||
import {
|
||||
@@ -243,25 +244,76 @@ export async function generatePaymentLink(tenantId: string) {
|
||||
const ownerEmail = await getTenantOwnerEmail(tenantId);
|
||||
if (!ownerEmail) throw new Error('No admin user found');
|
||||
|
||||
const subscription = await getActiveSubscription(tenantId);
|
||||
const plan = subscription?.plan || tenant.plan;
|
||||
const amount = subscription?.amount || 0;
|
||||
let subscription = await getActiveSubscription(tenantId);
|
||||
const plan = (subscription?.plan || tenant.plan) as Plan;
|
||||
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({
|
||||
tenantId,
|
||||
reason: `Horux360 - Plan ${plan} - ${tenant.nombre}`,
|
||||
amount,
|
||||
payerEmail: ownerEmail,
|
||||
frequency,
|
||||
});
|
||||
|
||||
// Update subscription with new MP preapproval ID
|
||||
if (subscription) {
|
||||
await prisma.subscription.update({
|
||||
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 };
|
||||
@@ -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`
|
||||
: `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({
|
||||
tenantId: params.tenantId,
|
||||
reason,
|
||||
@@ -637,8 +737,14 @@ export async function applyApprovedUpgrade(subscriptionId: string): Promise<void
|
||||
const newPlan = sub.upgradeTargetPlan as Plan;
|
||||
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 (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 {
|
||||
await mpService.updatePreapprovalAmount(sub.mpPreapprovalId, newAmount);
|
||||
} 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.subscription.update({
|
||||
@@ -1085,7 +1192,7 @@ export async function sendExpiryReminders(): Promise<{ sent: number; resetOnly:
|
||||
{ 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;
|
||||
@@ -1129,13 +1236,27 @@ export async function sendExpiryReminders(): Promise<{ sent: number; resetOnly:
|
||||
|
||||
// Hay algo que avisar.
|
||||
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);
|
||||
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++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const isTrialFlow = sub.status === 'trial' || sub.status === 'trial_expired';
|
||||
for (const ownerEmail of emailsToNotify) {
|
||||
if (isTrialFlow) {
|
||||
if (bucket === 0) {
|
||||
await emailService.sendTrialExpired(ownerEmail, {
|
||||
@@ -1157,6 +1278,7 @@ export async function sendExpiryReminders(): Promise<{ sent: number; resetOnly:
|
||||
expiresAt: sub.currentPeriodEnd.toLocaleDateString('es-MX', { dateStyle: 'long' }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.subscription.update({
|
||||
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.
|
||||
* 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(
|
||||
tenantId: string,
|
||||
@@ -61,9 +64,49 @@ export async function getRegimenesActivosClavesEfectivos(
|
||||
if (rows.length > 0 && rows[0].regimen_fiscal) {
|
||||
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[]) {
|
||||
|
||||
@@ -72,9 +72,17 @@ export async function querySat(
|
||||
requestType: 'metadata' | 'cfdi' = 'cfdi'
|
||||
): Promise<QueryResult> {
|
||||
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(
|
||||
formatDateForSat(fechaInicio),
|
||||
formatDateForSat(fechaFin)
|
||||
formatDateForSat(adjustedFechaFin)
|
||||
);
|
||||
|
||||
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 {
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
|
||||
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} 00:00:00`;
|
||||
}
|
||||
|
||||
@@ -30,20 +30,20 @@ export async function loginSatCsf(
|
||||
const publicPage = await context.newPage();
|
||||
publicPage.setDefaultTimeout(60_000);
|
||||
|
||||
await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle' });
|
||||
await publicPage.waitForTimeout(2000);
|
||||
await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle', timeout: 120_000 });
|
||||
await publicPage.waitForTimeout(3000);
|
||||
|
||||
// Click acordeón "Obtén tu constancia" / "Obtener constancia"
|
||||
const obtenerLocator = publicPage.locator(
|
||||
'text=/Obt[eé]n\\s+la\\s+constancia|Obt[eé]n\\s+tu\\s+constancia|Obtener\\s+constancia|Obtener\\s+la\\s+constancia/i',
|
||||
).first();
|
||||
await obtenerLocator.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await obtenerLocator.waitFor({ state: 'visible', timeout: 120_000 });
|
||||
await obtenerLocator.scrollIntoViewIfNeeded();
|
||||
await obtenerLocator.click();
|
||||
await publicPage.waitForTimeout(1500);
|
||||
|
||||
// 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();
|
||||
const loginPage = await popupPromise;
|
||||
await loginPage.waitForLoadState('domcontentloaded');
|
||||
@@ -56,7 +56,7 @@ export async function loginSatCsf(
|
||||
const efirmaBtn = loginPage
|
||||
.locator('button:has-text("e.firma"):not(:has-text("portable")), input[type="button"][value="e.firma" i], input[type="submit"][value="e.firma" i]')
|
||||
.first();
|
||||
await efirmaBtn.waitFor({ state: 'visible', timeout: 30_000 });
|
||||
await efirmaBtn.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await efirmaBtn.scrollIntoViewIfNeeded();
|
||||
await efirmaBtn.click();
|
||||
|
||||
@@ -82,7 +82,7 @@ export async function loginSatCsf(
|
||||
return rfc !== null && rfc.value.length >= 12;
|
||||
},
|
||||
null,
|
||||
{ timeout: 60_000 },
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
rfcPopulated = true;
|
||||
} catch {
|
||||
@@ -121,7 +121,7 @@ export async function loginSatCsf(
|
||||
// Esperar a que salga del dominio de login y aterrice en el portal SAT
|
||||
await loginPage.waitForURL(
|
||||
url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'),
|
||||
{ timeout: 60_000 },
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
await loginPage.waitForLoadState('networkidle').catch(() => undefined);
|
||||
await loginPage.waitForTimeout(2000);
|
||||
|
||||
@@ -299,7 +299,7 @@ async function saveCfdis(
|
||||
cfdi_tipo_relacion=$88, cfdis_relacionados=$89,
|
||||
last_sat_sync=NOW(), sat_sync_job_id=$90::uuid,
|
||||
actualizado_en=NOW()
|
||||
WHERE uuid = $1`,
|
||||
WHERE LOWER(uuid) = LOWER($1)`,
|
||||
[cfdi.uuid, ...vals]
|
||||
);
|
||||
// Re-insert conceptos for updated CFDI
|
||||
@@ -355,7 +355,7 @@ async function saveCfdis(
|
||||
[...vals, contribuyenteId]
|
||||
);
|
||||
// Get the inserted cfdi id and save conceptos
|
||||
const { rows: [newRow] } = await pool.query(`SELECT id FROM cfdis WHERE uuid = $1`, [cfdi.uuid]);
|
||||
const { rows: [newRow] } = await pool.query(`SELECT id FROM cfdis WHERE LOWER(uuid) = LOWER($1)`, [cfdi.uuid]);
|
||||
if (newRow) await saveConceptosWithRetry(pool, newRow.id, cfdi);
|
||||
inserted++;
|
||||
}
|
||||
@@ -609,30 +609,35 @@ async function requestAndDownload(
|
||||
});
|
||||
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
|
||||
// SOLO si el rango de fechas es idéntico (mismo dateFrom/dateTo).
|
||||
if (!existingMap[kindKey]) {
|
||||
const previousJob = await prisma.satSyncJob.findFirst({
|
||||
where: {
|
||||
tenantId: jobRow?.tenantId,
|
||||
contribuyenteId: jobRow?.contribuyenteId ?? null,
|
||||
id: { not: jobId },
|
||||
dateFrom: jobRow?.dateFrom,
|
||||
dateTo: jobRow?.dateTo,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { satRequestIds: true },
|
||||
});
|
||||
if (previousJob?.satRequestIds) {
|
||||
const prevMap = previousJob.satRequestIds as Record<string, string>;
|
||||
if (prevMap[kindKey]) {
|
||||
console.log(`[SAT] Reutilizando requestId de job previo (${label}): ${prevMap[kindKey]}`);
|
||||
// Copiar al job actual para futuros usos
|
||||
await persistSatRequestId(jobId, kindKey, prevMap[kindKey]);
|
||||
existingMap = { ...existingMap, [kindKey]: prevMap[kindKey] };
|
||||
}
|
||||
}
|
||||
}
|
||||
// if (!existingMap[kindKey]) {
|
||||
// const previousJob = await prisma.satSyncJob.findFirst({
|
||||
// where: {
|
||||
// tenantId: jobRow?.tenantId,
|
||||
// contribuyenteId: jobRow?.contribuyenteId ?? null,
|
||||
// id: { not: jobId },
|
||||
// dateFrom: jobRow?.dateFrom,
|
||||
// dateTo: jobRow?.dateTo,
|
||||
// },
|
||||
// orderBy: { createdAt: 'desc' },
|
||||
// select: { satRequestIds: true },
|
||||
// });
|
||||
// if (previousJob?.satRequestIds) {
|
||||
// const prevMap = previousJob.satRequestIds as Record<string, string>;
|
||||
// if (prevMap[kindKey]) {
|
||||
// console.log(`[SAT] Reutilizando requestId de job previo (${label}): ${prevMap[kindKey]}`);
|
||||
// // Copiar al job actual para futuros usos
|
||||
// await persistSatRequestId(jobId, kindKey, prevMap[kindKey]);
|
||||
// existingMap = { ...existingMap, [kindKey]: prevMap[kindKey] };
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
let requestId: string | null = existingMap[kindKey] || null;
|
||||
let verifyResult: Awaited<ReturnType<typeof verifySatRequest>> | undefined;
|
||||
|
||||
@@ -14,10 +14,10 @@ export interface SweepResult {
|
||||
}
|
||||
|
||||
const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
|
||||
initial: 8,
|
||||
initial: 24,
|
||||
daily: 4,
|
||||
incremental: 2,
|
||||
custom: 4,
|
||||
custom: 24,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface TareaCatalogo {
|
||||
active: boolean;
|
||||
orden: number;
|
||||
createdAt: Date;
|
||||
auxiliarAsignadoId?: string | null;
|
||||
auxiliarAsignadoNombre?: string | null;
|
||||
}
|
||||
|
||||
export interface TareaPeriodo {
|
||||
@@ -47,6 +49,8 @@ const ROW_TO_TAREA = (r: any): TareaCatalogo => ({
|
||||
active: r.active,
|
||||
orden: r.orden,
|
||||
createdAt: r.created_at,
|
||||
auxiliarAsignadoId: r.auxiliarAsignadoId ?? null,
|
||||
auxiliarAsignadoNombre: r.auxiliarAsignadoNombre ?? null,
|
||||
});
|
||||
|
||||
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[]> {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM tareas_catalogo
|
||||
WHERE contribuyente_id = $1 AND active = true
|
||||
ORDER BY orden, nombre`,
|
||||
`SELECT
|
||||
tc.*,
|
||||
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)],
|
||||
);
|
||||
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 }));
|
||||
}
|
||||
|
||||
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 ───
|
||||
|
||||
const ROLES_SUPERVISOR = new Set(['owner', 'cfo', 'supervisor']);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { prisma } from '../config/database.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { getDespachoPlanLimits } from './plan-catalogo.service.js';
|
||||
import { emailService } from './email/email.service.js';
|
||||
import type { UserListItem, UserInvite, UserUpdate, Role } from '@horux/shared';
|
||||
|
||||
/**
|
||||
@@ -99,6 +100,13 @@ export async function inviteUsuario(tenantId: string, data: UserInvite): Promise
|
||||
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);
|
||||
@@ -224,8 +232,9 @@ export async function createUsuarioGlobal(
|
||||
// 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) {
|
||||
const tempPassword = randomBytes(4).toString('hex');
|
||||
tempPassword = randomBytes(4).toString('hex');
|
||||
const passwordHash = await bcrypt.hash(tempPassword, 12);
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
@@ -235,6 +244,13 @@ export async function createUsuarioGlobal(
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
|
||||
import { getAllInvitations, createInvitation, cancelInvitation } from '@/lib/api/trial-invitations';
|
||||
import { getTenants } from '@/lib/api/tenants';
|
||||
import { Gift, X, Clock, CheckCircle2, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
|
||||
interface TenantOption {
|
||||
id: string;
|
||||
nombre: string;
|
||||
rfc: string;
|
||||
}
|
||||
|
||||
interface Invitation {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
plan: string;
|
||||
durationDays: number;
|
||||
status: string;
|
||||
token: string;
|
||||
sentAt: string;
|
||||
expiresAt: string;
|
||||
acceptedAt: string | null;
|
||||
tenant: {
|
||||
nombre: string;
|
||||
rfc: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function InvitacionesTrialTab() {
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [invitations, setInvitations] = useState<Invitation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [selectedTenantId, setSelectedTenantId] = useState('');
|
||||
const [durationDays, setDurationDays] = useState('30');
|
||||
const [plan, setPlan] = useState('business_control');
|
||||
const [message, setMessage] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [tenantsData, invitationsData] = await Promise.all([
|
||||
getTenants(),
|
||||
getAllInvitations(),
|
||||
]);
|
||||
setTenants(tenantsData);
|
||||
setInvitations(invitationsData);
|
||||
} catch (err: any) {
|
||||
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al cargar datos' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!selectedTenantId || !durationDays) {
|
||||
setMessage({ kind: 'err', text: 'Selecciona un despacho y duración' });
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
await createInvitation({
|
||||
tenantId: selectedTenantId,
|
||||
plan,
|
||||
durationDays: parseInt(durationDays, 10),
|
||||
});
|
||||
setMessage({ kind: 'ok', text: 'Invitación enviada correctamente' });
|
||||
setSelectedTenantId('');
|
||||
setDurationDays('30');
|
||||
loadData();
|
||||
} catch (err: any) {
|
||||
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al crear invitación' });
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel(id: string) {
|
||||
if (!confirm('¿Seguro que quieres cancelar esta invitación?')) return;
|
||||
try {
|
||||
await cancelInvitation(id);
|
||||
setMessage({ kind: 'ok', text: 'Invitación cancelada' });
|
||||
loadData();
|
||||
} catch (err: any) {
|
||||
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al cancelar' });
|
||||
}
|
||||
}
|
||||
|
||||
function statusIcon(status: string) {
|
||||
switch (status) {
|
||||
case 'pending': return <Clock className="h-4 w-4 text-amber-500" />;
|
||||
case 'accepted': return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
||||
case 'expired': return <AlertTriangle className="h-4 w-4 text-red-500" />;
|
||||
case 'cancelled': return <X className="h-4 w-4 text-gray-500" />;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
switch (status) {
|
||||
case 'pending': return 'Pendiente';
|
||||
case 'accepted': return 'Aceptada';
|
||||
case 'expired': return 'Expirada';
|
||||
case 'cancelled': return 'Cancelada';
|
||||
default: return status;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Toast de resultado */}
|
||||
{message && (
|
||||
<div
|
||||
className={`max-w-3xl rounded-lg px-4 py-3 text-sm ${
|
||||
message.kind === 'ok'
|
||||
? 'bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-300'
|
||||
: 'bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-300'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Formulario de creación */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Nueva invitación</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Despacho</Label>
|
||||
<Select value={selectedTenantId} onValueChange={setSelectedTenantId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona un despacho" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tenants.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.nombre} ({t.rfc})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Plan</Label>
|
||||
<Select value={plan} onValueChange={setPlan}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="business_control">Business Control</SelectItem>
|
||||
<SelectItem value="business_cloud">Enterprise</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Duración (días)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={durationDays}
|
||||
onChange={(e) => setDurationDays(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleCreate} disabled={creating}>
|
||||
{creating ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Gift className="h-4 w-4 mr-2" />}
|
||||
Enviar invitación
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabla de invitaciones */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Historial de invitaciones</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : invitations.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-8">No hay invitaciones enviadas</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2 px-3">Despacho</th>
|
||||
<th className="text-left py-2 px-3">Plan</th>
|
||||
<th className="text-left py-2 px-3">Días</th>
|
||||
<th className="text-left py-2 px-3">Estado</th>
|
||||
<th className="text-left py-2 px-3">Enviado</th>
|
||||
<th className="text-left py-2 px-3">Expira</th>
|
||||
<th className="text-left py-2 px-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invitations.map((inv) => (
|
||||
<tr key={inv.id} className="border-b hover:bg-muted/50">
|
||||
<td className="py-2 px-3">
|
||||
<div className="font-medium">{inv.tenant?.nombre || '—'}</div>
|
||||
<div className="text-xs text-muted-foreground">{inv.tenant?.rfc || '—'}</div>
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
{inv.plan === 'business_control' ? 'Business Control' : inv.plan === 'business_cloud' ? 'Enterprise' : inv.plan}
|
||||
</td>
|
||||
<td className="py-2 px-3">{inv.durationDays}</td>
|
||||
<td className="py-2 px-3">
|
||||
<span className="flex items-center gap-1">
|
||||
{statusIcon(inv.status)}
|
||||
{statusLabel(inv.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
{new Date(inv.sentAt).toLocaleDateString('es-MX')}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
{new Date(inv.expiresAt).toLocaleDateString('es-MX')}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
{inv.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => handleCancel(inv.id)}
|
||||
className="text-destructive hover:underline text-xs"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
|
||||
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Tabs, TabsList, TabsTrigger, TabsContent } from '@horux/shared-ui';
|
||||
import { useAllUsuarios, useCreateUsuarioGlobal, useUpdateUsuarioGlobal, useDeleteUsuarioGlobal } from '@/lib/hooks/use-usuarios';
|
||||
import { getTenants, type Tenant } from '@/lib/api/tenants';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { isGlobalAdminRfc } from '@horux/shared';
|
||||
import { Users, Pencil, Trash2, Shield, Eye, Calculator, Building2, X, Check, UserCog, UserCheck, User, Briefcase, Plus } from 'lucide-react';
|
||||
import { cn } from '@horux/shared-ui';
|
||||
import InvitacionesTrialTab from '../_components/invitaciones-trial-tab';
|
||||
|
||||
// Mapa de roles + fallback defensivo. El fork despacho introduce roles
|
||||
// adicionales (cfo, supervisor, auxiliar, cliente) que no estaban en
|
||||
@@ -43,6 +44,7 @@ export default function AdminUsuariosPage() {
|
||||
const [editingUser, setEditingUser] = useState<EditingUser | null>(null);
|
||||
const [filterTenant, setFilterTenant] = useState<string>('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('usuarios');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [createFormData, setCreateFormData] = useState({
|
||||
email: '',
|
||||
@@ -152,6 +154,13 @@ export default function AdminUsuariosPage() {
|
||||
|
||||
return (
|
||||
<DashboardShell title="Administracion de Usuarios">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} defaultValue="usuarios" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="usuarios">Usuarios</TabsTrigger>
|
||||
<TabsTrigger value="invitaciones-trial">Invitaciones Trial</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="usuarios">
|
||||
<div className="space-y-4">
|
||||
{/* Filtros */}
|
||||
<Card>
|
||||
@@ -425,6 +434,12 @@ export default function AdminUsuariosPage() {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="invitaciones-trial">
|
||||
<InvitacionesTrialTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ import {
|
||||
Button, Card, CardContent, CardHeader, CardTitle, Input, Label,
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
Tabs, TabsList, TabsTrigger, TabsContent,
|
||||
cn,
|
||||
} from '@horux/shared-ui';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
FolderOpen, Plus, Trash2, ChevronDown, ChevronUp, X,
|
||||
Users, Building2, FolderPlus, UserCog,
|
||||
Users, Building2, FolderPlus, UserCog, ClipboardList,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useCarteras, useCreateCartera, useDeleteCartera,
|
||||
@@ -25,14 +26,16 @@ import { useUsuarios } from '@/lib/hooks/use-usuarios';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import type { Cartera } from '@/lib/api/carteras';
|
||||
import SeguimientoAuxiliares from './seguimiento-auxiliares';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* SubcarteraCard */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function SubcarteraCard({ sub, usuarios, contribuyentes, onDelete }: {
|
||||
function SubcarteraCard({ sub, usuarios, contribuyentes, parentEntidadIds, onDelete }: {
|
||||
sub: Cartera;
|
||||
usuarios: any[];
|
||||
contribuyentes: any[];
|
||||
parentEntidadIds: string[];
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -47,7 +50,7 @@ function SubcarteraCard({ sub, usuarios, contribuyentes, onDelete }: {
|
||||
);
|
||||
|
||||
const available = (contribuyentes ?? []).filter(
|
||||
(c: any) => !(entidadIds ?? []).includes(c.id)
|
||||
(c: any) => (parentEntidadIds ?? []).includes(c.id) && !(entidadIds ?? []).includes(c.id)
|
||||
);
|
||||
|
||||
const auxiliarUser = usuarios?.find((u: any) => u.id === sub.auxiliarUserId);
|
||||
@@ -319,6 +322,7 @@ function CarteraDetail({ cartera, canEdit = true, canManageSubcarteras = true }:
|
||||
sub={sub}
|
||||
usuarios={usuarios ?? []}
|
||||
contribuyentes={contribuyentes ?? []}
|
||||
parentEntidadIds={entidadIds ?? []}
|
||||
onDelete={() => handleDeleteSubcartera(sub.id)}
|
||||
/>
|
||||
))}
|
||||
@@ -396,6 +400,10 @@ export default function CarterasPage() {
|
||||
const canEditCartera = userRole === 'owner'; // Edit/delete top-level carteras + add/remove RFCs
|
||||
const canManageSubcarteras = userRole === 'owner' || userRole === 'supervisor'; // Create subcarteras
|
||||
const isAuxiliar = userRole === 'auxiliar';
|
||||
const isSupervisor = userRole === 'supervisor';
|
||||
const isOwner = userRole === 'owner';
|
||||
const puedeVerSeguimiento = isOwner || isSupervisor;
|
||||
const [activeTab, setActiveTab] = useState('carteras');
|
||||
const { data: carteras, isLoading } = useCarteras();
|
||||
const { data: supervisores } = useSupervisores();
|
||||
const { data: usuarios } = useUsuarios();
|
||||
@@ -440,24 +448,8 @@ export default function CarterasPage() {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardShell title="Carteras">
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isAuxiliar ? 'Carteras asignadas a ti' : 'Organiza contribuyentes en carteras y asigna subcarteras a cada auxiliar'}
|
||||
</p>
|
||||
</div>
|
||||
{canCreate && (
|
||||
<Button onClick={() => setShowCreate(true)} className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" /> Nueva cartera
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
const CarterasList = () => (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground">Cargando...</p>
|
||||
) : !carteras || carteras.length === 0 ? (
|
||||
@@ -487,6 +479,48 @@ export default function CarterasPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardShell title="Carteras">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isAuxiliar ? 'Carteras asignadas a ti' : 'Organiza contribuyentes en carteras y asigna subcarteras a cada auxiliar'}
|
||||
</p>
|
||||
</div>
|
||||
{canCreate && activeTab === 'carteras' && (
|
||||
<Button onClick={() => setShowCreate(true)} className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" /> Nueva cartera
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{puedeVerSeguimiento ? (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} defaultValue="carteras" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="carteras">
|
||||
<FolderOpen className="h-4 w-4 mr-1.5" /> Carteras
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="seguimiento">
|
||||
<ClipboardList className="h-4 w-4 mr-1.5" /> Seguimiento de Auxiliares
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="carteras">
|
||||
<CarterasList />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="seguimiento">
|
||||
<SeguimientoAuxiliares />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<CarterasList />
|
||||
)}
|
||||
|
||||
{/* Create dialog */}
|
||||
<Dialog open={showCreate} onOpenChange={open => { if (!open) resetForm(); }}>
|
||||
|
||||
346
apps/web/app/(dashboard)/carteras/seguimiento-auxiliares.tsx
Normal file
346
apps/web/app/(dashboard)/carteras/seguimiento-auxiliares.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button, Card, CardContent, CardHeader, CardTitle,
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
Tabs, TabsList, TabsTrigger, TabsContent,
|
||||
} from '@horux/shared-ui';
|
||||
import {
|
||||
useAsignacionesSupervisor,
|
||||
useSinAsignar,
|
||||
useAsignarObligacion,
|
||||
useDesasignarObligacion,
|
||||
useAsignarTarea,
|
||||
useDesasignarTarea,
|
||||
useAuxiliaresElegibles,
|
||||
} from '@/lib/hooks/use-asignaciones';
|
||||
import { useUsuarios } from '@/lib/hooks/use-usuarios';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { UserCheck, UserX, UserCog, Plus } from 'lucide-react';
|
||||
|
||||
export default function SeguimientoAuxiliares() {
|
||||
const { user } = useAuthStore();
|
||||
const { data: asignaciones, isLoading: loadingAsignadas } = useAsignacionesSupervisor();
|
||||
const { data: sinAsignar, isLoading: loadingSinAsignar } = useSinAsignar();
|
||||
const { data: usuarios } = useUsuarios();
|
||||
const asignarObligacionMut = useAsignarObligacion();
|
||||
const desasignarObligacionMut = useDesasignarObligacion();
|
||||
const asignarTareaMut = useAsignarTarea();
|
||||
const desasignarTareaMut = useDesasignarTarea();
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalType, setModalType] = useState<'obligacion' | 'tarea'>('obligacion');
|
||||
const [modalItem, setModalItem] = useState<any>(null);
|
||||
const [selectedAuxiliar, setSelectedAuxiliar] = useState('');
|
||||
|
||||
const auxiliares = (usuarios ?? []).filter((u: any) => u.role === 'auxiliar');
|
||||
|
||||
const { data: elegiblesData, isLoading: loadingElegibles } = useAuxiliaresElegibles(modalItem?.contribuyenteId);
|
||||
const auxiliaresIdsElegibles = elegiblesData?.auxiliares ?? [];
|
||||
const auxiliaresFiltrados = auxiliares.filter((a: any) => auxiliaresIdsElegibles.includes(a.id));
|
||||
const puedeAsignar = !loadingElegibles && auxiliaresFiltrados.length > 0;
|
||||
|
||||
const openAssignModal = (type: 'obligacion' | 'tarea', item: any) => {
|
||||
setModalType(type);
|
||||
setModalItem(item);
|
||||
setSelectedAuxiliar(item.auxiliarUserId || '');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!selectedAuxiliar || !modalItem) return;
|
||||
try {
|
||||
if (modalType === 'obligacion') {
|
||||
await asignarObligacionMut.mutateAsync({
|
||||
contribuyenteId: modalItem.contribuyenteId,
|
||||
obligacionId: modalItem.obligacionId,
|
||||
auxiliarUserId: selectedAuxiliar,
|
||||
});
|
||||
} else {
|
||||
await asignarTareaMut.mutateAsync({
|
||||
tareaId: modalItem.tareaId,
|
||||
auxiliarUserId: selectedAuxiliar,
|
||||
});
|
||||
}
|
||||
setModalOpen(false);
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.message || 'Error al asignar');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnassign = async (type: 'obligacion' | 'tarea', item: any) => {
|
||||
if (!confirm('¿Eliminar la asignación?')) return;
|
||||
try {
|
||||
if (type === 'obligacion') {
|
||||
await desasignarObligacionMut.mutateAsync({
|
||||
contribuyenteId: item.contribuyenteId,
|
||||
obligacionId: item.obligacionId,
|
||||
});
|
||||
} else {
|
||||
await desasignarTareaMut.mutateAsync({ tareaId: item.tareaId });
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.message || 'Error al desasignar');
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingAsignadas || loadingSinAsignar) {
|
||||
return <p className="text-muted-foreground">Cargando asignaciones...</p>;
|
||||
}
|
||||
|
||||
const obligacionesAsignadas = asignaciones?.obligaciones ?? [];
|
||||
const tareasAsignadas = asignaciones?.tareas ?? [];
|
||||
const obligacionesSinAsignar = sinAsignar?.obligaciones ?? [];
|
||||
const tareasSinAsignar = sinAsignar?.tareas ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Tabs defaultValue="asignadas" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="asignadas">Asignadas</TabsTrigger>
|
||||
<TabsTrigger value="sin-asignar">Sin asignar</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="asignadas">
|
||||
<Tabs defaultValue="obligaciones" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="obligaciones">Obligaciones ({obligacionesAsignadas.length})</TabsTrigger>
|
||||
<TabsTrigger value="tareas">Tareas ({tareasAsignadas.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="obligaciones">
|
||||
<AsignacionesTable
|
||||
items={obligacionesAsignadas}
|
||||
tipo="obligacion"
|
||||
modo="asignadas"
|
||||
auxiliares={auxiliares}
|
||||
onAssign={(item) => openAssignModal('obligacion', item)}
|
||||
onUnassign={(item) => handleUnassign('obligacion', item)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tareas">
|
||||
<AsignacionesTable
|
||||
items={tareasAsignadas}
|
||||
tipo="tarea"
|
||||
modo="asignadas"
|
||||
auxiliares={auxiliares}
|
||||
onAssign={(item) => openAssignModal('tarea', item)}
|
||||
onUnassign={(item) => handleUnassign('tarea', item)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sin-asignar">
|
||||
<Tabs defaultValue="obligaciones" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="obligaciones">Obligaciones ({obligacionesSinAsignar.length})</TabsTrigger>
|
||||
<TabsTrigger value="tareas">Tareas ({tareasSinAsignar.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="obligaciones">
|
||||
<SinAsignarTable
|
||||
items={obligacionesSinAsignar}
|
||||
tipo="obligacion"
|
||||
auxiliares={auxiliares}
|
||||
onAssign={(item) => openAssignModal('obligacion', item)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tareas">
|
||||
<SinAsignarTable
|
||||
items={tareasSinAsignar}
|
||||
tipo="tarea"
|
||||
auxiliares={auxiliares}
|
||||
onAssign={(item) => openAssignModal('tarea', item)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Modal de asignación */}
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{modalItem?.auxiliarUserId ? 'Reasignar' : 'Asignar'} {modalType === 'obligacion' ? 'obligación' : 'tarea'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{modalType === 'obligacion' ? modalItem?.obligacionNombre : modalItem?.tareaNombre}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Contribuyente: {modalItem?.contribuyenteRazonSocial} ({modalItem?.contribuyenteRfc})
|
||||
</p>
|
||||
{loadingElegibles ? (
|
||||
<p className="text-sm text-muted-foreground">Verificando subcarteras...</p>
|
||||
) : auxiliaresFiltrados.length === 0 ? (
|
||||
<p className="text-sm text-red-600">
|
||||
Ningún auxiliar tiene este contribuyente en su subcartera. No se puede asignar.
|
||||
</p>
|
||||
) : (
|
||||
<Select value={selectedAuxiliar} onValueChange={setSelectedAuxiliar}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona un auxiliar" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{auxiliaresFiltrados.map((a: any) => (
|
||||
<SelectItem key={a.id} value={a.id}>{a.nombre} ({a.email})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={handleAssign} disabled={!selectedAuxiliar || !puedeAsignar}>
|
||||
{modalItem?.auxiliarUserId ? 'Reasignar' : 'Asignar'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AsignacionesTable({
|
||||
items,
|
||||
tipo,
|
||||
modo,
|
||||
auxiliares,
|
||||
onAssign,
|
||||
onUnassign,
|
||||
}: {
|
||||
items: any[];
|
||||
tipo: 'obligacion' | 'tarea';
|
||||
modo: 'asignadas';
|
||||
auxiliares: any[];
|
||||
onAssign: (item: any) => void;
|
||||
onUnassign: (item: any) => void;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
No hay {tipo === 'obligacion' ? 'obligaciones' : 'tareas'} asignadas.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium">Auxiliar</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Contribuyente</th>
|
||||
<th className="text-left px-4 py-3 font-medium">{tipo === 'obligacion' ? 'Obligación' : 'Tarea'}</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Asignado</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-muted/30">
|
||||
<td className="px-4 py-3">
|
||||
{item.auxiliarNombre ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80 flex items-center gap-1 w-fit">
|
||||
<UserCheck className="h-3 w-3" /> {item.auxiliarNombre}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-border text-muted-foreground hover:bg-secondary/80 flex items-center gap-1 w-fit">
|
||||
<UserX className="h-3 w-3" /> Sin asignar
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{item.contribuyenteRazonSocial}</div>
|
||||
<div className="text-xs text-muted-foreground">{item.contribuyenteRfc}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">{tipo === 'obligacion' ? item.obligacionNombre : item.tareaNombre}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{item.asignadoAt ? new Date(item.asignadoAt).toLocaleDateString('es-MX') : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => onAssign(item)}>
|
||||
<UserCog className="h-4 w-4" />
|
||||
</Button>
|
||||
{item.auxiliarUserId && (
|
||||
<Button variant="ghost" size="sm" className="text-red-600" onClick={() => onUnassign(item)}>
|
||||
<UserX className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SinAsignarTable({
|
||||
items,
|
||||
tipo,
|
||||
auxiliares,
|
||||
onAssign,
|
||||
}: {
|
||||
items: any[];
|
||||
tipo: 'obligacion' | 'tarea';
|
||||
auxiliares: any[];
|
||||
onAssign: (item: any) => void;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
No hay {tipo === 'obligacion' ? 'obligaciones' : 'tareas'} sin asignar.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium">Contribuyente</th>
|
||||
<th className="text-left px-4 py-3 font-medium">{tipo === 'obligacion' ? 'Obligación' : 'Tarea'}</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{items.map((item, idx) => (
|
||||
<tr key={`${item.obligacionId || item.tareaId}-${idx}`} className="hover:bg-muted/30">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{item.contribuyenteRazonSocial}</div>
|
||||
<div className="text-xs text-muted-foreground">{item.contribuyenteRfc}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">{tipo === 'obligacion' ? item.obligacionNombre : item.tareaNombre}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => onAssign(item)}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Asignar
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useDebounce } from '@horux/shared-ui';
|
||||
import { Header } from '@/components/layouts/header';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Popover, PopoverTrigger, PopoverContent } from '@horux/shared-ui';
|
||||
import { useCfdis, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
|
||||
import { createManyCfdis, searchEmisores, searchReceptores, getCfdis, getConceptosList, type EmisorReceptor } from '@/lib/api/cfdi';
|
||||
import { createManyCfdis, searchEmisores, searchReceptores, getCfdis, getConceptosList, downloadXmlsZip, type EmisorReceptor } from '@/lib/api/cfdi';
|
||||
import { cancelarFactura, downloadPdf } from '@/lib/api/facturacion';
|
||||
import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared';
|
||||
import type { CreateCfdiData } from '@/lib/api/cfdi';
|
||||
@@ -261,6 +261,7 @@ export default function CfdiPage() {
|
||||
const [loadingEmisor, setLoadingEmisor] = useState(false);
|
||||
const [loadingReceptor, setLoadingReceptor] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [downloadingXmls, setDownloadingXmls] = useState(false);
|
||||
|
||||
// Debounced values for autocomplete
|
||||
const debouncedEmisor = useDebounce(columnFilters.emisor, 300);
|
||||
@@ -424,6 +425,7 @@ export default function CfdiPage() {
|
||||
'Fecha Emisión': formatCfdiDate(cfdi.fechaEmision),
|
||||
'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante),
|
||||
'Uso CFDI': (cfdi as any).usoCfdi || '',
|
||||
'Forma de Pago': cfdi.formaPago || '',
|
||||
'Serie': cfdi.serie || '',
|
||||
'Folio': cfdi.folio || '',
|
||||
'RFC Emisor': cfdi.rfcEmisor,
|
||||
@@ -540,6 +542,7 @@ export default function CfdiPage() {
|
||||
'Fecha Emisión': formatCfdiDate(cfdi.fechaEmision),
|
||||
'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante),
|
||||
'Uso CFDI': (cfdi as any).usoCfdi || '',
|
||||
'Forma de Pago': cfdi.formaPago || '',
|
||||
'Serie': cfdi.serie || '',
|
||||
'Folio': cfdi.folio || '',
|
||||
'RFC Emisor': cfdi.rfcEmisor,
|
||||
@@ -1698,6 +1701,7 @@ export default function CfdiPage() {
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
<th className="pb-3 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm text-center">
|
||||
@@ -1714,6 +1718,21 @@ export default function CfdiPage() {
|
||||
<td className="py-2 text-xs" title={row.unidad || ''}>{row.clave_unidad || '-'}</td>
|
||||
<td className="py-2 text-right">${Number(row.valor_unitario ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right font-medium">${Number(row.importe ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</td>
|
||||
<td className="py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleViewCfdi(row.cfdi_id)}
|
||||
disabled={loadingCfdi === row.cfdi_id}
|
||||
title="Ver CFDI"
|
||||
>
|
||||
{loadingCfdi === row.cfdi_id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -1760,6 +1779,48 @@ export default function CfdiPage() {
|
||||
<FileText className="h-4 w-4" />
|
||||
CFDIs ({data?.total || 0})
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if ((data?.total || 0) > 1000) {
|
||||
if (!confirm('Solo se descargarán los primeros 1,000 XMLs. ¿Continuar?')) return;
|
||||
}
|
||||
try {
|
||||
setDownloadingXmls(true);
|
||||
const blob = await downloadXmlsZip({
|
||||
tipo: filters.tipo,
|
||||
tipoComprobante: filters.tipoComprobante,
|
||||
estado: filters.estado,
|
||||
fechaInicio: filters.fechaInicio,
|
||||
fechaFin: filters.fechaFin,
|
||||
rfc: filters.rfc,
|
||||
emisor: filters.emisor,
|
||||
receptor: filters.receptor,
|
||||
search: filters.search,
|
||||
contribuyenteId: filters.contribuyenteId,
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `cfdis-xml-${Date.now()}.zip`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err: any) {
|
||||
alert(err?.response?.data?.message || err?.message || 'Error al descargar XMLs');
|
||||
} finally {
|
||||
setDownloadingXmls(false);
|
||||
}
|
||||
}}
|
||||
disabled={downloadingXmls || !data?.total}
|
||||
>
|
||||
{downloadingXmls ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Descargar XMLs
|
||||
</Button>
|
||||
{hasActiveColumnFilters && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Filtros activos:</span>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import { useCfdisConConciliacion, useConciliar, useDesconciliar } from '@/lib/hooks/use-conciliacion';
|
||||
import { useBancos } from '@/lib/hooks/use-bancos';
|
||||
import { useRegimenesDelPeriodo } from '@/lib/hooks/use-dashboard';
|
||||
import { PeriodSelector, RegimenSelector } from '@horux/shared-ui';
|
||||
import { PeriodSelector, RegimenSelector, useDebounce } from '@horux/shared-ui';
|
||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||
import { Header } from '@/components/layouts/header';
|
||||
import { Card, CardContent, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Input, Label, Popover, PopoverTrigger, PopoverContent } from '@horux/shared-ui';
|
||||
@@ -29,6 +29,98 @@ function getMonthRange(year: number, month: number) {
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function FilterHeader({
|
||||
label,
|
||||
filterKey,
|
||||
filters,
|
||||
setFilters,
|
||||
openFilter,
|
||||
setOpenFilter,
|
||||
suggestions,
|
||||
}: {
|
||||
label: string;
|
||||
filterKey: string;
|
||||
filters: Record<string, string>;
|
||||
setFilters: any;
|
||||
openFilter: string | null;
|
||||
setOpenFilter: (v: string | null) => void;
|
||||
suggestions: string[];
|
||||
}) {
|
||||
const rawValue = (filters as any)[filterKey] || '';
|
||||
const [localValue, setLocalValue] = useState(rawValue);
|
||||
const debouncedValue = useDebounce(localValue, 300);
|
||||
|
||||
// Sync local state when popover opens or external filter changes
|
||||
useEffect(() => {
|
||||
setLocalValue(rawValue);
|
||||
}, [rawValue, openFilter === filterKey]);
|
||||
|
||||
// Update parent filter only when debounced value changes
|
||||
useEffect(() => {
|
||||
if (debouncedValue !== rawValue) {
|
||||
setFilters((prev: any) => ({ ...prev, [filterKey]: debouncedValue }));
|
||||
}
|
||||
}, [debouncedValue]);
|
||||
|
||||
const hasFilter = !!rawValue;
|
||||
const filteredSuggestions = localValue.length >= 1
|
||||
? suggestions.filter((s) => s.toLowerCase().includes(localValue.toLowerCase())).slice(0, 8)
|
||||
: suggestions.slice(0, 8);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{label}
|
||||
<Popover open={openFilter === filterKey} onOpenChange={(open) => setOpenFilter(open ? filterKey : null)}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={`p-1 rounded hover:bg-muted ${hasFilter ? 'text-primary' : ''}`}>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="start">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Filtrar por {label}</h4>
|
||||
<div className="relative">
|
||||
<Label className="text-xs">Contiene</Label>
|
||||
<Input
|
||||
placeholder={`Buscar ${label.toLowerCase()}...`}
|
||||
className="h-8 text-sm"
|
||||
value={localValue}
|
||||
onChange={(e) => setLocalValue(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{filteredSuggestions.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-background border rounded-md shadow-lg max-h-40 overflow-y-auto z-50">
|
||||
{filteredSuggestions.map((s, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className="w-full text-left px-3 py-1.5 text-sm hover:bg-muted truncate"
|
||||
onClick={() => {
|
||||
setLocalValue(s);
|
||||
setFilters((prev: any) => ({ ...prev, [filterKey]: s }));
|
||||
setOpenFilter(null);
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => setOpenFilter(null)}>Aplicar</Button>
|
||||
{hasFilter && (
|
||||
<Button size="sm" variant="outline" onClick={() => { setFilters((prev: any) => ({ ...prev, [filterKey]: '' })); setOpenFilter(null); }}>
|
||||
Limpiar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConciliacionPage() {
|
||||
const now = new Date();
|
||||
const defaultRange = getMonthRange(now.getFullYear(), now.getMonth() + 1);
|
||||
@@ -53,7 +145,7 @@ export default function ConciliacionPage() {
|
||||
const [openFilterPendientes, setOpenFilterPendientes] = useState<string | null>(null);
|
||||
|
||||
// Filtros por columna — Conciliadas
|
||||
const [filtersConciliadas, setFiltersConciliadas] = useState({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '' });
|
||||
const [filtersConciliadas, setFiltersConciliadas] = useState({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '', banco: '' });
|
||||
const [openFilterConciliadas, setOpenFilterConciliadas] = useState<string | null>(null);
|
||||
|
||||
const { user } = useAuthStore();
|
||||
@@ -75,8 +167,22 @@ export default function ConciliacionPage() {
|
||||
const pendientes = cfdis?.filter((c) => c.conciliado !== 'true') || [];
|
||||
const conciliadas = cfdis?.filter((c) => c.conciliado === 'true') || [];
|
||||
|
||||
// Sugerencias únicas para filtros de columna (de todos los CFDIs cargados)
|
||||
const allCfdis = cfdis || [];
|
||||
const uniqueSuggestions = {
|
||||
rfcEmisor: [...new Set(allCfdis.map((c: any) => c.rfcEmisor).filter(Boolean))].sort(),
|
||||
nombreEmisor: [...new Set(allCfdis.map((c: any) => c.nombreEmisor).filter(Boolean))].sort(),
|
||||
rfcReceptor: [...new Set(allCfdis.map((c: any) => c.rfcReceptor).filter(Boolean))].sort(),
|
||||
nombreReceptor: [...new Set(allCfdis.map((c: any) => c.nombreReceptor).filter(Boolean))].sort(),
|
||||
banco: [...new Set(conciliadas.map((c: any) => c.conciliacion?.banco).filter(Boolean))].sort(),
|
||||
};
|
||||
|
||||
// Score cards — tipo P usa monto_pago_mxn, otros usan total_mxn
|
||||
const getMonto = (c: any) => Number(c.montoMxn || c.totalMxn || 0);
|
||||
// I+P-E: Ingresos y Pagos suman, Egresos restan
|
||||
const getMonto = (c: any) => {
|
||||
const monto = Number(c.montoMxn || c.totalMxn || 0);
|
||||
return c.tipoComprobante === 'E' ? -monto : monto;
|
||||
};
|
||||
const montoConciliado = conciliadas.reduce((s, c) => s + getMonto(c), 0);
|
||||
const montoPendiente = pendientes.reduce((s, c) => s + getMonto(c), 0);
|
||||
|
||||
@@ -87,7 +193,7 @@ export default function ConciliacionPage() {
|
||||
setSortConciliadas(null);
|
||||
setFiltersPendientes({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '' });
|
||||
setOpenFilterPendientes(null);
|
||||
setFiltersConciliadas({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '' });
|
||||
setFiltersConciliadas({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '', banco: '' });
|
||||
setOpenFilterConciliadas(null);
|
||||
}, [activeTab, fechaInicio, fechaFin, regimenSeleccionado]);
|
||||
|
||||
@@ -137,12 +243,13 @@ export default function ConciliacionPage() {
|
||||
}
|
||||
};
|
||||
|
||||
function matchesColumnFilters(c: any, filters: { rfcEmisor: string; nombreEmisor: string; rfcReceptor: string; nombreReceptor: string }) {
|
||||
function matchesColumnFilters(c: any, filters: Record<string, string>) {
|
||||
const rfcEmisorMatch = !filters.rfcEmisor || (c.rfcEmisor || '').toLowerCase().includes(filters.rfcEmisor.toLowerCase());
|
||||
const nombreEmisorMatch = !filters.nombreEmisor || (c.nombreEmisor || '').toLowerCase().includes(filters.nombreEmisor.toLowerCase());
|
||||
const rfcReceptorMatch = !filters.rfcReceptor || (c.rfcReceptor || '').toLowerCase().includes(filters.rfcReceptor.toLowerCase());
|
||||
const nombreReceptorMatch = !filters.nombreReceptor || (c.nombreReceptor || '').toLowerCase().includes(filters.nombreReceptor.toLowerCase());
|
||||
return rfcEmisorMatch && nombreEmisorMatch && rfcReceptorMatch && nombreReceptorMatch;
|
||||
const bancoMatch = !filters.banco || ((c.conciliacion?.banco || '') + ' ****' + (c.conciliacion?.terminacionCuenta || '')).toLowerCase().includes(filters.banco.toLowerCase());
|
||||
return rfcEmisorMatch && nombreEmisorMatch && rfcReceptorMatch && nombreReceptorMatch && bancoMatch;
|
||||
}
|
||||
|
||||
function sortCfdis(list: any[], sort: { field: 'fecha' | 'total'; dir: 'asc' | 'desc' } | null) {
|
||||
@@ -163,58 +270,6 @@ export default function ConciliacionPage() {
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function FilterHeader({
|
||||
label,
|
||||
filterKey,
|
||||
filters,
|
||||
setFilters,
|
||||
openFilter,
|
||||
setOpenFilter,
|
||||
}: {
|
||||
label: string;
|
||||
filterKey: string;
|
||||
filters: { rfcEmisor: string; nombreEmisor: string; rfcReceptor: string; nombreReceptor: string };
|
||||
setFilters: React.Dispatch<React.SetStateAction<{ rfcEmisor: string; nombreEmisor: string; rfcReceptor: string; nombreReceptor: string }>>;
|
||||
openFilter: string | null;
|
||||
setOpenFilter: (v: string | null) => void;
|
||||
}) {
|
||||
const hasFilter = !!(filters as any)[filterKey];
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{label}
|
||||
<Popover open={openFilter === filterKey} onOpenChange={(open) => setOpenFilter(open ? filterKey : null)}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={`p-1 rounded hover:bg-muted ${hasFilter ? 'text-primary' : ''}`}>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="start">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Filtrar por {label}</h4>
|
||||
<div>
|
||||
<Label className="text-xs">Contiene</Label>
|
||||
<Input
|
||||
placeholder={`Buscar ${label.toLowerCase()}...`}
|
||||
className="h-8 text-sm"
|
||||
value={(filters as any)[filterKey]}
|
||||
onChange={(e) => setFilters((prev: any) => ({ ...prev, [filterKey]: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => setOpenFilter(null)}>Aplicar</Button>
|
||||
{hasFilter && (
|
||||
<Button size="sm" variant="outline" onClick={() => { setFilters((prev: any) => ({ ...prev, [filterKey]: '' })); setOpenFilter(null); }}>
|
||||
Limpiar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pendientesOrdenados = sortCfdis(
|
||||
pendientes.filter((c) => matchesColumnFilters(c, filtersPendientes)),
|
||||
sortPendientes
|
||||
@@ -321,13 +376,8 @@ export default function ConciliacionPage() {
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-medium mb-4">Por conciliar ({pendientesOrdenados.length})</h3>
|
||||
{pendientesOrdenados.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No hay CFDIs pendientes de conciliar
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full text-base">
|
||||
<thead>
|
||||
<tr className="border-b text-center text-muted-foreground">
|
||||
{!isVisor && (
|
||||
@@ -345,10 +395,19 @@ export default function ConciliacionPage() {
|
||||
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortPendientes(prev => prev?.field === 'fecha' ? { field: 'fecha', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'fecha', dir: 'asc' })}>
|
||||
<span className="flex items-center justify-center gap-1">Fecha <ArrowUpDown className="h-3 w-3" /></span>
|
||||
</th>
|
||||
<th className="pb-3 font-medium"><FilterHeader label="RFC Emisor" filterKey="rfcEmisor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} /></th>
|
||||
<th className="pb-3 font-medium"><FilterHeader label="Nombre Emisor" filterKey="nombreEmisor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} /></th>
|
||||
<th className="pb-3 font-medium"><FilterHeader label="RFC Receptor" filterKey="rfcReceptor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} /></th>
|
||||
<th className="pb-3 font-medium"><FilterHeader label="Nombre Receptor" filterKey="nombreReceptor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} /></th>
|
||||
{activeTab === 'EMITIDO' ? (
|
||||
<>
|
||||
<th className="pb-3 font-medium"><FilterHeader label="RFC Receptor" filterKey="rfcReceptor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} suggestions={uniqueSuggestions.rfcReceptor} /></th>
|
||||
<th className="pb-3 font-medium"><FilterHeader label="Nombre Receptor" filterKey="nombreReceptor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} suggestions={uniqueSuggestions.nombreReceptor} /></th>
|
||||
<th className="pb-3 font-medium">Régimen Emisor</th>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<th className="pb-3 font-medium"><FilterHeader label="RFC Emisor" filterKey="rfcEmisor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} suggestions={uniqueSuggestions.rfcEmisor} /></th>
|
||||
<th className="pb-3 font-medium"><FilterHeader label="Nombre Emisor" filterKey="nombreEmisor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} suggestions={uniqueSuggestions.nombreEmisor} /></th>
|
||||
<th className="pb-3 font-medium">Régimen Receptor</th>
|
||||
</>
|
||||
)}
|
||||
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortPendientes(prev => prev?.field === 'total' ? { field: 'total', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'total', dir: 'asc' })}>
|
||||
<span className="flex items-center justify-center gap-1">Total MXN <ArrowUpDown className="h-3 w-3" /></span>
|
||||
</th>
|
||||
@@ -357,7 +416,14 @@ export default function ConciliacionPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pendientesOrdenados.map((cfdi) => (
|
||||
{pendientesOrdenados.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={isVisor ? 9 : 10} className="py-4 text-base text-muted-foreground text-center">
|
||||
No hay CFDIs pendientes de conciliar
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
pendientesOrdenados.map((cfdi) => (
|
||||
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
|
||||
{!isVisor && (
|
||||
<td className="py-2">
|
||||
@@ -368,24 +434,33 @@ export default function ConciliacionPage() {
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="py-2 font-mono text-xs text-center" title={cfdi.uuid}>
|
||||
<td className="py-2 font-mono text-sm text-center" title={cfdi.uuid}>
|
||||
{cfdi.uuid?.substring(0, 8)}
|
||||
</td>
|
||||
<td className="py-2 text-xs text-center">
|
||||
<td className="py-2 text-sm text-center">
|
||||
{toCfdiDate(cfdi.fechaPagoP || cfdi.fechaEmision).toLocaleDateString('es-MX')}
|
||||
</td>
|
||||
<td className="py-2 font-mono text-xs text-center">{cfdi.rfcEmisor}</td>
|
||||
<td className="py-2 text-xs truncate max-w-[120px] text-center">
|
||||
{cfdi.nombreEmisor}
|
||||
</td>
|
||||
<td className="py-2 font-mono text-xs text-center">{cfdi.rfcReceptor}</td>
|
||||
<td className="py-2 text-xs truncate max-w-[120px] text-center">
|
||||
{activeTab === 'EMITIDO' ? (
|
||||
<>
|
||||
<td className="py-2 font-mono text-sm text-center">{cfdi.rfcReceptor}</td>
|
||||
<td className="py-2 text-sm truncate max-w-[120px] text-center">
|
||||
{cfdi.nombreReceptor}
|
||||
</td>
|
||||
<td className="py-2 text-xs font-medium text-center">
|
||||
<td className="py-2 text-sm text-center">{cfdi.regimenFiscalEmisor || '-'}</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className="py-2 font-mono text-sm text-center">{cfdi.rfcEmisor}</td>
|
||||
<td className="py-2 text-sm truncate max-w-[120px] text-center">
|
||||
{cfdi.nombreEmisor}
|
||||
</td>
|
||||
<td className="py-2 text-sm text-center">{cfdi.regimenFiscalReceptor || '-'}</td>
|
||||
</>
|
||||
)}
|
||||
<td className="py-2 text-sm font-medium text-center">
|
||||
{formatCurrencyConciliacion(getMonto(cfdi))}
|
||||
</td>
|
||||
<td className="py-2 text-xs text-center">{cfdi.metodoPago || '-'}</td>
|
||||
<td className="py-2 text-sm text-center">{cfdi.metodoPago || '-'}</td>
|
||||
<td className="py-2 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -397,11 +472,11 @@ export default function ConciliacionPage() {
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -453,51 +528,55 @@ export default function ConciliacionPage() {
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-medium mb-4">Conciliadas ({conciliadasOrdenadas.length})</h3>
|
||||
{conciliadasOrdenadas.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No hay CFDIs conciliados</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full text-base">
|
||||
<thead>
|
||||
<tr className="border-b text-center text-muted-foreground">
|
||||
<th className="pb-3 font-medium">UUID</th>
|
||||
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortConciliadas(prev => prev?.field === 'fecha' ? { field: 'fecha', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'fecha', dir: 'asc' })}>
|
||||
<span className="flex items-center justify-center gap-1">Fecha Emisión <ArrowUpDown className="h-3 w-3" /></span>
|
||||
</th>
|
||||
<th className="pb-3 font-medium"><FilterHeader label="RFC Emisor" filterKey="rfcEmisor" filters={filtersConciliadas} setFilters={setFiltersConciliadas} openFilter={openFilterConciliadas} setOpenFilter={setOpenFilterConciliadas} /></th>
|
||||
<th className="pb-3 font-medium"><FilterHeader label="Nombre Emisor" filterKey="nombreEmisor" filters={filtersConciliadas} setFilters={setFiltersConciliadas} openFilter={openFilterConciliadas} setOpenFilter={setOpenFilterConciliadas} /></th>
|
||||
<th className="pb-3 font-medium"><FilterHeader label={activeTab === 'EMITIDO' ? 'RFC Receptor' : 'RFC Emisor'} filterKey={activeTab === 'EMITIDO' ? 'rfcReceptor' : 'rfcEmisor'} filters={filtersConciliadas} setFilters={setFiltersConciliadas} openFilter={openFilterConciliadas} setOpenFilter={setOpenFilterConciliadas} suggestions={activeTab === 'EMITIDO' ? uniqueSuggestions.rfcReceptor : uniqueSuggestions.rfcEmisor} /></th>
|
||||
<th className="pb-3 font-medium"><FilterHeader label={activeTab === 'EMITIDO' ? 'Nombre Receptor' : 'Nombre Emisor'} filterKey={activeTab === 'EMITIDO' ? 'nombreReceptor' : 'nombreEmisor'} filters={filtersConciliadas} setFilters={setFiltersConciliadas} openFilter={openFilterConciliadas} setOpenFilter={setOpenFilterConciliadas} suggestions={activeTab === 'EMITIDO' ? uniqueSuggestions.nombreReceptor : uniqueSuggestions.nombreEmisor} /></th>
|
||||
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortConciliadas(prev => prev?.field === 'total' ? { field: 'total', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'total', dir: 'asc' })}>
|
||||
<span className="flex items-center justify-center gap-1">Total MXN <ArrowUpDown className="h-3 w-3" /></span>
|
||||
</th>
|
||||
<th className="pb-3 font-medium">Fecha Pago</th>
|
||||
<th className="pb-3 font-medium">Banco</th>
|
||||
<th className="pb-3 font-medium"><FilterHeader label="Banco" filterKey="banco" filters={filtersConciliadas} setFilters={setFiltersConciliadas} openFilter={openFilterConciliadas} setOpenFilter={setOpenFilterConciliadas} suggestions={uniqueSuggestions.banco} /></th>
|
||||
<th className="pb-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{conciliadasOrdenadas.map((cfdi) => (
|
||||
{conciliadasOrdenadas.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-4 text-base text-muted-foreground text-center">
|
||||
No hay CFDIs conciliados
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
conciliadasOrdenadas.map((cfdi) => (
|
||||
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
|
||||
<td className="py-2 font-mono text-xs text-center" title={cfdi.uuid}>
|
||||
<td className="py-2 font-mono text-sm text-center" title={cfdi.uuid}>
|
||||
{cfdi.uuid?.substring(0, 8)}
|
||||
</td>
|
||||
<td className="py-2 text-xs text-center">
|
||||
<td className="py-2 text-sm text-center">
|
||||
{toCfdiDate(cfdi.fechaPagoP || cfdi.fechaEmision).toLocaleDateString('es-MX')}
|
||||
</td>
|
||||
<td className="py-2 font-mono text-xs text-center">{cfdi.rfcEmisor}</td>
|
||||
<td className="py-2 text-xs truncate max-w-[120px] text-center">
|
||||
{cfdi.nombreEmisor}
|
||||
<td className="py-2 font-mono text-sm text-center">{activeTab === 'EMITIDO' ? cfdi.rfcReceptor : cfdi.rfcEmisor}</td>
|
||||
<td className="py-2 text-sm truncate max-w-[120px] text-center">
|
||||
{activeTab === 'EMITIDO' ? cfdi.nombreReceptor : cfdi.nombreEmisor}
|
||||
</td>
|
||||
<td className="py-2 text-xs font-medium text-center">
|
||||
<td className="py-2 text-sm font-medium text-center">
|
||||
{formatCurrencyConciliacion(getMonto(cfdi))}
|
||||
</td>
|
||||
<td className="py-2 text-xs text-center">
|
||||
<td className="py-2 text-sm text-center">
|
||||
{cfdi.conciliacion?.fechaDePago
|
||||
? new Date(
|
||||
(cfdi.conciliacion.fechaDePago.split('T')[0]) + 'T12:00:00',
|
||||
).toLocaleDateString('es-MX')
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="py-2 text-xs text-center">
|
||||
<td className="py-2 text-sm text-center">
|
||||
{cfdi.conciliacion
|
||||
? `${cfdi.conciliacion.banco} ****${cfdi.conciliacion.terminacionCuenta}`
|
||||
: '-'}
|
||||
@@ -524,11 +603,11 @@ export default function ConciliacionPage() {
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
|
||||
@@ -1,51 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Header } from '@/components/layouts/header';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@horux/shared-ui';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
||||
import { Bell, Loader2 } from 'lucide-react';
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
owner: 'Owner',
|
||||
supervisor: 'Supervisor',
|
||||
auxiliar: 'Auxiliar',
|
||||
cliente: 'Cliente',
|
||||
};
|
||||
|
||||
const EMAIL_LABELS: Record<string, { label: string; description: string; status: 'active' | 'pending' }> = {
|
||||
documento_subido: {
|
||||
label: 'Documento subido',
|
||||
description: 'Notificación cuando se sube una declaración o documento extra del contribuyente.',
|
||||
description: 'Cuando se sube una declaración o documento extra del contribuyente.',
|
||||
status: 'active',
|
||||
},
|
||||
weekly_update: {
|
||||
label: 'Reporte semanal',
|
||||
description: 'Resumen de KPIs, alertas y discrepancias enviado los lunes 8:00 AM.',
|
||||
status: 'pending',
|
||||
status: 'active',
|
||||
},
|
||||
subscription_expiring: {
|
||||
label: 'Vencimiento de suscripción',
|
||||
description: 'Aviso cuando la suscripción del despacho está por vencer.',
|
||||
status: 'pending',
|
||||
status: 'active',
|
||||
},
|
||||
recordatorio_fiscal: {
|
||||
label: 'Recordatorios fiscales',
|
||||
description: 'Avisos de obligaciones próximas a vencer (declaraciones, pagos provisionales).',
|
||||
status: 'pending',
|
||||
},
|
||||
alertas_nuevas: {
|
||||
label: 'Alertas nuevas',
|
||||
description: 'Notificación diaria cuando aparecen alertas fiscales nuevas para un contribuyente.',
|
||||
status: 'active',
|
||||
},
|
||||
recordatorio_proximo: {
|
||||
label: 'Recordatorios próximos',
|
||||
description: 'Avisos de recordatorios del calendario a 3, 1 y 0 días de su fecha límite.',
|
||||
status: 'active',
|
||||
},
|
||||
};
|
||||
|
||||
interface ContribuyentePrefs {
|
||||
contribuyenteId: string;
|
||||
rfc: string;
|
||||
nombre: string;
|
||||
preferences: Record<string, boolean>;
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
emailTypes: string[];
|
||||
data: ContribuyentePrefs[];
|
||||
roles: string[];
|
||||
preferences: Record<string, Record<string, boolean>>;
|
||||
}
|
||||
|
||||
export default function NotificacionesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedContribuyenteId } = useContribuyenteStore();
|
||||
|
||||
const { data, isLoading } = useQuery<ListResponse>({
|
||||
queryKey: ['notification-preferences'],
|
||||
@@ -55,32 +63,23 @@ export default function NotificacionesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Aplica el filtro del selector global de contribuyente. Si hay uno
|
||||
// seleccionado, solo se muestra esa fila. "Todos" muestra todos.
|
||||
const visibles = useMemo(() => {
|
||||
if (!data) return [];
|
||||
if (!selectedContribuyenteId) return data.data;
|
||||
return data.data.filter(c => c.contribuyenteId === selectedContribuyenteId);
|
||||
}, [data, selectedContribuyenteId]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({ contribuyenteId, emailType, enabled }: { contribuyenteId: string; emailType: string; enabled: boolean }) => {
|
||||
await apiClient.put('/notificaciones', {
|
||||
contribuyenteId,
|
||||
preferences: { [emailType]: enabled },
|
||||
});
|
||||
mutationFn: async ({ emailType, role, enabled }: { emailType: string; role: string; enabled: boolean }) => {
|
||||
await apiClient.put('/notificaciones', { emailType, role, enabled });
|
||||
},
|
||||
onMutate: async ({ contribuyenteId, emailType, enabled }) => {
|
||||
onMutate: async ({ emailType, role, enabled }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['notification-preferences'] });
|
||||
const previous = queryClient.getQueryData<ListResponse>(['notification-preferences']);
|
||||
if (previous) {
|
||||
queryClient.setQueryData<ListResponse>(['notification-preferences'], {
|
||||
...previous,
|
||||
data: previous.data.map(c =>
|
||||
c.contribuyenteId === contribuyenteId
|
||||
? { ...c, preferences: { ...c.preferences, [emailType]: enabled } }
|
||||
: c,
|
||||
),
|
||||
preferences: {
|
||||
...previous.preferences,
|
||||
[emailType]: {
|
||||
...previous.preferences[emailType],
|
||||
[role]: enabled,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return { previous };
|
||||
@@ -93,6 +92,9 @@ export default function NotificacionesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const roles = data?.roles ?? [];
|
||||
const emailTypes = data?.emailTypes ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Notificaciones" />
|
||||
@@ -101,10 +103,10 @@ export default function NotificacionesPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Bell className="h-4 w-4" />
|
||||
Correos informativos por contribuyente
|
||||
Correos informativos por rol
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Por default todos los correos están activados. Desactiva los que no quieras recibir para cada cliente. Los correos críticos (welcome, recuperación de contraseña, confirmación de pago) siempre se envían independientemente de esta configuración.
|
||||
Activa o desactiva cada notificación según el rol del usuario en el despacho. Por default todos están activados. Los correos críticos (welcome, recuperación de contraseña, confirmación de pago) siempre se envían.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
@@ -114,35 +116,30 @@ export default function NotificacionesPage() {
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Cargando...
|
||||
</div>
|
||||
) : visibles.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
{selectedContribuyenteId
|
||||
? 'El contribuyente seleccionado no tiene preferencias configuradas todavía.'
|
||||
: 'No hay contribuyentes en este despacho.'}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
visibles.map(contrib => (
|
||||
<Card key={contrib.contribuyenteId}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{contrib.nombre}
|
||||
</CardTitle>
|
||||
<CardDescription className="font-mono text-xs">{contrib.rfc}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{(data?.emailTypes ?? []).map(type => {
|
||||
<Card>
|
||||
<CardContent className="p-0 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="text-left font-medium px-4 py-3 w-1/3">Notificación</th>
|
||||
{roles.map(role => (
|
||||
<th key={role} className="text-center font-medium px-4 py-3 min-w-[100px]">
|
||||
{ROLE_LABELS[role] ?? role}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{emailTypes.map(type => {
|
||||
const meta = EMAIL_LABELS[type];
|
||||
if (!meta) return null;
|
||||
const checked = contrib.preferences[type] !== false;
|
||||
const isPending = meta.status === 'pending';
|
||||
return (
|
||||
<div key={type} className="flex items-start justify-between gap-4 py-2 border-b last:border-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<tr key={type} className="border-b last:border-0">
|
||||
<td className="px-4 py-3 align-top">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">{meta.label}</span>
|
||||
<span className="font-medium">{meta.label}</span>
|
||||
{isPending && (
|
||||
<span className="text-[10px] uppercase tracking-wide bg-muted text-muted-foreground rounded px-1.5 py-0.5">
|
||||
Próximamente
|
||||
@@ -150,29 +147,37 @@ export default function NotificacionesPage() {
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{meta.description}</p>
|
||||
</div>
|
||||
<label className="inline-flex items-center cursor-pointer flex-shrink-0">
|
||||
</td>
|
||||
{roles.map(role => {
|
||||
const checked = data?.preferences?.[type]?.[role] !== false;
|
||||
return (
|
||||
<td key={role} className="px-4 py-3 text-center align-middle">
|
||||
<label className={`inline-flex items-center ${isPending ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={checked}
|
||||
disabled={isPending}
|
||||
onChange={e =>
|
||||
mutation.mutate({
|
||||
contribuyenteId: contrib.contribuyenteId,
|
||||
emailType: type,
|
||||
role,
|
||||
enabled: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="relative w-10 h-6 bg-muted peer-checked:bg-primary rounded-full peer-focus:ring-2 peer-focus:ring-primary/30 transition-colors after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-transform peer-checked:after:translate-x-4" />
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
|
||||
@@ -189,6 +189,7 @@ export default function ObligacionesPage() {
|
||||
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
||||
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
||||
cuatrimestral: 'bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300',
|
||||
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||
eventual: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
||||
};
|
||||
|
||||
@@ -379,6 +379,7 @@ export default function ConfiguracionPage() {
|
||||
const empresaNombre = viewingTenantName || user?.tenantName;
|
||||
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
|
||||
const isDespacho = isDespachoTenant(user?.tenantRfc);
|
||||
const showFullConfig = ['owner', 'cfo', 'supervisor'].includes(user?.role || '');
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -440,7 +441,7 @@ export default function ConfiguracionPage() {
|
||||
)}
|
||||
|
||||
{/* Regímenes Fiscales, Domicilio Fiscal, Bancos */}
|
||||
{(user?.role === 'owner' || user?.role === 'cfo') && (
|
||||
{(user?.role === 'owner' || user?.role === 'cfo' || user?.role === 'supervisor') && (
|
||||
isDespacho && !selectedContribuyenteId ? (
|
||||
<Card>
|
||||
<CardContent className="py-6 text-center text-muted-foreground">
|
||||
@@ -456,6 +457,8 @@ export default function ConfiguracionPage() {
|
||||
)
|
||||
)}
|
||||
|
||||
{showFullConfig && (
|
||||
<>
|
||||
{/* SAT Configuration */}
|
||||
<Link href="/configuracion/sat">
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
@@ -477,7 +480,7 @@ export default function ConfiguracionPage() {
|
||||
</Link>
|
||||
|
||||
{/* Obligaciones Fiscales */}
|
||||
{(user?.role === 'owner' || user?.role === 'cfo') && (
|
||||
{(user?.role === 'owner' || user?.role === 'cfo' || user?.role === 'supervisor') && (
|
||||
<Link href="/configuracion/obligaciones">
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardHeader>
|
||||
@@ -518,7 +521,7 @@ export default function ConfiguracionPage() {
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Preferencias de Facturación (auto-emisión de pagos de suscripción) */}
|
||||
{/* Preferencias de Facturación */}
|
||||
<Link href="/configuracion/facturacion">
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardHeader>
|
||||
@@ -538,26 +541,6 @@ export default function ConfiguracionPage() {
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Seguridad */}
|
||||
<Link href="/configuracion/seguridad">
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Seguridad
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Cambia tu contraseña y gestiona las sesiones activas de tu cuenta
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Actualiza tu contraseña o cierra todas las sesiones activas si sospechas un acceso no autorizado.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* CSD / Facturapi */}
|
||||
<Link href="/configuracion/csd">
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
@@ -577,6 +560,28 @@ export default function ConfiguracionPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Seguridad */}
|
||||
<Link href="/configuracion/seguridad">
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Seguridad
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Cambia tu contraseña y gestiona las sesiones activas de tu cuenta
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Actualiza tu contraseña o cierra todas las sesiones activas si sospechas un acceso no autorizado.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Admin global: edición de precios */}
|
||||
{isGlobalAdmin && (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { apiClient } from '@/lib/api/client';
|
||||
import { subscribeMe, changeMyPlan, cancelMySubscription, upgradeMe, generatePaymentLink } from '@/lib/api/subscription';
|
||||
import { getPendingInvitation, acceptInvitation } from '@/lib/api/trial-invitations';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { getSubscriptionState } from '@horux/shared';
|
||||
|
||||
type Despachoplan = 'trial' | 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus' | 'custom';
|
||||
type PaidPlan = 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus';
|
||||
@@ -24,6 +25,7 @@ interface PlanInfo {
|
||||
dbMode: string;
|
||||
trialEndsAt: string | null;
|
||||
isTrialActive: boolean;
|
||||
planPrice: number | null;
|
||||
subscription: SubscriptionInfo | null;
|
||||
}
|
||||
|
||||
@@ -88,9 +90,14 @@ export default function PlanesDespachoPage() {
|
||||
// El usuario puede cancelar si tiene una suscripción que aún corre (paid, trial,
|
||||
// custom). Si ya está cancelada o expirada, no hay nada que cancelar.
|
||||
const subStatus = planInfo?.subscription?.status ?? null;
|
||||
const hasActiveSub = subStatus != null
|
||||
&& subStatus !== 'cancelled'
|
||||
&& subStatus !== 'trial_expired';
|
||||
const subState = planInfo?.subscription ? getSubscriptionState(planInfo.subscription) : null;
|
||||
const hasActiveSub = subState?.isActive || subState?.isTrial || subState?.isCancelledInPeriod || false;
|
||||
// Estados en los que se puede generar un link de pago (incluye trial, vencido y pending).
|
||||
const isPayableStatus = subStatus === 'trial'
|
||||
|| subStatus === 'trial_expired'
|
||||
|| subStatus === 'pending'
|
||||
|| hasActiveSub;
|
||||
const isCurrentPlanPaid = currentPlan === planInfo?.subscription?.plan && subState?.isActive === true;
|
||||
|
||||
/** Resuelve la frecuencia para un plan. Mi Empresa y Mi Empresa+ leen su
|
||||
* propio toggle; el resto (business_*) siempre annual. */
|
||||
@@ -105,6 +112,15 @@ export default function PlanesDespachoPage() {
|
||||
setBusy(plan);
|
||||
setMessage(null);
|
||||
try {
|
||||
// Si el plan actual está pendiente de pago, solo regeneramos el link de pago.
|
||||
if (currentPlan === plan && subState?.isPending) {
|
||||
return await handlePagarAhora();
|
||||
}
|
||||
// Si tiene una sub pendiente en otro plan, no permitir cambiar hasta pagar.
|
||||
if (subState?.isPending) {
|
||||
setMessage({ kind: 'err', text: 'Completa el pago del plan actual antes de cambiar de plan.' });
|
||||
return;
|
||||
}
|
||||
// Sin sub activa: subscribe directo → MP (preapproval del plan completo).
|
||||
const result = await subscribeMe({ plan, frequency });
|
||||
window.open(result.paymentUrl, '_blank');
|
||||
@@ -190,10 +206,10 @@ export default function PlanesDespachoPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function ActiveBadge() {
|
||||
function CurrentPlanBadge({ pending }: { pending?: boolean }) {
|
||||
return (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-green-600 text-white text-xs px-3 py-1 rounded-full font-medium whitespace-nowrap">
|
||||
Plan actual
|
||||
<div className={`absolute -top-3 left-1/2 -translate-x-1/2 text-white text-xs px-3 py-1 rounded-full font-medium whitespace-nowrap ${pending ? 'bg-yellow-600' : 'bg-green-600'}`}>
|
||||
{pending ? 'Plan actual — pendiente' : 'Plan actual'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -225,9 +241,25 @@ export default function PlanesDespachoPage() {
|
||||
|
||||
function PlanActionButton({ plan }: { plan: PaidPlan }) {
|
||||
const isCurrent = currentPlan === plan;
|
||||
if (isCurrent) {
|
||||
if (isCurrent && isCurrentPlanPaid) {
|
||||
return <Button disabled className="w-full">Plan actual</Button>;
|
||||
}
|
||||
if (isCurrent) {
|
||||
return (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => handleContratar(plan)}
|
||||
disabled={busy === plan}
|
||||
>
|
||||
{busy === plan ? 'Procesando...' : (
|
||||
<>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Pagar este plan
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
const label = hasActiveSub ? 'Cambiar a este plan' : 'Contratar';
|
||||
return (
|
||||
<Button
|
||||
@@ -302,7 +334,7 @@ export default function PlanesDespachoPage() {
|
||||
)}
|
||||
|
||||
{/* Banner de suscripción activa */}
|
||||
{!loading && planInfo?.subscription && hasPaidPlan && (() => {
|
||||
{!loading && planInfo?.subscription && hasPaidPlan && subState?.isActive && (() => {
|
||||
const sub = planInfo.subscription;
|
||||
const periodEndDate = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null;
|
||||
const fechaFormato = periodEndDate
|
||||
@@ -329,18 +361,45 @@ export default function PlanesDespachoPage() {
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Botón "Pagar mi período actual" — visible cuando la sub corre y hay
|
||||
un monto > 0 que cobrar. Crea una MP Preference one-off por el monto
|
||||
actual (custom $10, paid plan, lo que sea). Útil para pre-pagar antes
|
||||
del cobro automático o cuando no hay preapproval recurrente activo. */}
|
||||
{!loading && hasActiveSub && planInfo?.subscription && Number(planInfo.subscription.amount) > 0 && (() => {
|
||||
{/* Banner de suscripción pendiente */}
|
||||
{!loading && planInfo?.subscription && hasPaidPlan && subState?.isPending && (
|
||||
<div className="flex items-start gap-3 bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
|
||||
<Clock className="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm space-y-0.5">
|
||||
<div className="font-semibold text-yellow-800 dark:text-yellow-300">
|
||||
Suscripción pendiente de pago
|
||||
</div>
|
||||
<div className="text-yellow-700 dark:text-yellow-400">
|
||||
Tu suscripción aún no está activa. Completa el pago para evitar la suspensión del servicio.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Banner de trial vencido */}
|
||||
{!loading && subStatus === 'trial_expired' && hasPaidPlan && (
|
||||
<div className="flex items-start gap-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
|
||||
<Clock className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold text-red-800 dark:text-red-300">Tu período de prueba terminó</span>
|
||||
<span className="text-red-700 dark:text-red-400"> — elige un plan o paga el plan actual para recuperar el acceso.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Botón "Pagar mi período actual" — visible cuando se puede pagar y hay
|
||||
un monto definido (subscription.amount > 0 o precio de catálogo).
|
||||
Crea una MP Preference one-off por el monto actual. */}
|
||||
{!loading && isPayableStatus && planInfo?.subscription && (() => {
|
||||
const sub = planInfo.subscription!;
|
||||
const effectiveAmount = Number(sub.amount) > 0 ? Number(sub.amount) : (planInfo.planPrice ?? 0);
|
||||
if (!effectiveAmount) return null;
|
||||
const periodEnd = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null;
|
||||
const fechaFmt = periodEnd
|
||||
? periodEnd.toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
: null;
|
||||
const dias = periodEnd ? Math.max(0, Math.ceil((periodEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24))) : null;
|
||||
const montoFmt = Number(sub.amount).toLocaleString('es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
const montoFmt = effectiveAmount.toLocaleString('es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg px-5 py-4 max-w-3xl mx-auto">
|
||||
<CreditCard className="h-6 w-6 text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||
@@ -388,7 +447,7 @@ export default function PlanesDespachoPage() {
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 max-w-7xl mx-auto">
|
||||
{/* Mi Empresa */}
|
||||
<Card className={`relative flex flex-col${currentPlan === 'mi_empresa' ? ' ring-2 ring-green-500' : ''}`}>
|
||||
{currentPlan === 'mi_empresa' && <ActiveBadge />}
|
||||
{currentPlan === 'mi_empresa' && <CurrentPlanBadge pending={subState?.isPending} />}
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto bg-emerald-100 dark:bg-emerald-900 rounded-full p-3 w-fit mb-2">
|
||||
<Cloud className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
|
||||
@@ -422,7 +481,7 @@ export default function PlanesDespachoPage() {
|
||||
|
||||
{/* Mi Empresa + */}
|
||||
<Card className={`relative flex flex-col${currentPlan === 'mi_empresa_plus' ? ' ring-2 ring-green-500' : ''}`}>
|
||||
{currentPlan === 'mi_empresa_plus' && <ActiveBadge />}
|
||||
{currentPlan === 'mi_empresa_plus' && <CurrentPlanBadge pending={subState?.isPending} />}
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto bg-teal-100 dark:bg-teal-900 rounded-full p-3 w-fit mb-2">
|
||||
<Cloud className="h-6 w-6 text-teal-600 dark:text-teal-400" />
|
||||
@@ -459,7 +518,7 @@ export default function PlanesDespachoPage() {
|
||||
{/* Business Control */}
|
||||
<Card className={`relative flex flex-col${currentPlan === 'business_control' ? ' ring-2 ring-green-500' : ' border-primary ring-2 ring-primary/20'}`}>
|
||||
{currentPlan === 'business_control'
|
||||
? <ActiveBadge />
|
||||
? <CurrentPlanBadge pending={subState?.isPending} />
|
||||
: (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground text-xs px-3 py-1 rounded-full">
|
||||
Más popular
|
||||
@@ -494,7 +553,7 @@ export default function PlanesDespachoPage() {
|
||||
|
||||
{/* Enterprise (key interna: business_cloud) */}
|
||||
<Card className={`relative flex flex-col${currentPlan === 'business_cloud' ? ' ring-2 ring-green-500' : ''}`}>
|
||||
{currentPlan === 'business_cloud' && <ActiveBadge />}
|
||||
{currentPlan === 'business_cloud' && <CurrentPlanBadge pending={subState?.isPending} />}
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto bg-purple-100 dark:bg-purple-900 rounded-full p-3 w-fit mb-2">
|
||||
<Cloud className="h-6 w-6 text-purple-600 dark:text-purple-400" />
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useAuthStore } from '@/stores/auth-store';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { Plus, Pencil, Trash2, Building2, Sparkles } from 'lucide-react';
|
||||
import { AddonsDialog } from './addons-dialog';
|
||||
import { DESPACHO_PLANS } from '@horux/shared';
|
||||
|
||||
const TRIAL_LIMIT_TOOLTIP = 'Límite de contribuyentes para la prueba gratuita, para continuar agregando contribuyentes, selecciona un plan.';
|
||||
|
||||
@@ -30,11 +31,21 @@ export default function ContribuyentesPage() {
|
||||
// deshabilita el botón con tooltip explicativo.
|
||||
const { data: planInfo } = useQuery({
|
||||
queryKey: ['my-plan-info'],
|
||||
queryFn: () => apiClient.get<{ isTrialActive: boolean }>('/despachos/me/plan').then(r => r.data),
|
||||
queryFn: () => apiClient.get<{ plan: string; isTrialActive: boolean }>('/despachos/me/plan').then(r => r.data),
|
||||
});
|
||||
const activeCount = (contribuyentes ?? []).filter((c: any) => c.active !== false).length;
|
||||
const trialAtLimit = (planInfo?.isTrialActive ?? false) && activeCount >= 5;
|
||||
|
||||
// Contador de RFCs disponibles en el plan
|
||||
const planKey = planInfo?.plan as keyof typeof DESPACHO_PLANS | undefined;
|
||||
const planMaxRfcs = planKey ? DESPACHO_PLANS[planKey]?.maxRfcs ?? undefined : undefined;
|
||||
const rfcCounterText = (() => {
|
||||
if (planInfo?.isTrialActive) return `${activeCount} de 5 RFCs`;
|
||||
if (planMaxRfcs != null && planMaxRfcs < 0) return `${activeCount} RFCs`;
|
||||
if (planMaxRfcs !== undefined) return `${activeCount} de ${planMaxRfcs} RFCs`;
|
||||
return `${activeCount} RFCs`;
|
||||
})();
|
||||
|
||||
const resetForm = () => { setForm({ rfc: '', razonSocial: '' }); setAssignSelf(true); setShowDialog(false); setEditingId(null); };
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -77,10 +88,16 @@ export default function ContribuyentesPage() {
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
const canCreate = user?.role === 'owner' || user?.role === 'cfo' || user?.role === 'supervisor';
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-2xl font-bold">Contribuyentes</h1><p className="text-sm text-muted-foreground">RFCs que gestiona tu despacho</p></div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Contribuyentes</h1>
|
||||
<p className="text-sm text-muted-foreground">RFCs que gestiona tu despacho · {rfcCounterText}</p>
|
||||
</div>
|
||||
{canCreate && (
|
||||
<Button
|
||||
onClick={() => { resetForm(); setShowDialog(true); }}
|
||||
disabled={trialAtLimit}
|
||||
@@ -89,6 +106,7 @@ export default function ContribuyentesPage() {
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Agregar RFC
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? <p className="text-muted-foreground">Cargando...</p> : !contribuyentes || contribuyentes.length === 0 ? (
|
||||
@@ -96,6 +114,7 @@ export default function ContribuyentesPage() {
|
||||
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold">Sin contribuyentes</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 mb-4">Agrega el primer RFC para empezar.</p>
|
||||
{canCreate && (
|
||||
<Button
|
||||
onClick={() => { resetForm(); setShowDialog(true); }}
|
||||
disabled={trialAtLimit}
|
||||
@@ -103,6 +122,7 @@ export default function ContribuyentesPage() {
|
||||
>
|
||||
Agregar primer RFC
|
||||
</Button>
|
||||
)}
|
||||
</CardContent></Card>
|
||||
) : (
|
||||
<div className="grid gap-3 lg:grid-cols-2 3xl:grid-cols-3 4xl:grid-cols-4">{contribuyentes.map((c) => (
|
||||
@@ -111,11 +131,16 @@ export default function ContribuyentesPage() {
|
||||
<p className="font-semibold">{c.nombre}</p>
|
||||
<p className="text-sm text-muted-foreground font-mono">{c.rfc}</p>
|
||||
{c.regimenFiscal && <p className="text-xs text-muted-foreground mt-1">Régimen: {c.regimenFiscal}</p>}
|
||||
{c.supervisorNombre && <p className="text-xs text-muted-foreground mt-1">Supervisor: {c.supervisorNombre}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(user?.role === 'owner' || user?.role === 'cfo') && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setAddonsTarget({ id: c.id, nombre: c.nombre })} title="Add-ons"><Sparkles className="h-4 w-4" /></Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(c)}><Pencil className="h-4 w-4" /></Button>
|
||||
{user?.role === 'owner' && (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDeactivate(c.id, c.rfc)} className="text-destructive hover:text-destructive"><Trash2 className="h-4 w-4" /></Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
))}</div>
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
AlertTriangle,
|
||||
ShoppingCart,
|
||||
CheckSquare,
|
||||
FileMinus,
|
||||
FilePlus,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@horux/shared-ui';
|
||||
import { FiscalDisclaimer } from '@/components/fiscal-disclaimer';
|
||||
@@ -118,6 +120,15 @@ export default function DashboardPage() {
|
||||
? kpis?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
|
||||
: kpis?.ivaBalance || 0;
|
||||
|
||||
// Notas de crédito
|
||||
const ncsEmitidasDisplay = regimenSeleccionado
|
||||
? kpis?.ncsEmitidasPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
|
||||
: kpis?.ncsEmitidas || 0;
|
||||
|
||||
const ncsRecibidasDisplay = regimenSeleccionado
|
||||
? kpis?.ncsRecibidasPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
|
||||
: kpis?.ncsRecibidas || 0;
|
||||
|
||||
const ivaAnterior = regimenSeleccionado
|
||||
? kpisAnterior?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
|
||||
: kpisAnterior?.ivaBalance || 0;
|
||||
@@ -126,9 +137,15 @@ export default function DashboardPage() {
|
||||
? Math.round(((ivaDisplay - ivaAnterior) / Math.abs(ivaAnterior)) * 10000) / 100
|
||||
: null;
|
||||
|
||||
const utilidadDisplay = ingresosDisplay - egresosDisplay;
|
||||
const margenDisplay = ingresosDisplay > 0
|
||||
? Math.round((utilidadDisplay / ingresosDisplay) * 10000) / 100
|
||||
// Utilidad ajustada por notas de crédito:
|
||||
// Ingresos netos = Ingresos − NCs emitidas
|
||||
// Egresos netos = Gastos − NCs recibidas
|
||||
// Utilidad neta = Ingresos netos − Egresos netos
|
||||
const ingresosNetosDisplay = ingresosDisplay - ncsEmitidasDisplay;
|
||||
const egresosNetosDisplay = egresosDisplay - ncsRecibidasDisplay;
|
||||
const utilidadDisplay = ingresosNetosDisplay - egresosNetosDisplay;
|
||||
const margenDisplay = ingresosNetosDisplay > 0
|
||||
? Math.round((utilidadDisplay / ingresosNetosDisplay) * 10000) / 100
|
||||
: 0;
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
@@ -203,7 +220,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<KpiCard
|
||||
title={regimenSeleccionado ? `Ingresos del Mes (${regimenSeleccionado})` : 'Ingresos del Mes'}
|
||||
value={ingresosDisplay}
|
||||
@@ -216,6 +233,13 @@ export default function DashboardPage() {
|
||||
}
|
||||
href={drillUrl('Ingresos del Mes - CFDIs', { bucket: 'ingresos' })}
|
||||
/>
|
||||
<KpiCard
|
||||
title={regimenSeleccionado ? `NCs Emitidas (${regimenSeleccionado})` : 'NCs Emitidas'}
|
||||
value={ncsEmitidasDisplay}
|
||||
icon={<FileMinus className="h-4 w-4" />}
|
||||
trend="neutral"
|
||||
trendValue="Notas de crédito emitidas"
|
||||
/>
|
||||
<KpiCard
|
||||
title={regimenSeleccionado ? `Gastos del Mes (${regimenSeleccionado})` : 'Gastos del Mes'}
|
||||
value={egresosDisplay}
|
||||
@@ -229,11 +253,18 @@ export default function DashboardPage() {
|
||||
href={drillUrl('Gastos del Mes - CFDIs', { bucket: 'gastos' })}
|
||||
/>
|
||||
<KpiCard
|
||||
title="Utilidad"
|
||||
title={regimenSeleccionado ? `NCs Recibidas (${regimenSeleccionado})` : 'NCs Recibidas'}
|
||||
value={ncsRecibidasDisplay}
|
||||
icon={<FilePlus className="h-4 w-4" />}
|
||||
trend="neutral"
|
||||
trendValue="Notas de crédito recibidas"
|
||||
/>
|
||||
<KpiCard
|
||||
title={regimenSeleccionado ? `Utilidad Neta (${regimenSeleccionado})` : 'Utilidad Neta'}
|
||||
value={utilidadDisplay}
|
||||
icon={<Wallet className="h-4 w-4" />}
|
||||
trend={utilidadDisplay > 0 ? 'up' : 'down'}
|
||||
trendValue={`${margenDisplay}% margen`}
|
||||
trendValue={`${margenDisplay}% margen · incluye NCs`}
|
||||
/>
|
||||
<KpiCard
|
||||
title={regimenSeleccionado ? `Balance IVA (${regimenSeleccionado})` : 'Balance IVA'}
|
||||
@@ -252,7 +283,7 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Desglose por régimen */}
|
||||
{!regimenSeleccionado && kpis && (
|
||||
(kpis.ingresosPorRegimen.length > 1 || kpis.egresosPorRegimen.length > 1 || kpis.ivaBalancePorRegimen.length > 1) && (
|
||||
(kpis.ingresosPorRegimen.length > 1 || kpis.egresosPorRegimen.length > 1 || kpis.ivaBalancePorRegimen.length > 1 || kpis.ncsEmitidasPorRegimen.length > 1 || kpis.ncsRecibidasPorRegimen.length > 1) && (
|
||||
<div className="grid gap-4 md:grid-cols-2 3xl:grid-cols-3">
|
||||
{kpis.ingresosPorRegimen.length > 1 && (
|
||||
<Card>
|
||||
@@ -316,6 +347,46 @@ export default function DashboardPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{kpis.ncsEmitidasPorRegimen.length > 1 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-medium">NCs Emitidas por Regimen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{kpis.ncsEmitidasPorRegimen.map((r) => (
|
||||
<div key={r.regimenClave} className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-mono font-bold bg-muted px-2 py-1 rounded">{r.regimenClave}</span>
|
||||
<span className="text-sm">{r.regimenDescripcion}</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold">{formatCurrency(r.monto)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{kpis.ncsRecibidasPorRegimen.length > 1 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-medium">NCs Recibidas por Regimen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{kpis.ncsRecibidasPorRegimen.map((r) => (
|
||||
<div key={r.regimenClave} className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-mono font-bold bg-muted px-2 py-1 rounded">{r.regimenClave}</span>
|
||||
<span className="text-sm">{r.regimenDescripcion}</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold">{formatCurrency(r.monto)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
@@ -23,9 +23,11 @@ import {
|
||||
import { PapeleriaTab } from '@/components/documentos/papeleria-tab';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import * as docsApi from '@/lib/api/documentos';
|
||||
import { getObligacionesPorPeriodo, type ObligacionPeriodo } from '@/lib/api/obligaciones';
|
||||
|
||||
const MESES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
||||
const IMPUESTOS: Impuesto[] = ['IVA', 'ISR', 'IEPS', 'SUELDOS', 'DIOT', 'OTRO'];
|
||||
const IMPUESTOS: Impuesto[] = ['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'];
|
||||
const OBLIGACIONES_ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'];
|
||||
const PERIODICIDADES: { value: Periodicidad; label: string }[] = [
|
||||
{ value: 'mensual', label: 'Mensual' },
|
||||
{ value: 'bimestral', label: 'Bimestral' },
|
||||
@@ -76,7 +78,7 @@ function getPeriodLabel(periodicidad: string, mes: number): string {
|
||||
const options = getPeriodOptions(periodicidad as Periodicidad);
|
||||
return options.find(o => o.value === mes)?.label || MESES[mes - 1] || String(mes);
|
||||
}
|
||||
const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar'];
|
||||
const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'];
|
||||
|
||||
function EstatusBadge({ estatus }: { estatus: string }) {
|
||||
if (estatus === 'Positiva') return <span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"><CheckCircle2 className="h-3 w-3" /> {estatus}</span>;
|
||||
@@ -87,7 +89,7 @@ function EstatusBadge({ estatus }: { estatus: string }) {
|
||||
export default function DocumentosPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const canConsultarOpinion = user?.role === 'owner' || user?.role === 'cfo';
|
||||
const canSeePapeleria = user?.role !== 'cliente';
|
||||
const canSeePapeleria = true; // Todos los roles pueden ver papelería (cliente con restricciones)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -504,7 +506,7 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
const [tipo, setTipo] = useState<'normal' | 'complementaria'>('normal');
|
||||
const [periodicidad, setPeriodicidad] = useState<Periodicidad>('mensual');
|
||||
const yearsOptions = Array.from({ length: 6 }, (_, i) => currentYear - i);
|
||||
const [impuestos, setImpuestos] = useState<Impuesto[]>([]);
|
||||
const [obligacionesIds, setObligacionesIds] = useState<string[]>([]);
|
||||
const [montoPago, setMontoPago] = useState('');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [ligaFile, setLigaFile] = useState<File | null>(null);
|
||||
@@ -512,6 +514,15 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const periodOptions = getPeriodOptions(periodicidad);
|
||||
const periodo = `${año}-${String(mes).padStart(2, '0')}`;
|
||||
|
||||
const obligacionesQ = useQuery({
|
||||
queryKey: ['obligaciones-periodo-declaracion', selectedContribuyenteId, periodo],
|
||||
queryFn: () => selectedContribuyenteId
|
||||
? getObligacionesPorPeriodo(selectedContribuyenteId, periodo, false)
|
||||
: Promise.resolve({ data: [], periodo }),
|
||||
enabled: !!selectedContribuyenteId,
|
||||
});
|
||||
|
||||
const handlePeriodicidadChange = (p: Periodicidad) => {
|
||||
setPeriodicidad(p);
|
||||
@@ -522,21 +533,21 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleImpuesto = (i: Impuesto) => {
|
||||
setImpuestos(prev => prev.includes(i) ? prev.filter(x => x !== i) : [...prev, i]);
|
||||
const toggleObligacion = (id: string) => {
|
||||
setObligacionesIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
|
||||
};
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
if (!file) return setErr('Selecciona el PDF de la declaración');
|
||||
if (impuestos.length === 0) return setErr('Selecciona al menos un impuesto');
|
||||
if (obligacionesIds.length === 0) return setErr('Selecciona al menos una obligación fiscal');
|
||||
try {
|
||||
const pdfBase64 = await fileToBase64(file);
|
||||
const ligaPagoBase64 = ligaFile ? await fileToBase64(ligaFile) : undefined;
|
||||
const montoNum = montoPago.trim() !== '' ? parseFloat(montoPago) : undefined;
|
||||
await create.mutateAsync({
|
||||
año, mes, tipo, periodicidad, impuestos,
|
||||
año, mes, tipo, periodicidad, obligacionesIds,
|
||||
montoPago: montoNum,
|
||||
pdfBase64, pdfFilename: file.name,
|
||||
ligaPagoBase64,
|
||||
@@ -606,16 +617,51 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Impuestos cubiertos</Label>
|
||||
<div className="grid grid-cols-3 gap-2 mt-1">
|
||||
{IMPUESTOS.map(i => (
|
||||
<label key={i} className={`flex items-center gap-2 px-3 py-2 rounded-md border cursor-pointer text-sm ${impuestos.includes(i) ? 'bg-primary/10 border-primary' : 'hover:bg-muted'}`}>
|
||||
<input type="checkbox" checked={impuestos.includes(i)} onChange={() => toggleImpuesto(i)} className="accent-primary" />
|
||||
{i}
|
||||
<Label>Obligaciones fiscales cubiertas</Label>
|
||||
{!selectedContribuyenteId ? (
|
||||
<p className="text-sm text-muted-foreground mt-1">Selecciona un contribuyente para ver sus obligaciones.</p>
|
||||
) : obligacionesQ.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Cargando obligaciones...
|
||||
</div>
|
||||
) : obligacionesQ.error ? (
|
||||
<p className="text-sm text-red-600 mt-1">Error al cargar obligaciones.</p>
|
||||
) : obligacionesQ.data?.data.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground mt-1">No hay obligaciones fiscales configuradas para este periodo.</p>
|
||||
) : (
|
||||
<div className="space-y-3 mt-2 max-h-60 overflow-y-auto rounded-md border p-3">
|
||||
{Array.from(new Set((obligacionesQ.data?.data || []).map(o => o.categoria || 'Sin categoría'))).map((categoria) => (
|
||||
<div key={categoria}>
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground mb-1.5">{categoria}</p>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{(obligacionesQ.data?.data || [])
|
||||
.filter(o => (o.categoria || 'Sin categoría') === categoria)
|
||||
.map((o) => (
|
||||
<label
|
||||
key={o.id}
|
||||
className={`flex items-start gap-2 px-3 py-2 rounded-md border cursor-pointer text-sm ${obligacionesIds.includes(o.id) ? 'bg-primary/10 border-primary' : 'hover:bg-muted'}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={obligacionesIds.includes(o.id)}
|
||||
onChange={() => toggleObligacion(o.id)}
|
||||
className="accent-primary mt-0.5"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="font-medium">{o.nombre}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2 capitalize">({o.frecuencia || '—'})</span>
|
||||
{o.requierePago && (
|
||||
<span className="block text-[10px] text-muted-foreground">Requiere comprobante de pago</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Selecciona todos los impuestos que incluye esta declaración — definen qué recordatorios se desactivan.</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">Selecciona las obligaciones fiscales que cubre esta declaración. Al guardar se marcarán como presentadas y, si aplica, quedarán a la espera de su comprobante de pago.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -700,7 +746,7 @@ function ComprobantePagoDialog({ declaracion, onClose }: { declaracion: Declarac
|
||||
// Extras — PDFs libres (acuses, contratos, poderes, estados de cuenta, etc.)
|
||||
// ============================================================================
|
||||
|
||||
const ROLES_UPLOAD_EXTRA = ['owner', 'cfo', 'contador', 'auxiliar'];
|
||||
const ROLES_UPLOAD_EXTRA = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'];
|
||||
|
||||
function ExtrasTab() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { formatCurrency, toCfdiDate } from '@/lib/utils';
|
||||
import { exportToExcel } from '@/lib/export-excel';
|
||||
import { useTableSort } from '@horux/shared-ui';
|
||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||
import { getCfdiById } from '@/lib/api/cfdi';
|
||||
import { Eye, Download } from 'lucide-react';
|
||||
import type { Cfdi } from '@horux/shared';
|
||||
|
||||
@@ -44,6 +45,7 @@ export default function DrillDownPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const titulo = searchParams.get('titulo') || 'Detalle de CFDIs';
|
||||
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
|
||||
const [loadingCfdiId, setLoadingCfdiId] = useState<number | null>(null);
|
||||
const { selectedContribuyenteId } = useContribuyenteStore();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
@@ -154,7 +156,23 @@ export default function DrillDownPage() {
|
||||
<td className="py-2 text-xs font-mono">{cfdi.regimenEmisor || '-'}</td>
|
||||
<td className="py-2 text-xs font-mono">{cfdi.regimenReceptor || '-'}</td>
|
||||
<td className="py-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={loadingCfdiId === cfdi.id}
|
||||
onClick={async () => {
|
||||
setLoadingCfdiId(cfdi.id);
|
||||
try {
|
||||
const fullCfdi = await getCfdiById(String(cfdi.id));
|
||||
setSelectedCfdi(fullCfdi);
|
||||
} catch (err) {
|
||||
console.error('Error cargando CFDI completo:', err);
|
||||
} finally {
|
||||
setLoadingCfdiId(null);
|
||||
}
|
||||
}}
|
||||
title="Ver factura"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
|
||||
@@ -554,12 +554,26 @@ export default function FacturacionPage() {
|
||||
? clavesUnidad?.filter(u => !SERVICE_UNITS.includes(u.clave))
|
||||
: clavesUnidad;
|
||||
|
||||
const prodSearchAbort = useRef<AbortController | null>(null);
|
||||
|
||||
const handleSearchProduct = async (q: string, idx: number) => {
|
||||
setProdSearch(q);
|
||||
setSearchingIdx(idx);
|
||||
if (q.length < 2) { setProdResults([]); return; }
|
||||
const results = await searchClaveProdServ(q);
|
||||
setProdResults(results);
|
||||
setProdResults([]);
|
||||
if (q.length < 2) return;
|
||||
|
||||
prodSearchAbort.current?.abort();
|
||||
prodSearchAbort.current = new AbortController();
|
||||
|
||||
try {
|
||||
const results = await searchClaveProdServ(q, prodSearchAbort.current.signal);
|
||||
setProdResults(results ?? []);
|
||||
} catch (err: any) {
|
||||
if (err.name !== 'AbortError' && err.code !== 'ERR_CANCELED') {
|
||||
console.error('Error buscando clave SAT:', err);
|
||||
}
|
||||
setProdResults([]);
|
||||
}
|
||||
};
|
||||
|
||||
const selectProduct = (idx: number, clave: string, descripcion: string) => {
|
||||
@@ -1418,6 +1432,7 @@ export default function FacturacionPage() {
|
||||
onChange={e => handleSearchProduct(e.target.value, idx)}
|
||||
onFocus={() => { setSearchingIdx(idx); setProdSearch(c.productKey); }}
|
||||
placeholder="Buscar clave SAT..."
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
<Search className="absolute right-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Header } from '@/components/layouts/header';
|
||||
import {
|
||||
ClipboardList,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
Building2,
|
||||
@@ -75,7 +75,7 @@ export default function PendientesPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [singleObligaciones, setSingleObligaciones] = useState<ObligacionPeriodo[]>([]);
|
||||
const [filter, setFilter] = useState<'todos' | 'mis'>('todos');
|
||||
const [toggling, setToggling] = useState<string | null>(null);
|
||||
|
||||
|
||||
// Single contribuyente view — fetch period-aware data
|
||||
useEffect(() => {
|
||||
@@ -132,31 +132,7 @@ export default function PendientesPage() {
|
||||
const pendientesCount = singleObligaciones.filter((o) => o.periodStatus === 'pendiente').length;
|
||||
const categorias = [...new Set(singleObligaciones.map((o) => o.categoria || 'Sin categoría'))];
|
||||
|
||||
const toggleComplete = async (obligacionId: string, currentStatus: string, periodoAplica: string) => {
|
||||
if (!selectedContribuyenteId) return;
|
||||
const key = `${obligacionId}:${periodoAplica}`;
|
||||
setToggling(key);
|
||||
try {
|
||||
if (currentStatus === 'completada') {
|
||||
await apiClient.post(
|
||||
`/contribuyentes/${selectedContribuyenteId}/obligaciones/${obligacionId}/uncomplete-periodo`,
|
||||
{ periodo: periodoAplica }
|
||||
);
|
||||
} else {
|
||||
await apiClient.post(
|
||||
`/contribuyentes/${selectedContribuyenteId}/obligaciones/${obligacionId}/complete-periodo`,
|
||||
{ periodo: periodoAplica }
|
||||
);
|
||||
}
|
||||
// Refetch
|
||||
const { data } = await apiClient.get(`/contribuyentes/${selectedContribuyenteId}/obligaciones/periodo?periodo=${periodo}&atrasados=true`);
|
||||
setSingleObligaciones(data.data || []);
|
||||
} catch {
|
||||
// silent — state stays as-is
|
||||
} finally {
|
||||
setToggling(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Status badge
|
||||
const statusBadge = (status: string) => {
|
||||
@@ -171,6 +147,7 @@ export default function PendientesPage() {
|
||||
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
||||
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
||||
cuatrimestral: 'bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300',
|
||||
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||
};
|
||||
return f ? (
|
||||
@@ -311,18 +288,15 @@ export default function PendientesPage() {
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => toggleComplete(ob.id, ob.periodStatus, ob.periodoAplica)}
|
||||
disabled={toggling === toggleKey}
|
||||
className="shrink-0 focus:outline-none"
|
||||
title={ob.periodStatus === 'completada' ? 'Marcar como pendiente' : 'Marcar como completada'}
|
||||
>
|
||||
<span className="shrink-0">
|
||||
{ob.periodStatus === 'completada' ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : ob.periodStatus === 'atrasada' ? (
|
||||
<AlertTriangle className="h-4 w-4 text-red-400" />
|
||||
) : (
|
||||
<Circle className={cn('h-4 w-4', ob.periodStatus === 'atrasada' ? 'text-red-400' : 'text-muted-foreground')} />
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
<div>
|
||||
<p className={cn('text-sm font-medium', ob.periodStatus === 'completada' && 'line-through')}>{ob.nombre}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
|
||||
223
apps/web/app/(dashboard)/tareas/page.tsx
Normal file
223
apps/web/app/(dashboard)/tareas/page.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, Button, cn } from '@horux/shared-ui';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import { useMisTareas, useCompletarTareaPeriodo, useDescompletarTareaPeriodo } from '@/lib/hooks/use-tareas-mis';
|
||||
import { CheckCircle2, Circle, AlertTriangle, Clock, Building2 } from 'lucide-react';
|
||||
|
||||
const RECURRENCIAS: Record<string, string> = {
|
||||
semanal: 'Semanal',
|
||||
quincenal: 'Quincenal',
|
||||
mensual: 'Mensual',
|
||||
bimestral: 'Bimestral',
|
||||
trimestral: 'Trimestral',
|
||||
semestral: 'Semestral',
|
||||
anual: 'Anual',
|
||||
};
|
||||
|
||||
const DIAS_SEMANA = ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado'];
|
||||
|
||||
interface TareaItem {
|
||||
id: string;
|
||||
contribuyenteId: string;
|
||||
contribuyenteRfc: string;
|
||||
contribuyenteRazonSocial: string;
|
||||
nombre: string;
|
||||
descripcion: string | null;
|
||||
recurrencia: string;
|
||||
diaSemana: number | null;
|
||||
diaMes: number | null;
|
||||
soloSupervisorCompleta: boolean;
|
||||
periodoActual: {
|
||||
id: string;
|
||||
fechaLimite: string;
|
||||
completada: boolean;
|
||||
completadaAt: string | null;
|
||||
completadaPor: string | null;
|
||||
notas: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function TareasPage() {
|
||||
const { data: tareas, isLoading } = useMisTareas();
|
||||
const completarMut = useCompletarTareaPeriodo();
|
||||
const descompletarMut = useDescompletarTareaPeriodo();
|
||||
const [filter, setFilter] = useState<'todas' | 'pendientes' | 'completadas'>('todas');
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardShell title="Mis Tareas">
|
||||
<p className="text-muted-foreground">Cargando tareas...</p>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
const all = tareas ?? [];
|
||||
const filtered = all.filter((t: TareaItem) => {
|
||||
if (filter === 'pendientes') return !t.periodoActual?.completada;
|
||||
if (filter === 'completadas') return t.periodoActual?.completada;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Agrupar por contribuyente
|
||||
const grouped = filtered.reduce((acc: Record<string, TareaItem[]>, t: TareaItem) => {
|
||||
const key = t.contribuyenteId;
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(t);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const contribuyenteMap = all.reduce((acc: Record<string, { rfc: string; razonSocial: string }>, t: TareaItem) => {
|
||||
if (!acc[t.contribuyenteId]) {
|
||||
acc[t.contribuyenteId] = { rfc: t.contribuyenteRfc, razonSocial: t.contribuyenteRazonSocial };
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const pendingCount = all.filter((t: TareaItem) => !t.periodoActual?.completada).length;
|
||||
const completedCount = all.filter((t: TareaItem) => t.periodoActual?.completada).length;
|
||||
|
||||
return (
|
||||
<DashboardShell title="Mis Tareas">
|
||||
<div className="space-y-4">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-amber-100 dark:bg-amber-900 rounded-full p-2">
|
||||
<Clock className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{pendingCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Pendientes</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-green-100 dark:bg-green-900 rounded-full p-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{completedCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Completadas</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2">
|
||||
{(['todas', 'pendientes', 'completadas'] as const).map((f) => (
|
||||
<Button
|
||||
key={f}
|
||||
variant={filter === f ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter(f)}
|
||||
>
|
||||
{f === 'todas' ? 'Todas' : f === 'pendientes' ? 'Pendientes' : 'Completadas'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tareas por contribuyente */}
|
||||
{Object.keys(grouped).length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
No hay tareas {filter === 'pendientes' ? 'pendientes' : filter === 'completadas' ? 'completadas' : ''}.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
Object.keys(grouped).map((contribuyenteId) => {
|
||||
const info = contribuyenteMap[contribuyenteId];
|
||||
const items = grouped[contribuyenteId];
|
||||
return (
|
||||
<Card key={contribuyenteId}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{info.razonSocial}</span>
|
||||
<span className="text-muted-foreground font-normal">({info.rfc})</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{items.map((t: TareaItem) => {
|
||||
const p = t.periodoActual;
|
||||
const fl = p ? new Date(p.fechaLimite) : null;
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||
const atrasada = !!fl && !p?.completada && fl < today;
|
||||
const recLabel = RECURRENCIAS[t.recurrencia] || t.recurrencia;
|
||||
const cuando = (t.recurrencia === 'semanal' || t.recurrencia === 'quincenal')
|
||||
? DIAS_SEMANA[t.diaSemana ?? 1]
|
||||
: `día ${t.diaMes}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className={cn(
|
||||
'flex items-center gap-3 py-2 border-b last:border-0',
|
||||
p?.completada && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!p) return;
|
||||
if (p.completada) {
|
||||
descompletarMut.mutate(p.id);
|
||||
} else {
|
||||
completarMut.mutate(p.id);
|
||||
}
|
||||
}}
|
||||
disabled={!p || completarMut.isPending || descompletarMut.isPending}
|
||||
title={p?.completada ? 'Marcar pendiente' : 'Marcar completada'}
|
||||
className="flex-shrink-0 focus:outline-none"
|
||||
>
|
||||
{p?.completada ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
) : atrasada ? (
|
||||
<AlertTriangle className="h-5 w-5 text-red-400" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={cn('text-sm font-medium', p?.completada && 'line-through text-muted-foreground')}>
|
||||
{t.nombre}
|
||||
</span>
|
||||
{t.soloSupervisorCompleta && (
|
||||
<span className="text-[10px] uppercase bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-200 rounded px-1.5 py-0.5">
|
||||
Supervisor
|
||||
</span>
|
||||
)}
|
||||
{atrasada && (
|
||||
<span className="text-[10px] uppercase bg-red-100 text-red-700 rounded px-1.5 py-0.5">
|
||||
Atrasada
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{t.descripcion && (
|
||||
<p className="text-xs text-muted-foreground truncate">{t.descripcion}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{recLabel} · {cuando}
|
||||
{fl && ` · vence ${fl.toLocaleDateString('es-MX', { day: 'numeric', month: 'short' })}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -77,10 +77,14 @@ export default function UsuariosPage() {
|
||||
const deleteUsuario = useDeleteUsuario();
|
||||
|
||||
const isDespacho = isDespachoTenant(currentUser?.tenantRfc);
|
||||
const inviteRoles = isDespacho ? despachoInviteRoles : legacyInviteRoles;
|
||||
const inviteRoles = isDespacho
|
||||
? (currentUser?.role === 'supervisor'
|
||||
? despachoInviteRoles.filter(r => r.value === 'cliente')
|
||||
: despachoInviteRoles)
|
||||
: legacyInviteRoles;
|
||||
const defaultInviteRole = isDespacho ? 'auxiliar' : 'visor';
|
||||
|
||||
const isAdmin = currentUser?.role === 'owner' || currentUser?.role === 'cfo';
|
||||
const isAdmin = currentUser?.role === 'owner' || currentUser?.role === 'cfo' || currentUser?.role === 'supervisor';
|
||||
|
||||
const [showInvite, setShowInvite] = useState(false);
|
||||
const [inviteForm, setInviteForm] = useState<{ email: string; nombre: string; role: UserInvite['role']; supervisorUserId?: string }>({
|
||||
@@ -96,15 +100,18 @@ export default function UsuariosPage() {
|
||||
const [savingAccesos, setSavingAccesos] = useState(false);
|
||||
|
||||
// Edit supervisor modal (para auxiliares)
|
||||
const [editingSupervisorUser, setEditingSupervisorUser] = useState<{ id: string; nombre: string } | null>(null);
|
||||
const [editingSupervisorUser, setEditingSupervisorUser] = useState<{ id: string; nombre: string; supervisorNombre?: string | null } | null>(null);
|
||||
const [selectedSupervisorId, setSelectedSupervisorId] = useState<string>('');
|
||||
const [savingSupervisor, setSavingSupervisor] = useState(false);
|
||||
|
||||
const [currentSupervisorNombre, setCurrentSupervisorNombre] = useState<string>('');
|
||||
|
||||
const openEditSupervisor = async (userId: string, nombre: string) => {
|
||||
try {
|
||||
const res = await apiClient.get<{ supervisorUserId: string | null }>(`/usuarios/${userId}/supervisor`);
|
||||
const res = await apiClient.get<{ supervisorUserId: string | null; supervisorNombre: string | null }>(`/usuarios/${userId}/supervisor`);
|
||||
setSelectedSupervisorId(res.data.supervisorUserId ?? '');
|
||||
setEditingSupervisorUser({ id: userId, nombre });
|
||||
setCurrentSupervisorNombre(res.data.supervisorNombre ?? '');
|
||||
setEditingSupervisorUser({ id: userId, nombre, supervisorNombre: res.data.supervisorNombre });
|
||||
} catch {
|
||||
alert('Error al cargar supervisor');
|
||||
}
|
||||
@@ -483,7 +490,14 @@ export default function UsuariosPage() {
|
||||
<div className="space-y-2 py-2">
|
||||
{supervisores && supervisores.length > 0 ? (
|
||||
<Select value={selectedSupervisorId || 'none'} onValueChange={(v) => setSelectedSupervisorId(v === 'none' ? '' : v)}>
|
||||
<SelectTrigger><SelectValue placeholder="Selecciona un supervisor..." /></SelectTrigger>
|
||||
<SelectTrigger className="w-full">
|
||||
{(() => {
|
||||
if (!selectedSupervisorId || selectedSupervisorId === 'none') return <span className="text-muted-foreground">Sin supervisor asignado</span>;
|
||||
const s = supervisores?.find(x => x.userId === selectedSupervisorId);
|
||||
if (s) return <span>{s.nombre} — {s.email}</span>;
|
||||
return <span>{currentSupervisorNombre || editingSupervisorUser?.supervisorNombre || selectedSupervisorId}</span>;
|
||||
})()}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Sin supervisor asignado</SelectItem>
|
||||
{supervisores.map(s => (
|
||||
@@ -491,6 +505,12 @@ export default function UsuariosPage() {
|
||||
{s.nombre} — {s.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
{/* Si el supervisor actual no está en la lista de carteras, mostrarlo igual */}
|
||||
{selectedSupervisorId && !supervisores.some(s => s.userId === selectedSupervisorId) && (
|
||||
<SelectItem value={selectedSupervisorId}>
|
||||
{currentSupervisorNombre || editingSupervisorUser?.supervisorNombre || selectedSupervisorId}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
|
||||
@@ -33,6 +33,16 @@ export function ContribuyenteSelector() {
|
||||
}
|
||||
}, [contribuyentes, selectedContribuyenteId, setSelectedContribuyente]);
|
||||
|
||||
// Clear invalid selection (e.g. stale localStorage from another tenant/session)
|
||||
useEffect(() => {
|
||||
if (contribuyentes && contribuyentes.length > 0 && selectedContribuyenteId) {
|
||||
const exists = contribuyentes.some(c => c.id === selectedContribuyenteId);
|
||||
if (!exists) {
|
||||
clearSelectedContribuyente();
|
||||
}
|
||||
}
|
||||
}, [contribuyentes, selectedContribuyenteId, clearSelectedContribuyente]);
|
||||
|
||||
if (isLoading || !contribuyentes || contribuyentes.length === 0) return null;
|
||||
if (pathname && HIDDEN_PATHS.some(p => pathname === p || pathname.startsWith(`${p}/`))) return null;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ interface NavItem {
|
||||
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
|
||||
|
||||
const ITEMS: NavItem[] = [
|
||||
{ href: '/despachos/contribuyentes', label: 'Contribuyentes', icon: Building2, roles: ['owner', 'cfo', 'contador', 'visor', 'supervisor', 'auxiliar'] },
|
||||
{ href: '/despachos/contribuyentes', label: 'Contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
|
||||
{ href: '/despachos/mis-asignados', label: 'Mis asignados', icon: UserCheck, roles: ['owner', 'cfo', 'supervisor', 'auxiliar', 'contador', 'visor'] },
|
||||
{ href: '/despachos/equipo', label: 'Equipo', icon: Users, roles: ['owner', 'cfo', 'supervisor'] },
|
||||
];
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
||||
import { Upload, Download, Trash2, CheckCircle2, XCircle, Clock, AlertTriangle, MessageSquare } from 'lucide-react';
|
||||
import { Upload, Download, Trash2, CheckCircle2, XCircle, Clock, AlertTriangle, MessageSquare, UserCheck } from 'lucide-react';
|
||||
|
||||
const MESES = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'];
|
||||
const ALLOWED_MIMES = [
|
||||
@@ -37,6 +37,9 @@ interface Papeleria {
|
||||
requiereAprobacion: boolean;
|
||||
estado: 'pendiente' | 'aprobado' | 'rechazado' | null;
|
||||
comentarioRechazo: string | null;
|
||||
requiereAprobacionCliente: boolean;
|
||||
estadoCliente: 'pendiente' | 'aprobado' | 'rechazado' | null;
|
||||
comentarioRechazoCliente: string | null;
|
||||
subidoPor: string;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -54,28 +57,59 @@ function fileToBase64(file: File): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
function EstadoBadge({ estado, requiereAprobacion }: { estado: string | null; requiereAprobacion: boolean }) {
|
||||
if (!requiereAprobacion) {
|
||||
function estadoGlobal(item: Papeleria): 'sin_aprobacion' | 'pendiente' | 'aprobado' | 'rechazado' {
|
||||
const reqOwner = item.requiereAprobacion;
|
||||
const reqCliente = item.requiereAprobacionCliente;
|
||||
const estOwner = item.estado;
|
||||
const estCliente = item.estadoCliente;
|
||||
|
||||
if (!reqOwner && !reqCliente) return 'sin_aprobacion';
|
||||
if (estOwner === 'rechazado' || estCliente === 'rechazado') return 'rechazado';
|
||||
if (reqOwner && reqCliente) {
|
||||
if (estOwner === 'aprobado' && estCliente === 'aprobado') return 'aprobado';
|
||||
return 'pendiente';
|
||||
}
|
||||
if (reqOwner) return estOwner ?? 'pendiente';
|
||||
return estCliente ?? 'pendiente';
|
||||
}
|
||||
|
||||
function EstadoBadge({ item }: { item: Papeleria }) {
|
||||
const global = estadoGlobal(item);
|
||||
|
||||
if (global === 'sin_aprobacion') {
|
||||
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-muted-foreground">Sin aprobación</span>;
|
||||
}
|
||||
if (estado === 'aprobado') {
|
||||
if (global === 'aprobado') {
|
||||
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"><CheckCircle2 className="h-3 w-3" /> Aprobado</span>;
|
||||
}
|
||||
if (estado === 'rechazado') {
|
||||
if (global === 'rechazado') {
|
||||
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"><XCircle className="h-3 w-3" /> Rechazado</span>;
|
||||
}
|
||||
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"><Clock className="h-3 w-3" /> Pendiente</span>;
|
||||
|
||||
// Pendiente — mostrar quién falta
|
||||
const faltaOwner = item.requiereAprobacion && item.estado !== 'aprobado';
|
||||
const faltaCliente = item.requiereAprobacionCliente && item.estadoCliente !== 'aprobado';
|
||||
let label = 'Pendiente';
|
||||
if (faltaOwner && faltaCliente) label = 'Pendiente (ambos)';
|
||||
else if (faltaOwner) label = 'Pendiente (owner)';
|
||||
else if (faltaCliente) label = 'Pendiente (cliente)';
|
||||
|
||||
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"><Clock className="h-3 w-3" /> {label}</span>;
|
||||
}
|
||||
|
||||
export function PapeleriaTab() {
|
||||
const user = useAuthStore(s => s.user);
|
||||
const { selectedContribuyenteId } = useContribuyenteStore();
|
||||
const queryClient = useQueryClient();
|
||||
const canApprove = user?.role ? ROLES_APROBADOR.has(user.role) : false;
|
||||
const isCliente = user?.role === 'cliente';
|
||||
const canApproveOwner = user?.role ? ROLES_APROBADOR.has(user.role) : false;
|
||||
const canUpload = !isCliente;
|
||||
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [rechazoFor, setRechazoFor] = useState<Papeleria | null>(null);
|
||||
const [comentarioRechazo, setComentarioRechazo] = useState('');
|
||||
const [rechazoClienteFor, setRechazoClienteFor] = useState<Papeleria | null>(null);
|
||||
const [comentarioRechazoCliente, setComentarioRechazoCliente] = useState('');
|
||||
|
||||
// Filtros
|
||||
const currentYear = new Date().getFullYear();
|
||||
@@ -105,6 +139,7 @@ export function PapeleriaTab() {
|
||||
const [anio, setAnio] = useState(currentYear);
|
||||
const [mes, setMes] = useState(new Date().getMonth() + 1);
|
||||
const [requiereAprobacion, setRequiereAprobacion] = useState(false);
|
||||
const [requiereAprobacionCliente, setRequiereAprobacionCliente] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
|
||||
const resetUpload = () => {
|
||||
@@ -114,6 +149,7 @@ export function PapeleriaTab() {
|
||||
setAnio(currentYear);
|
||||
setMes(new Date().getMonth() + 1);
|
||||
setRequiereAprobacion(false);
|
||||
setRequiereAprobacionCliente(false);
|
||||
setUploadError(null);
|
||||
};
|
||||
|
||||
@@ -130,6 +166,7 @@ export function PapeleriaTab() {
|
||||
anio,
|
||||
mes,
|
||||
requiereAprobacion,
|
||||
requiereAprobacionCliente,
|
||||
archivoBase64: base64,
|
||||
archivoFilename: file.name,
|
||||
archivoMime: file.type,
|
||||
@@ -172,11 +209,33 @@ export function PapeleriaTab() {
|
||||
},
|
||||
});
|
||||
|
||||
const aprobarClienteMutation = useMutation({
|
||||
mutationFn: async (id: number) => apiClient.post(`/papeleria/${id}/aprobar-cliente`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const rechazarClienteMutation = useMutation({
|
||||
mutationFn: async ({ id, comentario }: { id: number; comentario: string | null }) =>
|
||||
apiClient.post(`/papeleria/${id}/rechazar-cliente`, { comentario }),
|
||||
onSuccess: () => {
|
||||
setRechazoClienteFor(null);
|
||||
setComentarioRechazoCliente('');
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const eliminarMutation = useMutation({
|
||||
mutationFn: async (id: number) => apiClient.delete(`/papeleria/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const items = query.data ?? [];
|
||||
const años = useMemo(() => {
|
||||
const set = new Set<number>([currentYear]);
|
||||
items.forEach(i => set.add(i.anio));
|
||||
return [...set].sort((a, b) => b - a);
|
||||
}, [items, currentYear]);
|
||||
|
||||
if (!selectedContribuyenteId) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -187,13 +246,6 @@ export function PapeleriaTab() {
|
||||
);
|
||||
}
|
||||
|
||||
const items = query.data ?? [];
|
||||
const años = useMemo(() => {
|
||||
const set = new Set<number>([currentYear]);
|
||||
items.forEach(i => set.add(i.anio));
|
||||
return [...set].sort((a, b) => b - a);
|
||||
}, [items, currentYear]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filtros + upload */}
|
||||
@@ -236,9 +288,11 @@ export function PapeleriaTab() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{canUpload && (
|
||||
<Button onClick={() => setShowUpload(true)}>
|
||||
<Upload className="h-4 w-4 mr-2" /> Subir documento
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Listado */}
|
||||
@@ -258,7 +312,7 @@ export function PapeleriaTab() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">{it.nombre}</span>
|
||||
<EstadoBadge estado={it.estado} requiereAprobacion={it.requiereAprobacion} />
|
||||
<EstadoBadge item={it} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{MESES[it.mes - 1]} {it.anio}
|
||||
</span>
|
||||
@@ -270,10 +324,31 @@ export function PapeleriaTab() {
|
||||
{it.archivoFilename} · {(it.archivoSize / 1024).toFixed(0)} KB
|
||||
· subido {new Date(it.createdAt).toLocaleDateString('es-MX')}
|
||||
</p>
|
||||
{/* Mostrar estado detallado para no-clientes */}
|
||||
{!isCliente && (it.requiereAprobacion || it.requiereAprobacionCliente) && (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{it.requiereAprobacion && (
|
||||
<span className={`text-xs inline-flex items-center gap-1 ${it.estado === 'aprobado' ? 'text-green-700 dark:text-green-400' : it.estado === 'rechazado' ? 'text-red-700 dark:text-red-400' : 'text-yellow-700 dark:text-yellow-400'}`}>
|
||||
<UserCheck className="h-3 w-3" /> Owner: {it.estado ?? '—'}
|
||||
</span>
|
||||
)}
|
||||
{it.requiereAprobacionCliente && (
|
||||
<span className={`text-xs inline-flex items-center gap-1 ${it.estadoCliente === 'aprobado' ? 'text-green-700 dark:text-green-400' : it.estadoCliente === 'rechazado' ? 'text-red-700 dark:text-red-400' : 'text-yellow-700 dark:text-yellow-400'}`}>
|
||||
<UserCheck className="h-3 w-3" /> Cliente: {it.estadoCliente ?? '—'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{it.estado === 'rechazado' && it.comentarioRechazo && (
|
||||
<p className="text-xs mt-1 flex items-start gap-1 text-red-700 dark:text-red-400">
|
||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
||||
<span>{it.comentarioRechazo}</span>
|
||||
<span><strong>Owner:</strong> {it.comentarioRechazo}</span>
|
||||
</p>
|
||||
)}
|
||||
{it.estadoCliente === 'rechazado' && it.comentarioRechazoCliente && (
|
||||
<p className="text-xs mt-1 flex items-start gap-1 text-red-700 dark:text-red-400">
|
||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
||||
<span><strong>Cliente:</strong> {it.comentarioRechazoCliente}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -281,7 +356,8 @@ export function PapeleriaTab() {
|
||||
<Button variant="ghost" size="icon" onClick={() => downloadMutation.mutate(it)} title="Descargar">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
{canApprove && it.requiereAprobacion && it.estado === 'pendiente' && (
|
||||
{/* Botones owner/supervisor */}
|
||||
{canApproveOwner && it.requiereAprobacion && it.estado === 'pendiente' && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
@@ -299,6 +375,26 @@ export function PapeleriaTab() {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{/* Botones cliente */}
|
||||
{isCliente && it.requiereAprobacionCliente && it.estadoCliente === 'pendiente' && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
onClick={() => aprobarClienteMutation.mutate(it.id)}
|
||||
title="Aprobar"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
onClick={() => setRechazoClienteFor(it)}
|
||||
title="Rechazar"
|
||||
>
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{canUpload && (
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
onClick={() => confirm(`¿Eliminar "${it.nombre}"?`) && eliminarMutation.mutate(it.id)}
|
||||
@@ -306,6 +402,7 @@ export function PapeleriaTab() {
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -375,6 +472,14 @@ export function PapeleriaTab() {
|
||||
/>
|
||||
Este documento requiere aprobación de owner/supervisor
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={requiereAprobacionCliente}
|
||||
onChange={e => setRequiereAprobacionCliente(e.target.checked)}
|
||||
/>
|
||||
Este documento requiere aprobación del cliente
|
||||
</label>
|
||||
{uploadError && (
|
||||
<p className="text-xs text-destructive flex items-start gap-1">
|
||||
<AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
||||
@@ -394,7 +499,7 @@ export function PapeleriaTab() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Modal Rechazo */}
|
||||
{/* Modal Rechazo Owner */}
|
||||
<Dialog open={!!rechazoFor} onOpenChange={(o) => { if (!o) { setRechazoFor(null); setComentarioRechazo(''); } }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -426,6 +531,39 @@ export function PapeleriaTab() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Modal Rechazo Cliente */}
|
||||
<Dialog open={!!rechazoClienteFor} onOpenChange={(o) => { if (!o) { setRechazoClienteFor(null); setComentarioRechazoCliente(''); } }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rechazar documento</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm">
|
||||
Vas a rechazar <strong>{rechazoClienteFor?.nombre}</strong>. El comentario es opcional.
|
||||
</p>
|
||||
<div>
|
||||
<Label>Comentario (opcional)</Label>
|
||||
<Input
|
||||
value={comentarioRechazoCliente}
|
||||
onChange={e => setComentarioRechazoCliente(e.target.value)}
|
||||
placeholder="Motivo del rechazo..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setRechazoClienteFor(null); setComentarioRechazoCliente(''); }}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => rechazoClienteFor && rechazarClienteMutation.mutate({ id: rechazoClienteFor.id, comentario: comentarioRechazoCliente || null })}
|
||||
className={cn('bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
|
||||
>
|
||||
Rechazar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ClipboardList,
|
||||
CreditCard,
|
||||
Gift,
|
||||
CheckSquare2,
|
||||
UserCog,
|
||||
Shield,
|
||||
FileWarning,
|
||||
@@ -54,17 +55,17 @@ const navigation: NavItem[] = [
|
||||
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
|
||||
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
|
||||
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
|
||||
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo', 'supervisor', 'contador', 'auxiliar'] },
|
||||
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
|
||||
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo', 'supervisor', 'auxiliar', 'cliente'] },
|
||||
];
|
||||
|
||||
const adminNavigation: NavItem[] = [
|
||||
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
||||
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
|
||||
{ name: 'Staff', href: '/admin/staff', icon: Shield },
|
||||
{ name: 'Invitaciones Trial', href: '/admin/invitaciones-trial', icon: Gift },
|
||||
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
|
||||
];
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ClipboardList,
|
||||
CreditCard,
|
||||
Gift,
|
||||
CheckSquare2,
|
||||
UserCog,
|
||||
Shield,
|
||||
FileWarning,
|
||||
@@ -53,17 +54,17 @@ const navigation: NavItem[] = [
|
||||
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
|
||||
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
|
||||
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
|
||||
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo', 'supervisor', 'contador', 'auxiliar'] },
|
||||
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
|
||||
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
|
||||
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo', 'supervisor', 'auxiliar', 'cliente'] },
|
||||
];
|
||||
|
||||
const adminNavigation: NavItem[] = [
|
||||
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
||||
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
|
||||
{ name: 'Staff', href: '/admin/staff', icon: Shield },
|
||||
{ name: 'Invitaciones Trial', href: '/admin/invitaciones-trial', icon: Gift },
|
||||
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
|
||||
];
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user