Compare commits
18 Commits
5dd53cebac
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7df27ce66d | ||
|
|
b217342a96 | ||
|
|
8a1fbceb38 | ||
|
|
3f3253d41b | ||
|
|
63908f9e9d | ||
|
|
ed6cfed312 | ||
|
|
ab6b76fcb8 | ||
|
|
b52ff875be | ||
|
|
66d68c652c | ||
|
|
d3b326e78c | ||
|
|
b1eaf41681 | ||
|
|
bd7e499ab7 | ||
|
|
44144ebf9d | ||
|
|
314a74982c | ||
|
|
76d3f00f29 | ||
|
|
214410d2fb | ||
|
|
199922272f | ||
|
|
6e54efe5e4 |
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "subscriptions" ADD COLUMN "mp_preference_id" TEXT;
|
||||||
@@ -358,6 +358,7 @@ model Subscription {
|
|||||||
tenantId String @map("tenant_id")
|
tenantId String @map("tenant_id")
|
||||||
plan Plan
|
plan Plan
|
||||||
mpPreapprovalId String? @map("mp_preapproval_id")
|
mpPreapprovalId String? @map("mp_preapproval_id")
|
||||||
|
mpPreferenceId String? @map("mp_preference_id")
|
||||||
status String @default("pending")
|
status String @default("pending")
|
||||||
amount Decimal @db.Decimal(10, 2)
|
amount Decimal @db.Decimal(10, 2)
|
||||||
frequency String @default("monthly")
|
frequency String @default("monthly")
|
||||||
|
|||||||
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();
|
||||||
|
});
|
||||||
@@ -187,11 +187,13 @@ class TenantConnectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove idle pools (not accessed in last 5 minutes).
|
* Remove idle pools (not accessed in last 12 hours).
|
||||||
|
* SAT syncs (initial/daily) can run for hours in background;
|
||||||
|
* a 5-minute timeout caused 'pool already ended' errors mid-sync.
|
||||||
*/
|
*/
|
||||||
private cleanupIdlePools(): void {
|
private cleanupIdlePools(): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const maxIdle = 5 * 60 * 1000;
|
const maxIdle = 12 * 60 * 60 * 1000;
|
||||||
|
|
||||||
for (const [tenantId, entry] of this.pools.entries()) {
|
for (const [tenantId, entry] of this.pools.entries()) {
|
||||||
if (now - entry.lastAccess.getTime() > maxIdle) {
|
if (now - entry.lastAccess.getTime() > maxIdle) {
|
||||||
|
|||||||
@@ -2,53 +2,67 @@ export interface ObligacionFiscal {
|
|||||||
id: string;
|
id: string;
|
||||||
nombre: string;
|
nombre: string;
|
||||||
fundamento: string;
|
fundamento: string;
|
||||||
frecuencia: 'mensual' | 'bimestral' | 'trimestral' | 'anual' | 'eventual';
|
frecuencia: 'mensual' | 'bimestral' | 'trimestral' | 'cuatrimestral' | 'anual' | 'eventual';
|
||||||
fechaLimite: string;
|
fechaLimite: string;
|
||||||
aplica: 'PM' | 'PF' | 'ambos';
|
aplica: 'PM' | 'PF' | 'ambos';
|
||||||
regimenes: string[] | null; // null = all regimes
|
regimenes: string[] | null; // null = all regimes
|
||||||
condicion: string | null;
|
condicion: string | null;
|
||||||
categoria: string;
|
categoria: string;
|
||||||
recomendadaPorDefecto: boolean;
|
recomendadaPorDefecto: boolean;
|
||||||
|
/** Si true, la obligación requiere comprobante de pago para cerrarse. */
|
||||||
|
requierePago: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OBLIGACIONES_CATALOGO: ObligacionFiscal[] = [
|
export const OBLIGACIONES_CATALOGO: ObligacionFiscal[] = [
|
||||||
// === FEDERALES MENSUALES (día 17) ===
|
// === FEDERALES MENSUALES (día 17) ===
|
||||||
{ id: 'isr-provisional', nombre: 'Pago provisional de ISR', fundamento: 'Art. 14 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', recomendadaPorDefecto: true },
|
{ id: 'isr-provisional', nombre: 'Pago provisional de ISR', fundamento: 'Art. 14 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', requierePago: true, recomendadaPorDefecto: true },
|
||||||
{ id: 'iva-mensual', nombre: 'Pago mensual definitivo de IVA', fundamento: 'Art. 5-D LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', recomendadaPorDefecto: true },
|
{ id: 'iva-mensual', nombre: 'Pago mensual definitivo de IVA', fundamento: 'Art. 5-D LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', requierePago: true, recomendadaPorDefecto: true },
|
||||||
{ id: 'ret-isr-sueldos', nombre: 'Retenciones de ISR por sueldos y salarios', fundamento: 'Art. 96 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', recomendadaPorDefecto: false },
|
{ id: 'actividades-vulnerables', nombre: 'Aviso de actividades vulnerables', fundamento: 'LFPIORPI', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
|
||||||
{ id: 'ret-isr-asimilados', nombre: 'Retenciones de ISR por asimilados a salarios', fundamento: 'Art. 94 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', recomendadaPorDefecto: false },
|
{ id: 'ret-isr-sueldos', nombre: 'Retenciones de ISR por sueldos y salarios', fundamento: 'Art. 96 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
|
||||||
{ id: 'ret-isr-honorarios', nombre: 'Retenciones de ISR por honorarios y arrendamiento a PF', fundamento: 'Art. 106/116 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'PM que contrate PF', categoria: 'Federal mensual', recomendadaPorDefecto: false },
|
{ id: 'ret-isr-asimilados', nombre: 'Retenciones de ISR por asimilados a salarios', fundamento: 'Art. 94 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
|
||||||
{ id: 'ret-iva', nombre: 'Retenciones de IVA (servicios, fletes, outsourcing)', fundamento: 'Art. 1-A LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Según supuesto', categoria: 'Federal mensual', recomendadaPorDefecto: false },
|
{ id: 'ret-isr-honorarios', nombre: 'Retenciones de ISR por honorarios y arrendamiento a PF', fundamento: 'Art. 106/116 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'PM que contrate PF', categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
|
||||||
{ id: 'ieps', nombre: 'Pago definitivo de IEPS', fundamento: 'Art. 5 LIEPS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Productores/importadores', categoria: 'Federal mensual', recomendadaPorDefecto: false },
|
{ id: 'ret-iva', nombre: 'Retenciones de IVA (servicios, fletes, outsourcing)', fundamento: 'Art. 1-A LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Según supuesto', categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
|
||||||
|
{ id: 'ieps', nombre: 'Pago definitivo de IEPS', fundamento: 'Art. 5 LIEPS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Productores/importadores', categoria: 'Federal mensual', requierePago: true, recomendadaPorDefecto: false },
|
||||||
|
|
||||||
// === INFORMATIVAS MENSUALES ===
|
// === INFORMATIVAS MENSUALES ===
|
||||||
{ id: 'diot', nombre: 'DIOT (Declaración Informativa de Operaciones con Terceros)', fundamento: 'Art. 32 LIVA', frecuencia: 'mensual', fechaLimite: 'Último día del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false },
|
{ id: 'diot', nombre: 'DIOT (Declaración Informativa de Operaciones con Terceros)', fundamento: 'Art. 32 LIVA', frecuencia: 'mensual', fechaLimite: 'Último día del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', requierePago: false, recomendadaPorDefecto: false },
|
||||||
{ id: 'cont-balanza', nombre: 'Contabilidad Electrónica - Balanza de comprobación', fundamento: 'CFF Art. 28', frecuencia: 'mensual', fechaLimite: 'Día 3 del segundo mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false },
|
{ id: 'cont-balanza', nombre: 'Contabilidad Electrónica - Balanza de comprobación', fundamento: 'CFF Art. 28', frecuencia: 'mensual', fechaLimite: 'Día 3 del segundo mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', requierePago: false, recomendadaPorDefecto: false },
|
||||||
{ id: 'cont-catalogo', nombre: 'Contabilidad Electrónica - Catálogo de cuentas', fundamento: 'CFF Art. 28', frecuencia: 'eventual', fechaLimite: 'Cuando haya modificación', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false },
|
{ id: 'cont-catalogo', nombre: 'Contabilidad Electrónica - Catálogo de cuentas', fundamento: 'CFF Art. 28', frecuencia: 'eventual', fechaLimite: 'Cuando haya modificación', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', requierePago: false, recomendadaPorDefecto: false },
|
||||||
|
|
||||||
|
// === FEDERALES TRIMESTRALES ===
|
||||||
|
{ id: 'ieps-trimestral', nombre: 'Declaración Informativa Múltiple del IEPS', fundamento: 'LIEPS', frecuencia: 'trimestral', fechaLimite: 'Día 17 de abril, julio, octubre y enero', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal trimestral', requierePago: false, recomendadaPorDefecto: false },
|
||||||
|
|
||||||
// === RESICO PM ===
|
// === RESICO PM ===
|
||||||
{ id: 'isr-resico-pm', nombre: 'Pago provisional ISR RESICO-PM', fundamento: 'Art. 206 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: ['626'], condicion: null, categoria: 'RESICO PM', recomendadaPorDefecto: true },
|
{ id: 'isr-resico-pm', nombre: 'Pago provisional ISR RESICO-PM', fundamento: 'Art. 206 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: ['626'], condicion: null, categoria: 'RESICO PM', requierePago: true, recomendadaPorDefecto: true },
|
||||||
|
|
||||||
// === RESICO PF ===
|
// === RESICO PF ===
|
||||||
{ id: 'isr-resico-pf', nombre: 'Pago mensual ISR RESICO PF (1%-2.5%)', fundamento: 'Art. 113-E LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PF', regimenes: ['626'], condicion: null, categoria: 'RESICO PF', recomendadaPorDefecto: true },
|
{ id: 'isr-resico-pf', nombre: 'Pago mensual ISR RESICO PF (1%-2.5%)', fundamento: 'Art. 113-E LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PF', regimenes: ['626'], condicion: null, categoria: 'RESICO PF', requierePago: true, recomendadaPorDefecto: true },
|
||||||
|
|
||||||
// === ANUALES PM ===
|
// === ANUALES PM ===
|
||||||
{ id: 'anual-isr-pm', nombre: 'Declaración Anual de ISR PM', fundamento: 'Art. 76 LISR', frecuencia: 'anual', fechaLimite: '31 de marzo', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: true },
|
{ id: 'anual-isr-pm', nombre: 'Declaración Anual de ISR PM', fundamento: 'Art. 76 LISR', frecuencia: 'anual', fechaLimite: '31 de marzo', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', requierePago: true, recomendadaPorDefecto: true },
|
||||||
{ id: 'issif', nombre: 'ISSIF (Información sobre Situación Fiscal)', fundamento: 'CFF Art. 32-H', frecuencia: 'anual', fechaLimite: 'Con la declaración anual', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: false },
|
{ id: 'declaracion-transparencia', nombre: 'Declaración Informativa de transparencia', fundamento: 'LFTAIPG', frecuencia: 'anual', fechaLimite: 'Día 31 de mayo', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Federal anual', requierePago: false, recomendadaPorDefecto: false },
|
||||||
{ id: 'dictamen-fiscal', nombre: 'Dictamen Fiscal', fundamento: 'CFF Art. 32-A', frecuencia: 'anual', fechaLimite: '15 de mayo', aplica: 'PM', regimenes: null, condicion: 'Ingresos > $1,855M o grupos', categoria: 'Anual', recomendadaPorDefecto: false },
|
{ id: 'issif', nombre: 'ISSIF (Información sobre Situación Fiscal)', fundamento: 'CFF Art. 32-H', frecuencia: 'anual', fechaLimite: 'Con la declaración anual', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', requierePago: false, recomendadaPorDefecto: false },
|
||||||
{ id: 'dim', nombre: 'DIM - Declaraciones Informativas Múltiples', fundamento: 'CFF', frecuencia: 'anual', fechaLimite: '15 de febrero', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: false },
|
{ id: 'dictamen-fiscal', nombre: 'Dictamen Fiscal', fundamento: 'CFF Art. 32-A', frecuencia: 'anual', fechaLimite: '15 de mayo', aplica: 'PM', regimenes: null, condicion: 'Ingresos > $1,855M o grupos', categoria: 'Anual', requierePago: false, recomendadaPorDefecto: false },
|
||||||
|
{ id: 'dim', nombre: 'DIM - Declaraciones Informativas Múltiples', fundamento: 'CFF', frecuencia: 'anual', fechaLimite: '15 de febrero', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', requierePago: false, recomendadaPorDefecto: false },
|
||||||
|
|
||||||
// === ANUALES PF ===
|
// === ANUALES PF ===
|
||||||
{ id: 'anual-isr-pf', nombre: 'Declaración Anual PF', fundamento: 'Art. 150 LISR', frecuencia: 'anual', fechaLimite: '30 de abril', aplica: 'PF', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: true },
|
{ id: 'anual-isr-pf', nombre: 'Declaración Anual PF', fundamento: 'Art. 150 LISR', frecuencia: 'anual', fechaLimite: '30 de abril', aplica: 'PF', regimenes: null, condicion: null, categoria: 'Anual', requierePago: true, recomendadaPorDefecto: true },
|
||||||
|
|
||||||
// === SEGURIDAD SOCIAL ===
|
// === SEGURIDAD SOCIAL ===
|
||||||
{ id: 'imss-cuotas', nombre: 'Cuotas obrero-patronales IMSS', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
|
{ id: 'imss-cuotas', nombre: 'Cuotas obrero-patronales IMSS', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
|
||||||
{ id: 'infonavit', nombre: 'Aportaciones INFONAVIT + amortizaciones', fundamento: 'LINFONAVIT', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
|
{ id: 'sipare', nombre: 'SIPARE - Cuotas obrero-patronales', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 15 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
|
||||||
{ id: 'sar-retiro', nombre: 'SAR / Retiro', fundamento: 'LSS', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
|
{ id: 'infonavit', nombre: 'Aportaciones INFONAVIT + amortizaciones', fundamento: 'LINFONAVIT', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
|
||||||
{ id: 'prima-riesgo', nombre: 'Determinación Prima de Riesgo de Trabajo', fundamento: 'LSS Art. 74', frecuencia: 'anual', fechaLimite: 'Febrero', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
|
{ id: 'sar-retiro', nombre: 'SAR / Retiro', fundamento: 'LSS', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
|
||||||
|
{ id: 'sisub', nombre: 'Sistema de Información de Subcontratación', fundamento: 'LFT', frecuencia: 'cuatrimestral', fechaLimite: 'Día 17 de enero, mayo y septiembre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: false, recomendadaPorDefecto: false },
|
||||||
|
{ id: 'prima-riesgo', nombre: 'Determinación Prima de Riesgo de Trabajo', fundamento: 'LSS Art. 74', frecuencia: 'anual', fechaLimite: 'Febrero', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
|
||||||
|
|
||||||
|
// === CRÉDITOS DE LOS TRABAJADORES ===
|
||||||
|
{ id: 'fonacot', nombre: 'Crédito FONACOT', fundamento: 'Ley FONACOT', frecuencia: 'mensual', fechaLimite: 'Día 5 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Créditos de los trabajadores', requierePago: true, recomendadaPorDefecto: false },
|
||||||
|
|
||||||
// === ESTATALES ===
|
// === ESTATALES ===
|
||||||
{ id: 'isn', nombre: 'ISN - Impuesto Sobre Nómina', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Varía por estado (CDMX día 17)', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Estatal', recomendadaPorDefecto: false },
|
{ id: 'isn', nombre: 'ISN - Impuesto Sobre Nómina', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Varía por estado (CDMX día 17)', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Estatal', requierePago: true, recomendadaPorDefecto: false },
|
||||||
|
{ id: 'isrtp', nombre: 'Impuesto sobre remuneración al trabajo', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Día 10 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Estatal', requierePago: true, recomendadaPorDefecto: false },
|
||||||
|
{ id: 'ish', nombre: 'ISH - Impuesto Sobre Hospedaje', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Día 15 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Estatal', requierePago: true, recomendadaPorDefecto: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -125,7 +125,9 @@ export async function resolverAlertaManual(req: Request, res: Response, next: Ne
|
|||||||
export async function getAlertasAutomaticas(req: Request, res: Response, next: NextFunction) {
|
export async function getAlertasAutomaticas(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||||
|
console.log(`[AlertasCtrl] GET /automaticas tenant=${req.user!.tenantId} contribuyente=${contribuyenteId || 'null'} user=${req.user!.userId} role=${req.user!.role}`);
|
||||||
const alertas = await generarAlertasAutomaticas(req.tenantPool!, req.user!.tenantId, contribuyenteId || null);
|
const alertas = await generarAlertasAutomaticas(req.tenantPool!, req.user!.tenantId, contribuyenteId || null);
|
||||||
|
console.log(`[AlertasCtrl] GET /automaticas devuelve ${alertas.length} alertas: ${alertas.map(a => a.id).join(', ') || 'ninguna'}`);
|
||||||
res.json(alertas);
|
res.json(alertas);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export async function getClavesUnidad(req: Request, res: Response, next: NextFun
|
|||||||
} catch (error) { next(error); }
|
} catch (error) { next(error); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeRegex(str: string): string {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
export async function searchClaveProdServ(req: Request, res: Response, next: NextFunction) {
|
export async function searchClaveProdServ(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const q = (req.query.q as string || '').trim();
|
const q = (req.query.q as string || '').trim();
|
||||||
@@ -44,11 +48,10 @@ export async function searchClaveProdServ(req: Request, res: Response, next: Nex
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Buscar por clave o descripción
|
// Buscar por clave o descripción
|
||||||
// Primero buscar por clave, luego por texto
|
|
||||||
const data = await prisma.catClaveProdServ.findMany({
|
const data = await prisma.catClaveProdServ.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
{ clave: { startsWith: q } },
|
{ clave: { startsWith: q, mode: 'insensitive' } },
|
||||||
{ descripcion: { contains: q, mode: 'insensitive' } },
|
{ descripcion: { contains: q, mode: 'insensitive' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -68,8 +71,8 @@ export async function searchClaveProdServ(req: Request, res: Response, next: Nex
|
|||||||
return res.json(fallback);
|
return res.json(fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buscar con variantes comunes de acentos
|
// Buscar con variantes comunes de acentos, escapando caracteres regex primero
|
||||||
const withAccents = normalized
|
const withAccents = escapeRegex(normalized)
|
||||||
.replace(/a/gi, '[aá]').replace(/e/gi, '[eé]')
|
.replace(/a/gi, '[aá]').replace(/e/gi, '[eé]')
|
||||||
.replace(/i/gi, '[ií]').replace(/o/gi, '[oó]').replace(/u/gi, '[uú]')
|
.replace(/i/gi, '[ií]').replace(/o/gi, '[oó]').replace(/u/gi, '[uú]')
|
||||||
.replace(/n/gi, '[nñ]');
|
.replace(/n/gi, '[nñ]');
|
||||||
|
|||||||
@@ -170,6 +170,15 @@ export async function addClienteAcceso(req: Request, res: Response, next: NextFu
|
|||||||
const { userId } = req.body;
|
const { userId } = req.body;
|
||||||
if (!userId || typeof userId !== 'string') return next(new AppError(400, 'userId requerido'));
|
if (!userId || typeof userId !== 'string') return next(new AppError(400, 'userId requerido'));
|
||||||
const entidadId = String(req.params.id);
|
const entidadId = String(req.params.id);
|
||||||
|
|
||||||
|
// Seguridad: supervisor solo puede asignar contribuyentes que supervise
|
||||||
|
if (req.user!.role === 'supervisor') {
|
||||||
|
const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
|
||||||
|
if (!visibleIds.includes(entidadId)) {
|
||||||
|
return next(new AppError(403, 'No tienes acceso a este contribuyente'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await req.tenantPool!.query(
|
await req.tenantPool!.query(
|
||||||
'INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
'INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||||
[userId, entidadId],
|
[userId, entidadId],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||||||
import { signupDespacho } from '../services/despacho.service.js';
|
import { signupDespacho } from '../services/despacho.service.js';
|
||||||
import { AppError } from '../middlewares/error.middleware.js';
|
import { AppError } from '../middlewares/error.middleware.js';
|
||||||
import { prisma } from '../config/database.js';
|
import { prisma } from '../config/database.js';
|
||||||
|
import { getPlanPrice } from '../services/payment/subscription.service.js';
|
||||||
|
|
||||||
const signupSchema = z.object({
|
const signupSchema = z.object({
|
||||||
despacho: z.object({
|
despacho: z.object({
|
||||||
@@ -47,7 +48,7 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
|
|||||||
// business_control desde una TrialInvitation), respetamos ese plan
|
// business_control desde una TrialInvitation), respetamos ese plan
|
||||||
// para que el feature-gate y los límites funcionen correctamente.
|
// para que el feature-gate y los límites funcionen correctamente.
|
||||||
const subscription = await prisma.subscription.findFirst({
|
const subscription = await prisma.subscription.findFirst({
|
||||||
where: { tenantId, status: { in: ['authorized', 'pending', 'paused', 'trial'] } },
|
where: { tenantId, status: { in: ['authorized', 'pending', 'paused', 'trial', 'trial_expired'] } },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
select: {
|
select: {
|
||||||
status: true, amount: true, plan: true,
|
status: true, amount: true, plan: true,
|
||||||
@@ -64,6 +65,18 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
|
|||||||
currentPlan = String(tenant.plan);
|
currentPlan = String(tenant.plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Precio de catálogo del plan actual (primer año, anual). La UI lo usa
|
||||||
|
// cuando la suscripción aún no tiene monto (trial/trial_expired) para
|
||||||
|
// mostrar el CTA de pago.
|
||||||
|
let planPrice: number | null = null;
|
||||||
|
if (currentPlan && currentPlan !== 'trial' && currentPlan !== 'custom') {
|
||||||
|
try {
|
||||||
|
planPrice = await getPlanPrice(currentPlan as any, 'annual', 'firstYear');
|
||||||
|
} catch {
|
||||||
|
planPrice = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Estado de suscripción activa (si hay) — alimenta la UI con el monto
|
// Estado de suscripción activa (si hay) — alimenta la UI con el monto
|
||||||
// recurrente actual, fecha de próxima renovación y si el primer pago
|
// recurrente actual, fecha de próxima renovación y si el primer pago
|
||||||
// (cuando aplica dualidad firstYear) ya fue completado.
|
// (cuando aplica dualidad firstYear) ya fue completado.
|
||||||
@@ -72,6 +85,7 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
|
|||||||
dbMode: tenant.dbMode,
|
dbMode: tenant.dbMode,
|
||||||
trialEndsAt: tenant.trialEndsAt?.toISOString() ?? null,
|
trialEndsAt: tenant.trialEndsAt?.toISOString() ?? null,
|
||||||
isTrialActive,
|
isTrialActive,
|
||||||
|
planPrice,
|
||||||
subscription: subscription
|
subscription: subscription
|
||||||
? {
|
? {
|
||||||
status: subscription.status,
|
status: subscription.status,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getOpiniones, getOpinionPdf, consultarOpinion, consultarOpinionContribu
|
|||||||
import * as declaracionesService from '../services/declaraciones.service.js';
|
import * as declaracionesService from '../services/declaraciones.service.js';
|
||||||
import * as constanciaService from '../services/constancia.service.js';
|
import * as constanciaService from '../services/constancia.service.js';
|
||||||
import * as extrasService from '../services/documentos-extras.service.js';
|
import * as extrasService from '../services/documentos-extras.service.js';
|
||||||
|
import * as obligacionEvidenciasService from '../services/obligacion-evidencias.service.js';
|
||||||
import { notifyDocumentoSubido } from '../services/notify-upload.service.js';
|
import { notifyDocumentoSubido } from '../services/notify-upload.service.js';
|
||||||
import { AppError } from '../middlewares/error.middleware.js';
|
import { AppError } from '../middlewares/error.middleware.js';
|
||||||
|
|
||||||
@@ -81,8 +82,9 @@ const createDeclaracionSchema = z.object({
|
|||||||
año: z.number().int().min(2020).max(2100),
|
año: z.number().int().min(2020).max(2100),
|
||||||
mes: z.number().int().min(1).max(12),
|
mes: z.number().int().min(1).max(12),
|
||||||
tipo: z.enum(['normal', 'complementaria']),
|
tipo: z.enum(['normal', 'complementaria']),
|
||||||
periodicidad: z.enum(['mensual', 'bimestral', 'trimestral', 'semestral', 'anual']).optional(),
|
periodicidad: z.enum(['mensual', 'bimestral', 'trimestral', 'cuatrimestral', 'semestral', 'anual']).optional(),
|
||||||
impuestos: z.array(z.enum(['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'])).min(1, 'Selecciona al menos un impuesto'),
|
impuestos: z.array(z.enum(['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'])).optional(),
|
||||||
|
obligacionesIds: z.array(z.string().uuid()).optional(),
|
||||||
montoPago: z.number().min(0).optional(),
|
montoPago: z.number().min(0).optional(),
|
||||||
pdfBase64: z.string().min(100),
|
pdfBase64: z.string().min(100),
|
||||||
pdfFilename: z.string().min(1).max(255),
|
pdfFilename: z.string().min(1).max(255),
|
||||||
@@ -92,6 +94,9 @@ const createDeclaracionSchema = z.object({
|
|||||||
}).refine(
|
}).refine(
|
||||||
d => !d.ligaPagoBase64 || !!d.ligaPagoFilename,
|
d => !d.ligaPagoBase64 || !!d.ligaPagoFilename,
|
||||||
{ message: 'Si incluyes liga de pago, también debes mandar su nombre de archivo', path: ['ligaPagoFilename'] },
|
{ message: 'Si incluyes liga de pago, también debes mandar su nombre de archivo', path: ['ligaPagoFilename'] },
|
||||||
|
).refine(
|
||||||
|
d => (d.obligacionesIds && d.obligacionesIds.length > 0) || (d.impuestos && d.impuestos.length > 0),
|
||||||
|
{ message: 'Selecciona al menos una obligación fiscal o un impuesto', path: ['obligacionesIds'] },
|
||||||
);
|
);
|
||||||
|
|
||||||
export async function listarDeclaraciones(req: Request, res: Response, next: NextFunction) {
|
export async function listarDeclaraciones(req: Request, res: Response, next: NextFunction) {
|
||||||
@@ -119,6 +124,7 @@ export async function crearDeclaracion(req: Request, res: Response, next: NextFu
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Notificación fire-and-forget a owners del despacho + supervisor del RFC.
|
// Notificación fire-and-forget a owners del despacho + supervisor del RFC.
|
||||||
|
// Incluye como adjuntos el acuse de declaración y la liga de pago (si se subió).
|
||||||
// No bloquea la respuesta ni falla la creación si SMTP no está configurado.
|
// No bloquea la respuesta ni falla la creación si SMTP no está configurado.
|
||||||
notifyDocumentoSubido({
|
notifyDocumentoSubido({
|
||||||
pool: req.tenantPool!,
|
pool: req.tenantPool!,
|
||||||
@@ -126,6 +132,7 @@ export async function crearDeclaracion(req: Request, res: Response, next: NextFu
|
|||||||
contribuyenteId: contribuyenteId ?? null,
|
contribuyenteId: contribuyenteId ?? null,
|
||||||
subidoPor: req.user!.email,
|
subidoPor: req.user!.email,
|
||||||
kind: 'declaracion',
|
kind: 'declaracion',
|
||||||
|
declaracionId: result.declaracion.id,
|
||||||
declaracion: {
|
declaracion: {
|
||||||
periodo: `${MESES[data.mes - 1]} ${data.año}`,
|
periodo: `${MESES[data.mes - 1]} ${data.año}`,
|
||||||
tipo: data.tipo,
|
tipo: data.tipo,
|
||||||
@@ -334,3 +341,91 @@ export async function listarCategoriasExtras(req: Request, res: Response, next:
|
|||||||
res.json(data);
|
res.json(data);
|
||||||
} catch (error) { next(error); }
|
} catch (error) { next(error); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Obligación evidencias — documentos que cierran obligaciones fiscales
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const createEvidenciaObligacionSchema = z.object({
|
||||||
|
contribuyenteId: z.string().uuid('contribuyenteId inválido'),
|
||||||
|
obligacionId: z.string().uuid('obligacionId inválido'),
|
||||||
|
periodo: z.string().regex(/^\d{4}-\d{2}$/, 'periodo debe ser YYYY-MM'),
|
||||||
|
tipoDocumento: z.enum(['declaracion', 'pago', 'acuse', 'complemento']),
|
||||||
|
pdfBase64: z.string().min(100, 'PDF requerido'),
|
||||||
|
pdfFilename: z.string().min(1).max(255),
|
||||||
|
notas: z.string().max(2000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function listarEvidenciasObligacion(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||||
|
if (!contribuyenteId) return next(new AppError(400, 'contribuyenteId requerido'));
|
||||||
|
const periodo = req.query.periodo as string | undefined;
|
||||||
|
const obligacionId = req.query.obligacionId as string | undefined;
|
||||||
|
const data = await obligacionEvidenciasService.listEvidencias(req.tenantPool!, contribuyenteId, {
|
||||||
|
periodo,
|
||||||
|
obligacionId,
|
||||||
|
});
|
||||||
|
res.json(data);
|
||||||
|
} catch (error) { next(error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function crearEvidenciaObligacion(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para subir documentos' });
|
||||||
|
const data = createEvidenciaObligacionSchema.parse(req.body);
|
||||||
|
const result = await obligacionEvidenciasService.createEvidencia(req.tenantPool!, {
|
||||||
|
...data,
|
||||||
|
subidoPor: req.user!.userId,
|
||||||
|
subidoPorEmail: req.user!.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notificación fire-and-forget a owners + supervisor del contribuyente.
|
||||||
|
const { rows: obRows } = await req.tenantPool!.query<{ nombre: string }>(
|
||||||
|
'SELECT nombre FROM obligaciones_contribuyente WHERE id = $1',
|
||||||
|
[data.obligacionId],
|
||||||
|
);
|
||||||
|
notifyDocumentoSubido({
|
||||||
|
pool: req.tenantPool!,
|
||||||
|
tenantId: req.viewingTenantId ?? req.user!.tenantId,
|
||||||
|
contribuyenteId: data.contribuyenteId,
|
||||||
|
subidoPor: req.user!.email,
|
||||||
|
kind: 'obligacion_evidencia',
|
||||||
|
evidencia: {
|
||||||
|
obligacionNombre: obRows[0]?.nombre || 'Obligación fiscal',
|
||||||
|
periodo: data.periodo,
|
||||||
|
tipoDocumento: data.tipoDocumento,
|
||||||
|
filename: data.pdfFilename,
|
||||||
|
},
|
||||||
|
pdfBase64: data.pdfBase64,
|
||||||
|
}).catch((err: any) => console.error('[notifyDocumentoSubido obligacion_evidencia]', err?.message || err));
|
||||||
|
|
||||||
|
res.status(201).json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function descargarEvidenciaObligacion(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const id = parseInt(String(req.params.id));
|
||||||
|
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
|
||||||
|
const pdf = await obligacionEvidenciasService.getEvidenciaPdf(req.tenantPool!, id);
|
||||||
|
if (!pdf) return next(new AppError(404, 'Evidencia no encontrada'));
|
||||||
|
res.setHeader('Content-Type', pdf.mime);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${pdf.filename}"`);
|
||||||
|
res.send(pdf.buffer);
|
||||||
|
} catch (error) { next(error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function eliminarEvidenciaObligacion(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para eliminar documentos' });
|
||||||
|
const id = parseInt(String(req.params.id));
|
||||||
|
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
|
||||||
|
const result = await obligacionEvidenciasService.deleteEvidencia(req.tenantPool!, id);
|
||||||
|
if (!result) return next(new AppError(404, 'Evidencia no encontrada'));
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) { next(error); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,29 +3,42 @@ import { z } from 'zod';
|
|||||||
import { AppError } from '../middlewares/error.middleware.js';
|
import { AppError } from '../middlewares/error.middleware.js';
|
||||||
import {
|
import {
|
||||||
EMAIL_TYPES,
|
EMAIL_TYPES,
|
||||||
getEmailPreferencesPorContribuyente,
|
NOTIFICATION_ROLES,
|
||||||
setContribuyenteEmailPreferences,
|
getRoleEmailPreferences,
|
||||||
|
setRoleEmailPreference,
|
||||||
|
type EmailType,
|
||||||
|
type NotificationRole,
|
||||||
} from '../services/notification-preferences.service.js';
|
} from '../services/notification-preferences.service.js';
|
||||||
|
|
||||||
export async function listPreferences(req: Request, res: Response, next: NextFunction) {
|
export async function listPreferences(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const data = await getEmailPreferencesPorContribuyente(req.tenantPool!);
|
const preferences = await getRoleEmailPreferences(req.tenantPool!);
|
||||||
res.json({ emailTypes: EMAIL_TYPES, data });
|
res.json({
|
||||||
|
emailTypes: EMAIL_TYPES,
|
||||||
|
roles: NOTIFICATION_ROLES,
|
||||||
|
preferences,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateSchema = z.object({
|
const updateSchema = z.object({
|
||||||
contribuyenteId: z.string().uuid(),
|
emailType: z.enum([...EMAIL_TYPES] as [string, ...string[]]),
|
||||||
preferences: z.record(z.string(), z.boolean()),
|
role: z.enum([...NOTIFICATION_ROLES] as [string, ...string[]]),
|
||||||
|
enabled: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function updatePreferences(req: Request, res: Response, next: NextFunction) {
|
export async function updatePreferences(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const { contribuyenteId, preferences } = updateSchema.parse(req.body);
|
const { emailType, role, enabled } = updateSchema.parse(req.body);
|
||||||
const updated = await setContribuyenteEmailPreferences(req.tenantPool!, contribuyenteId, preferences);
|
const preferences = await setRoleEmailPreference(
|
||||||
res.json({ contribuyenteId, preferences: updated });
|
req.tenantPool!,
|
||||||
|
emailType as EmailType,
|
||||||
|
role as NotificationRole,
|
||||||
|
enabled,
|
||||||
|
);
|
||||||
|
res.json({ preferences });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -184,7 +184,18 @@ export async function subscribeMe(req: Request, res: Response, next: NextFunctio
|
|||||||
if (msg.includes('MercadoPago no está configurado')) {
|
if (msg.includes('MercadoPago no está configurado')) {
|
||||||
return res.status(503).json({ message: msg });
|
return res.status(503).json({ message: msg });
|
||||||
}
|
}
|
||||||
// Otros errores de MP al crear preapproval (monto inválido, email inválido, etc.)
|
// Errores de negocio de MP (monto fuera de límites, payer igual collector, etc.)
|
||||||
|
if (msg.includes('Cannot pay an amount greater than')) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: 'El monto del plan supera el límite de cobro recurrente de MercadoPago ($10,000 MXN). Usa el pago anual único o contacta a soporte.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (msg.includes('Payer and collector cannot be the same user')) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: 'El correo del pagador no puede ser el mismo que el de la cuenta de MercadoPago del vendedor.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Otros errores de MP al crear preapproval/preference
|
||||||
if (msg.includes('Unauthorized access') || error?.status === 401) {
|
if (msg.includes('Unauthorized access') || error?.status === 401) {
|
||||||
return res.status(503).json({
|
return res.status(503).json({
|
||||||
message: 'MercadoPago rechazó la solicitud. Verifica que MP_ACCESS_TOKEN sea válido y esté vigente.',
|
message: 'MercadoPago rechazó la solicitud. Verifica que MP_ACCESS_TOKEN sea válido y esté vigente.',
|
||||||
|
|||||||
@@ -65,11 +65,16 @@ export async function getAllUsuarios(req: Request, res: Response, next: NextFunc
|
|||||||
|
|
||||||
export async function inviteUsuario(req: Request, res: Response, next: NextFunction) {
|
export async function inviteUsuario(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (req.user!.role !== 'owner') {
|
if (!['owner', 'cfo', 'supervisor'].includes(req.user!.role)) {
|
||||||
throw new AppError(403, 'Solo los dueños pueden invitar usuarios');
|
throw new AppError(403, 'No autorizado para invitar usuarios');
|
||||||
}
|
}
|
||||||
const data = inviteSchema.parse(req.body);
|
const data = inviteSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Los supervisores solo pueden invitar clientes
|
||||||
|
if (req.user!.role === 'supervisor' && data.role !== 'cliente') {
|
||||||
|
throw new AppError(403, 'Los supervisores solo pueden invitar clientes');
|
||||||
|
}
|
||||||
|
|
||||||
// Validate: auxiliar requires a supervisor
|
// Validate: auxiliar requires a supervisor
|
||||||
if (data.role === 'auxiliar' && !data.supervisorUserId) {
|
if (data.role === 'auxiliar' && !data.supervisorUserId) {
|
||||||
throw new AppError(400, 'Debes asignar un supervisor al auxiliar');
|
throw new AppError(400, 'Debes asignar un supervisor al auxiliar');
|
||||||
|
|||||||
@@ -10,6 +10,21 @@ import { despachoPlanTieneDualidadDb } from '../services/plan-catalogo.service.j
|
|||||||
import { emailService } from '../services/email/email.service.js';
|
import { emailService } from '../services/email/email.service.js';
|
||||||
import { getTenantOwnerEmail } from '../utils/memberships.js';
|
import { getTenantOwnerEmail } from '../utils/memberships.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula la siguiente fecha de fin de período según la frecuencia.
|
||||||
|
* Usa el mismo algoritmo que Mercado Pago: mismo día del mes siguiente,
|
||||||
|
* ajustando al último día si el mes destino tiene menos días.
|
||||||
|
*/
|
||||||
|
function computeNextPeriodEnd(date: Date, frequency: string): Date {
|
||||||
|
const d = new Date(date);
|
||||||
|
if (frequency === 'monthly') {
|
||||||
|
d.setMonth(d.getMonth() + 1);
|
||||||
|
} else if (frequency === 'annual' || frequency === 'yearly') {
|
||||||
|
d.setFullYear(d.getFullYear() + 1);
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleMercadoPagoWebhook(req: Request, res: Response, next: NextFunction) {
|
export async function handleMercadoPagoWebhook(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const { type, data } = req.body;
|
const { type, data } = req.body;
|
||||||
@@ -159,6 +174,57 @@ async function handlePaymentNotification(paymentId: string) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detecta pagos únicos de suscripción anual (planes >$10k). external_reference = `subscription:${tenantId}:${subscriptionId}`
|
||||||
|
if (payment.externalReference.startsWith('subscription:')) {
|
||||||
|
const parts = payment.externalReference.split(':');
|
||||||
|
const tenantId = parts[1];
|
||||||
|
const subscriptionId = parts[2];
|
||||||
|
if (!tenantId || !subscriptionId) {
|
||||||
|
console.warn('[WEBHOOK] external_reference de subscription malformado:', payment.externalReference);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentRecord = await subscriptionService.recordPayment({
|
||||||
|
tenantId,
|
||||||
|
subscriptionId,
|
||||||
|
mpPaymentId: paymentId,
|
||||||
|
amount: payment.transactionAmount || 0,
|
||||||
|
status: payment.status || 'unknown',
|
||||||
|
paymentMethod: payment.paymentMethodId || 'unknown',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (payment.status === 'approved') {
|
||||||
|
const subscription = await prisma.subscription.findUnique({ where: { id: subscriptionId } });
|
||||||
|
if (subscription) {
|
||||||
|
const now = new Date();
|
||||||
|
const periodEnd = computeNextPeriodEnd(now, 'annual');
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.subscription.update({
|
||||||
|
where: { id: subscription.id },
|
||||||
|
data: {
|
||||||
|
status: 'authorized',
|
||||||
|
currentPeriodStart: now,
|
||||||
|
currentPeriodEnd: periodEnd,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.tenant.update({
|
||||||
|
where: { id: tenantId },
|
||||||
|
data: { plan: subscription.plan },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
subscriptionService.invalidateSubscriptionCache(tenantId);
|
||||||
|
console.log(`[WEBHOOK] Suscripción ${subscriptionId} activada por pago único anual hasta ${periodEnd.toISOString()}`);
|
||||||
|
}
|
||||||
|
// Auto-emisión de factura (fail-soft)
|
||||||
|
await invoicingService.emitInvoiceIfApplicable(paymentRecord.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof process.send === 'function') {
|
||||||
|
process.send({ type: 'invalidate-tenant-cache', tenantId });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Flujo normal: pago recurrente del preapproval
|
// Flujo normal: pago recurrente del preapproval
|
||||||
const tenantId = payment.externalReference;
|
const tenantId = payment.externalReference;
|
||||||
const subscription = await prisma.subscription.findFirst({
|
const subscription = await prisma.subscription.findFirst({
|
||||||
@@ -187,9 +253,20 @@ async function handlePaymentNotification(paymentId: string) {
|
|||||||
// precio de renewal. Se detecta comparando el monto cobrado contra lo que
|
// precio de renewal. Se detecta comparando el monto cobrado contra lo que
|
||||||
// `getPlanPrice(phase='firstYear')` devolvería para este plan.
|
// `getPlanPrice(phase='firstYear')` devolvería para este plan.
|
||||||
const esPrimerPago = subscription.status === 'pending';
|
const esPrimerPago = subscription.status === 'pending';
|
||||||
|
const updateData: { status: string; currentPeriodEnd?: Date } = { status: 'authorized' };
|
||||||
|
|
||||||
|
// Extender currentPeriodEnd para renovaciones recurrentes.
|
||||||
|
// El primer pago ya tiene currentPeriodEnd establecido al crear la suscripción;
|
||||||
|
// solo extendemos en pagos subsecuentes para reflejar el nuevo período cobrado.
|
||||||
|
if (!esPrimerPago && subscription.currentPeriodEnd) {
|
||||||
|
const nextPeriodEnd = computeNextPeriodEnd(subscription.currentPeriodEnd, subscription.frequency);
|
||||||
|
updateData.currentPeriodEnd = nextPeriodEnd;
|
||||||
|
console.log(`[WEBHOOK] Subscription ${subscription.id} extended to ${nextPeriodEnd.toISOString()} (${subscription.frequency})`);
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.subscription.update({
|
await prisma.subscription.update({
|
||||||
where: { id: subscription.id },
|
where: { id: subscription.id },
|
||||||
data: { status: 'authorized' },
|
data: updateData,
|
||||||
});
|
});
|
||||||
subscriptionService.invalidateSubscriptionCache(tenantId);
|
subscriptionService.invalidateSubscriptionCache(tenantId);
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import { resetExpiredMonthlyTimbres } from '../services/facturapi.service.js';
|
|||||||
import { purgeDeclaracionesAntiguas } from '../services/declaraciones.service.js';
|
import { purgeDeclaracionesAntiguas } from '../services/declaraciones.service.js';
|
||||||
import { consultarConstancia, purgeConstanciasAntiguas } from '../services/constancia.service.js';
|
import { consultarConstancia, purgeConstanciasAntiguas } from '../services/constancia.service.js';
|
||||||
import { tenantDb } from '../config/database.js';
|
import { tenantDb } from '../config/database.js';
|
||||||
|
import type { Pool } from 'pg';
|
||||||
|
|
||||||
const SYNC_CRON_SCHEDULE = '0 3 * * *'; // 3:00 AM todos los días
|
const SYNC_CRON_SCHEDULE = '0 3 * * *'; // 3:00 AM todos los días
|
||||||
|
const RECOVERY_CRON_SCHEDULE = '0 10 * * *'; // 10:00 AM todos los días
|
||||||
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas
|
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas
|
||||||
const OPINION_CRON_SCHEDULE = '0 4 * * 0'; // Sundays 4:00 AM
|
const OPINION_CRON_SCHEDULE = '0 4 * * 0'; // Sundays 4:00 AM
|
||||||
const CSF_CRON_SCHEDULE = '0 4 1 * *'; // Día 1 de cada mes 04:00 AM (CSF mensual)
|
const CSF_CRON_SCHEDULE = '0 4 1 * *'; // Día 1 de cada mes 04:00 AM (CSF mensual)
|
||||||
@@ -20,6 +22,38 @@ const EXPIRY_REMINDERS_CRON = '0 9 * * *'; // 9:00 AM diario — avisos p
|
|||||||
|
|
||||||
let isRunning = false;
|
let isRunning = false;
|
||||||
let isIncrementalRunning = false;
|
let isIncrementalRunning = false;
|
||||||
|
let isRecoveryRunning = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si un tenant tiene FIEL a nivel tenant (legacy Horux 360)
|
||||||
|
* o a nivel contribuyente (modelo despacho).
|
||||||
|
*/
|
||||||
|
async function hasAnyFielConfigured(tenantId: string, databaseName?: string | null): Promise<boolean> {
|
||||||
|
// 1) FIEL legacy a nivel tenant
|
||||||
|
const hasLegacy = await hasFielConfigured(tenantId);
|
||||||
|
if (hasLegacy) return true;
|
||||||
|
|
||||||
|
// 2) FIEL por contribuyente (modelo despacho)
|
||||||
|
if (!databaseName) {
|
||||||
|
const tenant = await prisma.tenant.findUnique({
|
||||||
|
where: { id: tenantId },
|
||||||
|
select: { databaseName: true },
|
||||||
|
});
|
||||||
|
databaseName = tenant?.databaseName;
|
||||||
|
}
|
||||||
|
if (!databaseName) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pool = await tenantDb.getPool(tenantId, databaseName);
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT 1 FROM fiel_contribuyente WHERE is_active = true LIMIT 1`
|
||||||
|
);
|
||||||
|
return rows.length > 0;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[SAT Cron] Error verificando FIEL contribuyente para tenant ${tenantId}:`, err.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obtiene los tenants que tienen FIEL configurada y activa
|
* Obtiene los tenants que tienen FIEL configurada y activa
|
||||||
@@ -27,13 +61,13 @@ let isIncrementalRunning = false;
|
|||||||
async function getTenantsWithFiel(): Promise<string[]> {
|
async function getTenantsWithFiel(): Promise<string[]> {
|
||||||
const tenants = await prisma.tenant.findMany({
|
const tenants = await prisma.tenant.findMany({
|
||||||
where: { active: true },
|
where: { active: true },
|
||||||
select: { id: true },
|
select: { id: true, databaseName: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const tenantsWithFiel: string[] = [];
|
const tenantsWithFiel: string[] = [];
|
||||||
|
|
||||||
for (const tenant of tenants) {
|
for (const tenant of tenants) {
|
||||||
const hasFiel = await hasFielConfigured(tenant.id);
|
const hasFiel = await hasAnyFielConfigured(tenant.id, tenant.databaseName);
|
||||||
if (hasFiel) {
|
if (hasFiel) {
|
||||||
tenantsWithFiel.push(tenant.id);
|
tenantsWithFiel.push(tenant.id);
|
||||||
}
|
}
|
||||||
@@ -172,12 +206,12 @@ async function getTenantsConSatIncremental(): Promise<string[]> {
|
|||||||
|
|
||||||
const tenants = await prisma.tenant.findMany({
|
const tenants = await prisma.tenant.findMany({
|
||||||
where: { active: true, plan: { in: planNames as any } },
|
where: { active: true, plan: { in: planNames as any } },
|
||||||
select: { id: true },
|
select: { id: true, databaseName: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
for (const tenant of tenants) {
|
for (const tenant of tenants) {
|
||||||
if (await hasFielConfigured(tenant.id)) {
|
if (await hasAnyFielConfigured(tenant.id, tenant.databaseName)) {
|
||||||
result.push(tenant.id);
|
result.push(tenant.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,12 +385,153 @@ async function runCsfJob(): Promise<void> {
|
|||||||
console.error(`[CSF Cron] Error para ${tenant.rfc}:`, error.message);
|
console.error(`[CSF Cron] Error para ${tenant.rfc}:`, error.message);
|
||||||
failed++;
|
failed++;
|
||||||
}
|
}
|
||||||
|
// Delay entre tenants para no saturar al SAT y reducir bloqueos por IP
|
||||||
|
await new Promise(r => setTimeout(r, 30_000));
|
||||||
}
|
}
|
||||||
console.log(`[CSF Cron] Completado — éxito: ${success}, fallidos: ${failed}, sin FIEL: ${skipped}`);
|
console.log(`[CSF Cron] Completado — éxito: ${success}, fallidos: ${failed}, sin FIEL: ${skipped}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getYesterdayEnd(): Date {
|
||||||
|
const now = new Date();
|
||||||
|
return new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 59, 59);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasIncompleteCfdis(pool: Pool, contribuyenteId: string): Promise<boolean> {
|
||||||
|
const { rows } = await pool.query<{ count: string }>(`
|
||||||
|
SELECT COUNT(*)::text as count
|
||||||
|
FROM cfdis
|
||||||
|
WHERE contribuyente_id = $1
|
||||||
|
AND status = 'Vigente'
|
||||||
|
AND tipo_comprobante IN ('I', 'E', 'P', 'N')
|
||||||
|
AND xml_original IS NULL
|
||||||
|
`, [contribuyenteId]);
|
||||||
|
return Number(rows[0]?.count || 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOldestIncompleteCfdiDate(pool: Pool, contribuyenteId: string): Promise<Date | null> {
|
||||||
|
const { rows } = await pool.query<{ fecha_emision: Date | null }>(`
|
||||||
|
SELECT MIN(fecha_emision) as fecha_emision
|
||||||
|
FROM cfdis
|
||||||
|
WHERE contribuyente_id = $1
|
||||||
|
AND status = 'Vigente'
|
||||||
|
AND tipo_comprobante IN ('I', 'E', 'P', 'N')
|
||||||
|
AND xml_original IS NULL
|
||||||
|
`, [contribuyenteId]);
|
||||||
|
return rows[0]?.fecha_emision || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRecoveryJob(jobId: string): Promise<void> {
|
||||||
|
while (true) {
|
||||||
|
const job = await prisma.satSyncJob.findUnique({ where: { id: jobId } });
|
||||||
|
if (!job || job.status === 'completed' || job.status === 'failed') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 60000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recoverContribuyente(tenantId: string, databaseName: string, contribuyenteId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const status = await getSyncStatus(tenantId, contribuyenteId);
|
||||||
|
if (status.hasActiveSync) {
|
||||||
|
console.log(`[SAT Recovery] ${contribuyenteId} tiene sync activo, omitiendo`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = await tenantDb.getPool(tenantId, databaseName);
|
||||||
|
const hasIncomplete = await hasIncompleteCfdis(pool, contribuyenteId);
|
||||||
|
|
||||||
|
const lastDaily = await prisma.satSyncJob.findFirst({
|
||||||
|
where: { tenantId, contribuyenteId, type: 'daily' },
|
||||||
|
orderBy: { startedAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasIncomplete && lastDaily?.status !== 'failed') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateTo = getYesterdayEnd();
|
||||||
|
let dateFrom = new Date(dateTo.getFullYear() - 1, dateTo.getMonth(), dateTo.getDate());
|
||||||
|
|
||||||
|
if (hasIncomplete) {
|
||||||
|
const oldest = await getOldestIncompleteCfdiDate(pool, contribuyenteId);
|
||||||
|
if (oldest) {
|
||||||
|
dateFrom = new Date(oldest.getFullYear(), oldest.getMonth(), 1);
|
||||||
|
dateFrom.setMonth(dateFrom.getMonth() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[SAT Recovery] Recuperando ${contribuyenteId}: ${dateFrom.toISOString()} → ${dateTo.toISOString()}`);
|
||||||
|
const jobId = await startSync(tenantId, 'initial', dateFrom, dateTo, contribuyenteId);
|
||||||
|
console.log(`[SAT Recovery] Job ${jobId} iniciado`);
|
||||||
|
|
||||||
|
await waitForRecoveryJob(jobId);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[SAT Recovery] Error recuperando ${contribuyenteId}:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recoverTenant(tenantId: string): Promise<void> {
|
||||||
|
const tenant = await prisma.tenant.findUnique({
|
||||||
|
where: { id: tenantId },
|
||||||
|
select: { databaseName: true },
|
||||||
|
});
|
||||||
|
if (!tenant?.databaseName) return;
|
||||||
|
|
||||||
|
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
||||||
|
const { rows } = await pool.query<{ entidad_id: string }>('SELECT entidad_id FROM contribuyentes');
|
||||||
|
const contribuyenteIds = rows.map(r => r.entidad_id);
|
||||||
|
|
||||||
|
if (contribuyenteIds.length === 0) {
|
||||||
|
const status = await getSyncStatus(tenantId);
|
||||||
|
if (status.hasActiveSync) return;
|
||||||
|
const lastDaily = await prisma.satSyncJob.findFirst({
|
||||||
|
where: { tenantId, contribuyenteId: null, type: 'daily' },
|
||||||
|
orderBy: { startedAt: 'desc' },
|
||||||
|
});
|
||||||
|
if (lastDaily?.status === 'failed') {
|
||||||
|
const dateTo = getYesterdayEnd();
|
||||||
|
const dateFrom = new Date(dateTo.getFullYear() - 1, dateTo.getMonth(), dateTo.getDate());
|
||||||
|
console.log(`[SAT Recovery] Recuperando tenant legacy ${tenantId}`);
|
||||||
|
const jobId = await startSync(tenantId, 'initial', dateFrom, dateTo);
|
||||||
|
await waitForRecoveryJob(jobId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const contribuyenteId of contribuyenteIds) {
|
||||||
|
await recoverContribuyente(tenantId, tenant.databaseName, contribuyenteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runRecoverySyncJob(): Promise<void> {
|
||||||
|
if (isRecoveryRunning) {
|
||||||
|
console.log('[SAT Recovery] Ya en ejecución, omitiendo');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRecoveryRunning = true;
|
||||||
|
console.log('[SAT Recovery] Iniciando job de recuperación');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tenantIds = await getTenantsWithFiel();
|
||||||
|
console.log(`[SAT Recovery] ${tenantIds.length} tenants con FIEL`);
|
||||||
|
|
||||||
|
for (const tenantId of tenantIds) {
|
||||||
|
await recoverTenant(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[SAT Recovery] Job de recuperación completado');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[SAT Recovery] Error:', error.message);
|
||||||
|
} finally {
|
||||||
|
isRecoveryRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let scheduledTask: ReturnType<typeof cron.schedule> | null = null;
|
let scheduledTask: ReturnType<typeof cron.schedule> | null = null;
|
||||||
let retryTask: ReturnType<typeof cron.schedule> | null = null;
|
let retryTask: ReturnType<typeof cron.schedule> | null = null;
|
||||||
|
let recoveryTask: ReturnType<typeof cron.schedule> | null = null;
|
||||||
let opinionTask: ReturnType<typeof cron.schedule> | null = null;
|
let opinionTask: ReturnType<typeof cron.schedule> | null = null;
|
||||||
let csfTask: ReturnType<typeof cron.schedule> | null = null;
|
let csfTask: ReturnType<typeof cron.schedule> | null = null;
|
||||||
let incrementalTask: ReturnType<typeof cron.schedule> | null = null;
|
let incrementalTask: ReturnType<typeof cron.schedule> | null = null;
|
||||||
@@ -397,6 +572,19 @@ export function startSatSyncJob(): void {
|
|||||||
timezone: 'America/Mexico_City',
|
timezone: 'America/Mexico_City',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cron de recuperación: 10:00 AM diario. Revisa si el sync diario falló o si
|
||||||
|
// hay CFDIs vigentes sin XML, y relanza un sync `initial` con rango extendido
|
||||||
|
// para completar los XML faltantes.
|
||||||
|
recoveryTask = cron.schedule(RECOVERY_CRON_SCHEDULE, async () => {
|
||||||
|
try {
|
||||||
|
await runRecoverySyncJob();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[SAT Recovery Cron] Error:', error.message);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timezone: 'America/Mexico_City',
|
||||||
|
});
|
||||||
|
|
||||||
// Cron watchdog: cada 2h marca como `failed` los jobs que quedaron stale
|
// Cron watchdog: cada 2h marca como `failed` los jobs que quedaron stale
|
||||||
// (pending con nextRetryAt > 12h atrás, running con startedAt > 4h atrás).
|
// (pending con nextRetryAt > 12h atrás, running con startedAt > 4h atrás).
|
||||||
// Thresholds sobreescribibles vía env (STALE_PENDING_HOURS / STALE_RUNNING_HOURS)
|
// Thresholds sobreescribibles vía env (STALE_PENDING_HOURS / STALE_RUNNING_HOURS)
|
||||||
@@ -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] Job programado para: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||||
console.log(`[SAT Cron] Retry programado cada hora`);
|
console.log(`[SAT Cron] Retry programado cada hora`);
|
||||||
|
console.log(`[SAT Recovery Cron] Programado para: ${RECOVERY_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||||
console.log(`[Opinion Cron] Programado para: ${OPINION_CRON_SCHEDULE} (America/Mexico_City)`);
|
console.log(`[Opinion Cron] Programado para: ${OPINION_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||||
console.log(`[CSF Cron] Programado para: ${CSF_CRON_SCHEDULE} (America/Mexico_City)`);
|
console.log(`[CSF Cron] Programado para: ${CSF_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||||
console.log(`[SAT Cron Inc] Incremental Enterprise programado para: ${INCREMENTAL_CRON_SCHEDULE} (America/Mexico_City)`);
|
console.log(`[SAT Cron Inc] Incremental Enterprise programado para: ${INCREMENTAL_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||||
@@ -521,6 +710,10 @@ export function stopSatSyncJob(): void {
|
|||||||
retryTask.stop();
|
retryTask.stop();
|
||||||
retryTask = null;
|
retryTask = null;
|
||||||
}
|
}
|
||||||
|
if (recoveryTask) {
|
||||||
|
recoveryTask.stop();
|
||||||
|
recoveryTask = null;
|
||||||
|
}
|
||||||
if (opinionTask) {
|
if (opinionTask) {
|
||||||
opinionTask.stop();
|
opinionTask.stop();
|
||||||
opinionTask = null;
|
opinionTask = null;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { tenantDb } from '../config/database.js';
|
|||||||
import { getKpis } from '../services/dashboard.service.js';
|
import { getKpis } from '../services/dashboard.service.js';
|
||||||
import { generarAlertasAutomaticas, getDiscrepanciasPorMes } from '../services/alertas-auto.service.js';
|
import { generarAlertasAutomaticas, getDiscrepanciasPorMes } from '../services/alertas-auto.service.js';
|
||||||
import { emailService } from '../services/email/email.service.js';
|
import { emailService } from '../services/email/email.service.js';
|
||||||
|
import { filterRecipientsByRole } from '../services/notification-preferences.service.js';
|
||||||
|
|
||||||
const SCHEDULE = '0 8 * * 1'; // Lunes 8:00 AM
|
const SCHEDULE = '0 8 * * 1'; // Lunes 8:00 AM
|
||||||
|
|
||||||
@@ -45,19 +46,27 @@ export async function sendWeeklyUpdateForTenant(tenantId: string): Promise<{ sen
|
|||||||
return { sent: 0 };
|
return { sent: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recipientes: owners activos del tenant
|
// Pool del tenant para queries de preferencias y CFDI
|
||||||
|
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
||||||
|
|
||||||
|
// Recipientes: owners activos del tenant (filtrados por preferencias de rol)
|
||||||
const owners = await prisma.tenantMembership.findMany({
|
const owners = await prisma.tenantMembership.findMany({
|
||||||
where: { tenantId, isOwner: true, active: true },
|
where: { tenantId, isOwner: true, active: true },
|
||||||
include: { user: { select: { email: true, nombre: true, active: true } } },
|
include: { user: { select: { email: true, nombre: true, active: true } } },
|
||||||
});
|
});
|
||||||
const recipients = owners.filter(o => o.user.active);
|
const activeOwners = owners.filter(o => o.user.active);
|
||||||
if (recipients.length === 0) {
|
if (activeOwners.length === 0) {
|
||||||
console.log(`[Weekly] Tenant ${tenant.rfc} sin owners activos, skip`);
|
console.log(`[Weekly] Tenant ${tenant.rfc} sin owners activos, skip`);
|
||||||
return { sent: 0 };
|
return { sent: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pool del tenant para queries de CFDI
|
const recipientsWithRole = activeOwners.map(o => ({ email: o.user.email, role: 'owner' as const }));
|
||||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
const allowedEmails = new Set(await filterRecipientsByRole(pool, 'weekly_update', recipientsWithRole));
|
||||||
|
const recipients = activeOwners.filter(o => allowedEmails.has(o.user.email));
|
||||||
|
if (recipients.length === 0) {
|
||||||
|
console.log(`[Weekly] Tenant ${tenant.rfc} sin owners con weekly_update habilitado, skip`);
|
||||||
|
return { sent: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
const { fechaInicio, fechaFin, periodoLabel } = currentMonthRange();
|
const { fechaInicio, fechaFin, periodoLabel } = currentMonthRange();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -35,4 +35,10 @@ router.post('/extras', documentosController.crearExtra);
|
|||||||
router.get('/extras/:id/pdf', documentosController.descargarExtraPdf);
|
router.get('/extras/:id/pdf', documentosController.descargarExtraPdf);
|
||||||
router.delete('/extras/:id', documentosController.eliminarExtra);
|
router.delete('/extras/:id', documentosController.eliminarExtra);
|
||||||
|
|
||||||
|
// Evidencias de obligaciones fiscales
|
||||||
|
router.get('/obligacion-evidencias', documentosController.listarEvidenciasObligacion);
|
||||||
|
router.post('/obligacion-evidencias', documentosController.crearEvidenciaObligacion);
|
||||||
|
router.get('/obligacion-evidencias/:id/pdf', documentosController.descargarEvidenciaObligacion);
|
||||||
|
router.delete('/obligacion-evidencias/:id', documentosController.eliminarEvidenciaObligacion);
|
||||||
|
|
||||||
export { router as documentosRoutes };
|
export { router as documentosRoutes };
|
||||||
|
|||||||
@@ -66,11 +66,13 @@ export async function getClientesStats(range: ClientesStatsRange): Promise<Clien
|
|||||||
paymentsCount: payments._count,
|
paymentsCount: payments._count,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3) Clientes que NO renovaron: subs cuyo currentPeriodEnd cae en el rango
|
// 3) Clientes que NO renovaron:
|
||||||
// y que están en status terminal (cancelled, trial_expired, paused) o sin
|
// a) Subs cuyo currentPeriodEnd cae en el rango y están en status terminal
|
||||||
// payment posterior aprobado. Nota: un sub `authorized` con periodEnd
|
// (cancelled, trial_expired, paused).
|
||||||
// pasado es un "se renovó automáticamente" — para detectar no-renovaciones
|
// b) Tenants cuyo trialEndsAt ya pasó y NO tienen suscripción authorized
|
||||||
// miramos status efectivo + ausencia de payment en los siguientes 7 días.
|
// (incluye trials que nunca convirtieron o cuya sub fue borrada).
|
||||||
|
// c) Tenants con sub trial vencida (currentPeriodEnd < ahora) que nunca
|
||||||
|
// fue marcada trial_expired por el cron.
|
||||||
const subsExpiradas = await prisma.subscription.findMany({
|
const subsExpiradas = await prisma.subscription.findMany({
|
||||||
where: {
|
where: {
|
||||||
currentPeriodEnd: { gte: range.from, lte: range.to },
|
currentPeriodEnd: { gte: range.from, lte: range.to },
|
||||||
@@ -84,14 +86,99 @@ export async function getClientesStats(range: ClientesStatsRange): Promise<Clien
|
|||||||
tenant: { select: { id: true, nombre: true, rfc: true } },
|
tenant: { select: { id: true, nombre: true, rfc: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const noRenovaciones = subsExpiradas.map(s => ({
|
|
||||||
tenantId: s.tenantId,
|
const noRenovacionesMap = new Map<string, ClientesStats['noRenovaciones'][number]>();
|
||||||
tenantNombre: s.tenant?.nombre ?? '',
|
for (const s of subsExpiradas) {
|
||||||
rfc: s.tenant?.rfc ?? '',
|
noRenovacionesMap.set(s.tenantId, {
|
||||||
plan: String(s.plan),
|
tenantId: s.tenantId,
|
||||||
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '',
|
tenantNombre: s.tenant?.nombre ?? '',
|
||||||
statusActual: s.status,
|
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)
|
// 4) Usuarios por cliente (memberships activos por tenant)
|
||||||
const memberships = await prisma.tenantMembership.findMany({
|
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.
|
* Genera todas las alertas automáticas para un tenant.
|
||||||
|
* Cada alerta se envuelve en try/catch para que un fallo en una no
|
||||||
|
* bloquee el resto (robustez ante timeouts o errores transitorios).
|
||||||
*/
|
*/
|
||||||
export async function generarAlertasAutomaticas(
|
export async function generarAlertasAutomaticas(
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
contribuyenteId?: string | null,
|
contribuyenteId?: string | null,
|
||||||
): Promise<AlertaAuto[]> {
|
): Promise<AlertaAuto[]> {
|
||||||
const alertas = await Promise.all([
|
const generadores: { name: string; fn: () => Promise<AlertaAuto | null> }[] = [
|
||||||
alertaListaNegraPropia(pool, tenantId, contribuyenteId),
|
{ name: 'lista-negra-propia', fn: () => alertaListaNegraPropia(pool, tenantId, contribuyenteId) },
|
||||||
alertaClienteListaNegra(pool, contribuyenteId),
|
{ name: 'lista-negra-clientes', fn: () => alertaClienteListaNegra(pool, contribuyenteId) },
|
||||||
alertaProveedorListaNegra(pool, contribuyenteId),
|
{ name: 'lista-negra-proveedores', fn: () => alertaProveedorListaNegra(pool, contribuyenteId) },
|
||||||
alertaDiscrepanciaRegimen(pool, tenantId, contribuyenteId),
|
{ name: 'discrepancia-regimen', fn: () => alertaDiscrepanciaRegimen(pool, tenantId, contribuyenteId) },
|
||||||
alertaConcentracionClientes(pool, contribuyenteId),
|
{ name: 'concentracion-clientes', fn: () => alertaConcentracionClientes(pool, contribuyenteId) },
|
||||||
alertaConcentracionProveedores(pool, contribuyenteId),
|
{ name: 'concentracion-proveedores', fn: () => alertaConcentracionProveedores(pool, contribuyenteId) },
|
||||||
alertaRiesgoCambiario(pool, contribuyenteId),
|
{ name: 'riesgo-cambiario', fn: () => alertaRiesgoCambiario(pool, contribuyenteId) },
|
||||||
alertaRiesgoCancelaciones(pool, contribuyenteId),
|
{ name: 'riesgo-cancelaciones', fn: () => alertaRiesgoCancelaciones(pool, contribuyenteId) },
|
||||||
alertaRiesgoTransaccional(pool, contribuyenteId),
|
{ name: 'riesgo-transaccional', fn: () => alertaRiesgoTransaccional(pool, contribuyenteId) },
|
||||||
alertaCancelacionPeriodoAnterior(pool, contribuyenteId),
|
{ name: 'cancelacion-periodo-anterior', fn: () => alertaCancelacionPeriodoAnterior(pool, contribuyenteId) },
|
||||||
alertaOpinionCumplimiento(pool, contribuyenteId),
|
{ name: 'opinion-cumplimiento', fn: () => alertaOpinionCumplimiento(pool, contribuyenteId) },
|
||||||
alertaTipoRelacionSospechosa(pool, contribuyenteId),
|
{ name: 'tipo-relacion-sospechosa', fn: () => alertaTipoRelacionSospechosa(pool, contribuyenteId) },
|
||||||
alertaTareasProximasVencer(pool, contribuyenteId),
|
{ name: 'tareas-proximas-vencer', fn: () => alertaTareasProximasVencer(pool, contribuyenteId) },
|
||||||
alertaResicoPfLimiteIngresos(pool, contribuyenteId),
|
{ name: 'resico-pf-limite-ingresos', fn: () => alertaResicoPfLimiteIngresos(pool, contribuyenteId) },
|
||||||
]);
|
];
|
||||||
|
|
||||||
return alertas.filter((a): a is AlertaAuto => a !== null);
|
const alertas: AlertaAuto[] = [];
|
||||||
|
for (const g of generadores) {
|
||||||
|
try {
|
||||||
|
const a = await g.fn();
|
||||||
|
if (a) alertas.push(a);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[AlertasAuto] Fallo ${g.name} (tenant=${tenantId}, contribuyente=${contribuyenteId}):`, err.message || err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alertas.length > 0) {
|
||||||
|
console.log(`[AlertasAuto] tenant=${tenantId} contribuyente=${contribuyenteId || 'null'} generadas=${alertas.map(a => a.id).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return alertas;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ function appliesToPeriod(frecuencia: string | null, periodo: string): boolean {
|
|||||||
case 'mensual': return true;
|
case 'mensual': return true;
|
||||||
case 'bimestral': return month % 2 === 1;
|
case 'bimestral': return month % 2 === 1;
|
||||||
case 'trimestral': return [1, 4, 7, 10].includes(month);
|
case 'trimestral': return [1, 4, 7, 10].includes(month);
|
||||||
|
case 'cuatrimestral': return [1, 5, 9].includes(month);
|
||||||
case 'anual': return month === 3 || month === 4;
|
case 'anual': return month === 3 || month === 4;
|
||||||
case 'eventual': return false;
|
case 'eventual': return false;
|
||||||
default: return true;
|
default: return true;
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ export async function generarEventosDesdeObligaciones(
|
|||||||
if (freq === 'mensual') monthsToGenerate.push(m);
|
if (freq === 'mensual') monthsToGenerate.push(m);
|
||||||
else if (freq === 'bimestral' && m % 2 === 1) monthsToGenerate.push(m);
|
else if (freq === 'bimestral' && m % 2 === 1) monthsToGenerate.push(m);
|
||||||
else if (freq === 'trimestral' && [1, 4, 7, 10].includes(m)) monthsToGenerate.push(m);
|
else if (freq === 'trimestral' && [1, 4, 7, 10].includes(m)) monthsToGenerate.push(m);
|
||||||
|
else if (freq === 'cuatrimestral' && [1, 5, 9].includes(m)) monthsToGenerate.push(m);
|
||||||
else if (freq === 'anual' && (m === 3 || m === 4)) monthsToGenerate.push(m);
|
else if (freq === 'anual' && (m === 3 || m === 4)) monthsToGenerate.push(m);
|
||||||
// 'eventual' and unknown: skip auto-generation
|
// 'eventual' and unknown: skip auto-generation
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ function rowToConstancia(r: any): ConstanciaRow {
|
|||||||
* sincroniza automáticamente domicilio + regímenes activos con lo que reporta
|
* sincroniza automáticamente domicilio + regímenes activos con lo que reporta
|
||||||
* el SAT. El auto-fill NO es destructivo para datos custom del usuario:
|
* el SAT. El auto-fill NO es destructivo para datos custom del usuario:
|
||||||
* solo sobreescribe campos si la CSF tiene un valor no-vacío.
|
* solo sobreescribe campos si la CSF tiene un valor no-vacío.
|
||||||
|
*
|
||||||
|
* Incluye retry con backoff (3 intentos) para robustez ante timeouts
|
||||||
|
* transitorios del portal SAT (mantenimiento nocturno, congestión, etc.).
|
||||||
*/
|
*/
|
||||||
export async function consultarConstancia(tenantId: string): Promise<ConstanciaRow> {
|
export async function consultarConstancia(tenantId: string): Promise<ConstanciaRow> {
|
||||||
const fiel = await getDecryptedFiel(tenantId);
|
const fiel = await getDecryptedFiel(tenantId);
|
||||||
@@ -55,72 +58,78 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
|
|||||||
});
|
});
|
||||||
if (!tenant) throw new Error('Tenant no encontrado');
|
if (!tenant) throw new Error('Tenant no encontrado');
|
||||||
|
|
||||||
const tempId = randomUUID();
|
const MAX_RETRIES = 3;
|
||||||
const tempDir = join(tmpdir(), `horux-csf-${tempId}`);
|
const RETRY_DELAYS = [5_000, 15_000, 30_000]; // backoff
|
||||||
mkdirSync(tempDir, { recursive: true, mode: 0o700 });
|
|
||||||
const cerPath = join(tempDir, 'cert.cer');
|
|
||||||
const keyPath = join(tempDir, 'key.key');
|
|
||||||
|
|
||||||
try {
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||||
writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 });
|
const tempId = randomUUID();
|
||||||
writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
|
const tempDir = join(tmpdir(), `horux-csf-${tempId}`);
|
||||||
|
mkdirSync(tempDir, { recursive: true, mode: 0o700 });
|
||||||
|
const cerPath = join(tempDir, 'cert.cer');
|
||||||
|
const keyPath = join(tempDir, 'key.key');
|
||||||
|
|
||||||
// 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,
|
|
||||||
args: ['--disable-blink-features=AutomationControlled'],
|
|
||||||
ignoreDefaultArgs: ['--enable-automation'],
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 });
|
||||||
setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 3 minutos')), PROCESS_TIMEOUT),
|
writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
|
||||||
);
|
|
||||||
|
|
||||||
const resultPromise = (async () => {
|
const headless = process.env.SAT_HEADLESS !== 'false';
|
||||||
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password, fiel.rfc);
|
const browser = await chromium.launch({
|
||||||
const pdfBuffer = await extractCsfPdf(session);
|
headless,
|
||||||
const csf = await parseCsfPdf(pdfBuffer);
|
args: ['--disable-blink-features=AutomationControlled'],
|
||||||
|
ignoreDefaultArgs: ['--enable-automation'],
|
||||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
});
|
||||||
const { rows } = await pool.query(
|
try {
|
||||||
`INSERT INTO constancias_situacion_fiscal
|
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||||
(rfc, id_cif, razon_social, estatus_padron, fecha_emision, datos, pdf)
|
setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 5 minutos')), 300_000),
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
RETURNING id, rfc, id_cif, razon_social, estatus_padron, fecha_emision,
|
|
||||||
datos, fecha_consulta, created_at`,
|
|
||||||
[
|
|
||||||
csf.rfc,
|
|
||||||
csf.idCIF,
|
|
||||||
csf.razonSocial ?? [csf.nombre, csf.primerApellido, csf.segundoApellido].filter(Boolean).join(' ') ?? null,
|
|
||||||
csf.estatusPadron,
|
|
||||||
csf.lugarFechaEmision,
|
|
||||||
JSON.stringify(csf),
|
|
||||||
pdfBuffer,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-fill domicilio del tenant + regímenes activos desde el CSF.
|
const resultPromise = (async () => {
|
||||||
// Se hace después del INSERT para que si algo falla en la sincronización
|
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password, fiel.rfc);
|
||||||
// la CSF ya quedó guardada y el usuario puede verla.
|
const pdfBuffer = await extractCsfPdf(session);
|
||||||
await sincronizarDatosFiscales(tenantId, csf).catch(err => {
|
const csf = await parseCsfPdf(pdfBuffer);
|
||||||
console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err);
|
|
||||||
});
|
|
||||||
|
|
||||||
return rowToConstancia(rows[0]);
|
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
||||||
})();
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO constancias_situacion_fiscal
|
||||||
|
(rfc, id_cif, razon_social, estatus_padron, fecha_emision, datos, pdf)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id, rfc, id_cif, razon_social, estatus_padron, fecha_emision,
|
||||||
|
datos, fecha_consulta, created_at`,
|
||||||
|
[
|
||||||
|
csf.rfc,
|
||||||
|
csf.idCIF,
|
||||||
|
csf.razonSocial ?? [csf.nombre, csf.primerApellido, csf.segundoApellido].filter(Boolean).join(' ') ?? null,
|
||||||
|
csf.estatusPadron,
|
||||||
|
csf.lugarFechaEmision,
|
||||||
|
JSON.stringify(csf),
|
||||||
|
pdfBuffer,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return await Promise.race([resultPromise, timeoutPromise]);
|
await sincronizarDatosFiscales(tenantId, csf).catch(err => {
|
||||||
|
console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return rowToConstancia(rows[0]);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return await Promise.race([resultPromise, timeoutPromise]);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const willRetry = attempt < MAX_RETRIES - 1;
|
||||||
|
console.error(`[CSF] Intento ${attempt + 1}/${MAX_RETRIES} falló para tenant ${tenantId}: ${err.message}${willRetry ? ` — reintentando en ${RETRY_DELAYS[attempt]}ms...` : ''}`);
|
||||||
|
if (!willRetry) throw err;
|
||||||
|
await new Promise(r => setTimeout(r, RETRY_DELAYS[attempt]));
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
try { unlinkSync(cerPath); } catch { /* ok */ }
|
||||||
|
try { unlinkSync(keyPath); } catch { /* ok */ }
|
||||||
|
try { rmdirSync(tempDir); } catch { /* ok */ }
|
||||||
}
|
}
|
||||||
} 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 ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId);
|
||||||
const esEmisor = ctx.esEmisor;
|
const esEmisor = ctx.esEmisor;
|
||||||
const esReceptor = ctx.esReceptor;
|
const esReceptor = ctx.esReceptor;
|
||||||
const ingresosData = await calcularIngresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId);
|
const [
|
||||||
const egresosData = await calcularEgresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId);
|
ingresosData,
|
||||||
const adquisicionData = await calcularAdquisicionesMercancias(pool, tenantId, fechaInicio, fechaFin, conciliacion, contribuyenteId);
|
egresosData,
|
||||||
const ivaData = await calcularIvaBalancePorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId);
|
adquisicionData,
|
||||||
|
ivaData,
|
||||||
|
ncsEmitidasData,
|
||||||
|
ncsRecibidasData,
|
||||||
|
] = await Promise.all([
|
||||||
|
calcularIngresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
|
||||||
|
calcularEgresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
|
||||||
|
calcularAdquisicionesMercancias(pool, tenantId, fechaInicio, fechaFin, conciliacion, contribuyenteId),
|
||||||
|
calcularIvaBalancePorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
|
||||||
|
calcularNcsEmitidasPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
|
||||||
|
calcularNcsRecibidasPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
|
||||||
|
]);
|
||||||
|
|
||||||
// IVA a favor año actual: desde enero del año en curso
|
// IVA a favor año actual: desde enero del año en curso
|
||||||
const ivaAFavorAcumulado = await calcularIvaAFavorAcumulado(pool, tenantId, fechaFin, undefined, conciliacion, contribuyenteId);
|
const ivaAFavorAcumulado = await calcularIvaAFavorAcumulado(pool, tenantId, fechaFin, undefined, conciliacion, contribuyenteId);
|
||||||
@@ -1163,6 +1174,10 @@ export async function getKpis(
|
|||||||
cfdisEmitidosPorRegimen: emitidosPorRegimen,
|
cfdisEmitidosPorRegimen: emitidosPorRegimen,
|
||||||
cfdisRecibidos: recibidosPorRegimen.reduce((s: number, r: any) => s + r.total, 0),
|
cfdisRecibidos: recibidosPorRegimen.reduce((s: number, r: any) => s + r.total, 0),
|
||||||
cfdisRecibidosPorRegimen: recibidosPorRegimen,
|
cfdisRecibidosPorRegimen: recibidosPorRegimen,
|
||||||
|
ncsEmitidas: ncsEmitidasData.total,
|
||||||
|
ncsEmitidasPorRegimen: ncsEmitidasData.porRegimen,
|
||||||
|
ncsRecibidas: ncsRecibidasData.total,
|
||||||
|
ncsRecibidasPorRegimen: ncsRecibidasData.porRegimen,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,38 @@
|
|||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
|
import { createEvidencia } from './obligacion-evidencias.service.js';
|
||||||
|
|
||||||
|
function normalize(s: string): string {
|
||||||
|
return s
|
||||||
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[.,;:()]/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dadas las obligaciones seleccionadas para una declaración, infiere los
|
||||||
|
* impuestos que cubre. Se usa para mantener la resolución de alertas legacy
|
||||||
|
* (decl-*, pago-*) sin exponer el campo en la UI.
|
||||||
|
*/
|
||||||
|
function inferirImpuestosDeObligaciones(
|
||||||
|
obligaciones: Array<{ id: string; nombre: string; catalogoId?: string | null }>,
|
||||||
|
): Impuesto[] {
|
||||||
|
const set = new Set<Impuesto>();
|
||||||
|
for (const ob of obligaciones) {
|
||||||
|
const nombre = normalize(ob.nombre);
|
||||||
|
const catalogoId = normalize(ob.catalogoId || '');
|
||||||
|
if (nombre.includes('diot') || catalogoId.includes('diot')) {
|
||||||
|
set.add('DIOT');
|
||||||
|
} else if (nombre.includes('iva') || catalogoId.includes('iva')) {
|
||||||
|
set.add('IVA');
|
||||||
|
}
|
||||||
|
if (nombre.includes('isr') || catalogoId.includes('isr')) set.add('ISR');
|
||||||
|
if (nombre.includes('ieps') || catalogoId.includes('ieps')) set.add('IEPS');
|
||||||
|
if (nombre.includes('isn') || catalogoId.includes('isn')) set.add('ISN');
|
||||||
|
if (nombre.includes('ish') || catalogoId.includes('ish')) set.add('ISH');
|
||||||
|
}
|
||||||
|
return Array.from(set);
|
||||||
|
}
|
||||||
|
|
||||||
// Mapeo: impuesto de la declaración → reglas para matchear obligaciones del
|
// Mapeo: impuesto de la declaración → reglas para matchear obligaciones del
|
||||||
// contribuyente. `include` son substrings que DEBE contener el nombre de la
|
// contribuyente. `include` son substrings que DEBE contener el nombre de la
|
||||||
@@ -25,17 +59,28 @@ const IMPUESTO_A_OBLIGACION_KEYWORDS: Record<string, { include: string[]; exclud
|
|||||||
* periodo sigue marcado completado — el usuario decidirá si re-abrirlo
|
* periodo sigue marcado completado — el usuario decidirá si re-abrirlo
|
||||||
* manualmente.
|
* manualmente.
|
||||||
*/
|
*/
|
||||||
async function completarObligacionesPorDeclaracion(
|
/**
|
||||||
|
* Al subir una declaración o comprobante de pago, registra una evidencia para
|
||||||
|
* cada obligación del contribuyente que corresponda al impuesto declarado.
|
||||||
|
*
|
||||||
|
* - Obligaciones informativas (`requierePago = false`) se marcan completadas al
|
||||||
|
* recibir cualquier documento de declaración/acuse.
|
||||||
|
* - Obligaciones de pago (`requierePago = true`) se marcan completadas solo al
|
||||||
|
* recibir un comprobante de pago (`tipo_documento = 'pago'`).
|
||||||
|
*/
|
||||||
|
async function registrarEvidenciasPorDeclaracion(
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
contribuyenteId: string,
|
contribuyenteId: string,
|
||||||
impuestos: string[],
|
impuestos: string[],
|
||||||
periodo: string,
|
periodo: string,
|
||||||
/** UUID del usuario que subió la declaración (obligacion_periodos.completada_por es uuid). */
|
/** UUID del usuario que subió el documento. */
|
||||||
completadaPor: string,
|
subidoPor: string,
|
||||||
declaracionId: number,
|
pdfBase64: string,
|
||||||
/** Periodicidad de la declaración. Si no se provee, se asume 'mensual'. */
|
pdfFilename: string,
|
||||||
|
tipoDocumento: 'declaracion' | 'pago',
|
||||||
|
/** Periodicidad de la declaración. Si no se provee, asume 'mensual'. */
|
||||||
periodicidad: string = 'mensual',
|
periodicidad: string = 'mensual',
|
||||||
): Promise<number> {
|
): Promise<{ count: number; obligacionesAfectadas: string[] }> {
|
||||||
// Get active obligations for this contribuyente (incluye frecuencia para filtrar)
|
// Get active obligations for this contribuyente (incluye frecuencia para filtrar)
|
||||||
const { rows: obligaciones } = await pool.query<{ id: string; nombre: string; frecuencia: string | null }>(
|
const { rows: obligaciones } = await pool.query<{ id: string; nombre: string; frecuencia: string | null }>(
|
||||||
`SELECT id, nombre, frecuencia FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND activa = true`,
|
`SELECT id, nombre, frecuencia FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND activa = true`,
|
||||||
@@ -43,6 +88,7 @@ async function completarObligacionesPorDeclaracion(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
const obligacionesAfectadas: string[] = [];
|
||||||
|
|
||||||
for (const impuesto of impuestos) {
|
for (const impuesto of impuestos) {
|
||||||
const rules = IMPUESTO_A_OBLIGACION_KEYWORDS[impuesto];
|
const rules = IMPUESTO_A_OBLIGACION_KEYWORDS[impuesto];
|
||||||
@@ -55,33 +101,109 @@ async function completarObligacionesPorDeclaracion(
|
|||||||
if (!matches) continue;
|
if (!matches) continue;
|
||||||
|
|
||||||
// Filtro por periodicidad/frecuencia: una declaración mensual no debe
|
// Filtro por periodicidad/frecuencia: una declaración mensual no debe
|
||||||
// cerrar obligaciones anuales del mismo impuesto (ej. ISR mensual no
|
// cerrar obligaciones anuales del mismo impuesto.
|
||||||
// cubre "Declaración anual de ISR"). Si la obligación tiene frecuencia
|
|
||||||
// explícita y no coincide con la periodicidad de la declaración, skip.
|
|
||||||
// `eventual` obligaciones no se tocan automáticamente.
|
|
||||||
const obFrec = (ob.frecuencia || '').toLowerCase();
|
const obFrec = (ob.frecuencia || '').toLowerCase();
|
||||||
if (obFrec === 'eventual') continue;
|
if (obFrec === 'eventual') continue;
|
||||||
if (obFrec && obFrec !== periodicidad.toLowerCase()) continue;
|
if (obFrec && obFrec !== periodicidad.toLowerCase()) continue;
|
||||||
|
|
||||||
// Mark obligation as completed for this period, with FK a la declaración
|
await createEvidencia(pool, {
|
||||||
await pool.query(`
|
obligacionId: ob.id,
|
||||||
INSERT INTO obligacion_periodos (obligacion_id, periodo, completada, completada_at, completada_por, notas, declaracion_id)
|
periodo,
|
||||||
VALUES ($1, $2, true, now(), $3, $4, $5)
|
contribuyenteId,
|
||||||
ON CONFLICT (obligacion_id, periodo)
|
tipoDocumento,
|
||||||
DO UPDATE SET completada = true, completada_at = now(), completada_por = $3, declaracion_id = $5
|
pdfBase64,
|
||||||
`, [ob.id, periodo, completadaPor, `Declaración ${impuesto} subida`, declaracionId]);
|
pdfFilename,
|
||||||
|
notas: `${tipoDocumento === 'pago' ? 'Pago' : 'Declaración'} ${impuesto}`,
|
||||||
// Resolve the ob-* alert for this obligation+period
|
subidoPor,
|
||||||
await pool.query(
|
});
|
||||||
`UPDATE alertas SET resuelta = true WHERE tipo = $1 AND resuelta = false`,
|
|
||||||
[`ob-${ob.id}-${periodo}`],
|
|
||||||
);
|
|
||||||
|
|
||||||
|
if (!obligacionesAfectadas.includes(ob.id)) obligacionesAfectadas.push(ob.id);
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return count;
|
return { count, obligacionesAfectadas };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cuando una declaración tiene monto $0, no se requiere comprobante de pago.
|
||||||
|
* Esta función marca `pago_presentado = true` (y `completada = true`) en los
|
||||||
|
* periodos de las obligaciones afectadas para reflejar que el pago está saldado.
|
||||||
|
*/
|
||||||
|
async function confirmarPagoPeriodoSinComprobante(
|
||||||
|
pool: Pool,
|
||||||
|
obligacionesAfectadas: string[],
|
||||||
|
periodo: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const now = new Date();
|
||||||
|
for (const obligacionId of obligacionesAfectadas) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO obligacion_periodos
|
||||||
|
(obligacion_id, periodo, declaracion_presentada, pago_presentado, completada, completada_at, completada_por)
|
||||||
|
VALUES ($1, $2, true, true, true, $3, $4)
|
||||||
|
ON CONFLICT (obligacion_id, periodo)
|
||||||
|
DO UPDATE SET
|
||||||
|
pago_presentado = true,
|
||||||
|
completada = true,
|
||||||
|
completada_at = COALESCE(obligacion_periodos.completada_at, $3),
|
||||||
|
completada_por = COALESCE(obligacion_periodos.completada_por, $4)`,
|
||||||
|
[obligacionId, periodo, now, userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resolver alerta ob-* si existe
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE alertas SET resuelta = true WHERE tipo = $1 AND resuelta = false`,
|
||||||
|
[`ob-${obligacionId}-${periodo}`],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registra una evidencia por cada obligación seleccionada.
|
||||||
|
* - Obligaciones informativas se completan con `declaracion`/`acuse`/`complemento`.
|
||||||
|
* - Obligaciones de pago requieren evidencia `pago` para cerrarse.
|
||||||
|
*/
|
||||||
|
async function registrarEvidenciasPorObligaciones(
|
||||||
|
pool: Pool,
|
||||||
|
obligaciones: Array<{ id: string; nombre: string; catalogoId?: string | null }>,
|
||||||
|
contribuyenteId: string,
|
||||||
|
periodo: string,
|
||||||
|
subidoPor: string,
|
||||||
|
pdfBase64: string,
|
||||||
|
pdfFilename: string,
|
||||||
|
tipoDocumento: 'declaracion' | 'pago',
|
||||||
|
notas?: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const afectadas: string[] = [];
|
||||||
|
for (const ob of obligaciones) {
|
||||||
|
await createEvidencia(pool, {
|
||||||
|
obligacionId: ob.id,
|
||||||
|
periodo,
|
||||||
|
contribuyenteId,
|
||||||
|
tipoDocumento,
|
||||||
|
pdfBase64,
|
||||||
|
pdfFilename,
|
||||||
|
notas: notas || `${tipoDocumento === 'pago' ? 'Comprobante de pago' : 'Declaración'}: ${ob.nombre}`,
|
||||||
|
subidoPor,
|
||||||
|
});
|
||||||
|
afectadas.push(ob.id);
|
||||||
|
}
|
||||||
|
return afectadas;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getObligacionesPorIds(
|
||||||
|
pool: Pool,
|
||||||
|
contribuyenteId: string,
|
||||||
|
obligacionesIds: string[],
|
||||||
|
): Promise<Array<{ id: string; nombre: string; catalogoId: string | null }>> {
|
||||||
|
const { rows } = await pool.query<{ id: string; nombre: string; catalogo_id: string | null }>(
|
||||||
|
`SELECT id, nombre, catalogo_id
|
||||||
|
FROM obligaciones_contribuyente
|
||||||
|
WHERE contribuyente_id = $1 AND id = ANY($2::uuid[]) AND activa = true`,
|
||||||
|
[contribuyenteId, obligacionesIds],
|
||||||
|
);
|
||||||
|
return rows.map(r => ({ id: r.id, nombre: r.nombre, catalogoId: r.catalogo_id }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,7 +218,7 @@ async function completarObligacionesPorDeclaracion(
|
|||||||
|
|
||||||
export type Impuesto = 'IVA' | 'ISR' | 'IEPS' | 'ISN' | 'DIOT' | 'OTRO' | 'ISH';
|
export type Impuesto = 'IVA' | 'ISR' | 'IEPS' | 'ISN' | 'DIOT' | 'OTRO' | 'ISH';
|
||||||
|
|
||||||
export type Periodicidad = 'mensual' | 'bimestral' | 'trimestral' | 'semestral' | 'anual';
|
export type Periodicidad = 'mensual' | 'bimestral' | 'trimestral' | 'cuatrimestral' | 'semestral' | 'anual';
|
||||||
|
|
||||||
export interface DeclaracionRow {
|
export interface DeclaracionRow {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -232,7 +354,10 @@ export async function createDeclaracion(
|
|||||||
mes: number;
|
mes: number;
|
||||||
tipo: 'normal' | 'complementaria';
|
tipo: 'normal' | 'complementaria';
|
||||||
periodicidad?: Periodicidad;
|
periodicidad?: Periodicidad;
|
||||||
impuestos: string[];
|
/** Legacy: se infiere de obligacionesIds si no se envía. */
|
||||||
|
impuestos?: string[];
|
||||||
|
/** Obligaciones fiscales que cubre esta declaración. */
|
||||||
|
obligacionesIds?: string[];
|
||||||
montoPago?: number | null;
|
montoPago?: number | null;
|
||||||
pdfBase64: string; // PDF de la declaración (base64)
|
pdfBase64: string; // PDF de la declaración (base64)
|
||||||
pdfFilename: string;
|
pdfFilename: string;
|
||||||
@@ -253,6 +378,16 @@ export async function createDeclaracion(
|
|||||||
// If monto_pago is exactly 0, auto-mark as paid (no payment receipt needed)
|
// If monto_pago is exactly 0, auto-mark as paid (no payment receipt needed)
|
||||||
const pagadoAt = montoPago === 0 ? new Date() : null;
|
const pagadoAt = montoPago === 0 ? new Date() : null;
|
||||||
|
|
||||||
|
// Resolvemos obligaciones e impuestos.
|
||||||
|
let obligacionesSeleccionadas: Array<{ id: string; nombre: string; catalogoId: string | null }> = [];
|
||||||
|
let impuestos: string[] = data.impuestos ?? [];
|
||||||
|
if (data.contribuyenteId && data.obligacionesIds && data.obligacionesIds.length > 0) {
|
||||||
|
obligacionesSeleccionadas = await getObligacionesPorIds(pool, data.contribuyenteId, data.obligacionesIds);
|
||||||
|
if (impuestos.length === 0) {
|
||||||
|
impuestos = inferirImpuestosDeObligaciones(obligacionesSeleccionadas);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`INSERT INTO declaraciones_provisionales
|
`INSERT INTO declaraciones_provisionales
|
||||||
@@ -262,46 +397,55 @@ export async function createDeclaracion(
|
|||||||
RETURNING id, año, mes, tipo, periodicidad, impuestos, monto_pago, pdf_filename,
|
RETURNING id, año, mes, tipo, periodicidad, impuestos, monto_pago, pdf_filename,
|
||||||
pdf_liga_pago_filename, pdf_pago_filename, pagado_at, creado_por, notas,
|
pdf_liga_pago_filename, pdf_pago_filename, pagado_at, creado_por, notas,
|
||||||
created_at, updated_at`,
|
created_at, updated_at`,
|
||||||
[data.año, data.mes, data.tipo, periodicidad, data.impuestos, montoPago,
|
[data.año, data.mes, data.tipo, periodicidad, impuestos, montoPago,
|
||||||
buf, data.pdfFilename, ligaBuf, data.ligaPagoFilename ?? null,
|
buf, data.pdfFilename, ligaBuf, data.ligaPagoFilename ?? null,
|
||||||
data.notas ?? null, data.creadoPor, pagadoAt, data.contribuyenteId ?? null],
|
data.notas ?? null, data.creadoPor, pagadoAt, data.contribuyenteId ?? null],
|
||||||
);
|
);
|
||||||
|
|
||||||
const declaracion = rowToDeclaracion(rows[0]);
|
const declaracion = rowToDeclaracion(rows[0]);
|
||||||
|
|
||||||
// Auto-resolver alertas. Reglas:
|
// Guardar relación con obligaciones para que el comprobante de pago
|
||||||
// - tipo='normal': resuelve alertas de declaración (decl-*) del mes.
|
// posterior se aplique a las mismas obligaciones.
|
||||||
// El pago se resuelve por separado al subir comprobante.
|
if (obligacionesSeleccionadas.length > 0) {
|
||||||
// - tipo='complementaria': sustituye a la normal en términos de
|
const values = obligacionesSeleccionadas.map((_, i) => `($1, $${i + 2})`).join(',');
|
||||||
// obligación de pago — al subirla se resuelven AMBAS (decl-* y
|
await pool.query(
|
||||||
// pago-*) porque el cliente pagará usando la complementaria,
|
`INSERT INTO declaracion_obligaciones (declaracion_id, obligacion_id) VALUES ${values}`,
|
||||||
// no la normal. La alerta de declaración ya estaría resuelta
|
[declaracion.id, ...obligacionesSeleccionadas.map(o => o.id)],
|
||||||
// si la normal se subió antes; el resolver es idempotente.
|
);
|
||||||
const prefijosDecl = data.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_DECL[i] || []);
|
}
|
||||||
|
|
||||||
|
// Auto-resolver alertas legacy (decl-*, pago-*).
|
||||||
|
const prefijosDecl = impuestos.flatMap(i => IMPUESTO_A_PREFIJO_DECL[i] || []);
|
||||||
let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosDecl, data.año, data.mes);
|
let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosDecl, data.año, data.mes);
|
||||||
if (data.tipo === 'complementaria' || montoPago === 0) {
|
if (data.tipo === 'complementaria' || montoPago === 0) {
|
||||||
// complementaria: sustituye normal para pago → resolver ambas
|
const prefijosPago = impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []);
|
||||||
// monto 0: nada que pagar → resolver alertas de pago también
|
|
||||||
const prefijosPago = data.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []);
|
|
||||||
alertasResueltas += await resolverAlertasPorPeriodo(pool, prefijosPago, data.año, data.mes);
|
alertasResueltas += await resolverAlertasPorPeriodo(pool, prefijosPago, data.año, data.mes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-complete obligaciones del contribuyente SOLO si la declaración
|
// Registrar evidencias de declaración en las obligaciones seleccionadas.
|
||||||
// también cubre el pago (complementaria sustituye a la normal para el
|
// Fallback legacy: si no se enviaron obligaciones, se usa el keyword matching
|
||||||
// pago; monto=0 significa "nada que pagar"). Una declaración normal con
|
// anterior a partir de impuestos.
|
||||||
// monto>0 solo presenta el acuse — la obligación de pago sigue abierta
|
let obligacionesAfectadas: string[] = obligacionesSeleccionadas.map(o => o.id);
|
||||||
// y se marca completada hasta que se suba el comprobante via
|
if (data.contribuyenteId && data.creadoPorUserId) {
|
||||||
// `uploadComprobantePago`. Esto mantiene las alertas `pago-*` y `ob-*`
|
const periodo = `${data.año}-${String(data.mes).padStart(2, '0')}`;
|
||||||
// visibles hasta que realmente se cierre el ciclo.
|
|
||||||
const cubrePago = data.tipo === 'complementaria' || montoPago === 0;
|
if (obligacionesSeleccionadas.length > 0) {
|
||||||
if (data.contribuyenteId && cubrePago) {
|
await registrarEvidenciasPorObligaciones(
|
||||||
if (!data.creadoPorUserId) {
|
pool, obligacionesSeleccionadas, data.contribuyenteId, periodo, data.creadoPorUserId,
|
||||||
console.warn('[createDeclaracion] Sin creadoPorUserId — no se auto-completan obligaciones del contribuyente');
|
data.pdfBase64, data.pdfFilename, 'declaracion', data.notas,
|
||||||
} else {
|
|
||||||
const periodo = `${data.año}-${String(data.mes).padStart(2, '0')}`;
|
|
||||||
alertasResueltas += await completarObligacionesPorDeclaracion(
|
|
||||||
pool, data.contribuyenteId, data.impuestos, periodo, data.creadoPorUserId, declaracion.id, periodicidad,
|
|
||||||
);
|
);
|
||||||
|
} else if (impuestos.length > 0) {
|
||||||
|
const { obligacionesAfectadas: afectadas } = await registrarEvidenciasPorDeclaracion(
|
||||||
|
pool, data.contribuyenteId, impuestos, periodo, data.creadoPorUserId,
|
||||||
|
data.pdfBase64, data.pdfFilename, 'declaracion', periodicidad,
|
||||||
|
);
|
||||||
|
obligacionesAfectadas = afectadas;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si la declaración es por $0, no se requiere comprobante de pago:
|
||||||
|
// marcar el pago como presentado automáticamente.
|
||||||
|
if (montoPago === 0 && obligacionesAfectadas.length > 0) {
|
||||||
|
await confirmarPagoPeriodoSinComprobante(pool, obligacionesAfectadas, periodo, data.creadoPorUserId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,20 +484,35 @@ export async function uploadComprobantePago(
|
|||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
const declaracion = rowToDeclaracion(row);
|
const declaracion = rowToDeclaracion(row);
|
||||||
|
|
||||||
// Auto-resolver alertas de pago para los impuestos del periodo
|
// Auto-resolver alertas de pago legacy.
|
||||||
const prefijosPago = declaracion.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []);
|
const prefijosPago = declaracion.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []);
|
||||||
let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosPago, declaracion.año, declaracion.mes);
|
let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosPago, declaracion.año, declaracion.mes);
|
||||||
|
|
||||||
// Al subirse el comprobante de pago, la obligación ahora SÍ está completada
|
// Registrar evidencias de pago en las obligaciones vinculadas a esta declaración.
|
||||||
// (declaración + pago). Marcar `obligacion_periodos.completada=true` y
|
// Fallback legacy: si no hay relaciones, se usa keyword matching por impuestos.
|
||||||
// resolver los `ob-*` alerts. Requires contribuyenteId (guardado en la
|
|
||||||
// declaración) y userId (del caller).
|
|
||||||
if (row.contribuyente_id && data.uploadedByUserId) {
|
if (row.contribuyente_id && data.uploadedByUserId) {
|
||||||
const periodo = `${declaracion.año}-${String(declaracion.mes).padStart(2, '0')}`;
|
const periodo = `${declaracion.año}-${String(declaracion.mes).padStart(2, '0')}`;
|
||||||
const periodicidad = row.periodicidad || 'mensual';
|
|
||||||
alertasResueltas += await completarObligacionesPorDeclaracion(
|
const { rows: relaciones } = await pool.query<{ obligacion_id: string }>(
|
||||||
pool, row.contribuyente_id, declaracion.impuestos, periodo, data.uploadedByUserId, declaracion.id, periodicidad,
|
`SELECT obligacion_id FROM declaracion_obligaciones WHERE declaracion_id = $1`,
|
||||||
|
[id],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (relaciones.length > 0) {
|
||||||
|
const obligaciones = await getObligacionesPorIds(
|
||||||
|
pool, row.contribuyente_id, relaciones.map(r => r.obligacion_id),
|
||||||
|
);
|
||||||
|
await registrarEvidenciasPorObligaciones(
|
||||||
|
pool, obligaciones, row.contribuyente_id, periodo, data.uploadedByUserId,
|
||||||
|
data.pdfBase64, data.pdfFilename, 'pago', declaracion.notas ?? undefined,
|
||||||
|
);
|
||||||
|
} else if (declaracion.impuestos.length > 0) {
|
||||||
|
const periodicidad = row.periodicidad || 'mensual';
|
||||||
|
await registrarEvidenciasPorDeclaracion(
|
||||||
|
pool, row.contribuyente_id, declaracion.impuestos, periodo, data.uploadedByUserId,
|
||||||
|
data.pdfBase64, data.pdfFilename, 'pago', periodicidad,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { declaracion, alertasResueltas };
|
return { declaracion, alertasResueltas };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createEmailTransport } from '@horux/core';
|
import { createEmailTransport, type EmailAttachment } from '@horux/core';
|
||||||
import { env } from '../../config/env.js';
|
import { env } from '../../config/env.js';
|
||||||
|
|
||||||
const transport = createEmailTransport(
|
const transport = createEmailTransport(
|
||||||
@@ -13,8 +13,8 @@ const transport = createEmailTransport(
|
|||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
async function sendEmail(to: string, subject: string, html: string) {
|
async function sendEmail(to: string, subject: string, html: string, attachments?: EmailAttachment[]) {
|
||||||
await transport.send(to, subject, html);
|
await transport.send(to, subject, html, attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const emailService = {
|
export const emailService = {
|
||||||
@@ -128,10 +128,14 @@ export const emailService = {
|
|||||||
* Notifica la subida de una declaración o documento extra al despacho.
|
* Notifica la subida de una declaración o documento extra al despacho.
|
||||||
* `recipients` debe venir deduplicado por el caller. El subject se
|
* `recipients` debe venir deduplicado por el caller. El subject se
|
||||||
* genera a partir del kind y RFC del contribuyente.
|
* genera a partir del kind y RFC del contribuyente.
|
||||||
|
*
|
||||||
|
* Para declaraciones, `attachments` puede contener los PDFs subidos
|
||||||
|
* (acuse + liga de pago) para enviarlos adjuntos al correo.
|
||||||
*/
|
*/
|
||||||
sendDocumentoSubido: async (
|
sendDocumentoSubido: async (
|
||||||
recipients: string[],
|
recipients: string[],
|
||||||
data: import('./templates/documento-subido.js').DocumentoSubidoData,
|
data: import('./templates/documento-subido.js').DocumentoSubidoData,
|
||||||
|
attachments?: EmailAttachment[],
|
||||||
) => {
|
) => {
|
||||||
if (recipients.length === 0) return;
|
if (recipients.length === 0) return;
|
||||||
const { documentoSubidoEmail } = await import('./templates/documento-subido.js');
|
const { documentoSubidoEmail } = await import('./templates/documento-subido.js');
|
||||||
@@ -143,7 +147,7 @@ export const emailService = {
|
|||||||
// destinatario NO debe impedir enviar al siguiente.
|
// destinatario NO debe impedir enviar al siguiente.
|
||||||
for (const to of recipients) {
|
for (const to of recipients) {
|
||||||
try {
|
try {
|
||||||
await sendEmail(to, subject, html);
|
await sendEmail(to, subject, html, attachments);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`[Email] Fallo enviando documento-subido a ${to}:`, err?.message || err);
|
console.error(`[Email] Fallo enviando documento-subido a ${to}:`, err?.message || err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { baseTemplate, heading, infoBox, primaryButton, BRAND_COLORS as C } from
|
|||||||
|
|
||||||
export interface DocumentoSubidoData {
|
export interface DocumentoSubidoData {
|
||||||
/** Kind: para el título/subject. */
|
/** Kind: para el título/subject. */
|
||||||
kind: 'declaracion' | 'extra';
|
kind: 'declaracion' | 'extra' | 'obligacion_evidencia';
|
||||||
/** Quién subió el documento (email). */
|
/** Quién subió el documento (email). */
|
||||||
subidoPor: string;
|
subidoPor: string;
|
||||||
/** RFC del contribuyente. */
|
/** RFC del contribuyente. */
|
||||||
@@ -24,25 +24,38 @@ export interface DocumentoSubidoData {
|
|||||||
descripcion?: string | null;
|
descripcion?: string | null;
|
||||||
categoria?: string | null;
|
categoria?: string | null;
|
||||||
};
|
};
|
||||||
|
/** Si es evidencia de obligación fiscal. */
|
||||||
|
evidencia?: {
|
||||||
|
obligacionNombre: string;
|
||||||
|
periodo: string;
|
||||||
|
tipoDocumento: string;
|
||||||
|
filename: string;
|
||||||
|
};
|
||||||
/** URL al sistema (ej. https://despachos.horuxfin.com/documentos). */
|
/** URL al sistema (ej. https://despachos.horuxfin.com/documentos). */
|
||||||
link: string;
|
link: string;
|
||||||
|
/** Solo para declaraciones: los adjuntos se omitieron por exceder el límite de tamaño. */
|
||||||
|
attachmentsOmitted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function documentoSubidoEmail(data: DocumentoSubidoData): string {
|
export function documentoSubidoEmail(data: DocumentoSubidoData): string {
|
||||||
const titulo = data.kind === 'declaracion'
|
const titulo = data.kind === 'declaracion'
|
||||||
? 'Nueva declaración subida'
|
? 'Nueva declaración subida'
|
||||||
: 'Nuevo documento subido';
|
: data.kind === 'obligacion_evidencia'
|
||||||
|
? 'Nueva evidencia de obligación fiscal'
|
||||||
|
: 'Nuevo documento subido';
|
||||||
|
|
||||||
const contenidoEspecifico = data.kind === 'declaracion' && data.declaracion
|
const contenidoEspecifico = data.kind === 'declaracion' && data.declaracion
|
||||||
? declaracionBlock(data.declaracion)
|
? declaracionBlock(data.declaracion)
|
||||||
: data.extra
|
: data.kind === 'obligacion_evidencia' && data.evidencia
|
||||||
? extraBlock(data.extra)
|
? evidenciaBlock(data.evidencia)
|
||||||
: '';
|
: data.extra
|
||||||
|
? extraBlock(data.extra)
|
||||||
|
: '';
|
||||||
|
|
||||||
return baseTemplate(`
|
return baseTemplate(`
|
||||||
${heading(titulo)}
|
${heading(titulo)}
|
||||||
<p style="color:${C.textPrimary};margin:0 0 16px;">
|
<p style="color:${C.textPrimary};margin:0 0 16px;">
|
||||||
<strong>${escapeHtml(data.subidoPor)}</strong> subió un ${data.kind === 'declaracion' ? 'acuse de declaración' : 'documento'}
|
<strong>${escapeHtml(data.subidoPor)}</strong> subió ${data.kind === 'obligacion_evidencia' ? 'una evidencia de obligación fiscal' : data.kind === 'declaracion' ? 'un acuse de declaración' : 'un documento'}
|
||||||
para <strong>${escapeHtml(data.contribuyenteNombre)}</strong>.
|
para <strong>${escapeHtml(data.contribuyenteNombre)}</strong>.
|
||||||
</p>
|
</p>
|
||||||
${infoBox(`
|
${infoBox(`
|
||||||
@@ -57,6 +70,12 @@ export function documentoSubidoEmail(data: DocumentoSubidoData): string {
|
|||||||
<div style="margin-top:24px;">
|
<div style="margin-top:24px;">
|
||||||
${primaryButton('Ver en el sistema', data.link)}
|
${primaryButton('Ver en el sistema', data.link)}
|
||||||
</div>
|
</div>
|
||||||
|
${data.kind === 'declaracion' && data.attachmentsOmitted ? `
|
||||||
|
<p style="color:${C.textMuted};font-size:13px;margin-top:16px;">
|
||||||
|
Los documentos no se adjuntaron porque exceden el tamaño permitido por correo.
|
||||||
|
Puedes descargarlos desde el sistema.
|
||||||
|
</p>
|
||||||
|
` : ''}
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +95,19 @@ function declaracionBlock(d: NonNullable<DocumentoSubidoData['declaracion']>): s
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function evidenciaBlock(e: NonNullable<DocumentoSubidoData['evidencia']>): string {
|
||||||
|
return `
|
||||||
|
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Obligación</p>
|
||||||
|
<p style="margin:0 0 12px;color:${C.textPrimary};font-weight:600;">${escapeHtml(e.obligacionNombre)}</p>
|
||||||
|
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Periodo</p>
|
||||||
|
<p style="margin:0 0 12px;color:${C.textPrimary};">${escapeHtml(e.periodo)}</p>
|
||||||
|
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Tipo de documento</p>
|
||||||
|
<p style="margin:0 0 12px;color:${C.textPrimary};text-transform:capitalize;">${escapeHtml(e.tipoDocumento)}</p>
|
||||||
|
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Archivo</p>
|
||||||
|
<p style="margin:0 0 12px;color:${C.textPrimary};">${escapeHtml(e.filename)}</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function extraBlock(e: NonNullable<DocumentoSubidoData['extra']>): string {
|
function extraBlock(e: NonNullable<DocumentoSubidoData['extra']>): string {
|
||||||
return `
|
return `
|
||||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Documento</p>
|
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Documento</p>
|
||||||
|
|||||||
@@ -1,30 +1,49 @@
|
|||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tipos de correos informativos cuyo envío puede desactivarse por
|
* Tipos de correos informativos cuyo envío puede desactivarse por rol.
|
||||||
* contribuyente. NO incluye correos transaccionales críticos
|
* NO incluye correos transaccionales críticos (welcome, password-reset,
|
||||||
* (welcome, password-reset, payment-*) — esos siempre se envían.
|
* payment-*, invitaciones) — esos siempre se envían.
|
||||||
*
|
*
|
||||||
* Estado de implementación:
|
* Estado de implementación:
|
||||||
* - documento_subido: ✅ implementado (notify-upload.service.ts)
|
* - documento_subido: ✅ implementado (owner + supervisor del contribuyente)
|
||||||
* - weekly_update: ⏳ pendiente (job es tenant-wide hoy)
|
* - weekly_update: ✅ implementado (job tenant-wide, owners)
|
||||||
* - subscription_expiring: ⏳ pendiente (no es per-contribuyente hoy)
|
* - subscription_expiring: ✅ implementado (aviso a owner)
|
||||||
* - recordatorio_fiscal: ⏳ placeholder para futuras alertas
|
* - recordatorio_fiscal: ⏳ placeholder para futuras alertas
|
||||||
|
* - alertas_nuevas: ✅ implementado (supervisor + auxiliares + clientes)
|
||||||
|
* - recordatorio_proximo: ✅ implementado (auxiliar/supervisor/cliente/owner)
|
||||||
*/
|
*/
|
||||||
export const EMAIL_TYPES = [
|
export const EMAIL_TYPES = [
|
||||||
'documento_subido',
|
'documento_subido',
|
||||||
'weekly_update',
|
'weekly_update',
|
||||||
'subscription_expiring',
|
'subscription_expiring',
|
||||||
'recordatorio_fiscal',
|
'recordatorio_fiscal',
|
||||||
|
'alertas_nuevas',
|
||||||
|
'recordatorio_proximo',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type EmailType = (typeof EMAIL_TYPES)[number];
|
export type EmailType = (typeof EMAIL_TYPES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roles que pueden recibir notificaciones informativas. Se excluyen roles
|
||||||
|
* que hoy no son destinatarios de ninguna notificación (cfo, contador, visor).
|
||||||
|
*/
|
||||||
|
export const NOTIFICATION_ROLES = [
|
||||||
|
'owner',
|
||||||
|
'supervisor',
|
||||||
|
'auxiliar',
|
||||||
|
'cliente',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type NotificationRole = (typeof NOTIFICATION_ROLES)[number];
|
||||||
|
|
||||||
export type EmailPreferences = Record<EmailType, boolean>;
|
export type EmailPreferences = Record<EmailType, boolean>;
|
||||||
|
|
||||||
|
export type RoleEmailPreferences = Record<EmailType, Record<NotificationRole, boolean>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default: todo activado. Si el JSONB en BD viene vacío o falta una
|
* Default legacy (por contribuyente). Se mantiene por compatibilidad con la
|
||||||
* key, asumimos `true` para preservar el comportamiento previo.
|
* columna `contribuyentes.email_preferences`; la UI nueva ya no lo usa.
|
||||||
*/
|
*/
|
||||||
function applyDefaults(raw: Partial<Record<string, unknown>>): EmailPreferences {
|
function applyDefaults(raw: Partial<Record<string, unknown>>): EmailPreferences {
|
||||||
const out = {} as EmailPreferences;
|
const out = {} as EmailPreferences;
|
||||||
@@ -38,10 +57,10 @@ function sanitizeUuid(id: string): string {
|
|||||||
return id.replace(/[^a-f0-9-]/gi, '');
|
return id.replace(/[^a-f0-9-]/gi, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
* Lee las preferencias de un contribuyente. Devuelve defaults (todo
|
// Preferencias por contribuyente (legacy — conservado por compatibilidad)
|
||||||
* activado) si no hay fila o la columna está vacía.
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
*/
|
|
||||||
export async function getContribuyenteEmailPreferences(
|
export async function getContribuyenteEmailPreferences(
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
contribuyenteId: string,
|
contribuyenteId: string,
|
||||||
@@ -55,11 +74,6 @@ export async function getContribuyenteEmailPreferences(
|
|||||||
return applyDefaults(raw);
|
return applyDefaults(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Actualiza las preferencias de un contribuyente. Solo persiste las
|
|
||||||
* keys conocidas (filtra extras maliciosos). Merge sobre la columna
|
|
||||||
* existente (no sobreescribe keys no enviadas).
|
|
||||||
*/
|
|
||||||
export async function setContribuyenteEmailPreferences(
|
export async function setContribuyenteEmailPreferences(
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
contribuyenteId: string,
|
contribuyenteId: string,
|
||||||
@@ -81,10 +95,6 @@ export async function setContribuyenteEmailPreferences(
|
|||||||
return getContribuyenteEmailPreferences(pool, contribuyenteId);
|
return getContribuyenteEmailPreferences(pool, contribuyenteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Lee preferencias para múltiples contribuyentes en una sola query.
|
|
||||||
* Útil para la UI de `/configuracion/notificaciones` que lista todos.
|
|
||||||
*/
|
|
||||||
export async function getEmailPreferencesPorContribuyente(
|
export async function getEmailPreferencesPorContribuyente(
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
): Promise<Array<{ contribuyenteId: string; rfc: string; nombre: string; preferences: EmailPreferences }>> {
|
): Promise<Array<{ contribuyenteId: string; rfc: string; nombre: string; preferences: EmailPreferences }>> {
|
||||||
@@ -108,3 +118,89 @@ export async function getEmailPreferencesPorContribuyente(
|
|||||||
preferences: applyDefaults(r.email_preferences ?? {}),
|
preferences: applyDefaults(r.email_preferences ?? {}),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Preferencias por rol (nuevo modelo)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function applyRoleDefaults(raw: Array<{ email_type: string; role: string; enabled: boolean }>): RoleEmailPreferences {
|
||||||
|
const out = {} as RoleEmailPreferences;
|
||||||
|
for (const t of EMAIL_TYPES) {
|
||||||
|
out[t] = {} as Record<NotificationRole, boolean>;
|
||||||
|
for (const r of NOTIFICATION_ROLES) {
|
||||||
|
const row = raw.find(x => x.email_type === t && x.role === r);
|
||||||
|
out[t][r] = row ? row.enabled : true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lee las preferencias de notificación por rol. Si la tabla está vacía para
|
||||||
|
* un (type, role), asume `true` para no romper el comportamiento previo.
|
||||||
|
*/
|
||||||
|
export async function getRoleEmailPreferences(pool: Pool): Promise<RoleEmailPreferences> {
|
||||||
|
const { rows } = await pool.query<{ email_type: string; role: string; enabled: boolean }>(
|
||||||
|
`SELECT email_type, role, enabled FROM notification_role_preferences`
|
||||||
|
);
|
||||||
|
return applyRoleDefaults(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualiza una celda (emailType, role). Ignora valores desconocidos.
|
||||||
|
*/
|
||||||
|
export async function setRoleEmailPreference(
|
||||||
|
pool: Pool,
|
||||||
|
emailType: EmailType,
|
||||||
|
role: NotificationRole,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<RoleEmailPreferences> {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO notification_role_preferences (email_type, role, enabled)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (email_type, role) DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = NOW()`,
|
||||||
|
[emailType, role, enabled],
|
||||||
|
);
|
||||||
|
return getRoleEmailPreferences(pool);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devuelve true si el rol tiene habilitado el tipo de notificación.
|
||||||
|
* Fallback a true si no hay fila (comportamiento seguro).
|
||||||
|
*/
|
||||||
|
export async function isRoleEnabled(
|
||||||
|
pool: Pool,
|
||||||
|
emailType: EmailType,
|
||||||
|
role: NotificationRole,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { rows } = await pool.query<{ enabled: boolean }>(
|
||||||
|
`SELECT enabled FROM notification_role_preferences WHERE email_type = $1 AND role = $2`,
|
||||||
|
[emailType, role],
|
||||||
|
);
|
||||||
|
return rows[0]?.enabled ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecipientWithRole {
|
||||||
|
email: string;
|
||||||
|
role: NotificationRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtra una lista de destinatarios con rol según las preferencias guardadas.
|
||||||
|
* Si no hay preferencias para un (type, role), se conserva el destinatario.
|
||||||
|
*/
|
||||||
|
export async function filterRecipientsByRole(
|
||||||
|
pool: Pool,
|
||||||
|
emailType: EmailType,
|
||||||
|
recipients: RecipientWithRole[],
|
||||||
|
): Promise<string[]> {
|
||||||
|
const prefs = await getRoleEmailPreferences(pool);
|
||||||
|
const typePrefs = prefs[emailType];
|
||||||
|
const filtered = recipients.filter(r => {
|
||||||
|
if (!typePrefs) return true;
|
||||||
|
return typePrefs[r.role] !== false;
|
||||||
|
});
|
||||||
|
return [...new Set(filtered.map(r => r.email))];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { RecipientWithRole };
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ import { generarAlertasAutomaticas, type AlertaAuto } from './alertas-auto.servi
|
|||||||
import { emailService } from './email/email.service.js';
|
import { emailService } from './email/email.service.js';
|
||||||
import type { AlertaItem } from './email/templates/alertas-nuevas.js';
|
import type { AlertaItem } from './email/templates/alertas-nuevas.js';
|
||||||
import type { VentanaRecordatorio } from './email/templates/recordatorio-proximo.js';
|
import type { VentanaRecordatorio } from './email/templates/recordatorio-proximo.js';
|
||||||
|
import {
|
||||||
|
filterRecipientsByRole,
|
||||||
|
type RecipientWithRole,
|
||||||
|
type EmailType,
|
||||||
|
type NotificationRole,
|
||||||
|
} from './notification-preferences.service.js';
|
||||||
|
|
||||||
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
|
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
@@ -100,39 +106,60 @@ async function getUserContacts(userIds: string[]): Promise<UserContact[]> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Destinatarios de una alerta: supervisor + auxiliares + clientes del
|
* Destinatarios de una alerta: supervisor + auxiliares + clientes del
|
||||||
* contribuyente. Si el owner del tenant es supervisor, ya queda incluido
|
* contribuyente. Retorna emails con su rol para poder filtrar por
|
||||||
* (no se duplica).
|
* preferencias de notificación.
|
||||||
*/
|
*/
|
||||||
async function recipientsForAlerta(
|
async function recipientsForAlerta(
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
contribuyenteId: string,
|
contribuyenteId: string,
|
||||||
): Promise<string[]> {
|
): Promise<RecipientWithRole[]> {
|
||||||
const ids = await getUserIdsContribuyente(pool, contribuyenteId);
|
const ids = await getUserIdsContribuyente(pool, contribuyenteId);
|
||||||
const userIds = new Set<string>();
|
const byRole = new Map<string, NotificationRole>();
|
||||||
if (ids.supervisor) userIds.add(ids.supervisor);
|
if (ids.supervisor) byRole.set(ids.supervisor, 'supervisor');
|
||||||
ids.auxiliares.forEach(id => userIds.add(id));
|
ids.auxiliares.forEach(id => byRole.set(id, 'auxiliar'));
|
||||||
ids.clientes.forEach(id => userIds.add(id));
|
ids.clientes.forEach(id => byRole.set(id, 'cliente'));
|
||||||
const contacts = await getUserContacts([...userIds]);
|
|
||||||
return [...new Set(contacts.map(c => c.email))];
|
const contacts = await getUserContacts([...byRole.keys()]);
|
||||||
|
return contacts
|
||||||
|
.filter(c => byRole.has(c.userId))
|
||||||
|
.map(c => ({ email: c.email, role: byRole.get(c.userId)! }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserRole(
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<NotificationRole | null> {
|
||||||
|
const m = await prisma.tenantMembership.findFirst({
|
||||||
|
where: { userId, tenantId, active: true },
|
||||||
|
include: { rol: { select: { nombre: true } } },
|
||||||
|
});
|
||||||
|
if (!m) return null;
|
||||||
|
const role = m.rol.nombre;
|
||||||
|
if (role === 'owner' || role === 'supervisor' || role === 'auxiliar' || role === 'cliente') {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Destinatarios de un recordatorio. Los recordatorios del despacho son
|
* Destinatarios de un recordatorio. Los recordatorios del despacho son
|
||||||
* tenant-level (no atados a contribuyente). Para públicos: clientes con
|
* tenant-level (no atados a contribuyente). Retorna emails con rol para
|
||||||
* algún acceso + auxiliares de cualquier cartera; si no hay auxiliares,
|
* filtrado por preferencias.
|
||||||
* supervisores; si owner aparece como supervisor, también recibe.
|
|
||||||
*
|
*
|
||||||
|
* Públicos: clientes + auxiliares + supervisores + owners.
|
||||||
* Privados: solo el creador.
|
* Privados: solo el creador.
|
||||||
*/
|
*/
|
||||||
async function recipientsForRecordatorio(
|
async function recipientsForRecordatorio(
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
recordatorio: { creadoPor: string; privado: boolean },
|
recordatorio: { creadoPor: string; privado: boolean },
|
||||||
): Promise<string[]> {
|
): Promise<RecipientWithRole[]> {
|
||||||
if (recordatorio.privado) {
|
if (recordatorio.privado) {
|
||||||
|
const role = await getUserRole(tenantId, recordatorio.creadoPor);
|
||||||
|
if (!role) return [];
|
||||||
const contacts = await getUserContacts([recordatorio.creadoPor]);
|
const contacts = await getUserContacts([recordatorio.creadoPor]);
|
||||||
return [...new Set(contacts.map(c => c.email))];
|
return contacts.map(c => ({ email: c.email, role }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recordatorio público: lee universos relevantes del tenant.
|
// Recordatorio público: lee universos relevantes del tenant.
|
||||||
@@ -158,27 +185,19 @@ async function recipientsForRecordatorio(
|
|||||||
), ARRAY[]::uuid[]) AS cliente_user_ids
|
), ARRAY[]::uuid[]) AS cliente_user_ids
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const auxiliares = r?.auxiliar_user_ids ?? [];
|
const byRole = new Map<string, NotificationRole>();
|
||||||
const supervisores = r?.supervisor_user_ids ?? [];
|
(r?.auxiliar_user_ids ?? []).forEach(id => byRole.set(id, 'auxiliar'));
|
||||||
const clientes = r?.cliente_user_ids ?? [];
|
(r?.supervisor_user_ids ?? []).forEach(id => byRole.set(id, 'supervisor'));
|
||||||
|
(r?.cliente_user_ids ?? []).forEach(id => byRole.set(id, 'cliente'));
|
||||||
|
|
||||||
|
// Owners siempre se consideran owner aunque también aparezcan como supervisor.
|
||||||
const owners = await getOwnerUserIds(tenantId);
|
const owners = await getOwnerUserIds(tenantId);
|
||||||
|
owners.forEach(id => byRole.set(id, 'owner'));
|
||||||
|
|
||||||
// Regla del owner: clientes y auxiliares siempre. Si no hay auxiliares,
|
const contacts = await getUserContacts([...byRole.keys()]);
|
||||||
// agregar supervisores. Si owner es supervisor y no hay auxiliares,
|
return contacts
|
||||||
// owner queda incluido vía la lista de supervisores.
|
.filter(c => byRole.has(c.userId))
|
||||||
const userIds = new Set<string>();
|
.map(c => ({ email: c.email, role: byRole.get(c.userId)! }));
|
||||||
clientes.forEach(id => userIds.add(id));
|
|
||||||
auxiliares.forEach(id => userIds.add(id));
|
|
||||||
if (auxiliares.length === 0) {
|
|
||||||
supervisores.forEach(id => userIds.add(id));
|
|
||||||
// Solo si owner aparece como supervisor (intersección):
|
|
||||||
for (const ownerId of owners) {
|
|
||||||
if (supervisores.includes(ownerId)) userIds.add(ownerId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const contacts = await getUserContacts([...userIds]);
|
|
||||||
return [...new Set(contacts.map(c => c.email))];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
@@ -276,8 +295,10 @@ async function processAlertasContribuyente(
|
|||||||
return { nuevas: 0, resueltas };
|
return { nuevas: 0, resueltas };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Envía email batched a los responsables del contribuyente.
|
// Envía email batched a los responsables del contribuyente, filtrando por
|
||||||
const recipients = await recipientsForAlerta(pool, tenantId, contribuyente.entidadId);
|
// preferencias de rol para alertas_nuevas.
|
||||||
|
const recipientsWithRole = await recipientsForAlerta(pool, tenantId, contribuyente.entidadId);
|
||||||
|
const recipients = await filterRecipientsByRole(pool, 'alertas_nuevas', recipientsWithRole);
|
||||||
if (recipients.length === 0) {
|
if (recipients.length === 0) {
|
||||||
console.warn(`[Notifications] Sin destinatarios para alertas de ${contribuyente.rfc} (tenant ${tenant.rfc})`);
|
console.warn(`[Notifications] Sin destinatarios para alertas de ${contribuyente.rfc} (tenant ${tenant.rfc})`);
|
||||||
return { nuevas: nuevas.length, resueltas };
|
return { nuevas: nuevas.length, resueltas };
|
||||||
@@ -361,10 +382,11 @@ export async function processProximosRecordatorios(
|
|||||||
|
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
try {
|
try {
|
||||||
const recipients = await recipientsForRecordatorio(pool, tenantId, {
|
const recipientsWithRole = await recipientsForRecordatorio(pool, tenantId, {
|
||||||
creadoPor: r.creado_por,
|
creadoPor: r.creado_por,
|
||||||
privado: r.privado,
|
privado: r.privado,
|
||||||
});
|
});
|
||||||
|
const recipients = await filterRecipientsByRole(pool, 'recordatorio_proximo', recipientsWithRole);
|
||||||
if (recipients.length === 0) {
|
if (recipients.length === 0) {
|
||||||
console.warn(`[Notifications] Recordatorio ${r.id} (${tenant.rfc}) sin destinatarios — skip ${ventana}`);
|
console.warn(`[Notifications] Recordatorio ${r.id} (${tenant.rfc}) sin destinatarios — skip ${ventana}`);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ import { prisma } from '../config/database.js';
|
|||||||
import { emailService } from './email/email.service.js';
|
import { emailService } from './email/email.service.js';
|
||||||
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
|
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
import { getContribuyenteEmailPreferences } from './notification-preferences.service.js';
|
import { filterRecipientsByRole, type RecipientWithRole } from './notification-preferences.service.js';
|
||||||
import type { DocumentoSubidoData } from './email/templates/documento-subido.js';
|
import type { DocumentoSubidoData } from './email/templates/documento-subido.js';
|
||||||
|
import type { EmailAttachment } from '@horux/core';
|
||||||
|
|
||||||
|
/** Límite total de adjuntos para evitar rechazos por SMTP (20 MB). */
|
||||||
|
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notifica a los destinatarios relevantes cuando se sube una declaración
|
* Notifica a los destinatarios relevantes cuando se sube una declaración
|
||||||
@@ -26,7 +30,11 @@ export async function notifyDocumentoSubido(params: {
|
|||||||
subidoPor: string;
|
subidoPor: string;
|
||||||
kind: DocumentoSubidoData['kind'];
|
kind: DocumentoSubidoData['kind'];
|
||||||
declaracion?: DocumentoSubidoData['declaracion'];
|
declaracion?: DocumentoSubidoData['declaracion'];
|
||||||
|
declaracionId?: number;
|
||||||
extra?: DocumentoSubidoData['extra'];
|
extra?: DocumentoSubidoData['extra'];
|
||||||
|
evidencia?: DocumentoSubidoData['evidencia'];
|
||||||
|
/** PDF en base64 para adjuntar en notificaciones de evidencia de obligación. */
|
||||||
|
pdfBase64?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { pool, tenantId, contribuyenteId, subidoPor } = params;
|
const { pool, tenantId, contribuyenteId, subidoPor } = params;
|
||||||
|
|
||||||
@@ -34,10 +42,7 @@ export async function notifyDocumentoSubido(params: {
|
|||||||
// subject informativo ni supervisor — skip.
|
// subject informativo ni supervisor — skip.
|
||||||
if (!contribuyenteId) return;
|
if (!contribuyenteId) return;
|
||||||
|
|
||||||
// Respeta preferencias de notificación del contribuyente. Si el user
|
|
||||||
// desactivó `documento_subido` para este contribuyente, no enviar.
|
|
||||||
const prefs = await getContribuyenteEmailPreferences(pool, contribuyenteId);
|
|
||||||
if (!prefs.documento_subido) return;
|
|
||||||
|
|
||||||
const { rows } = await pool.query<{
|
const { rows } = await pool.query<{
|
||||||
rfc: string;
|
rfc: string;
|
||||||
@@ -54,14 +59,17 @@ export async function notifyDocumentoSubido(params: {
|
|||||||
const contrib = rows[0];
|
const contrib = rows[0];
|
||||||
|
|
||||||
// 2. Recipients. Owners primero; luego supervisor si aplica.
|
// 2. Recipients. Owners primero; luego supervisor si aplica.
|
||||||
const owners = await getTenantOwnerEmails(tenantId);
|
const ownerEmails = await getTenantOwnerEmails(tenantId);
|
||||||
const recipients = new Set<string>(owners);
|
const recipientsWithRole: RecipientWithRole[] = ownerEmails.map(email => ({ email, role: 'owner' }));
|
||||||
|
|
||||||
if (contrib.supervisor_user_id) {
|
if (contrib.supervisor_user_id) {
|
||||||
const supervisorEmail = await getUserEmailById(contrib.supervisor_user_id);
|
const supervisorEmail = await getUserEmailById(contrib.supervisor_user_id);
|
||||||
if (supervisorEmail) recipients.add(supervisorEmail);
|
if (supervisorEmail) recipientsWithRole.push({ email: supervisorEmail, role: 'supervisor' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filtra por preferencias de rol para documento_subido.
|
||||||
|
const recipients = new Set(await filterRecipientsByRole(pool, 'documento_subido', recipientsWithRole));
|
||||||
|
|
||||||
// Excluir al uploader: no notificarle su propia acción.
|
// Excluir al uploader: no notificarle su propia acción.
|
||||||
recipients.delete(subidoPor.toLowerCase());
|
recipients.delete(subidoPor.toLowerCase());
|
||||||
recipients.delete(subidoPor);
|
recipients.delete(subidoPor);
|
||||||
@@ -77,6 +85,23 @@ export async function notifyDocumentoSubido(params: {
|
|||||||
// 4. Link al sistema. Usa FRONTEND_URL del env.
|
// 4. Link al sistema. Usa FRONTEND_URL del env.
|
||||||
const link = `${env.FRONTEND_URL}/documentos`;
|
const link = `${env.FRONTEND_URL}/documentos`;
|
||||||
|
|
||||||
|
// Adjuntar los PDFs cuando se trata de una declaración recién creada o de una evidencia de obligación.
|
||||||
|
let attachments: EmailAttachment[] | undefined;
|
||||||
|
let attachmentsOmitted = false;
|
||||||
|
if (params.kind === 'declaracion' && params.declaracionId) {
|
||||||
|
const built = await buildDeclaracionAttachments(pool, params.declaracionId);
|
||||||
|
attachments = built.attachments;
|
||||||
|
attachmentsOmitted = built.omitted;
|
||||||
|
} else if (params.kind === 'obligacion_evidencia' && params.pdfBase64 && params.evidencia) {
|
||||||
|
const content = Buffer.from(params.pdfBase64, 'base64');
|
||||||
|
if (content.length > MAX_ATTACHMENT_BYTES) {
|
||||||
|
attachmentsOmitted = true;
|
||||||
|
console.warn(`[notifyDocumentoSubido] Evidencia de obligación excede ${MAX_ATTACHMENT_BYTES} bytes (${content.length}). Se envía sin adjunto.`);
|
||||||
|
} else {
|
||||||
|
attachments = [{ filename: params.evidencia.filename, content }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await emailService.sendDocumentoSubido(Array.from(recipients), {
|
await emailService.sendDocumentoSubido(Array.from(recipients), {
|
||||||
kind: params.kind,
|
kind: params.kind,
|
||||||
subidoPor,
|
subidoPor,
|
||||||
@@ -85,6 +110,46 @@ export async function notifyDocumentoSubido(params: {
|
|||||||
despachoNombre: tenant?.nombre,
|
despachoNombre: tenant?.nombre,
|
||||||
declaracion: params.declaracion,
|
declaracion: params.declaracion,
|
||||||
extra: params.extra,
|
extra: params.extra,
|
||||||
|
evidencia: params.evidencia,
|
||||||
link,
|
link,
|
||||||
});
|
attachmentsOmitted,
|
||||||
|
}, attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildDeclaracionAttachments(
|
||||||
|
pool: Pool,
|
||||||
|
declaracionId: number,
|
||||||
|
): Promise<{ attachments?: EmailAttachment[]; omitted: boolean }> {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT pdf_declaracion, pdf_filename,
|
||||||
|
pdf_liga_pago, pdf_liga_pago_filename
|
||||||
|
FROM declaraciones_provisionales
|
||||||
|
WHERE id = $1`,
|
||||||
|
[declaracionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) return { omitted: false };
|
||||||
|
|
||||||
|
let totalSize = 0;
|
||||||
|
const attachments: EmailAttachment[] = [];
|
||||||
|
|
||||||
|
if (row.pdf_declaracion && row.pdf_filename) {
|
||||||
|
const content = Buffer.from(row.pdf_declaracion);
|
||||||
|
totalSize += content.length;
|
||||||
|
attachments.push({ filename: row.pdf_filename, content });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.pdf_liga_pago && row.pdf_liga_pago_filename) {
|
||||||
|
const content = Buffer.from(row.pdf_liga_pago);
|
||||||
|
totalSize += content.length;
|
||||||
|
attachments.push({ filename: row.pdf_liga_pago_filename, content });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalSize > MAX_ATTACHMENT_BYTES) {
|
||||||
|
console.warn(`[notifyDocumentoSubido] Adjuntos de declaración ${declaracionId} exceden ${MAX_ATTACHMENT_BYTES} bytes (${totalSize}). Se envía sin adjuntos.`);
|
||||||
|
return { omitted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { attachments, omitted: false };
|
||||||
}
|
}
|
||||||
|
|||||||
272
apps/api/src/services/obligacion-evidencias.service.ts
Normal file
272
apps/api/src/services/obligacion-evidencias.service.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import type { Pool } from 'pg';
|
||||||
|
import { OBLIGACIONES_CATALOGO } from '../constants/obligaciones-fiscales.js';
|
||||||
|
|
||||||
|
export interface EvidenciaRow {
|
||||||
|
id: number;
|
||||||
|
obligacionId: string;
|
||||||
|
periodo: string;
|
||||||
|
contribuyenteId: string;
|
||||||
|
tipoDocumento: 'declaracion' | 'pago' | 'acuse' | 'complemento';
|
||||||
|
archivo: Buffer;
|
||||||
|
archivoFilename: string;
|
||||||
|
archivoMime: string;
|
||||||
|
notas: string | null;
|
||||||
|
subidoPor: string | null;
|
||||||
|
subidoPorEmail: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateEvidenciaInput {
|
||||||
|
obligacionId: string;
|
||||||
|
periodo: string;
|
||||||
|
contribuyenteId: string;
|
||||||
|
tipoDocumento: 'declaracion' | 'pago' | 'acuse' | 'complemento';
|
||||||
|
pdfBase64: string;
|
||||||
|
pdfFilename: string;
|
||||||
|
notas?: string;
|
||||||
|
subidoPor: string; // userId UUID
|
||||||
|
subidoPorEmail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToEvidencia(r: any): EvidenciaRow {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
obligacionId: r.obligacion_id,
|
||||||
|
periodo: r.periodo,
|
||||||
|
contribuyenteId: r.contribuyente_id,
|
||||||
|
tipoDocumento: r.tipo_documento,
|
||||||
|
archivo: Buffer.from(r.archivo),
|
||||||
|
archivoFilename: r.archivo_filename,
|
||||||
|
archivoMime: r.archivo_mime,
|
||||||
|
notas: r.notas,
|
||||||
|
subidoPor: r.subido_por,
|
||||||
|
subidoPorEmail: r.subido_por_email,
|
||||||
|
createdAt: r.created_at.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getObligacionContribuyente(pool: Pool, obligacionId: string): Promise<{ contribuyenteId: string; catalogoId: string | null } | null> {
|
||||||
|
const { rows } = await pool.query<{ contribuyente_id: string; catalogo_id: string | null }>(
|
||||||
|
`SELECT contribuyente_id, catalogo_id FROM obligaciones_contribuyente WHERE id = $1`,
|
||||||
|
[obligacionId],
|
||||||
|
);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
return { contribuyenteId: row.contribuyente_id, catalogoId: row.catalogo_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
function requierePago(obligacion: { catalogoId: string | null }): boolean {
|
||||||
|
if (!obligacion.catalogoId) return true; // conservador: sin catálogo, requiere pago
|
||||||
|
const catalogo = OBLIGACIONES_CATALOGO.find((o) => o.id === obligacion.catalogoId);
|
||||||
|
return catalogo?.requierePago ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esDocumentoDeclaracion(tipo: string): boolean {
|
||||||
|
return tipo === 'declaracion' || tipo === 'acuse' || tipo === 'complemento';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePeriodoStatus(
|
||||||
|
pool: Pool,
|
||||||
|
obligacionId: string,
|
||||||
|
periodo: string,
|
||||||
|
tipoDocumento: string,
|
||||||
|
reqPago: boolean,
|
||||||
|
completadaPor: string,
|
||||||
|
notas?: string,
|
||||||
|
): Promise<{ completada: boolean; declaracionPresentada: boolean; pagoPresentado: boolean }> {
|
||||||
|
const { rows } = await pool.query<{
|
||||||
|
declaracion_presentada: boolean;
|
||||||
|
pago_presentado: boolean;
|
||||||
|
completada: boolean;
|
||||||
|
}>(
|
||||||
|
`SELECT declaracion_presentada, pago_presentado, completada
|
||||||
|
FROM obligacion_periodos
|
||||||
|
WHERE obligacion_id = $1 AND periodo = $2`,
|
||||||
|
[obligacionId, periodo],
|
||||||
|
);
|
||||||
|
|
||||||
|
const existing = rows[0];
|
||||||
|
let declaracionPresentada = existing?.declaracion_presentada ?? false;
|
||||||
|
let pagoPresentado = existing?.pago_presentado ?? false;
|
||||||
|
|
||||||
|
if (esDocumentoDeclaracion(tipoDocumento)) declaracionPresentada = true;
|
||||||
|
if (tipoDocumento === 'pago') pagoPresentado = true;
|
||||||
|
|
||||||
|
const completada = !reqPago || pagoPresentado;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE obligacion_periodos
|
||||||
|
SET declaracion_presentada = $3,
|
||||||
|
pago_presentado = $4,
|
||||||
|
completada = $5,
|
||||||
|
completada_at = CASE WHEN $5 THEN COALESCE(completada_at, $6) ELSE completada_at END,
|
||||||
|
completada_por = CASE WHEN $5 THEN COALESCE(completada_por, $7) ELSE completada_por END,
|
||||||
|
notas = COALESCE($8, notas)
|
||||||
|
WHERE obligacion_id = $1 AND periodo = $2`,
|
||||||
|
[obligacionId, periodo, declaracionPresentada, pagoPresentado, completada, now, completadaPor, notas ?? null],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO obligacion_periodos
|
||||||
|
(obligacion_id, periodo, declaracion_presentada, pago_presentado, completada, completada_at, completada_por, notas)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[obligacionId, periodo, declaracionPresentada, pagoPresentado, completada, completada ? now : null, completada ? completadaPor : null, notas ?? null],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completada) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE alertas SET resuelta = true WHERE tipo = $1 AND resuelta = false`,
|
||||||
|
[`ob-${obligacionId}-${periodo}`],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { completada, declaracionPresentada, pagoPresentado };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recalcPeriodoStatus(
|
||||||
|
pool: Pool,
|
||||||
|
obligacionId: string,
|
||||||
|
periodo: string,
|
||||||
|
reqPago: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
const { rows } = await pool.query<{ tipo_documento: string }>(
|
||||||
|
`SELECT tipo_documento FROM obligacion_evidencias WHERE obligacion_id = $1 AND periodo = $2`,
|
||||||
|
[obligacionId, periodo],
|
||||||
|
);
|
||||||
|
|
||||||
|
const declaracionPresentada = rows.some((r) => esDocumentoDeclaracion(r.tipo_documento));
|
||||||
|
const pagoPresentado = rows.some((r) => r.tipo_documento === 'pago');
|
||||||
|
const completada = !reqPago || pagoPresentado;
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE obligacion_periodos
|
||||||
|
SET declaracion_presentada = $3,
|
||||||
|
pago_presentado = $4,
|
||||||
|
completada = $5,
|
||||||
|
completada_at = CASE WHEN $5 THEN COALESCE(completada_at, NOW()) ELSE completada_at END
|
||||||
|
WHERE obligacion_id = $1 AND periodo = $2`,
|
||||||
|
[obligacionId, periodo, declaracionPresentada, pagoPresentado, completada],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEvidencia(
|
||||||
|
pool: Pool,
|
||||||
|
data: CreateEvidenciaInput,
|
||||||
|
): Promise<{ evidencia: EvidenciaRow; completada: boolean; declaracionPresentada: boolean; pagoPresentado: boolean }> {
|
||||||
|
const obligacion = await getObligacionContribuyente(pool, data.obligacionId);
|
||||||
|
if (!obligacion) throw new Error('Obligación no encontrada');
|
||||||
|
if (obligacion.contribuyenteId !== data.contribuyenteId) throw new Error('La obligación no pertenece al contribuyente');
|
||||||
|
|
||||||
|
const reqPago = requierePago(obligacion);
|
||||||
|
const archivo = Buffer.from(data.pdfBase64, 'base64');
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO obligacion_evidencias
|
||||||
|
(obligacion_id, periodo, contribuyente_id, tipo_documento, archivo, archivo_filename, archivo_mime, notas, subido_por, subido_por_email)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING id, obligacion_id, periodo, contribuyente_id, tipo_documento, archivo, archivo_filename, archivo_mime,
|
||||||
|
notas, subido_por, subido_por_email, created_at`,
|
||||||
|
[data.obligacionId, data.periodo, data.contribuyenteId, data.tipoDocumento, archivo, data.pdfFilename, 'application/pdf', data.notas ?? null, data.subidoPor, data.subidoPorEmail],
|
||||||
|
);
|
||||||
|
|
||||||
|
const status = await updatePeriodoStatus(
|
||||||
|
pool,
|
||||||
|
data.obligacionId,
|
||||||
|
data.periodo,
|
||||||
|
data.tipoDocumento,
|
||||||
|
reqPago,
|
||||||
|
data.subidoPor,
|
||||||
|
data.notas,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { evidencia: rowToEvidencia(rows[0]), ...status };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEvidencias(
|
||||||
|
pool: Pool,
|
||||||
|
contribuyenteId: string,
|
||||||
|
filters?: { periodo?: string; obligacionId?: string },
|
||||||
|
): Promise<EvidenciaRow[]> {
|
||||||
|
const conditions: string[] = ['contribuyente_id = $1'];
|
||||||
|
const params: unknown[] = [contribuyenteId];
|
||||||
|
|
||||||
|
if (filters?.periodo) {
|
||||||
|
params.push(filters.periodo);
|
||||||
|
conditions.push(`periodo = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (filters?.obligacionId) {
|
||||||
|
params.push(filters.obligacionId);
|
||||||
|
conditions.push(`obligacion_id = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, obligacion_id, periodo, contribuyente_id, tipo_documento, archivo, archivo_filename, archivo_mime,
|
||||||
|
notas, subido_por, subido_por_email, created_at
|
||||||
|
FROM obligacion_evidencias
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return rows.map(rowToEvidencia);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEvidenciaPdf(
|
||||||
|
pool: Pool,
|
||||||
|
id: number,
|
||||||
|
): Promise<{ buffer: Buffer; filename: string; mime: string } | null> {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT archivo, archivo_filename, archivo_mime FROM obligacion_evidencias WHERE id = $1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (rows.length === 0 || !rows[0].archivo) return null;
|
||||||
|
return {
|
||||||
|
buffer: Buffer.from(rows[0].archivo),
|
||||||
|
filename: rows[0].archivo_filename || `evidencia-${id}.pdf`,
|
||||||
|
mime: rows[0].archivo_mime || 'application/pdf',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEvidencia(
|
||||||
|
pool: Pool,
|
||||||
|
id: number,
|
||||||
|
): Promise<{ obligacionId: string; periodo: string } | null> {
|
||||||
|
const { rows } = await pool.query<{ obligacion_id: string; periodo: string }>(
|
||||||
|
`DELETE FROM obligacion_evidencias WHERE id = $1 RETURNING obligacion_id, periodo`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const { obligacion_id: obligacionId, periodo } = rows[0];
|
||||||
|
const obligacion = await getObligacionContribuyente(pool, obligacionId);
|
||||||
|
if (obligacion) {
|
||||||
|
const reqPago = requierePago(obligacion);
|
||||||
|
await recalcPeriodoStatus(pool, obligacionId, periodo, reqPago);
|
||||||
|
}
|
||||||
|
return { obligacionId, periodo };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPeriodoStatus(
|
||||||
|
pool: Pool,
|
||||||
|
obligacionId: string,
|
||||||
|
periodo: string,
|
||||||
|
): Promise<{ completada: boolean; declaracionPresentada: boolean; pagoPresentado: boolean } | null> {
|
||||||
|
const { rows } = await pool.query<{
|
||||||
|
completada: boolean;
|
||||||
|
declaracion_presentada: boolean;
|
||||||
|
pago_presentado: boolean;
|
||||||
|
}>(
|
||||||
|
`SELECT completada, declaracion_presentada, pago_presentado
|
||||||
|
FROM obligacion_periodos
|
||||||
|
WHERE obligacion_id = $1 AND periodo = $2`,
|
||||||
|
[obligacionId, periodo],
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
return {
|
||||||
|
completada: rows[0].completada,
|
||||||
|
declaracionPresentada: rows[0].declaracion_presentada,
|
||||||
|
pagoPresentado: rows[0].pago_presentado,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
import { OBLIGACIONES_CATALOGO, getRecomendaciones, type ObligacionFiscal } from '../constants/obligaciones-fiscales.js';
|
import { OBLIGACIONES_CATALOGO, getRecomendaciones, type ObligacionFiscal } from '../constants/obligaciones-fiscales.js';
|
||||||
|
|
||||||
|
function requierePagoPorCatalogo(catalogoId: string | null): boolean {
|
||||||
|
if (!catalogoId) return true;
|
||||||
|
return OBLIGACIONES_CATALOGO.find((o) => o.id === catalogoId)?.requierePago ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keyword-based matching: each catalog entry has discriminant keywords
|
* Keyword-based matching: each catalog entry has discriminant keywords
|
||||||
* that must ALL appear in the SAT description (normalized, lowercase, no accents).
|
* that must ALL appear in the SAT description (normalized, lowercase, no accents).
|
||||||
@@ -255,6 +260,7 @@ export async function initRecomendaciones(
|
|||||||
function inferirFrecuencia(vencimiento: string): string {
|
function inferirFrecuencia(vencimiento: string): string {
|
||||||
const lower = vencimiento.toLowerCase();
|
const lower = vencimiento.toLowerCase();
|
||||||
if (lower.includes('mensual') || lower.includes('mes')) return 'mensual';
|
if (lower.includes('mensual') || lower.includes('mes')) return 'mensual';
|
||||||
|
if (lower.includes('cuatrimest')) return 'cuatrimestral';
|
||||||
if (lower.includes('bimest')) return 'bimestral';
|
if (lower.includes('bimest')) return 'bimestral';
|
||||||
if (lower.includes('trimest')) return 'trimestral';
|
if (lower.includes('trimest')) return 'trimestral';
|
||||||
if (lower.includes('anual') || lower.includes('ejercicio') || lower.includes('tres meses siguientes')) return 'anual';
|
if (lower.includes('anual') || lower.includes('ejercicio') || lower.includes('tres meses siguientes')) return 'anual';
|
||||||
@@ -351,13 +357,22 @@ export async function getObligacionesPorPeriodo(
|
|||||||
|
|
||||||
const [year, month] = periodo.split('-').map(Number);
|
const [year, month] = periodo.split('-').map(Number);
|
||||||
const currentPeriodo = new Date().toISOString().substring(0, 7);
|
const currentPeriodo = new Date().toISOString().substring(0, 7);
|
||||||
const results: Array<ObligacionContribuyente & { periodStatus: string; periodoAplica: string; declaracion: DeclaracionLink | null }> = [];
|
const results: Array<ObligacionContribuyente & {
|
||||||
|
periodStatus: string;
|
||||||
|
periodoAplica: string;
|
||||||
|
declaracion: DeclaracionLink | null;
|
||||||
|
declaracionPresentada: boolean;
|
||||||
|
pagoPresentado: boolean;
|
||||||
|
requierePago: boolean;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
// Get all completion records + associated declaration info for this contribuyente
|
// Get all completion records + associated declaration info for this contribuyente
|
||||||
const { rows: completions } = await pool.query<{
|
const { rows: completions } = await pool.query<{
|
||||||
obligacion_id: string;
|
obligacion_id: string;
|
||||||
periodo: string;
|
periodo: string;
|
||||||
completada: boolean;
|
completada: boolean;
|
||||||
|
declaracion_presentada: boolean;
|
||||||
|
pago_presentado: boolean;
|
||||||
declaracion_id: number | null;
|
declaracion_id: number | null;
|
||||||
decl_año: number | null;
|
decl_año: number | null;
|
||||||
decl_mes: number | null;
|
decl_mes: number | null;
|
||||||
@@ -365,6 +380,7 @@ export async function getObligacionesPorPeriodo(
|
|||||||
decl_pdf_filename: string | null;
|
decl_pdf_filename: string | null;
|
||||||
}>(`
|
}>(`
|
||||||
SELECT op.obligacion_id, op.periodo, op.completada,
|
SELECT op.obligacion_id, op.periodo, op.completada,
|
||||||
|
op.declaracion_presentada, op.pago_presentado,
|
||||||
op.declaracion_id,
|
op.declaracion_id,
|
||||||
dp.año AS decl_año,
|
dp.año AS decl_año,
|
||||||
dp.mes AS decl_mes,
|
dp.mes AS decl_mes,
|
||||||
@@ -377,10 +393,14 @@ export async function getObligacionesPorPeriodo(
|
|||||||
`, [contribuyenteId]);
|
`, [contribuyenteId]);
|
||||||
|
|
||||||
const completionMap = new Map<string, boolean>();
|
const completionMap = new Map<string, boolean>();
|
||||||
|
const declaracionPresentadaMap = new Map<string, boolean>();
|
||||||
|
const pagoPresentadoMap = new Map<string, boolean>();
|
||||||
const declaracionMap = new Map<string, DeclaracionLink | null>();
|
const declaracionMap = new Map<string, DeclaracionLink | null>();
|
||||||
for (const c of completions) {
|
for (const c of completions) {
|
||||||
const key = `${c.obligacion_id}:${c.periodo}`;
|
const key = `${c.obligacion_id}:${c.periodo}`;
|
||||||
completionMap.set(key, c.completada);
|
completionMap.set(key, c.completada);
|
||||||
|
declaracionPresentadaMap.set(key, c.declaracion_presentada);
|
||||||
|
pagoPresentadoMap.set(key, c.pago_presentado);
|
||||||
if (c.declaracion_id && c.decl_año != null && c.decl_mes != null && c.decl_tipo) {
|
if (c.declaracion_id && c.decl_año != null && c.decl_mes != null && c.decl_tipo) {
|
||||||
declaracionMap.set(key, {
|
declaracionMap.set(key, {
|
||||||
id: c.declaracion_id,
|
id: c.declaracion_id,
|
||||||
@@ -407,6 +427,9 @@ export async function getObligacionesPorPeriodo(
|
|||||||
periodStatus: isCompleted ? 'completada' : 'pendiente',
|
periodStatus: isCompleted ? 'completada' : 'pendiente',
|
||||||
periodoAplica: periodo,
|
periodoAplica: periodo,
|
||||||
declaracion: declaracionMap.get(key) ?? null,
|
declaracion: declaracionMap.get(key) ?? null,
|
||||||
|
declaracionPresentada: declaracionPresentadaMap.get(key) === true,
|
||||||
|
pagoPresentado: pagoPresentadoMap.get(key) === true,
|
||||||
|
requierePago: requierePagoPorCatalogo(ob.catalogoId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,6 +457,9 @@ export async function getObligacionesPorPeriodo(
|
|||||||
periodStatus: 'atrasada',
|
periodStatus: 'atrasada',
|
||||||
periodoAplica: pastPeriodo,
|
periodoAplica: pastPeriodo,
|
||||||
declaracion: null,
|
declaracion: null,
|
||||||
|
declaracionPresentada: declaracionPresentadaMap.get(pastKey) === true,
|
||||||
|
pagoPresentado: pagoPresentadoMap.get(pastKey) === true,
|
||||||
|
requierePago: requierePagoPorCatalogo(ob.catalogoId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -448,7 +474,14 @@ export async function getObligacionesPorPeriodo(
|
|||||||
return a.nombre.localeCompare(b.nombre);
|
return a.nombre.localeCompare(b.nombre);
|
||||||
});
|
});
|
||||||
|
|
||||||
return results as Array<ObligacionContribuyente & { periodStatus: 'pendiente' | 'completada' | 'atrasada'; periodoAplica: string; declaracion: DeclaracionLink | null }>;
|
return results as Array<ObligacionContribuyente & {
|
||||||
|
periodStatus: 'pendiente' | 'completada' | 'atrasada';
|
||||||
|
periodoAplica: string;
|
||||||
|
declaracion: DeclaracionLink | null;
|
||||||
|
declaracionPresentada: boolean;
|
||||||
|
pagoPresentado: boolean;
|
||||||
|
requierePago: boolean;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function appliesTo(frecuencia: string | null, periodo: string): boolean {
|
function appliesTo(frecuencia: string | null, periodo: string): boolean {
|
||||||
@@ -457,6 +490,7 @@ function appliesTo(frecuencia: string | null, periodo: string): boolean {
|
|||||||
case 'mensual': return true;
|
case 'mensual': return true;
|
||||||
case 'bimestral': return month % 2 === 1; // Jan, Mar, May...
|
case 'bimestral': return month % 2 === 1; // Jan, Mar, May...
|
||||||
case 'trimestral': return [1, 4, 7, 10].includes(month);
|
case 'trimestral': return [1, 4, 7, 10].includes(month);
|
||||||
|
case 'cuatrimestral': return [1, 5, 9].includes(month);
|
||||||
case 'anual': return month === 3 || month === 4; // March (PM) or April (PF) — show in both
|
case 'anual': return month === 3 || month === 4; // March (PM) or April (PF) — show in both
|
||||||
case 'eventual': return false; // Don't auto-show
|
case 'eventual': return false; // Don't auto-show
|
||||||
default: return true;
|
default: return true;
|
||||||
|
|||||||
@@ -348,6 +348,17 @@ export async function emitInvoiceIfApplicable(paymentId: string): Promise<void>
|
|||||||
data: { facturapiInvoiceId: invoice.id },
|
data: { facturapiInvoiceId: invoice.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Enviar factura por email al cliente cuando se factura con datos reales
|
||||||
|
// (no público en general). Fail-soft: si el envío falla, no bloquea.
|
||||||
|
if (customer?.email) {
|
||||||
|
try {
|
||||||
|
await facturapiService.sendInvoiceByEmail(emitter.id, invoice.id, customer.email);
|
||||||
|
console.log(`[Invoicing] Factura ${invoice.id} enviada a ${customer.email}`);
|
||||||
|
} catch (emailErr: any) {
|
||||||
|
console.error(`[Invoicing] Error enviando factura ${invoice.id} a ${customer.email}:`, emailErr.message || emailErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
auditLog({
|
auditLog({
|
||||||
tenantId: payment.tenantId,
|
tenantId: payment.tenantId,
|
||||||
action: 'invoice.emitted_auto',
|
action: 'invoice.emitted_auto',
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ const preApprovalClient = new PreApproval(config);
|
|||||||
const paymentClient = new MPPayment(config);
|
const paymentClient = new MPPayment(config);
|
||||||
const preferenceClient = new Preference(config);
|
const preferenceClient = new Preference(config);
|
||||||
|
|
||||||
|
/** Límite de la API legacy de preapproval de MercadoPago para MXN. */
|
||||||
|
export const MP_PREAPPROVAL_MAX_AMOUNT = 10000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fallback público para `back_url` cuando `FRONTEND_URL` apunta a localhost.
|
* Fallback público para `back_url` cuando `FRONTEND_URL` apunta a localhost.
|
||||||
* MercadoPago rechaza URLs `http://localhost...` o cualquier dominio no
|
* MercadoPago rechaza URLs `http://localhost...` o cualquier dominio no
|
||||||
@@ -227,6 +230,51 @@ export async function createProrationPreference(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una Preference (checkout de pago único) para el pago anual de una
|
||||||
|
* suscripción. Se usa cuando el monto supera el límite de preapproval ($10k).
|
||||||
|
* external_reference = `subscription:{tenantId}:{subscriptionId}` para que el
|
||||||
|
* webhook active el período anual al aprobarse.
|
||||||
|
*/
|
||||||
|
export async function createSubscriptionPreference(params: {
|
||||||
|
tenantId: string;
|
||||||
|
subscriptionId: string;
|
||||||
|
plan: string;
|
||||||
|
amount: number;
|
||||||
|
payerEmail: string;
|
||||||
|
}): Promise<{ preferenceId: string; checkoutUrl: string }> {
|
||||||
|
if (!env.MP_ACCESS_TOKEN) {
|
||||||
|
throw new Error('MercadoPago no está configurado (falta MP_ACCESS_TOKEN en .env).');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await preferenceClient.create({
|
||||||
|
body: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: `subscription-${params.subscriptionId}`,
|
||||||
|
title: `Horux360 - Plan ${params.plan} - Año completo`,
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: params.amount,
|
||||||
|
currency_id: 'MXN',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
payer: { email: resolvePayerEmail(params.payerEmail) },
|
||||||
|
external_reference: `subscription:${params.tenantId}:${params.subscriptionId}`,
|
||||||
|
back_urls: {
|
||||||
|
success: `${backUrlBase()}/configuracion/suscripcion?subscription=success`,
|
||||||
|
failure: `${backUrlBase()}/configuracion/suscripcion?subscription=failure`,
|
||||||
|
pending: `${backUrlBase()}/configuracion/suscripcion?subscription=pending`,
|
||||||
|
},
|
||||||
|
auto_return: 'approved',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
preferenceId: response.id!,
|
||||||
|
checkoutUrl: response.init_point!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Crea una Preference (checkout de pago único) para comprar un paquete de
|
* Crea una Preference (checkout de pago único) para comprar un paquete de
|
||||||
* timbres adicionales. external_reference = `timbres-pack:${paymentId}` para
|
* timbres adicionales. external_reference = `timbres-pack:${paymentId}` para
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { prisma } from '../../config/database.js';
|
import { prisma, tenantDb } from '../../config/database.js';
|
||||||
import * as mpService from './mercadopago.service.js';
|
import * as mpService from './mercadopago.service.js';
|
||||||
import { emailService } from '../email/email.service.js';
|
import { emailService } from '../email/email.service.js';
|
||||||
import { auditLog } from '../../utils/audit.js';
|
import { auditLog } from '../../utils/audit.js';
|
||||||
import { getTenantOwnerEmail } from '../../utils/memberships.js';
|
import { getTenantOwnerEmail, getTenantOwnerEmails } from '../../utils/memberships.js';
|
||||||
|
import { filterRecipientsByRole } from '../notification-preferences.service.js';
|
||||||
import { isDespachoPaidPlan, permiteOverage, type DespachoPricePhase } from '@horux/shared';
|
import { isDespachoPaidPlan, permiteOverage, type DespachoPricePhase } from '@horux/shared';
|
||||||
import { despachoPlanTieneDualidadDb, getPrecioDespachoDb } from '../plan-catalogo.service.js';
|
import { despachoPlanTieneDualidadDb, getPrecioDespachoDb } from '../plan-catalogo.service.js';
|
||||||
import {
|
import {
|
||||||
@@ -243,25 +244,76 @@ export async function generatePaymentLink(tenantId: string) {
|
|||||||
const ownerEmail = await getTenantOwnerEmail(tenantId);
|
const ownerEmail = await getTenantOwnerEmail(tenantId);
|
||||||
if (!ownerEmail) throw new Error('No admin user found');
|
if (!ownerEmail) throw new Error('No admin user found');
|
||||||
|
|
||||||
const subscription = await getActiveSubscription(tenantId);
|
let subscription = await getActiveSubscription(tenantId);
|
||||||
const plan = subscription?.plan || tenant.plan;
|
const plan = (subscription?.plan || tenant.plan) as Plan;
|
||||||
const amount = subscription?.amount || 0;
|
if (plan === 'custom' || plan === 'trial') {
|
||||||
|
throw new Error('No se puede generar link de pago para el plan actual');
|
||||||
|
}
|
||||||
|
|
||||||
if (!amount) throw new Error('No se encontró monto de suscripción');
|
const frequency = (subscription?.frequency as Frequency) || 'annual';
|
||||||
|
let amount = subscription?.amount ? Number(subscription.amount) : 0;
|
||||||
|
if (!amount) {
|
||||||
|
amount = await getPlanPrice(plan, frequency, 'firstYear');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Los planes Business Control / Enterprise exceden el límite de cobro recurrente
|
||||||
|
// de MercadoPago ($10k). Para esos montos usamos una Preference de pago único
|
||||||
|
// anual; el webhook activa el período de 1 año al aprobarse.
|
||||||
|
if (amount > mpService.MP_PREAPPROVAL_MAX_AMOUNT) {
|
||||||
|
if (!subscription) {
|
||||||
|
subscription = await prisma.subscription.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
plan: plan as any,
|
||||||
|
status: 'pending',
|
||||||
|
amount,
|
||||||
|
frequency,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
invalidateSubscriptionCache(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mp = await mpService.createSubscriptionPreference({
|
||||||
|
tenantId,
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
plan,
|
||||||
|
amount,
|
||||||
|
payerEmail: ownerEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.subscription.update({
|
||||||
|
where: { id: subscription.id },
|
||||||
|
data: { mpPreferenceId: mp.preferenceId, status: 'pending', amount },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { paymentUrl: mp.checkoutUrl };
|
||||||
|
}
|
||||||
|
|
||||||
const mp = await mpService.createPreapproval({
|
const mp = await mpService.createPreapproval({
|
||||||
tenantId,
|
tenantId,
|
||||||
reason: `Horux360 - Plan ${plan} - ${tenant.nombre}`,
|
reason: `Horux360 - Plan ${plan} - ${tenant.nombre}`,
|
||||||
amount,
|
amount,
|
||||||
payerEmail: ownerEmail,
|
payerEmail: ownerEmail,
|
||||||
|
frequency,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update subscription with new MP preapproval ID
|
|
||||||
if (subscription) {
|
if (subscription) {
|
||||||
await prisma.subscription.update({
|
await prisma.subscription.update({
|
||||||
where: { id: subscription.id },
|
where: { id: subscription.id },
|
||||||
data: { mpPreapprovalId: mp.preapprovalId },
|
data: { mpPreapprovalId: mp.preapprovalId, status: mp.status || 'pending' },
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.subscription.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
plan: plan as any,
|
||||||
|
status: mp.status || 'pending',
|
||||||
|
amount,
|
||||||
|
frequency,
|
||||||
|
mpPreapprovalId: mp.preapprovalId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
invalidateSubscriptionCache(tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { paymentUrl: mp.initPoint };
|
return { paymentUrl: mp.initPoint };
|
||||||
@@ -462,6 +514,54 @@ export async function subscribe(params: {
|
|||||||
? `${tenant.nombre} - Plan ${params.plan} - $${amount.toLocaleString('es-MX')} primer año, $${renewalAmount.toLocaleString('es-MX')} renovaciones`
|
? `${tenant.nombre} - Plan ${params.plan} - $${amount.toLocaleString('es-MX')} primer año, $${renewalAmount.toLocaleString('es-MX')} renovaciones`
|
||||||
: `Horux360 - Plan ${params.plan} (${params.frequency}) - ${tenant.nombre}`;
|
: `Horux360 - Plan ${params.plan} (${params.frequency}) - ${tenant.nombre}`;
|
||||||
|
|
||||||
|
// Planes Business Control / Enterprise superan el límite de cobro recurrente
|
||||||
|
// de MercadoPago ($10k). Se cobra el año completo vía Preference one-off; el
|
||||||
|
// webhook activa el período anual tras el primer pago aprobado.
|
||||||
|
if (amount > mpService.MP_PREAPPROVAL_MAX_AMOUNT) {
|
||||||
|
const subscription = await prisma.subscription.create({
|
||||||
|
data: {
|
||||||
|
tenantId: params.tenantId,
|
||||||
|
plan: params.plan,
|
||||||
|
status: 'pending',
|
||||||
|
amount,
|
||||||
|
frequency: params.frequency,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mp = await mpService.createSubscriptionPreference({
|
||||||
|
tenantId: params.tenantId,
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
plan: params.plan,
|
||||||
|
amount,
|
||||||
|
payerEmail: params.payerEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.subscription.update({
|
||||||
|
where: { id: subscription.id },
|
||||||
|
data: { mpPreferenceId: mp.preferenceId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.subscription.updateMany({
|
||||||
|
where: { tenantId: params.tenantId, status: 'trial' },
|
||||||
|
data: { status: 'trial_converted' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.tenant.update({
|
||||||
|
where: { id: params.tenantId },
|
||||||
|
data: { plan: params.plan },
|
||||||
|
});
|
||||||
|
|
||||||
|
invalidateSubscriptionCache(params.tenantId);
|
||||||
|
auditLog({
|
||||||
|
tenantId: params.tenantId,
|
||||||
|
action: 'subscription.created',
|
||||||
|
entityType: 'Subscription',
|
||||||
|
entityId: subscription.id,
|
||||||
|
metadata: { plan: params.plan, frequency: params.frequency, amount, paymentMethod: 'preference' },
|
||||||
|
});
|
||||||
|
return { subscription, paymentUrl: mp.checkoutUrl };
|
||||||
|
}
|
||||||
|
|
||||||
const mp = await mpService.createPreapproval({
|
const mp = await mpService.createPreapproval({
|
||||||
tenantId: params.tenantId,
|
tenantId: params.tenantId,
|
||||||
reason,
|
reason,
|
||||||
@@ -637,13 +737,20 @@ export async function applyApprovedUpgrade(subscriptionId: string): Promise<void
|
|||||||
const newPlan = sub.upgradeTargetPlan as Plan;
|
const newPlan = sub.upgradeTargetPlan as Plan;
|
||||||
const newAmount = Number(sub.upgradeTargetAmount);
|
const newAmount = Number(sub.upgradeTargetAmount);
|
||||||
|
|
||||||
// Actualiza el monto del preapproval en MP (si existe)
|
// Actualiza el monto del preapproval en MP (si existe). Si el nuevo monto
|
||||||
|
// supera el límite de cobro recurrente de MP ($10k), cancelamos el preapproval
|
||||||
|
// anterior: el plan alto se cobrará anualmente vía Preference one-off.
|
||||||
if (sub.mpPreapprovalId) {
|
if (sub.mpPreapprovalId) {
|
||||||
try {
|
if (newAmount > mpService.MP_PREAPPROVAL_MAX_AMOUNT) {
|
||||||
await mpService.updatePreapprovalAmount(sub.mpPreapprovalId, newAmount);
|
await mpService.cancelPreapproval(sub.mpPreapprovalId);
|
||||||
} catch (error: any) {
|
console.log(`[Upgrade] Preapproval ${sub.mpPreapprovalId} cancelado porque el nuevo monto $${newAmount} supera el límite de MP`);
|
||||||
console.error(`[Upgrade] Error actualizando preapproval ${sub.mpPreapprovalId}:`, error.message);
|
} else {
|
||||||
throw error; // Re-lanza para que MP reintente el webhook
|
try {
|
||||||
|
await mpService.updatePreapprovalAmount(sub.mpPreapprovalId, newAmount);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[Upgrade] Error actualizando preapproval ${sub.mpPreapprovalId}:`, error.message);
|
||||||
|
throw error; // Re-lanza para que MP reintente el webhook
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1085,7 +1192,7 @@ export async function sendExpiryReminders(): Promise<{ sent: number; resetOnly:
|
|||||||
{ status: 'trial_expired', currentPeriodEnd: { gte: oneDayAgo } },
|
{ status: 'trial_expired', currentPeriodEnd: { gte: oneDayAgo } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
include: { tenant: { select: { nombre: true, rfc: true } } },
|
include: { tenant: { select: { nombre: true, rfc: true, databaseName: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
let sent = 0;
|
let sent = 0;
|
||||||
@@ -1129,33 +1236,48 @@ export async function sendExpiryReminders(): Promise<{ sent: number; resetOnly:
|
|||||||
|
|
||||||
// Hay algo que avisar.
|
// Hay algo que avisar.
|
||||||
try {
|
try {
|
||||||
const ownerEmail = await getTenantOwnerEmail(sub.tenantId);
|
// Para suscripciones de pago, respeta preferencia 'subscription_expiring' del rol owner.
|
||||||
if (!ownerEmail) {
|
// Para trials siempre avisa al owner (no depende de preferencias de notificación informativa).
|
||||||
|
const isTrialFlow = sub.status === 'trial' || sub.status === 'trial_expired';
|
||||||
|
let emailsToNotify: string[] = [];
|
||||||
|
|
||||||
|
if (isTrialFlow) {
|
||||||
|
const ownerEmail = await getTenantOwnerEmail(sub.tenantId);
|
||||||
|
if (ownerEmail) emailsToNotify = [ownerEmail];
|
||||||
|
} else {
|
||||||
|
const pool = await tenantDb.getPool(sub.tenantId, sub.tenant.databaseName);
|
||||||
|
const ownerEmails = await getTenantOwnerEmails(sub.tenantId);
|
||||||
|
const recipientsWithRole = ownerEmails.map(email => ({ email, role: 'owner' as const }));
|
||||||
|
emailsToNotify = await filterRecipientsByRole(pool, 'subscription_expiring', recipientsWithRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emailsToNotify.length === 0) {
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTrialFlow = sub.status === 'trial' || sub.status === 'trial_expired';
|
for (const ownerEmail of emailsToNotify) {
|
||||||
if (isTrialFlow) {
|
if (isTrialFlow) {
|
||||||
if (bucket === 0) {
|
if (bucket === 0) {
|
||||||
await emailService.sendTrialExpired(ownerEmail, {
|
await emailService.sendTrialExpired(ownerEmail, {
|
||||||
nombre: sub.tenant.nombre,
|
nombre: sub.tenant.nombre,
|
||||||
despachoNombre: sub.tenant.nombre,
|
despachoNombre: sub.tenant.nombre,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
await emailService.sendTrialReminder(ownerEmail, {
|
||||||
|
nombre: sub.tenant.nombre,
|
||||||
|
despachoNombre: sub.tenant.nombre,
|
||||||
|
diasRestantes: Math.max(0, daysUntil),
|
||||||
|
wizardCompleto: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await emailService.sendTrialReminder(ownerEmail, {
|
await emailService.sendSubscriptionExpiring(ownerEmail, {
|
||||||
nombre: sub.tenant.nombre,
|
nombre: sub.tenant.nombre,
|
||||||
despachoNombre: sub.tenant.nombre,
|
plan: sub.plan,
|
||||||
diasRestantes: Math.max(0, daysUntil),
|
expiresAt: sub.currentPeriodEnd.toLocaleDateString('es-MX', { dateStyle: 'long' }),
|
||||||
wizardCompleto: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
await emailService.sendSubscriptionExpiring(ownerEmail, {
|
|
||||||
nombre: sub.tenant.nombre,
|
|
||||||
plan: sub.plan,
|
|
||||||
expiresAt: sub.currentPeriodEnd.toLocaleDateString('es-MX', { dateStyle: 'long' }),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.subscription.update({
|
await prisma.subscription.update({
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ export async function getRegimenesActivosClaves(tenantId: string): Promise<strin
|
|||||||
/**
|
/**
|
||||||
* Resuelve las claves de regímenes activos para la alerta de discrepancia.
|
* Resuelve las claves de regímenes activos para la alerta de discrepancia.
|
||||||
* Si hay contribuyenteId, lee de contribuyentes.regimen_fiscal (comma-separated).
|
* Si hay contribuyenteId, lee de contribuyentes.regimen_fiscal (comma-separated).
|
||||||
* Si no, fallback a TenantRegimenActivo (tabla central).
|
* Si no, combina TenantRegimenActivo (tabla central) con los regímenes de
|
||||||
|
* todos los contribuyentes activos del tenant. Esto evita que la alerta
|
||||||
|
* aparezca en el correo por-contribuyente pero desaparezca en el dashboard
|
||||||
|
* cuando no hay un contribuyente seleccionado.
|
||||||
*/
|
*/
|
||||||
export async function getRegimenesActivosClavesEfectivos(
|
export async function getRegimenesActivosClavesEfectivos(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
@@ -61,9 +64,49 @@ export async function getRegimenesActivosClavesEfectivos(
|
|||||||
if (rows.length > 0 && rows[0].regimen_fiscal) {
|
if (rows.length > 0 && rows[0].regimen_fiscal) {
|
||||||
return rows[0].regimen_fiscal.split(',').map((c: string) => c.trim()).filter(Boolean);
|
return rows[0].regimen_fiscal.split(',').map((c: string) => c.trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
return [];
|
// Fallback: si el contribuyente no tiene regimen_fiscal, usamos los del tenant
|
||||||
|
// para no perder la alerta si el campo quedó vacío accidentalmente.
|
||||||
|
const tenantRegimenes = await getRegimenesActivosClaves(tenantId);
|
||||||
|
if (tenantRegimenes.length > 0) return tenantRegimenes;
|
||||||
|
|
||||||
|
const { rows: allRows } = await pool.query(
|
||||||
|
`SELECT DISTINCT regimen_fiscal FROM contribuyentes WHERE regimen_fiscal IS NOT NULL AND regimen_fiscal <> ''`,
|
||||||
|
);
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const row of allRows) {
|
||||||
|
if (row.regimen_fiscal) {
|
||||||
|
for (const clave of row.regimen_fiscal.split(',')) {
|
||||||
|
const trimmed = clave.trim();
|
||||||
|
if (trimmed) set.add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(set);
|
||||||
}
|
}
|
||||||
return getRegimenesActivosClaves(tenantId);
|
|
||||||
|
const tenantRegimenes = await getRegimenesActivosClaves(tenantId);
|
||||||
|
|
||||||
|
// Fallback: si no hay regímenes configurados a nivel tenant, usamos los
|
||||||
|
// regímenes de todos los contribuyentes activos del tenant.
|
||||||
|
if (tenantRegimenes.length > 0) {
|
||||||
|
return tenantRegimenes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT DISTINCT regimen_fiscal FROM contribuyentes WHERE regimen_fiscal IS NOT NULL AND regimen_fiscal <> ''`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.regimen_fiscal) {
|
||||||
|
for (const clave of row.regimen_fiscal.split(',')) {
|
||||||
|
const trimmed = clave.trim();
|
||||||
|
if (trimmed) set.add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(set);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setRegimenesActivos(tenantId: string, regimenIds: number[]) {
|
export async function setRegimenesActivos(tenantId: string, regimenIds: number[]) {
|
||||||
|
|||||||
@@ -72,9 +72,17 @@ export async function querySat(
|
|||||||
requestType: 'metadata' | 'cfdi' = 'cfdi'
|
requestType: 'metadata' | 'cfdi' = 'cfdi'
|
||||||
): Promise<QueryResult> {
|
): Promise<QueryResult> {
|
||||||
try {
|
try {
|
||||||
|
// El SAT rechaza fechaInicial >= fechaFinal. Como formatDateForSat trunca
|
||||||
|
// a medianoche, dos fechas dentro del mismo día calendario resultan iguales.
|
||||||
|
// Ajustamos fechaFin al día siguiente para evitar el error.
|
||||||
|
let adjustedFechaFin = fechaFin;
|
||||||
|
if (formatDateForSat(fechaInicio) === formatDateForSat(fechaFin)) {
|
||||||
|
adjustedFechaFin = new Date(fechaFin.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
const period = DateTimePeriod.createFromValues(
|
const period = DateTimePeriod.createFromValues(
|
||||||
formatDateForSat(fechaInicio),
|
formatDateForSat(fechaInicio),
|
||||||
formatDateForSat(fechaFin)
|
formatDateForSat(adjustedFechaFin)
|
||||||
);
|
);
|
||||||
|
|
||||||
const downloadType = new DownloadType(tipo === 'emitidos' ? 'issued' : 'received');
|
const downloadType = new DownloadType(tipo === 'emitidos' ? 'issued' : 'received');
|
||||||
@@ -239,10 +247,11 @@ export async function downloadSatPackage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss)
|
* Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss).
|
||||||
|
* El SAT requiere hora 00:00:00; cualquier otra hora causa
|
||||||
|
* "Fecha final invalida" / "Fecha inicial invalida".
|
||||||
*/
|
*/
|
||||||
function formatDateForSat(date: Date): string {
|
function formatDateForSat(date: Date): string {
|
||||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} 00:00:00`;
|
||||||
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,20 +30,20 @@ export async function loginSatCsf(
|
|||||||
const publicPage = await context.newPage();
|
const publicPage = await context.newPage();
|
||||||
publicPage.setDefaultTimeout(60_000);
|
publicPage.setDefaultTimeout(60_000);
|
||||||
|
|
||||||
await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle' });
|
await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle', timeout: 120_000 });
|
||||||
await publicPage.waitForTimeout(2000);
|
await publicPage.waitForTimeout(3000);
|
||||||
|
|
||||||
// Click acordeón "Obtén tu constancia" / "Obtener constancia"
|
// Click acordeón "Obtén tu constancia" / "Obtener constancia"
|
||||||
const obtenerLocator = publicPage.locator(
|
const obtenerLocator = publicPage.locator(
|
||||||
'text=/Obt[eé]n\\s+la\\s+constancia|Obt[eé]n\\s+tu\\s+constancia|Obtener\\s+constancia|Obtener\\s+la\\s+constancia/i',
|
'text=/Obt[eé]n\\s+la\\s+constancia|Obt[eé]n\\s+tu\\s+constancia|Obtener\\s+constancia|Obtener\\s+la\\s+constancia/i',
|
||||||
).first();
|
).first();
|
||||||
await obtenerLocator.waitFor({ state: 'visible', timeout: 60_000 });
|
await obtenerLocator.waitFor({ state: 'visible', timeout: 120_000 });
|
||||||
await obtenerLocator.scrollIntoViewIfNeeded();
|
await obtenerLocator.scrollIntoViewIfNeeded();
|
||||||
await obtenerLocator.click();
|
await obtenerLocator.click();
|
||||||
await publicPage.waitForTimeout(1500);
|
await publicPage.waitForTimeout(1500);
|
||||||
|
|
||||||
// Click "SERVICIO" → popup
|
// Click "SERVICIO" → popup
|
||||||
const popupPromise = context.waitForEvent('page', { timeout: 60_000 });
|
const popupPromise = context.waitForEvent('page', { timeout: 120_000 });
|
||||||
await publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first().click();
|
await publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first().click();
|
||||||
const loginPage = await popupPromise;
|
const loginPage = await popupPromise;
|
||||||
await loginPage.waitForLoadState('domcontentloaded');
|
await loginPage.waitForLoadState('domcontentloaded');
|
||||||
@@ -56,7 +56,7 @@ export async function loginSatCsf(
|
|||||||
const efirmaBtn = loginPage
|
const efirmaBtn = loginPage
|
||||||
.locator('button:has-text("e.firma"):not(:has-text("portable")), input[type="button"][value="e.firma" i], input[type="submit"][value="e.firma" i]')
|
.locator('button:has-text("e.firma"):not(:has-text("portable")), input[type="button"][value="e.firma" i], input[type="submit"][value="e.firma" i]')
|
||||||
.first();
|
.first();
|
||||||
await efirmaBtn.waitFor({ state: 'visible', timeout: 30_000 });
|
await efirmaBtn.waitFor({ state: 'visible', timeout: 60_000 });
|
||||||
await efirmaBtn.scrollIntoViewIfNeeded();
|
await efirmaBtn.scrollIntoViewIfNeeded();
|
||||||
await efirmaBtn.click();
|
await efirmaBtn.click();
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ export async function loginSatCsf(
|
|||||||
return rfc !== null && rfc.value.length >= 12;
|
return rfc !== null && rfc.value.length >= 12;
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
{ timeout: 60_000 },
|
{ timeout: 120_000 },
|
||||||
);
|
);
|
||||||
rfcPopulated = true;
|
rfcPopulated = true;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -121,7 +121,7 @@ export async function loginSatCsf(
|
|||||||
// Esperar a que salga del dominio de login y aterrice en el portal SAT
|
// Esperar a que salga del dominio de login y aterrice en el portal SAT
|
||||||
await loginPage.waitForURL(
|
await loginPage.waitForURL(
|
||||||
url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'),
|
url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'),
|
||||||
{ timeout: 60_000 },
|
{ timeout: 120_000 },
|
||||||
);
|
);
|
||||||
await loginPage.waitForLoadState('networkidle').catch(() => undefined);
|
await loginPage.waitForLoadState('networkidle').catch(() => undefined);
|
||||||
await loginPage.waitForTimeout(2000);
|
await loginPage.waitForTimeout(2000);
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ async function saveCfdis(
|
|||||||
cfdi_tipo_relacion=$88, cfdis_relacionados=$89,
|
cfdi_tipo_relacion=$88, cfdis_relacionados=$89,
|
||||||
last_sat_sync=NOW(), sat_sync_job_id=$90::uuid,
|
last_sat_sync=NOW(), sat_sync_job_id=$90::uuid,
|
||||||
actualizado_en=NOW()
|
actualizado_en=NOW()
|
||||||
WHERE uuid = $1`,
|
WHERE LOWER(uuid) = LOWER($1)`,
|
||||||
[cfdi.uuid, ...vals]
|
[cfdi.uuid, ...vals]
|
||||||
);
|
);
|
||||||
// Re-insert conceptos for updated CFDI
|
// Re-insert conceptos for updated CFDI
|
||||||
@@ -355,7 +355,7 @@ async function saveCfdis(
|
|||||||
[...vals, contribuyenteId]
|
[...vals, contribuyenteId]
|
||||||
);
|
);
|
||||||
// Get the inserted cfdi id and save conceptos
|
// Get the inserted cfdi id and save conceptos
|
||||||
const { rows: [newRow] } = await pool.query(`SELECT id FROM cfdis WHERE uuid = $1`, [cfdi.uuid]);
|
const { rows: [newRow] } = await pool.query(`SELECT id FROM cfdis WHERE LOWER(uuid) = LOWER($1)`, [cfdi.uuid]);
|
||||||
if (newRow) await saveConceptosWithRetry(pool, newRow.id, cfdi);
|
if (newRow) await saveConceptosWithRetry(pool, newRow.id, cfdi);
|
||||||
inserted++;
|
inserted++;
|
||||||
}
|
}
|
||||||
@@ -609,30 +609,35 @@ async function requestAndDownload(
|
|||||||
});
|
});
|
||||||
let existingMap = (jobRow?.satRequestIds as Record<string, string> | null) || {};
|
let existingMap = (jobRow?.satRequestIds as Record<string, string> | null) || {};
|
||||||
|
|
||||||
|
// NOTA: se desactivó la reutilización de requestIds de jobs previos porque el SAT
|
||||||
|
// limita las descargas por solicitud. Reusar un requestId de un job anterior puede
|
||||||
|
// agotar el límite y devolver "Máximo de descargas permitidas", dejando el recovery
|
||||||
|
// sin poder descargar. Cada job nuevo crea sus propias solicitudes.
|
||||||
|
//
|
||||||
// Si no existe en el job actual, buscar en el job más reciente del mismo tenant/contribuyente
|
// Si no existe en el job actual, buscar en el job más reciente del mismo tenant/contribuyente
|
||||||
// SOLO si el rango de fechas es idéntico (mismo dateFrom/dateTo).
|
// SOLO si el rango de fechas es idéntico (mismo dateFrom/dateTo).
|
||||||
if (!existingMap[kindKey]) {
|
// if (!existingMap[kindKey]) {
|
||||||
const previousJob = await prisma.satSyncJob.findFirst({
|
// const previousJob = await prisma.satSyncJob.findFirst({
|
||||||
where: {
|
// where: {
|
||||||
tenantId: jobRow?.tenantId,
|
// tenantId: jobRow?.tenantId,
|
||||||
contribuyenteId: jobRow?.contribuyenteId ?? null,
|
// contribuyenteId: jobRow?.contribuyenteId ?? null,
|
||||||
id: { not: jobId },
|
// id: { not: jobId },
|
||||||
dateFrom: jobRow?.dateFrom,
|
// dateFrom: jobRow?.dateFrom,
|
||||||
dateTo: jobRow?.dateTo,
|
// dateTo: jobRow?.dateTo,
|
||||||
},
|
// },
|
||||||
orderBy: { createdAt: 'desc' },
|
// orderBy: { createdAt: 'desc' },
|
||||||
select: { satRequestIds: true },
|
// select: { satRequestIds: true },
|
||||||
});
|
// });
|
||||||
if (previousJob?.satRequestIds) {
|
// if (previousJob?.satRequestIds) {
|
||||||
const prevMap = previousJob.satRequestIds as Record<string, string>;
|
// const prevMap = previousJob.satRequestIds as Record<string, string>;
|
||||||
if (prevMap[kindKey]) {
|
// if (prevMap[kindKey]) {
|
||||||
console.log(`[SAT] Reutilizando requestId de job previo (${label}): ${prevMap[kindKey]}`);
|
// console.log(`[SAT] Reutilizando requestId de job previo (${label}): ${prevMap[kindKey]}`);
|
||||||
// Copiar al job actual para futuros usos
|
// // Copiar al job actual para futuros usos
|
||||||
await persistSatRequestId(jobId, kindKey, prevMap[kindKey]);
|
// await persistSatRequestId(jobId, kindKey, prevMap[kindKey]);
|
||||||
existingMap = { ...existingMap, [kindKey]: prevMap[kindKey] };
|
// existingMap = { ...existingMap, [kindKey]: prevMap[kindKey] };
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
let requestId: string | null = existingMap[kindKey] || null;
|
let requestId: string | null = existingMap[kindKey] || null;
|
||||||
let verifyResult: Awaited<ReturnType<typeof verifySatRequest>> | undefined;
|
let verifyResult: Awaited<ReturnType<typeof verifySatRequest>> | undefined;
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ export interface SweepResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
|
const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
|
||||||
initial: 8,
|
initial: 24,
|
||||||
daily: 4,
|
daily: 4,
|
||||||
incremental: 2,
|
incremental: 2,
|
||||||
custom: 4,
|
custom: 24,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,51 +1,59 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Header } from '@/components/layouts/header';
|
import { Header } from '@/components/layouts/header';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@horux/shared-ui';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@horux/shared-ui';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
|
||||||
import { Bell, Loader2 } from 'lucide-react';
|
import { Bell, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
owner: 'Owner',
|
||||||
|
supervisor: 'Supervisor',
|
||||||
|
auxiliar: 'Auxiliar',
|
||||||
|
cliente: 'Cliente',
|
||||||
|
};
|
||||||
|
|
||||||
const EMAIL_LABELS: Record<string, { label: string; description: string; status: 'active' | 'pending' }> = {
|
const EMAIL_LABELS: Record<string, { label: string; description: string; status: 'active' | 'pending' }> = {
|
||||||
documento_subido: {
|
documento_subido: {
|
||||||
label: 'Documento subido',
|
label: 'Documento subido',
|
||||||
description: 'Notificación cuando se sube una declaración o documento extra del contribuyente.',
|
description: 'Cuando se sube una declaración o documento extra del contribuyente.',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
},
|
},
|
||||||
weekly_update: {
|
weekly_update: {
|
||||||
label: 'Reporte semanal',
|
label: 'Reporte semanal',
|
||||||
description: 'Resumen de KPIs, alertas y discrepancias enviado los lunes 8:00 AM.',
|
description: 'Resumen de KPIs, alertas y discrepancias enviado los lunes 8:00 AM.',
|
||||||
status: 'pending',
|
status: 'active',
|
||||||
},
|
},
|
||||||
subscription_expiring: {
|
subscription_expiring: {
|
||||||
label: 'Vencimiento de suscripción',
|
label: 'Vencimiento de suscripción',
|
||||||
description: 'Aviso cuando la suscripción del despacho está por vencer.',
|
description: 'Aviso cuando la suscripción del despacho está por vencer.',
|
||||||
status: 'pending',
|
status: 'active',
|
||||||
},
|
},
|
||||||
recordatorio_fiscal: {
|
recordatorio_fiscal: {
|
||||||
label: 'Recordatorios fiscales',
|
label: 'Recordatorios fiscales',
|
||||||
description: 'Avisos de obligaciones próximas a vencer (declaraciones, pagos provisionales).',
|
description: 'Avisos de obligaciones próximas a vencer (declaraciones, pagos provisionales).',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
},
|
},
|
||||||
|
alertas_nuevas: {
|
||||||
|
label: 'Alertas nuevas',
|
||||||
|
description: 'Notificación diaria cuando aparecen alertas fiscales nuevas para un contribuyente.',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
recordatorio_proximo: {
|
||||||
|
label: 'Recordatorios próximos',
|
||||||
|
description: 'Avisos de recordatorios del calendario a 3, 1 y 0 días de su fecha límite.',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ContribuyentePrefs {
|
|
||||||
contribuyenteId: string;
|
|
||||||
rfc: string;
|
|
||||||
nombre: string;
|
|
||||||
preferences: Record<string, boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ListResponse {
|
interface ListResponse {
|
||||||
emailTypes: string[];
|
emailTypes: string[];
|
||||||
data: ContribuyentePrefs[];
|
roles: string[];
|
||||||
|
preferences: Record<string, Record<string, boolean>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NotificacionesPage() {
|
export default function NotificacionesPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { selectedContribuyenteId } = useContribuyenteStore();
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<ListResponse>({
|
const { data, isLoading } = useQuery<ListResponse>({
|
||||||
queryKey: ['notification-preferences'],
|
queryKey: ['notification-preferences'],
|
||||||
@@ -55,32 +63,23 @@ export default function NotificacionesPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Aplica el filtro del selector global de contribuyente. Si hay uno
|
|
||||||
// seleccionado, solo se muestra esa fila. "Todos" muestra todos.
|
|
||||||
const visibles = useMemo(() => {
|
|
||||||
if (!data) return [];
|
|
||||||
if (!selectedContribuyenteId) return data.data;
|
|
||||||
return data.data.filter(c => c.contribuyenteId === selectedContribuyenteId);
|
|
||||||
}, [data, selectedContribuyenteId]);
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async ({ contribuyenteId, emailType, enabled }: { contribuyenteId: string; emailType: string; enabled: boolean }) => {
|
mutationFn: async ({ emailType, role, enabled }: { emailType: string; role: string; enabled: boolean }) => {
|
||||||
await apiClient.put('/notificaciones', {
|
await apiClient.put('/notificaciones', { emailType, role, enabled });
|
||||||
contribuyenteId,
|
|
||||||
preferences: { [emailType]: enabled },
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onMutate: async ({ contribuyenteId, emailType, enabled }) => {
|
onMutate: async ({ emailType, role, enabled }) => {
|
||||||
await queryClient.cancelQueries({ queryKey: ['notification-preferences'] });
|
await queryClient.cancelQueries({ queryKey: ['notification-preferences'] });
|
||||||
const previous = queryClient.getQueryData<ListResponse>(['notification-preferences']);
|
const previous = queryClient.getQueryData<ListResponse>(['notification-preferences']);
|
||||||
if (previous) {
|
if (previous) {
|
||||||
queryClient.setQueryData<ListResponse>(['notification-preferences'], {
|
queryClient.setQueryData<ListResponse>(['notification-preferences'], {
|
||||||
...previous,
|
...previous,
|
||||||
data: previous.data.map(c =>
|
preferences: {
|
||||||
c.contribuyenteId === contribuyenteId
|
...previous.preferences,
|
||||||
? { ...c, preferences: { ...c.preferences, [emailType]: enabled } }
|
[emailType]: {
|
||||||
: c,
|
...previous.preferences[emailType],
|
||||||
),
|
[role]: enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { previous };
|
return { previous };
|
||||||
@@ -93,6 +92,9 @@ export default function NotificacionesPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const roles = data?.roles ?? [];
|
||||||
|
const emailTypes = data?.emailTypes ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="Notificaciones" />
|
<Header title="Notificaciones" />
|
||||||
@@ -101,10 +103,10 @@ export default function NotificacionesPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<Bell className="h-4 w-4" />
|
<Bell className="h-4 w-4" />
|
||||||
Correos informativos por contribuyente
|
Correos informativos por rol
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Por default todos los correos están activados. Desactiva los que no quieras recibir para cada cliente. Los correos críticos (welcome, recuperación de contraseña, confirmación de pago) siempre se envían independientemente de esta configuración.
|
Activa o desactiva cada notificación según el rol del usuario en el despacho. Por default todos están activados. Los correos críticos (welcome, recuperación de contraseña, confirmación de pago) siempre se envían.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -114,35 +116,30 @@ export default function NotificacionesPage() {
|
|||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Cargando...
|
Cargando...
|
||||||
</div>
|
</div>
|
||||||
) : visibles.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-8 text-center text-muted-foreground">
|
|
||||||
{selectedContribuyenteId
|
|
||||||
? 'El contribuyente seleccionado no tiene preferencias configuradas todavía.'
|
|
||||||
: 'No hay contribuyentes en este despacho.'}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
visibles.map(contrib => (
|
<Card>
|
||||||
<Card key={contrib.contribuyenteId}>
|
<CardContent className="p-0 overflow-x-auto">
|
||||||
<CardHeader>
|
<table className="w-full text-sm">
|
||||||
<CardTitle className="text-sm font-medium">
|
<thead>
|
||||||
{contrib.nombre}
|
<tr className="border-b bg-muted/50">
|
||||||
</CardTitle>
|
<th className="text-left font-medium px-4 py-3 w-1/3">Notificación</th>
|
||||||
<CardDescription className="font-mono text-xs">{contrib.rfc}</CardDescription>
|
{roles.map(role => (
|
||||||
</CardHeader>
|
<th key={role} className="text-center font-medium px-4 py-3 min-w-[100px]">
|
||||||
<CardContent>
|
{ROLE_LABELS[role] ?? role}
|
||||||
<div className="space-y-3">
|
</th>
|
||||||
{(data?.emailTypes ?? []).map(type => {
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{emailTypes.map(type => {
|
||||||
const meta = EMAIL_LABELS[type];
|
const meta = EMAIL_LABELS[type];
|
||||||
if (!meta) return null;
|
if (!meta) return null;
|
||||||
const checked = contrib.preferences[type] !== false;
|
|
||||||
const isPending = meta.status === 'pending';
|
const isPending = meta.status === 'pending';
|
||||||
return (
|
return (
|
||||||
<div key={type} className="flex items-start justify-between gap-4 py-2 border-b last:border-0">
|
<tr key={type} className="border-b last:border-0">
|
||||||
<div className="flex-1 min-w-0">
|
<td className="px-4 py-3 align-top">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-sm font-medium">{meta.label}</span>
|
<span className="font-medium">{meta.label}</span>
|
||||||
{isPending && (
|
{isPending && (
|
||||||
<span className="text-[10px] uppercase tracking-wide bg-muted text-muted-foreground rounded px-1.5 py-0.5">
|
<span className="text-[10px] uppercase tracking-wide bg-muted text-muted-foreground rounded px-1.5 py-0.5">
|
||||||
Próximamente
|
Próximamente
|
||||||
@@ -150,29 +147,37 @@ export default function NotificacionesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">{meta.description}</p>
|
<p className="text-xs text-muted-foreground mt-0.5">{meta.description}</p>
|
||||||
</div>
|
</td>
|
||||||
<label className="inline-flex items-center cursor-pointer flex-shrink-0">
|
{roles.map(role => {
|
||||||
<input
|
const checked = data?.preferences?.[type]?.[role] !== false;
|
||||||
type="checkbox"
|
return (
|
||||||
className="sr-only peer"
|
<td key={role} className="px-4 py-3 text-center align-middle">
|
||||||
checked={checked}
|
<label className={`inline-flex items-center ${isPending ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
|
||||||
onChange={e =>
|
<input
|
||||||
mutation.mutate({
|
type="checkbox"
|
||||||
contribuyenteId: contrib.contribuyenteId,
|
className="sr-only peer"
|
||||||
emailType: type,
|
checked={checked}
|
||||||
enabled: e.target.checked,
|
disabled={isPending}
|
||||||
})
|
onChange={e =>
|
||||||
}
|
mutation.mutate({
|
||||||
/>
|
emailType: type,
|
||||||
<div className="relative w-10 h-6 bg-muted peer-checked:bg-primary rounded-full peer-focus:ring-2 peer-focus:ring-primary/30 transition-colors after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-transform peer-checked:after:translate-x-4" />
|
role,
|
||||||
</label>
|
enabled: e.target.checked,
|
||||||
</div>
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="relative w-10 h-6 bg-muted peer-checked:bg-primary rounded-full peer-focus:ring-2 peer-focus:ring-primary/30 transition-colors after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-transform peer-checked:after:translate-x-4" />
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</tbody>
|
||||||
</CardContent>
|
</table>
|
||||||
</Card>
|
</CardContent>
|
||||||
))
|
</Card>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ export default function ObligacionesPage() {
|
|||||||
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||||
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
||||||
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
||||||
|
cuatrimestral: 'bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300',
|
||||||
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||||
eventual: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
eventual: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { apiClient } from '@/lib/api/client';
|
|||||||
import { subscribeMe, changeMyPlan, cancelMySubscription, upgradeMe, generatePaymentLink } from '@/lib/api/subscription';
|
import { subscribeMe, changeMyPlan, cancelMySubscription, upgradeMe, generatePaymentLink } from '@/lib/api/subscription';
|
||||||
import { getPendingInvitation, acceptInvitation } from '@/lib/api/trial-invitations';
|
import { getPendingInvitation, acceptInvitation } from '@/lib/api/trial-invitations';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { getSubscriptionState } from '@horux/shared';
|
||||||
|
|
||||||
type Despachoplan = 'trial' | 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus' | 'custom';
|
type Despachoplan = 'trial' | 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus' | 'custom';
|
||||||
type PaidPlan = 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus';
|
type PaidPlan = 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus';
|
||||||
@@ -24,6 +25,7 @@ interface PlanInfo {
|
|||||||
dbMode: string;
|
dbMode: string;
|
||||||
trialEndsAt: string | null;
|
trialEndsAt: string | null;
|
||||||
isTrialActive: boolean;
|
isTrialActive: boolean;
|
||||||
|
planPrice: number | null;
|
||||||
subscription: SubscriptionInfo | null;
|
subscription: SubscriptionInfo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,9 +90,14 @@ export default function PlanesDespachoPage() {
|
|||||||
// El usuario puede cancelar si tiene una suscripción que aún corre (paid, trial,
|
// El usuario puede cancelar si tiene una suscripción que aún corre (paid, trial,
|
||||||
// custom). Si ya está cancelada o expirada, no hay nada que cancelar.
|
// custom). Si ya está cancelada o expirada, no hay nada que cancelar.
|
||||||
const subStatus = planInfo?.subscription?.status ?? null;
|
const subStatus = planInfo?.subscription?.status ?? null;
|
||||||
const hasActiveSub = subStatus != null
|
const subState = planInfo?.subscription ? getSubscriptionState(planInfo.subscription) : null;
|
||||||
&& subStatus !== 'cancelled'
|
const hasActiveSub = subState?.isActive || subState?.isTrial || subState?.isCancelledInPeriod || false;
|
||||||
&& subStatus !== 'trial_expired';
|
// Estados en los que se puede generar un link de pago (incluye trial, vencido y pending).
|
||||||
|
const isPayableStatus = subStatus === 'trial'
|
||||||
|
|| subStatus === 'trial_expired'
|
||||||
|
|| subStatus === 'pending'
|
||||||
|
|| hasActiveSub;
|
||||||
|
const isCurrentPlanPaid = currentPlan === planInfo?.subscription?.plan && subState?.isActive === true;
|
||||||
|
|
||||||
/** Resuelve la frecuencia para un plan. Mi Empresa y Mi Empresa+ leen su
|
/** Resuelve la frecuencia para un plan. Mi Empresa y Mi Empresa+ leen su
|
||||||
* propio toggle; el resto (business_*) siempre annual. */
|
* propio toggle; el resto (business_*) siempre annual. */
|
||||||
@@ -105,6 +112,15 @@ export default function PlanesDespachoPage() {
|
|||||||
setBusy(plan);
|
setBusy(plan);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
|
// Si el plan actual está pendiente de pago, solo regeneramos el link de pago.
|
||||||
|
if (currentPlan === plan && subState?.isPending) {
|
||||||
|
return await handlePagarAhora();
|
||||||
|
}
|
||||||
|
// Si tiene una sub pendiente en otro plan, no permitir cambiar hasta pagar.
|
||||||
|
if (subState?.isPending) {
|
||||||
|
setMessage({ kind: 'err', text: 'Completa el pago del plan actual antes de cambiar de plan.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Sin sub activa: subscribe directo → MP (preapproval del plan completo).
|
// Sin sub activa: subscribe directo → MP (preapproval del plan completo).
|
||||||
const result = await subscribeMe({ plan, frequency });
|
const result = await subscribeMe({ plan, frequency });
|
||||||
window.open(result.paymentUrl, '_blank');
|
window.open(result.paymentUrl, '_blank');
|
||||||
@@ -190,10 +206,10 @@ export default function PlanesDespachoPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActiveBadge() {
|
function CurrentPlanBadge({ pending }: { pending?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-green-600 text-white text-xs px-3 py-1 rounded-full font-medium whitespace-nowrap">
|
<div className={`absolute -top-3 left-1/2 -translate-x-1/2 text-white text-xs px-3 py-1 rounded-full font-medium whitespace-nowrap ${pending ? 'bg-yellow-600' : 'bg-green-600'}`}>
|
||||||
Plan actual
|
{pending ? 'Plan actual — pendiente' : 'Plan actual'}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -225,9 +241,25 @@ export default function PlanesDespachoPage() {
|
|||||||
|
|
||||||
function PlanActionButton({ plan }: { plan: PaidPlan }) {
|
function PlanActionButton({ plan }: { plan: PaidPlan }) {
|
||||||
const isCurrent = currentPlan === plan;
|
const isCurrent = currentPlan === plan;
|
||||||
if (isCurrent) {
|
if (isCurrent && isCurrentPlanPaid) {
|
||||||
return <Button disabled className="w-full">Plan actual</Button>;
|
return <Button disabled className="w-full">Plan actual</Button>;
|
||||||
}
|
}
|
||||||
|
if (isCurrent) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => handleContratar(plan)}
|
||||||
|
disabled={busy === plan}
|
||||||
|
>
|
||||||
|
{busy === plan ? 'Procesando...' : (
|
||||||
|
<>
|
||||||
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
|
Pagar este plan
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
const label = hasActiveSub ? 'Cambiar a este plan' : 'Contratar';
|
const label = hasActiveSub ? 'Cambiar a este plan' : 'Contratar';
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -302,7 +334,7 @@ export default function PlanesDespachoPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Banner de suscripción activa */}
|
{/* Banner de suscripción activa */}
|
||||||
{!loading && planInfo?.subscription && hasPaidPlan && (() => {
|
{!loading && planInfo?.subscription && hasPaidPlan && subState?.isActive && (() => {
|
||||||
const sub = planInfo.subscription;
|
const sub = planInfo.subscription;
|
||||||
const periodEndDate = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null;
|
const periodEndDate = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null;
|
||||||
const fechaFormato = periodEndDate
|
const fechaFormato = periodEndDate
|
||||||
@@ -329,18 +361,45 @@ export default function PlanesDespachoPage() {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Botón "Pagar mi período actual" — visible cuando la sub corre y hay
|
{/* Banner de suscripción pendiente */}
|
||||||
un monto > 0 que cobrar. Crea una MP Preference one-off por el monto
|
{!loading && planInfo?.subscription && hasPaidPlan && subState?.isPending && (
|
||||||
actual (custom $10, paid plan, lo que sea). Útil para pre-pagar antes
|
<div className="flex items-start gap-3 bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
|
||||||
del cobro automático o cuando no hay preapproval recurrente activo. */}
|
<Clock className="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
|
||||||
{!loading && hasActiveSub && planInfo?.subscription && Number(planInfo.subscription.amount) > 0 && (() => {
|
<div className="text-sm space-y-0.5">
|
||||||
|
<div className="font-semibold text-yellow-800 dark:text-yellow-300">
|
||||||
|
Suscripción pendiente de pago
|
||||||
|
</div>
|
||||||
|
<div className="text-yellow-700 dark:text-yellow-400">
|
||||||
|
Tu suscripción aún no está activa. Completa el pago para evitar la suspensión del servicio.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Banner de trial vencido */}
|
||||||
|
{!loading && subStatus === 'trial_expired' && hasPaidPlan && (
|
||||||
|
<div className="flex items-start gap-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
|
||||||
|
<Clock className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-semibold text-red-800 dark:text-red-300">Tu período de prueba terminó</span>
|
||||||
|
<span className="text-red-700 dark:text-red-400"> — elige un plan o paga el plan actual para recuperar el acceso.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Botón "Pagar mi período actual" — visible cuando se puede pagar y hay
|
||||||
|
un monto definido (subscription.amount > 0 o precio de catálogo).
|
||||||
|
Crea una MP Preference one-off por el monto actual. */}
|
||||||
|
{!loading && isPayableStatus && planInfo?.subscription && (() => {
|
||||||
const sub = planInfo.subscription!;
|
const sub = planInfo.subscription!;
|
||||||
|
const effectiveAmount = Number(sub.amount) > 0 ? Number(sub.amount) : (planInfo.planPrice ?? 0);
|
||||||
|
if (!effectiveAmount) return null;
|
||||||
const periodEnd = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null;
|
const periodEnd = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null;
|
||||||
const fechaFmt = periodEnd
|
const fechaFmt = periodEnd
|
||||||
? periodEnd.toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' })
|
? periodEnd.toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||||
: null;
|
: null;
|
||||||
const dias = periodEnd ? Math.max(0, Math.ceil((periodEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24))) : null;
|
const dias = periodEnd ? Math.max(0, Math.ceil((periodEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24))) : null;
|
||||||
const montoFmt = Number(sub.amount).toLocaleString('es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
const montoFmt = effectiveAmount.toLocaleString('es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg px-5 py-4 max-w-3xl mx-auto">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg px-5 py-4 max-w-3xl mx-auto">
|
||||||
<CreditCard className="h-6 w-6 text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
<CreditCard className="h-6 w-6 text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||||
@@ -388,7 +447,7 @@ export default function PlanesDespachoPage() {
|
|||||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 max-w-7xl mx-auto">
|
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 max-w-7xl mx-auto">
|
||||||
{/* Mi Empresa */}
|
{/* Mi Empresa */}
|
||||||
<Card className={`relative flex flex-col${currentPlan === 'mi_empresa' ? ' ring-2 ring-green-500' : ''}`}>
|
<Card className={`relative flex flex-col${currentPlan === 'mi_empresa' ? ' ring-2 ring-green-500' : ''}`}>
|
||||||
{currentPlan === 'mi_empresa' && <ActiveBadge />}
|
{currentPlan === 'mi_empresa' && <CurrentPlanBadge pending={subState?.isPending} />}
|
||||||
<CardHeader className="text-center pb-2">
|
<CardHeader className="text-center pb-2">
|
||||||
<div className="mx-auto bg-emerald-100 dark:bg-emerald-900 rounded-full p-3 w-fit mb-2">
|
<div className="mx-auto bg-emerald-100 dark:bg-emerald-900 rounded-full p-3 w-fit mb-2">
|
||||||
<Cloud className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
|
<Cloud className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
|
||||||
@@ -422,7 +481,7 @@ export default function PlanesDespachoPage() {
|
|||||||
|
|
||||||
{/* Mi Empresa + */}
|
{/* Mi Empresa + */}
|
||||||
<Card className={`relative flex flex-col${currentPlan === 'mi_empresa_plus' ? ' ring-2 ring-green-500' : ''}`}>
|
<Card className={`relative flex flex-col${currentPlan === 'mi_empresa_plus' ? ' ring-2 ring-green-500' : ''}`}>
|
||||||
{currentPlan === 'mi_empresa_plus' && <ActiveBadge />}
|
{currentPlan === 'mi_empresa_plus' && <CurrentPlanBadge pending={subState?.isPending} />}
|
||||||
<CardHeader className="text-center pb-2">
|
<CardHeader className="text-center pb-2">
|
||||||
<div className="mx-auto bg-teal-100 dark:bg-teal-900 rounded-full p-3 w-fit mb-2">
|
<div className="mx-auto bg-teal-100 dark:bg-teal-900 rounded-full p-3 w-fit mb-2">
|
||||||
<Cloud className="h-6 w-6 text-teal-600 dark:text-teal-400" />
|
<Cloud className="h-6 w-6 text-teal-600 dark:text-teal-400" />
|
||||||
@@ -459,7 +518,7 @@ export default function PlanesDespachoPage() {
|
|||||||
{/* Business Control */}
|
{/* Business Control */}
|
||||||
<Card className={`relative flex flex-col${currentPlan === 'business_control' ? ' ring-2 ring-green-500' : ' border-primary ring-2 ring-primary/20'}`}>
|
<Card className={`relative flex flex-col${currentPlan === 'business_control' ? ' ring-2 ring-green-500' : ' border-primary ring-2 ring-primary/20'}`}>
|
||||||
{currentPlan === 'business_control'
|
{currentPlan === 'business_control'
|
||||||
? <ActiveBadge />
|
? <CurrentPlanBadge pending={subState?.isPending} />
|
||||||
: (
|
: (
|
||||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground text-xs px-3 py-1 rounded-full">
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground text-xs px-3 py-1 rounded-full">
|
||||||
Más popular
|
Más popular
|
||||||
@@ -494,7 +553,7 @@ export default function PlanesDespachoPage() {
|
|||||||
|
|
||||||
{/* Enterprise (key interna: business_cloud) */}
|
{/* Enterprise (key interna: business_cloud) */}
|
||||||
<Card className={`relative flex flex-col${currentPlan === 'business_cloud' ? ' ring-2 ring-green-500' : ''}`}>
|
<Card className={`relative flex flex-col${currentPlan === 'business_cloud' ? ' ring-2 ring-green-500' : ''}`}>
|
||||||
{currentPlan === 'business_cloud' && <ActiveBadge />}
|
{currentPlan === 'business_cloud' && <CurrentPlanBadge pending={subState?.isPending} />}
|
||||||
<CardHeader className="text-center pb-2">
|
<CardHeader className="text-center pb-2">
|
||||||
<div className="mx-auto bg-purple-100 dark:bg-purple-900 rounded-full p-3 w-fit mb-2">
|
<div className="mx-auto bg-purple-100 dark:bg-purple-900 rounded-full p-3 w-fit mb-2">
|
||||||
<Cloud className="h-6 w-6 text-purple-600 dark:text-purple-400" />
|
<Cloud className="h-6 w-6 text-purple-600 dark:text-purple-400" />
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
|
FileMinus,
|
||||||
|
FilePlus,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@horux/shared-ui';
|
import { cn } from '@horux/shared-ui';
|
||||||
import { FiscalDisclaimer } from '@/components/fiscal-disclaimer';
|
import { FiscalDisclaimer } from '@/components/fiscal-disclaimer';
|
||||||
@@ -118,6 +120,15 @@ export default function DashboardPage() {
|
|||||||
? kpis?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
|
? kpis?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
|
||||||
: kpis?.ivaBalance || 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
|
const ivaAnterior = regimenSeleccionado
|
||||||
? kpisAnterior?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
|
? kpisAnterior?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
|
||||||
: kpisAnterior?.ivaBalance || 0;
|
: kpisAnterior?.ivaBalance || 0;
|
||||||
@@ -126,9 +137,15 @@ export default function DashboardPage() {
|
|||||||
? Math.round(((ivaDisplay - ivaAnterior) / Math.abs(ivaAnterior)) * 10000) / 100
|
? Math.round(((ivaDisplay - ivaAnterior) / Math.abs(ivaAnterior)) * 10000) / 100
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const utilidadDisplay = ingresosDisplay - egresosDisplay;
|
// Utilidad ajustada por notas de crédito:
|
||||||
const margenDisplay = ingresosDisplay > 0
|
// Ingresos netos = Ingresos − NCs emitidas
|
||||||
? Math.round((utilidadDisplay / ingresosDisplay) * 10000) / 100
|
// 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;
|
: 0;
|
||||||
|
|
||||||
const formatCurrency = (value: number) =>
|
const formatCurrency = (value: number) =>
|
||||||
@@ -203,7 +220,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPIs */}
|
{/* 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
|
<KpiCard
|
||||||
title={regimenSeleccionado ? `Ingresos del Mes (${regimenSeleccionado})` : 'Ingresos del Mes'}
|
title={regimenSeleccionado ? `Ingresos del Mes (${regimenSeleccionado})` : 'Ingresos del Mes'}
|
||||||
value={ingresosDisplay}
|
value={ingresosDisplay}
|
||||||
@@ -216,6 +233,13 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
href={drillUrl('Ingresos del Mes - CFDIs', { bucket: 'ingresos' })}
|
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
|
<KpiCard
|
||||||
title={regimenSeleccionado ? `Gastos del Mes (${regimenSeleccionado})` : 'Gastos del Mes'}
|
title={regimenSeleccionado ? `Gastos del Mes (${regimenSeleccionado})` : 'Gastos del Mes'}
|
||||||
value={egresosDisplay}
|
value={egresosDisplay}
|
||||||
@@ -229,11 +253,18 @@ export default function DashboardPage() {
|
|||||||
href={drillUrl('Gastos del Mes - CFDIs', { bucket: 'gastos' })}
|
href={drillUrl('Gastos del Mes - CFDIs', { bucket: 'gastos' })}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<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}
|
value={utilidadDisplay}
|
||||||
icon={<Wallet className="h-4 w-4" />}
|
icon={<Wallet className="h-4 w-4" />}
|
||||||
trend={utilidadDisplay > 0 ? 'up' : 'down'}
|
trend={utilidadDisplay > 0 ? 'up' : 'down'}
|
||||||
trendValue={`${margenDisplay}% margen`}
|
trendValue={`${margenDisplay}% margen · incluye NCs`}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title={regimenSeleccionado ? `Balance IVA (${regimenSeleccionado})` : 'Balance IVA'}
|
title={regimenSeleccionado ? `Balance IVA (${regimenSeleccionado})` : 'Balance IVA'}
|
||||||
@@ -252,7 +283,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
{/* Desglose por régimen */}
|
{/* Desglose por régimen */}
|
||||||
{!regimenSeleccionado && kpis && (
|
{!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">
|
<div className="grid gap-4 md:grid-cols-2 3xl:grid-cols-3">
|
||||||
{kpis.ingresosPorRegimen.length > 1 && (
|
{kpis.ingresosPorRegimen.length > 1 && (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -316,6 +347,46 @@ export default function DashboardPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ import {
|
|||||||
import { PapeleriaTab } from '@/components/documentos/papeleria-tab';
|
import { PapeleriaTab } from '@/components/documentos/papeleria-tab';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import * as docsApi from '@/lib/api/documentos';
|
import * as docsApi from '@/lib/api/documentos';
|
||||||
|
import { getObligacionesPorPeriodo, type ObligacionPeriodo } from '@/lib/api/obligaciones';
|
||||||
|
|
||||||
const MESES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
const MESES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
||||||
const IMPUESTOS: Impuesto[] = ['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'];
|
const IMPUESTOS: Impuesto[] = ['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'];
|
||||||
|
const OBLIGACIONES_ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'];
|
||||||
const PERIODICIDADES: { value: Periodicidad; label: string }[] = [
|
const PERIODICIDADES: { value: Periodicidad; label: string }[] = [
|
||||||
{ value: 'mensual', label: 'Mensual' },
|
{ value: 'mensual', label: 'Mensual' },
|
||||||
{ value: 'bimestral', label: 'Bimestral' },
|
{ value: 'bimestral', label: 'Bimestral' },
|
||||||
@@ -504,7 +506,7 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
|
|||||||
const [tipo, setTipo] = useState<'normal' | 'complementaria'>('normal');
|
const [tipo, setTipo] = useState<'normal' | 'complementaria'>('normal');
|
||||||
const [periodicidad, setPeriodicidad] = useState<Periodicidad>('mensual');
|
const [periodicidad, setPeriodicidad] = useState<Periodicidad>('mensual');
|
||||||
const yearsOptions = Array.from({ length: 6 }, (_, i) => currentYear - i);
|
const yearsOptions = Array.from({ length: 6 }, (_, i) => currentYear - i);
|
||||||
const [impuestos, setImpuestos] = useState<Impuesto[]>([]);
|
const [obligacionesIds, setObligacionesIds] = useState<string[]>([]);
|
||||||
const [montoPago, setMontoPago] = useState('');
|
const [montoPago, setMontoPago] = useState('');
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [ligaFile, setLigaFile] = useState<File | null>(null);
|
const [ligaFile, setLigaFile] = useState<File | null>(null);
|
||||||
@@ -512,6 +514,15 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
|
|||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
const periodOptions = getPeriodOptions(periodicidad);
|
const periodOptions = getPeriodOptions(periodicidad);
|
||||||
|
const periodo = `${año}-${String(mes).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const obligacionesQ = useQuery({
|
||||||
|
queryKey: ['obligaciones-periodo-declaracion', selectedContribuyenteId, periodo],
|
||||||
|
queryFn: () => selectedContribuyenteId
|
||||||
|
? getObligacionesPorPeriodo(selectedContribuyenteId, periodo, false)
|
||||||
|
: Promise.resolve({ data: [], periodo }),
|
||||||
|
enabled: !!selectedContribuyenteId,
|
||||||
|
});
|
||||||
|
|
||||||
const handlePeriodicidadChange = (p: Periodicidad) => {
|
const handlePeriodicidadChange = (p: Periodicidad) => {
|
||||||
setPeriodicidad(p);
|
setPeriodicidad(p);
|
||||||
@@ -522,21 +533,21 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleImpuesto = (i: Impuesto) => {
|
const toggleObligacion = (id: string) => {
|
||||||
setImpuestos(prev => prev.includes(i) ? prev.filter(x => x !== i) : [...prev, i]);
|
setObligacionesIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async (e: React.FormEvent) => {
|
const submit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErr(null);
|
setErr(null);
|
||||||
if (!file) return setErr('Selecciona el PDF de la declaración');
|
if (!file) return setErr('Selecciona el PDF de la declaración');
|
||||||
if (impuestos.length === 0) return setErr('Selecciona al menos un impuesto');
|
if (obligacionesIds.length === 0) return setErr('Selecciona al menos una obligación fiscal');
|
||||||
try {
|
try {
|
||||||
const pdfBase64 = await fileToBase64(file);
|
const pdfBase64 = await fileToBase64(file);
|
||||||
const ligaPagoBase64 = ligaFile ? await fileToBase64(ligaFile) : undefined;
|
const ligaPagoBase64 = ligaFile ? await fileToBase64(ligaFile) : undefined;
|
||||||
const montoNum = montoPago.trim() !== '' ? parseFloat(montoPago) : undefined;
|
const montoNum = montoPago.trim() !== '' ? parseFloat(montoPago) : undefined;
|
||||||
await create.mutateAsync({
|
await create.mutateAsync({
|
||||||
año, mes, tipo, periodicidad, impuestos,
|
año, mes, tipo, periodicidad, obligacionesIds,
|
||||||
montoPago: montoNum,
|
montoPago: montoNum,
|
||||||
pdfBase64, pdfFilename: file.name,
|
pdfBase64, pdfFilename: file.name,
|
||||||
ligaPagoBase64,
|
ligaPagoBase64,
|
||||||
@@ -606,16 +617,51 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label>Impuestos cubiertos</Label>
|
<Label>Obligaciones fiscales cubiertas</Label>
|
||||||
<div className="grid grid-cols-3 gap-2 mt-1">
|
{!selectedContribuyenteId ? (
|
||||||
{IMPUESTOS.map(i => (
|
<p className="text-sm text-muted-foreground mt-1">Selecciona un contribuyente para ver sus obligaciones.</p>
|
||||||
<label key={i} className={`flex items-center gap-2 px-3 py-2 rounded-md border cursor-pointer text-sm ${impuestos.includes(i) ? 'bg-primary/10 border-primary' : 'hover:bg-muted'}`}>
|
) : obligacionesQ.isLoading ? (
|
||||||
<input type="checkbox" checked={impuestos.includes(i)} onChange={() => toggleImpuesto(i)} className="accent-primary" />
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-2">
|
||||||
{i}
|
<Loader2 className="h-4 w-4 animate-spin" /> Cargando obligaciones...
|
||||||
</label>
|
</div>
|
||||||
))}
|
) : obligacionesQ.error ? (
|
||||||
</div>
|
<p className="text-sm text-red-600 mt-1">Error al cargar obligaciones.</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">Selecciona todos los impuestos que incluye esta declaración — definen qué recordatorios se desactivan.</p>
|
) : obligacionesQ.data?.data.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">No hay obligaciones fiscales configuradas para este periodo.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 mt-2 max-h-60 overflow-y-auto rounded-md border p-3">
|
||||||
|
{Array.from(new Set((obligacionesQ.data?.data || []).map(o => o.categoria || 'Sin categoría'))).map((categoria) => (
|
||||||
|
<div key={categoria}>
|
||||||
|
<p className="text-xs font-semibold uppercase text-muted-foreground mb-1.5">{categoria}</p>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{(obligacionesQ.data?.data || [])
|
||||||
|
.filter(o => (o.categoria || 'Sin categoría') === categoria)
|
||||||
|
.map((o) => (
|
||||||
|
<label
|
||||||
|
key={o.id}
|
||||||
|
className={`flex items-start gap-2 px-3 py-2 rounded-md border cursor-pointer text-sm ${obligacionesIds.includes(o.id) ? 'bg-primary/10 border-primary' : 'hover:bg-muted'}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={obligacionesIds.includes(o.id)}
|
||||||
|
onChange={() => toggleObligacion(o.id)}
|
||||||
|
className="accent-primary mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="font-medium">{o.nombre}</span>
|
||||||
|
<span className="text-xs text-muted-foreground ml-2 capitalize">({o.frecuencia || '—'})</span>
|
||||||
|
{o.requierePago && (
|
||||||
|
<span className="block text-[10px] text-muted-foreground">Requiere comprobante de pago</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Selecciona las obligaciones fiscales que cubre esta declaración. Al guardar se marcarán como presentadas y, si aplica, quedarán a la espera de su comprobante de pago.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { formatCurrency, toCfdiDate } from '@/lib/utils';
|
|||||||
import { exportToExcel } from '@/lib/export-excel';
|
import { exportToExcel } from '@/lib/export-excel';
|
||||||
import { useTableSort } from '@horux/shared-ui';
|
import { useTableSort } from '@horux/shared-ui';
|
||||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||||
|
import { getCfdiById } from '@/lib/api/cfdi';
|
||||||
import { Eye, Download } from 'lucide-react';
|
import { Eye, Download } from 'lucide-react';
|
||||||
import type { Cfdi } from '@horux/shared';
|
import type { Cfdi } from '@horux/shared';
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ export default function DrillDownPage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const titulo = searchParams.get('titulo') || 'Detalle de CFDIs';
|
const titulo = searchParams.get('titulo') || 'Detalle de CFDIs';
|
||||||
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
|
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
|
||||||
|
const [loadingCfdiId, setLoadingCfdiId] = useState<number | null>(null);
|
||||||
const { selectedContribuyenteId } = useContribuyenteStore();
|
const { selectedContribuyenteId } = useContribuyenteStore();
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -154,7 +156,23 @@ export default function DrillDownPage() {
|
|||||||
<td className="py-2 text-xs font-mono">{cfdi.regimenEmisor || '-'}</td>
|
<td className="py-2 text-xs font-mono">{cfdi.regimenEmisor || '-'}</td>
|
||||||
<td className="py-2 text-xs font-mono">{cfdi.regimenReceptor || '-'}</td>
|
<td className="py-2 text-xs font-mono">{cfdi.regimenReceptor || '-'}</td>
|
||||||
<td className="py-2">
|
<td className="py-2">
|
||||||
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={loadingCfdiId === cfdi.id}
|
||||||
|
onClick={async () => {
|
||||||
|
setLoadingCfdiId(cfdi.id);
|
||||||
|
try {
|
||||||
|
const fullCfdi = await getCfdiById(String(cfdi.id));
|
||||||
|
setSelectedCfdi(fullCfdi);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error cargando CFDI completo:', err);
|
||||||
|
} finally {
|
||||||
|
setLoadingCfdiId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Ver factura"
|
||||||
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -554,12 +554,26 @@ export default function FacturacionPage() {
|
|||||||
? clavesUnidad?.filter(u => !SERVICE_UNITS.includes(u.clave))
|
? clavesUnidad?.filter(u => !SERVICE_UNITS.includes(u.clave))
|
||||||
: clavesUnidad;
|
: clavesUnidad;
|
||||||
|
|
||||||
|
const prodSearchAbort = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const handleSearchProduct = async (q: string, idx: number) => {
|
const handleSearchProduct = async (q: string, idx: number) => {
|
||||||
setProdSearch(q);
|
setProdSearch(q);
|
||||||
setSearchingIdx(idx);
|
setSearchingIdx(idx);
|
||||||
if (q.length < 2) { setProdResults([]); return; }
|
setProdResults([]);
|
||||||
const results = await searchClaveProdServ(q);
|
if (q.length < 2) return;
|
||||||
setProdResults(results);
|
|
||||||
|
prodSearchAbort.current?.abort();
|
||||||
|
prodSearchAbort.current = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await searchClaveProdServ(q, prodSearchAbort.current.signal);
|
||||||
|
setProdResults(results ?? []);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name !== 'AbortError' && err.code !== 'ERR_CANCELED') {
|
||||||
|
console.error('Error buscando clave SAT:', err);
|
||||||
|
}
|
||||||
|
setProdResults([]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectProduct = (idx: number, clave: string, descripcion: string) => {
|
const selectProduct = (idx: number, clave: string, descripcion: string) => {
|
||||||
@@ -1418,6 +1432,7 @@ export default function FacturacionPage() {
|
|||||||
onChange={e => handleSearchProduct(e.target.value, idx)}
|
onChange={e => handleSearchProduct(e.target.value, idx)}
|
||||||
onFocus={() => { setSearchingIdx(idx); setProdSearch(c.productKey); }}
|
onFocus={() => { setSearchingIdx(idx); setProdSearch(c.productKey); }}
|
||||||
placeholder="Buscar clave SAT..."
|
placeholder="Buscar clave SAT..."
|
||||||
|
autoComplete="off"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Search className="absolute right-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute right-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ export default function PendientesPage() {
|
|||||||
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||||
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
||||||
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
||||||
|
cuatrimestral: 'bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300',
|
||||||
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||||
};
|
};
|
||||||
return f ? (
|
return f ? (
|
||||||
|
|||||||
@@ -77,10 +77,14 @@ export default function UsuariosPage() {
|
|||||||
const deleteUsuario = useDeleteUsuario();
|
const deleteUsuario = useDeleteUsuario();
|
||||||
|
|
||||||
const isDespacho = isDespachoTenant(currentUser?.tenantRfc);
|
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 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 [showInvite, setShowInvite] = useState(false);
|
||||||
const [inviteForm, setInviteForm] = useState<{ email: string; nombre: string; role: UserInvite['role']; supervisorUserId?: string }>({
|
const [inviteForm, setInviteForm] = useState<{ email: string; nombre: string; role: UserInvite['role']; supervisorUserId?: string }>({
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ export function ContribuyenteSelector() {
|
|||||||
}
|
}
|
||||||
}, [contribuyentes, selectedContribuyenteId, setSelectedContribuyente]);
|
}, [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 (isLoading || !contribuyentes || contribuyentes.length === 0) return null;
|
||||||
if (pathname && HIDDEN_PATHS.some(p => pathname === p || pathname.startsWith(`${p}/`))) return null;
|
if (pathname && HIDDEN_PATHS.some(p => pathname === p || pathname.startsWith(`${p}/`))) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ const navigation: NavItem[] = [
|
|||||||
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
|
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
|
||||||
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
|
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
|
||||||
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo', 'supervisor', 'contador', 'auxiliar'] },
|
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo', 'supervisor', 'contador', 'auxiliar'] },
|
||||||
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
|
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo', 'supervisor', 'auxiliar'] },
|
||||||
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
|
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
|
||||||
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
|
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
|
||||||
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo', 'supervisor', 'auxiliar', 'cliente'] },
|
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo', 'supervisor', 'auxiliar', 'cliente'] },
|
||||||
|
|||||||
@@ -123,20 +123,6 @@ export function TareasTab({ contribuyenteId }: { contribuyenteId: string | null
|
|||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const completarMutation = useMutation({
|
|
||||||
mutationFn: async (periodoId: string) => apiClient.post(`/tareas/periodo/${periodoId}/completar`),
|
|
||||||
onSuccess: invalidate,
|
|
||||||
onError: (err: unknown) => {
|
|
||||||
const e = err as { response?: { data?: { message?: string } } };
|
|
||||||
alert(e.response?.data?.message || 'No se pudo marcar como completada');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const descompletarMutation = useMutation({
|
|
||||||
mutationFn: async (periodoId: string) => apiClient.delete(`/tareas/periodo/${periodoId}/completar`),
|
|
||||||
onSuccess: invalidate,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleEdit = (t: Tarea) => {
|
const handleEdit = (t: Tarea) => {
|
||||||
setEditingId(t.id);
|
setEditingId(t.id);
|
||||||
setForm({
|
setForm({
|
||||||
@@ -206,16 +192,11 @@ export function TareasTab({ contribuyenteId }: { contribuyenteId: string | null
|
|||||||
return (
|
return (
|
||||||
<Card key={t.id}>
|
<Card key={t.id}>
|
||||||
<CardContent className="py-3 flex items-center gap-3">
|
<CardContent className="py-3 flex items-center gap-3">
|
||||||
<button
|
<div className="flex-shrink-0" title={p?.completada ? 'Completada' : atrasada ? 'Atrasada' : 'Pendiente'}>
|
||||||
onClick={() => p && (p.completada ? descompletarMutation.mutate(p.id) : completarMutation.mutate(p.id))}
|
|
||||||
disabled={!p || completarMutation.isPending}
|
|
||||||
title={p?.completada ? 'Marcar pendiente' : 'Marcar completada'}
|
|
||||||
className="flex-shrink-0"
|
|
||||||
>
|
|
||||||
{p?.completada
|
{p?.completada
|
||||||
? <CheckCircle2 className="h-5 w-5 text-success" />
|
? <CheckCircle2 className="h-5 w-5 text-success" />
|
||||||
: <Circle className={`h-5 w-5 ${atrasada ? 'text-destructive' : 'text-muted-foreground'}`} />}
|
: <Circle className={`h-5 w-5 ${atrasada ? 'text-destructive' : 'text-muted-foreground'}`} />}
|
||||||
</button>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className={`text-sm font-medium ${p?.completada ? 'line-through text-muted-foreground' : ''}`}>
|
<span className={`text-sm font-medium ${p?.completada ? 'line-through text-muted-foreground' : ''}`}>
|
||||||
|
|||||||
@@ -20,5 +20,6 @@ export const getMetodosPago = () => apiClient.get<CatalogoItem[]>('/catalogos/me
|
|||||||
export const getUsosCfdi = () => apiClient.get<UsoCfdiItem[]>('/catalogos/uso-cfdi').then(r => r.data);
|
export const getUsosCfdi = () => apiClient.get<UsoCfdiItem[]>('/catalogos/uso-cfdi').then(r => r.data);
|
||||||
export const getMonedas = () => apiClient.get<MonedaItem[]>('/catalogos/moneda').then(r => r.data);
|
export const getMonedas = () => apiClient.get<MonedaItem[]>('/catalogos/moneda').then(r => r.data);
|
||||||
export const getClavesUnidad = () => apiClient.get<CatalogoItem[]>('/catalogos/clave-unidad').then(r => r.data);
|
export const getClavesUnidad = () => apiClient.get<CatalogoItem[]>('/catalogos/clave-unidad').then(r => r.data);
|
||||||
export const searchClaveProdServ = (q: string) => apiClient.get<CatalogoItem[]>(`/catalogos/clave-prod-serv?q=${encodeURIComponent(q)}`).then(r => r.data);
|
export const searchClaveProdServ = (q: string, signal?: AbortSignal) =>
|
||||||
|
apiClient.get<CatalogoItem[]>(`/catalogos/clave-prod-serv?q=${encodeURIComponent(q)}`, { signal }).then(r => r.data);
|
||||||
export const getObjetosImp = () => apiClient.get<CatalogoItem[]>('/catalogos/objeto-imp').then(r => r.data);
|
export const getObjetosImp = () => apiClient.get<CatalogoItem[]>('/catalogos/objeto-imp').then(r => r.data);
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ export interface CreateDeclaracionData {
|
|||||||
mes: number;
|
mes: number;
|
||||||
tipo: 'normal' | 'complementaria';
|
tipo: 'normal' | 'complementaria';
|
||||||
periodicidad?: Periodicidad;
|
periodicidad?: Periodicidad;
|
||||||
impuestos: Impuesto[];
|
/** Legacy: se infiere en backend si se envían obligacionesIds. */
|
||||||
|
impuestos?: Impuesto[];
|
||||||
|
/** Obligaciones fiscales que cubre esta declaración. */
|
||||||
|
obligacionesIds?: string[];
|
||||||
montoPago?: number;
|
montoPago?: number;
|
||||||
pdfBase64: string;
|
pdfBase64: string;
|
||||||
pdfFilename: string;
|
pdfFilename: string;
|
||||||
|
|||||||
47
apps/web/lib/api/obligaciones.ts
Normal file
47
apps/web/lib/api/obligaciones.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
export interface DeclaracionLink {
|
||||||
|
id: number;
|
||||||
|
año: number;
|
||||||
|
mes: number;
|
||||||
|
tipo: 'normal' | 'complementaria';
|
||||||
|
pdfFilename: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObligacionPeriodo {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
frecuencia: string | null;
|
||||||
|
fechaLimite: string | null;
|
||||||
|
categoria: string | null;
|
||||||
|
activa: boolean;
|
||||||
|
esRecomendada: boolean;
|
||||||
|
completada: boolean;
|
||||||
|
completadaAt: string | null;
|
||||||
|
completadaPor: string | null;
|
||||||
|
periodoCompletado: string | null;
|
||||||
|
periodStatus: 'pendiente' | 'completada' | 'atrasada';
|
||||||
|
periodoAplica: string;
|
||||||
|
declaracion: DeclaracionLink | null;
|
||||||
|
declaracionPresentada: boolean;
|
||||||
|
pagoPresentado: boolean;
|
||||||
|
requierePago: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObligacionesPorPeriodoResponse {
|
||||||
|
data: ObligacionPeriodo[];
|
||||||
|
periodo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getObligacionesPorPeriodo(
|
||||||
|
contribuyenteId: string,
|
||||||
|
periodo: string,
|
||||||
|
atrasados = false,
|
||||||
|
): Promise<ObligacionesPorPeriodoResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('periodo', periodo);
|
||||||
|
params.set('atrasados', String(atrasados));
|
||||||
|
return apiClient
|
||||||
|
.get<ObligacionesPorPeriodoResponse>(`/contribuyentes/${contribuyenteId}/obligaciones/periodo?${params}`)
|
||||||
|
.then((r) => r.data);
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@horux/shared": "workspace:*",
|
"@horux/shared": "workspace:*",
|
||||||
|
|||||||
346
docs/CAMBIOS-2026-05-04.md
Normal file
346
docs/CAMBIOS-2026-05-04.md
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
# Resumen de cambios - 4 de mayo de 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Catálogo de obligaciones fiscales: nuevas obligaciones predefinidas
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
|
||||||
|
Se agregaron 3 obligaciones fiscales predefinidas al catálogo maestro.
|
||||||
|
|
||||||
|
### Obligaciones agregadas
|
||||||
|
|
||||||
|
| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| `isrtp` | Impuesto sobre remuneración al trabajo | mensual | Día 10 del mes siguiente | PM y PF | Estatal | Ninguna | No |
|
||||||
|
| `ish` | ISH - Impuesto Sobre Hospedaje | mensual | Día 15 del mes siguiente | PM y PF | Estatal | Ninguna | No |
|
||||||
|
| `sipare` | SIPARE - Cuotas obrero-patronales | mensual | Día 15 del mes siguiente | PM y PF | Seguridad social | Con empleados | No |
|
||||||
|
|
||||||
|
### Archivo modificado
|
||||||
|
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/api/src/constants/obligaciones-fiscales.ts` | Se agregaron las 3 entradas al array `OBLIGACIONES_CATALOGO` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Fix: Suscripciones `pending` se mostraban como activas en /configuracion/planes-despacho
|
||||||
|
|
||||||
|
**Fecha:** 2026-06-18
|
||||||
|
|
||||||
|
### Problema
|
||||||
|
En la página **Configuración › Planes**, las suscripciones con estado `pending` (primer pago aún no completado) mostraban el banner verde **"Suscripción activa"** y el badge **"Plan actual"** en verde, dando la impresión de que el plan estaba pagado y vigente.
|
||||||
|
|
||||||
|
### Causa
|
||||||
|
El frontend evaluaba `subStatus === 'authorized' || subStatus === 'pending'` para mostrar el banner de activa, y consideraba `pending` como "plan actual pagado" (`isCurrentPlanPaid`).
|
||||||
|
|
||||||
|
### Solución
|
||||||
|
- Se derivó el estado real de la suscripción con `getSubscriptionState()` de `@horux/shared`.
|
||||||
|
- El banner **"Suscripción activa"** ahora solo aparece cuando la suscripción está realmente `authorized` y dentro de su período.
|
||||||
|
- Se agregó un banner amarillo **"Suscripción pendiente de pago"** para estados `pending`.
|
||||||
|
- El badge del plan actual cambia a amarillo y muestra **"Plan actual — pendiente"** cuando la suscripción está pendiente.
|
||||||
|
- El botón **"Cancelar suscripción"** ya no se muestra para suscripciones `pending`.
|
||||||
|
|
||||||
|
### Archivos modificados
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx` | Lógica de estado de suscripción, banners y badges |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Fix: Botón "Pagar este plan" fallaba para suscripciones `pending`
|
||||||
|
|
||||||
|
**Fecha:** 2026-06-18
|
||||||
|
|
||||||
|
### Problema
|
||||||
|
Al hacer clic en **"Pagar este plan"** en una suscripción con estado `pending`, se mostraba el error:
|
||||||
|
**"No hay suscripción activa para cambiar"** en lugar de abrir MercadoPago.
|
||||||
|
|
||||||
|
### Causa
|
||||||
|
El flujo `handleContratar` intentaba crear una nueva suscripción (`subscribeMe`), pero el backend rechazaba porque ya existía una `pending`. El frontend entonces caía en `upgradeMe` y luego `changeMyPlan`, ambos validan que haya una suscripción `authorized` o `trial` — `pending` no califica, por eso el error.
|
||||||
|
|
||||||
|
### Solución
|
||||||
|
En `handleContratar`:
|
||||||
|
- Si el usuario selecciona el plan actual y la suscripción está `pending`, se llama directamente a `generatePaymentLink` para regenerar el link de pago de MercadoPago.
|
||||||
|
- Si el usuario intenta cambiar a otro plan estando `pending`, se muestra:
|
||||||
|
*"Completa el pago del plan actual antes de cambiar de plan."*
|
||||||
|
|
||||||
|
### Archivos modificados
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx` | Lógica de `handleContratar` para estados `pending` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Adjuntar PDFs en el correo de declaración subida
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
|
||||||
|
### Cambio
|
||||||
|
Cuando se sube una declaración provisional (`POST /api/documentos/declaraciones`), el correo de notificación a owners y supervisor ahora incluye como adjuntos:
|
||||||
|
|
||||||
|
- El **acuse de declaración** (`pdf_declaracion`).
|
||||||
|
- La **liga de pago** (`pdf_liga_pago`), si se subió.
|
||||||
|
|
||||||
|
### Archivos modificados
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `packages/core/src/email/transport.ts` | `EmailTransport.send` acepta un arreglo opcional de `EmailAttachment` y lo pasa a `nodemailer.sendMail` |
|
||||||
|
| `apps/api/src/services/email/email.service.ts` | `sendEmail` y `sendDocumentoSubido` aceptan y reenvían `attachments` |
|
||||||
|
| `apps/api/src/services/notify-upload.service.ts` | Nueva función `buildDeclaracionAttachments` que lee los PDFs de `declaraciones_provisionales` y los pasa al correo |
|
||||||
|
| `apps/api/src/controllers/documentos.controller.ts` | Se pasa `declaracionId` a `notifyDocumentoSubido` para poder recuperar los PDFs |
|
||||||
|
|
||||||
|
### Notas
|
||||||
|
- Los documentos extra (`POST /api/documentos/extras`) **no** incluyen adjuntos; solo cambia el flujo de declaraciones.
|
||||||
|
- Si los adjuntos superan los 20 MB, se omiten y se deja un aviso en el cuerpo del correo para evitar rechazos por límite de SMTP.
|
||||||
|
|
||||||
|
## 5. Nueva obligación: FONACOT
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
|
||||||
|
### Cambio
|
||||||
|
Se agregó la obligación `fonacot` al catálogo maestro de obligaciones fiscales.
|
||||||
|
|
||||||
|
| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| `fonacot` | Crédito FONACOT | Mensual | Día 5 del mes siguiente | PM/PF | Créditos de los trabajadores | Con empleados | ❌ |
|
||||||
|
|
||||||
|
### Archivo modificado
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/api/src/constants/obligaciones-fiscales.ts` | Se agregó la entrada `fonacot` en la sección **Créditos de los trabajadores** |
|
||||||
|
|
||||||
|
## 6. Nueva obligación: Aviso de actividades vulnerables
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
|
||||||
|
### Cambio
|
||||||
|
Se agregó la obligación `actividades-vulnerables` al catálogo maestro.
|
||||||
|
|
||||||
|
| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| `actividades-vulnerables` | Aviso de actividades vulnerables | Mensual | Día 17 del mes siguiente | PM/PF | Federal mensual | — | ❌ |
|
||||||
|
|
||||||
|
### Archivo modificado
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/api/src/constants/obligaciones-fiscales.ts` | Se agregó la entrada `actividades-vulnerables` en la sección **Federales mensuales** |
|
||||||
|
|
||||||
|
## 7. Nueva obligación: Declaración Informativa de transparencia
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
|
||||||
|
### Cambio
|
||||||
|
Se agregó la obligación `declaracion-transparencia` al catálogo maestro.
|
||||||
|
|
||||||
|
| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| `declaracion-transparencia` | Declaración Informativa de transparencia | Anual | Día 31 de mayo | PM | Federal anual | — | ❌ |
|
||||||
|
|
||||||
|
### Archivo modificado
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/api/src/constants/obligaciones-fiscales.ts` | Se agregó la entrada `declaracion-transparencia` en la sección **Anuales PM** |
|
||||||
|
|
||||||
|
## 8. Nueva obligación: Declaración Informativa Múltiple del IEPS (trimestral)
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
|
||||||
|
### Cambio
|
||||||
|
Se agregó la obligación `ieps-trimestral` al catálogo maestro.
|
||||||
|
|
||||||
|
| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| `ieps-trimestral` | Declaración Informativa Múltiple del IEPS | Trimestral | Día 17 de abril, julio, octubre y enero | PM/PF | Federal trimestral | — | ❌ |
|
||||||
|
|
||||||
|
### Archivo modificado
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/api/src/constants/obligaciones-fiscales.ts` | Se agregó la entrada `ieps-trimestral` en la nueva sección **Federales trimestrales** |
|
||||||
|
|
||||||
|
## 9. Nueva obligación: SISUB y soporte de frecuencia cuatrimestral
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
|
||||||
|
### Cambio
|
||||||
|
Se agregó la obligación `sisub` al catálogo y se extendió el sistema para soportar obligaciones con frecuencia **cuatrimestral**.
|
||||||
|
|
||||||
|
| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| `sisub` | Sistema de Información de Subcontratación | Cuatrimestral | Día 17 de enero, mayo y septiembre | PM/PF | Seguridad social | Con empleados | ❌ |
|
||||||
|
|
||||||
|
### Archivos modificados
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/api/src/constants/obligaciones-fiscales.ts` | Agregada `sisub` y `cuatrimestral` al union type de `frecuencia` |
|
||||||
|
| `apps/api/src/services/obligaciones.service.ts` | `inferirFrecuencia` y `appliesTo` soportan `cuatrimestral` |
|
||||||
|
| `apps/api/src/services/calendario-fiscal.service.ts` | Generación de eventos para meses cuatrimestrales (`1, 5, 9`) |
|
||||||
|
| `apps/api/src/services/alertas-manuales.service.ts` | `appliesToPeriod` soporta `cuatrimestral` |
|
||||||
|
| `apps/api/src/services/declaraciones.service.ts` | `Periodicidad` incluye `cuatrimestral` |
|
||||||
|
| `apps/api/src/controllers/documentos.controller.ts` | Schema de declaraciones acepta `cuatrimestral` |
|
||||||
|
| `apps/api/src/migrations/tenant/052_declaraciones_cuatrimestral.sql` | CHECK de `periodicidad` permite `cuatrimestral` |
|
||||||
|
| `apps/web/app/(dashboard)/configuracion/obligaciones/page.tsx` | Badge de frecuencia `cuatrimestral` |
|
||||||
|
| `apps/web/app/(dashboard)/pendientes/page.tsx` | Badge de frecuencia `cuatrimestral` |
|
||||||
|
|
||||||
|
## 10. Fix: sincronización SAT — tipos de CFDI, UUID case-insensitive y reutilización de requestIds
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
|
||||||
|
### Cambios
|
||||||
|
- La verificación de CFDIs incompletos (`hasIncompleteCfdis` / `getOldestIncompleteCfdiDate`) ahora incluye los tipos de comprobante **P** (pago) y **N** (nómina), además de **I** (ingreso) y **E** (egreso).
|
||||||
|
- Al guardar/actualizar CFDIs, la comparación de `uuid` se hace con `LOWER()` para evitar duplicados por diferencias de mayúsculas/minúsculas.
|
||||||
|
- Se desactivó la reutilización de `requestId` de jobs SAT previos. Reusarlos puede agotar el límite de descargas del SAT y devolver **"Máximo de descargas permitidas"**, bloqueando el recovery.
|
||||||
|
- Se exportó `runRecoverySyncJob` para permitir su invocación manual desde scripts.
|
||||||
|
|
||||||
|
### Archivos modificados
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/api/src/jobs/sat-sync.job.ts` | Incluir `P` y `N` en consultas de CFDIs incompletos; exportar `runRecoverySyncJob` |
|
||||||
|
| `apps/api/src/services/sat/sat.service.ts` | Comparación `LOWER(uuid)`; comentar reutilización de `requestId` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Fix: drill-down de CFDIs carga el CFDI completo al visualizar
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
|
||||||
|
### Problema
|
||||||
|
En la vista de drill-down, al hacer clic en el ojo para ver un CFDI se usaba únicamente el objeto resumen de la lista, que no incluye conceptos ni todos los detalles.
|
||||||
|
|
||||||
|
### Solución
|
||||||
|
Ahora se llama a `getCfdiById(id)` para obtener el CFDI completo antes de abrir el visor, y se muestra un estado de carga mientras se resuelve la petición.
|
||||||
|
|
||||||
|
### Archivo modificado
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/web/app/(dashboard)/drill-down/page.tsx` | Carga completa del CFDI al hacer clic en "Ver factura" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Scripts de soporte: Demo Ventas y operaciones
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
|
||||||
|
Se crearon varios scripts de utilería bajo `apps/api/scripts/` para tareas de soporte y configuración de la cuenta Demo Ventas.
|
||||||
|
|
||||||
|
### Scripts principales
|
||||||
|
| Script | Propósito |
|
||||||
|
|---|---|
|
||||||
|
| `create-demo-ventas.ts` | Crea el tenant Demo Ventas, su BD, usuario owner y suscripción custom gratuita |
|
||||||
|
| `update-demo-ventas.ts` | Agrega usuarios supervisor/auxiliar/cliente y 5 contribuyentes adicionales a Demo Ventas |
|
||||||
|
| `seed-demo-obligaciones-tareas.ts` | Siembra obligaciones fiscales y tareas recurrentes para todos los contribuyentes de Demo Ventas |
|
||||||
|
| `fix-demo-carteras-asignaciones.ts` | Crea la subcartera del auxiliar y asigna contribuyentes, obligaciones y tareas de forma válida |
|
||||||
|
| `reset-demo-asignaciones.ts` | Deja Demo Ventas en estado "tutorial": elimina subcarteras, asignaciones y relación auxiliar-supervisor |
|
||||||
|
| `change-user-email.ts` | Cambia el correo de un usuario, genera contraseña temporal e invalida sesiones |
|
||||||
|
| `resend-welcome.ts` | Reenvía el correo de bienvenida a un usuario |
|
||||||
|
|
||||||
|
> Estos scripts no son parte del flujo productivo; se ejecutan manualmente vía `npx tsx`.
|
||||||
|
|
||||||
|
## 13. Automatización de cierre de obligaciones fiscales
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
|
||||||
|
### Cambio
|
||||||
|
Se automatiza el cierre de **todas las obligaciones fiscales** desde la sección existente **Documentos › Declaraciones**. Al subir una declaración o su comprobante de pago, el sistema crea automáticamente evidencias en `obligacion_evidencias` y actualiza el estado de cada obligación fiscal en `obligacion_periodos`.
|
||||||
|
|
||||||
|
### Reglas de cierre deterministas
|
||||||
|
- `requierePago = false` (informativas): se marcan completadas al subir la declaración (`declaracion`).
|
||||||
|
- `requierePago = true` (pago + declaración): la declaración marca `declaracion_presentada = true`; el periodo se cierra al subir el comprobante de pago (`pago`).
|
||||||
|
- Al subir una declaración con **monto $0**, se marca el pago como presentado automáticamente.
|
||||||
|
|
||||||
|
### Nuevas tablas y columnas
|
||||||
|
| Migración | Descripción |
|
||||||
|
|---|---|
|
||||||
|
| `053_obligacion_evidencias.sql` | Tabla genérica para evidencias de obligaciones (declaración, pago, acuse, complemento) |
|
||||||
|
| `054_obligacion_periodos_estados.sql` | Agrega `declaracion_presentada`, `pago_presentado` y `evidencia_id` a `obligacion_periodos` |
|
||||||
|
| `055_declaracion_obligaciones.sql` | Relaciona declaraciones provisionales con las obligaciones fiscales que cierran |
|
||||||
|
|
||||||
|
### Nuevos endpoints (uso interno / futuro)
|
||||||
|
| Método | Endpoint | Descripción |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/api/documentos/obligacion-evidencias` | Listar evidencias por contribuyente/periodo/obligación |
|
||||||
|
| `POST` | `/api/documentos/obligacion-evidencias` | Subir nueva evidencia |
|
||||||
|
| `GET` | `/api/documentos/obligacion-evidencias/:id/pdf` | Descargar PDF de evidencia |
|
||||||
|
| `DELETE` | `/api/documentos/obligacion-evidencias/:id` | Eliminar evidencia y recalcular estado del periodo |
|
||||||
|
|
||||||
|
### Archivos creados
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/api/src/services/obligacion-evidencias.service.ts` | Servicio para crear/listar/descargar/eliminar evidencias y actualizar `obligacion_periodos` |
|
||||||
|
| `apps/api/src/migrations/tenant/053_obligacion_evidencias.sql` | Tabla `obligacion_evidencias` |
|
||||||
|
| `apps/api/src/migrations/tenant/054_obligacion_periodos_estados.sql` | Columnas de estado en `obligacion_periodos` |
|
||||||
|
| `apps/api/src/migrations/tenant/055_declaracion_obligaciones.sql` | Relación declaración ↔ obligación |
|
||||||
|
| `apps/web/lib/api/obligaciones.ts` | Cliente API para obtener obligaciones por periodo |
|
||||||
|
|
||||||
|
### Archivos modificados
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/api/src/constants/obligaciones-fiscales.ts` | Campo `requierePago` en todas las obligaciones del catálogo |
|
||||||
|
| `apps/api/src/services/declaraciones.service.ts` | Crea evidencias en las obligaciones seleccionadas; vincula declaración con obligaciones; mantiene fallback legacy por impuestos |
|
||||||
|
| `apps/api/src/services/obligaciones.service.ts` | `getObligacionesPorPeriodo` devuelve `requierePago`, `declaracionPresentada`, `pagoPresentado` |
|
||||||
|
| `apps/api/src/services/notify-upload.service.ts` | Soporte para notificaciones de `obligacion_evidencia` |
|
||||||
|
| `apps/api/src/services/email/templates/documento-subido.ts` | Template para evidencias de obligación |
|
||||||
|
| `apps/api/src/controllers/documentos.controller.ts` | Schema de declaraciones acepta `obligacionesIds` |
|
||||||
|
| `apps/api/src/routes/documentos.routes.ts` | Rutas de evidencias |
|
||||||
|
| `apps/web/lib/api/declaraciones.ts` | `CreateDeclaracionData` acepta `obligacionesIds` |
|
||||||
|
| `apps/web/app/(dashboard)/documentos/page.tsx` | Diálogo de subida reemplaza “Impuestos cubiertos” por selector de obligaciones fiscales del periodo |
|
||||||
|
|
||||||
|
## 15. Fix: quitar toggle de completado en Configuración › Obligaciones fiscales › Tareas
|
||||||
|
|
||||||
|
**Fecha:** 2026-06-22
|
||||||
|
|
||||||
|
### Problema
|
||||||
|
En **Configuración › Obligaciones fiscales › Tareas** seguía apareciendo el botón para marcar tareas como completadas/pendientes manualmente, pero el estado de las obligaciones fiscales ahora se actualiza automáticamente desde **Documentos › Declaraciones**.
|
||||||
|
|
||||||
|
### Solución
|
||||||
|
- Se convirtió el icono de check/círculo en un indicador visual de estado (completada, pendiente, atrasada) sin interacción.
|
||||||
|
- Se eliminaron las mutaciones de completar/descompletar periodo del frontend.
|
||||||
|
|
||||||
|
### Archivo modificado
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/web/components/obligaciones/tareas-tab.tsx` | Icono de estado estático; eliminados `completarMutation` y `descompletarMutation` |
|
||||||
|
|
||||||
|
## 14. Fix: sugerencias de Clave Producto SAT en facturación
|
||||||
|
|
||||||
|
**Fecha:** 2026-06-22
|
||||||
|
|
||||||
|
### Problema
|
||||||
|
En **Facturación › Conceptos**, el campo **Clave Producto SAT** no mostraba sugerencias al escribir.
|
||||||
|
|
||||||
|
### Causa
|
||||||
|
La tabla `cat_clave_prod_serv` de la BD central estaba vacía; el catálogo nunca se había importado.
|
||||||
|
|
||||||
|
### Solución
|
||||||
|
- Se importó el catálogo oficial CFDI 4.0 (`c_ClaveProdServ`) desde los recursos de **phpcfdi/resources-sat-catalogs** (52,513 registros).
|
||||||
|
- Se creó el script `apps/api/scripts/import-clave-prod-serv.ts` para importaciones futuras.
|
||||||
|
- Se hizo más robusto el autocomplete del campo:
|
||||||
|
- `AbortController` para cancelar búsquedas anteriores.
|
||||||
|
- Manejo de errores y `autoComplete="off"`.
|
||||||
|
- Se sanitizó el fallback regex en el backend para evitar errores con caracteres especiales.
|
||||||
|
|
||||||
|
### Archivos creados
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/api/scripts/import-clave-prod-serv.ts` | Importa el catálogo desde CSV a PostgreSQL |
|
||||||
|
|
||||||
|
### Archivos modificados
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/api/src/controllers/catalogos.controller.ts` | Escapa regex en búsqueda fallback; búsqueda por clave insensible a mayúsculas |
|
||||||
|
| `apps/web/lib/api/catalogos.ts` | `searchClaveProdServ` acepta `AbortSignal` |
|
||||||
|
| `apps/web/app/(dashboard)/facturacion/page.tsx` | `handleSearchProduct` con `AbortController`, try/catch y `autoComplete="off"` |
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/HoruxDespachosNuevo
|
||||||
|
pnpm --filter @horux/core build
|
||||||
|
pnpm --filter api build
|
||||||
|
pnpm --filter web build
|
||||||
|
npx tsx apps/api/scripts/migrate-tenants.ts
|
||||||
|
pm2 reload horux-api
|
||||||
|
pm2 reload horux-web
|
||||||
|
```
|
||||||
|
|
||||||
|
**Estado:** ✅ Exitoso
|
||||||
@@ -8,8 +8,13 @@ export interface SmtpConfig {
|
|||||||
from: string;
|
from: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmailAttachment {
|
||||||
|
filename: string;
|
||||||
|
content: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EmailTransport {
|
export interface EmailTransport {
|
||||||
send(to: string, subject: string, html: string): Promise<void>;
|
send(to: string, subject: string, html: string, attachments?: EmailAttachment[]): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEmailTransport(config: SmtpConfig | null): EmailTransport {
|
export function createEmailTransport(config: SmtpConfig | null): EmailTransport {
|
||||||
@@ -21,7 +26,11 @@ export function createEmailTransport(config: SmtpConfig | null): EmailTransport
|
|||||||
console.warn('[EMAIL] SMTP not configured. Emails will be logged to console.');
|
console.warn('[EMAIL] SMTP not configured. Emails will be logged to console.');
|
||||||
return {
|
return {
|
||||||
sendMail: async (opts: any) => {
|
sendMail: async (opts: any) => {
|
||||||
console.log('[EMAIL] Would send:', { to: opts.to, subject: opts.subject });
|
console.log('[EMAIL] Would send:', {
|
||||||
|
to: opts.to,
|
||||||
|
subject: opts.subject,
|
||||||
|
attachments: opts.attachments?.map((a: any) => a.filename ?? a.path),
|
||||||
|
});
|
||||||
return { messageId: 'mock' };
|
return { messageId: 'mock' };
|
||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
@@ -42,7 +51,7 @@ export function createEmailTransport(config: SmtpConfig | null): EmailTransport
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async send(to: string, subject: string, html: string) {
|
async send(to: string, subject: string, html: string, attachments?: EmailAttachment[]) {
|
||||||
const transport = getTransporter();
|
const transport = getTransporter();
|
||||||
try {
|
try {
|
||||||
await transport.sendMail({
|
await transport.sendMail({
|
||||||
@@ -51,7 +60,9 @@ export function createEmailTransport(config: SmtpConfig | null): EmailTransport
|
|||||||
subject,
|
subject,
|
||||||
html,
|
html,
|
||||||
text: html.replace(/<[^>]*>/g, ''),
|
text: html.replace(/<[^>]*>/g, ''),
|
||||||
|
attachments,
|
||||||
});
|
});
|
||||||
|
console.log(`[EMAIL] Sent email to ${to} with ${attachments?.length ?? 0} attachment(s)`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[EMAIL] Error sending email:', error);
|
console.error('[EMAIL] Error sending email:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export interface KpiData {
|
|||||||
cfdisEmitidosPorRegimen: { regimen: string; total: number }[];
|
cfdisEmitidosPorRegimen: { regimen: string; total: number }[];
|
||||||
cfdisRecibidos: number;
|
cfdisRecibidos: number;
|
||||||
cfdisRecibidosPorRegimen: { regimen: string; total: number }[];
|
cfdisRecibidosPorRegimen: { regimen: string; total: number }[];
|
||||||
|
ncsEmitidas: number;
|
||||||
|
ncsEmitidasPorRegimen: IngresoRegimen[];
|
||||||
|
ncsRecibidas: number;
|
||||||
|
ncsRecibidasPorRegimen: IngresoRegimen[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IngresosEgresosData {
|
export interface IngresosEgresosData {
|
||||||
|
|||||||
Reference in New Issue
Block a user