chore: catálogo obligaciones, cierre automático, fixes SAT y facturación

- Catálogo de obligaciones fiscales expandido a 30 entradas con campo requierePago.
- Soporte de frecuencia cuatrimestral en obligaciones y declaraciones.
- Automatización de cierre de obligaciones fiscales desde Documentos › Declaraciones.
- Nuevas tablas obligacion_evidencias, obligacion_periodos estados y declaracion_obligaciones.
- Nuevo servicio obligacion-evidencias.service.ts y endpoints REST.
- Refactor de declaraciones.service.ts para vincular obligaciones y crear evidencias.
- Notificaciones por email para evidencias de obligaciones.
- Adjuntar PDFs en correo de declaración subida.
- Fix drill-down de CFDIs: carga completa al visualizar.
- Fix sincronización SAT: tipos P/N, UUID case-insensitive, no reutilizar requestId.
- Fix suscripciones pending en /configuracion/planes-despacho.
- Fix sugerencias de Clave Producto SAT: importar catálogo y robustecer autocomplete.
- Quitar toggle manual de completado en Configuración › Obligaciones fiscales › Tareas.
- Scripts de soporte para Demo Ventas y utilerías (change-user-email, resend-welcome, import-clave-prod-serv).
- Documentación de cambios en docs/CAMBIOS-2026-05-04.md.
This commit is contained in:
Horux Dev
2026-06-22 04:53:59 +00:00
parent b217342a96
commit 7df27ce66d
39 changed files with 2791 additions and 191 deletions

View 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();
});

View 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();
});

View 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();
});

View 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);
});

View 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();
});

View 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();
});

View 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();
});

View 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();
});

View File

@@ -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 },
]; ];
/** /**

View File

@@ -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ñ]');

View File

@@ -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); }
}

View File

@@ -402,7 +402,7 @@ async function hasIncompleteCfdis(pool: Pool, contribuyenteId: string): Promise<
FROM cfdis FROM cfdis
WHERE contribuyente_id = $1 WHERE contribuyente_id = $1
AND status = 'Vigente' AND status = 'Vigente'
AND tipo_comprobante IN ('I', 'E') AND tipo_comprobante IN ('I', 'E', 'P', 'N')
AND xml_original IS NULL AND xml_original IS NULL
`, [contribuyenteId]); `, [contribuyenteId]);
return Number(rows[0]?.count || 0) > 0; return Number(rows[0]?.count || 0) > 0;
@@ -414,7 +414,7 @@ async function getOldestIncompleteCfdiDate(pool: Pool, contribuyenteId: string):
FROM cfdis FROM cfdis
WHERE contribuyente_id = $1 WHERE contribuyente_id = $1
AND status = 'Vigente' AND status = 'Vigente'
AND tipo_comprobante IN ('I', 'E') AND tipo_comprobante IN ('I', 'E', 'P', 'N')
AND xml_original IS NULL AND xml_original IS NULL
`, [contribuyenteId]); `, [contribuyenteId]);
return rows[0]?.fecha_emision || null; return rows[0]?.fecha_emision || null;
@@ -504,7 +504,7 @@ async function recoverTenant(tenantId: string): Promise<void> {
} }
} }
async function runRecoverySyncJob(): Promise<void> { export async function runRecoverySyncJob(): Promise<void> {
if (isRecoveryRunning) { if (isRecoveryRunning) {
console.log('[SAT Recovery] Ya en ejecución, omitiendo'); console.log('[SAT Recovery] Ya en ejecución, omitiendo');
return; return;

View File

@@ -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'));

View 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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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
} }

View File

@@ -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-*`
// visibles hasta que realmente se cierre el ciclo.
const cubrePago = data.tipo === 'complementaria' || montoPago === 0;
if (data.contribuyenteId && cubrePago) {
if (!data.creadoPorUserId) {
console.warn('[createDeclaracion] Sin creadoPorUserId — no se auto-completan obligaciones del contribuyente');
} else {
const periodo = `${data.año}-${String(data.mes).padStart(2, '0')}`; const periodo = `${data.año}-${String(data.mes).padStart(2, '0')}`;
alertasResueltas += await completarObligacionesPorDeclaracion(
pool, data.contribuyenteId, data.impuestos, periodo, data.creadoPorUserId, declaracion.id, periodicidad, if (obligacionesSeleccionadas.length > 0) {
await registrarEvidenciasPorObligaciones(
pool, obligacionesSeleccionadas, data.contribuyenteId, periodo, data.creadoPorUserId,
data.pdfBase64, data.pdfFilename, 'declaracion', data.notas,
); );
} else if (impuestos.length > 0) {
const { obligacionesAfectadas: afectadas } = await registrarEvidenciasPorDeclaracion(
pool, data.contribuyenteId, impuestos, periodo, data.creadoPorUserId,
data.pdfBase64, data.pdfFilename, 'declaracion', periodicidad,
);
obligacionesAfectadas = afectadas;
}
// Si la declaración es por $0, no se requiere comprobante de pago:
// marcar el pago como presentado automáticamente.
if (montoPago === 0 && obligacionesAfectadas.length > 0) {
await confirmarPagoPeriodoSinComprobante(pool, obligacionesAfectadas, periodo, data.creadoPorUserId);
} }
} }
@@ -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 };

View File

@@ -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);
} }

View File

@@ -2,7 +2,7 @@ import { baseTemplate, heading, infoBox, primaryButton, BRAND_COLORS as C } from
export interface DocumentoSubidoData { export interface DocumentoSubidoData {
/** Kind: para el título/subject. */ /** Kind: para el título/subject. */
kind: 'declaracion' | 'extra'; kind: 'declaracion' | 'extra' | 'obligacion_evidencia';
/** Quién subió el documento (email). */ /** Quién subió el documento (email). */
subidoPor: string; subidoPor: string;
/** RFC del contribuyente. */ /** RFC del contribuyente. */
@@ -24,17 +24,30 @@ export interface DocumentoSubidoData {
descripcion?: string | null; descripcion?: string | null;
categoria?: string | null; categoria?: string | null;
}; };
/** Si es evidencia de obligación fiscal. */
evidencia?: {
obligacionNombre: string;
periodo: string;
tipoDocumento: string;
filename: string;
};
/** URL al sistema (ej. https://despachos.horuxfin.com/documentos). */ /** URL al sistema (ej. https://despachos.horuxfin.com/documentos). */
link: string; link: string;
/** Solo para declaraciones: los adjuntos se omitieron por exceder el límite de tamaño. */
attachmentsOmitted?: boolean;
} }
export function documentoSubidoEmail(data: DocumentoSubidoData): string { export function documentoSubidoEmail(data: DocumentoSubidoData): string {
const titulo = data.kind === 'declaracion' const titulo = data.kind === 'declaracion'
? 'Nueva declaración subida' ? 'Nueva declaración subida'
: data.kind === 'obligacion_evidencia'
? 'Nueva evidencia de obligación fiscal'
: 'Nuevo documento subido'; : 'Nuevo documento subido';
const contenidoEspecifico = data.kind === 'declaracion' && data.declaracion const contenidoEspecifico = data.kind === 'declaracion' && data.declaracion
? declaracionBlock(data.declaracion) ? declaracionBlock(data.declaracion)
: data.kind === 'obligacion_evidencia' && data.evidencia
? evidenciaBlock(data.evidencia)
: data.extra : data.extra
? extraBlock(data.extra) ? extraBlock(data.extra)
: ''; : '';
@@ -42,7 +55,7 @@ export function documentoSubidoEmail(data: DocumentoSubidoData): string {
return baseTemplate(` return baseTemplate(`
${heading(titulo)} ${heading(titulo)}
<p style="color:${C.textPrimary};margin:0 0 16px;"> <p style="color:${C.textPrimary};margin:0 0 16px;">
<strong>${escapeHtml(data.subidoPor)}</strong> subió un ${data.kind === 'declaracion' ? 'acuse de declaración' : 'documento'} <strong>${escapeHtml(data.subidoPor)}</strong> subió ${data.kind === 'obligacion_evidencia' ? 'una evidencia de obligación fiscal' : data.kind === 'declaracion' ? 'un acuse de declaración' : 'un documento'}
para <strong>${escapeHtml(data.contribuyenteNombre)}</strong>. para <strong>${escapeHtml(data.contribuyenteNombre)}</strong>.
</p> </p>
${infoBox(` ${infoBox(`
@@ -57,6 +70,12 @@ export function documentoSubidoEmail(data: DocumentoSubidoData): string {
<div style="margin-top:24px;"> <div style="margin-top:24px;">
${primaryButton('Ver en el sistema', data.link)} ${primaryButton('Ver en el sistema', data.link)}
</div> </div>
${data.kind === 'declaracion' && data.attachmentsOmitted ? `
<p style="color:${C.textMuted};font-size:13px;margin-top:16px;">
Los documentos no se adjuntaron porque exceden el tamaño permitido por correo.
Puedes descargarlos desde el sistema.
</p>
` : ''}
`); `);
} }
@@ -76,6 +95,19 @@ function declaracionBlock(d: NonNullable<DocumentoSubidoData['declaracion']>): s
`; `;
} }
function evidenciaBlock(e: NonNullable<DocumentoSubidoData['evidencia']>): string {
return `
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Obligación</p>
<p style="margin:0 0 12px;color:${C.textPrimary};font-weight:600;">${escapeHtml(e.obligacionNombre)}</p>
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Periodo</p>
<p style="margin:0 0 12px;color:${C.textPrimary};">${escapeHtml(e.periodo)}</p>
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Tipo de documento</p>
<p style="margin:0 0 12px;color:${C.textPrimary};text-transform:capitalize;">${escapeHtml(e.tipoDocumento)}</p>
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Archivo</p>
<p style="margin:0 0 12px;color:${C.textPrimary};">${escapeHtml(e.filename)}</p>
`;
}
function extraBlock(e: NonNullable<DocumentoSubidoData['extra']>): string { function extraBlock(e: NonNullable<DocumentoSubidoData['extra']>): string {
return ` return `
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Documento</p> <p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Documento</p>

View File

@@ -5,6 +5,10 @@ import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js'
import { env } from '../config/env.js'; import { env } from '../config/env.js';
import { filterRecipientsByRole, type RecipientWithRole } 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;
@@ -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 };
} }

View 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,
};
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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',
}; };

View File

@@ -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';
@@ -89,15 +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).
// Estados en los que se puede generar un link de pago (incluye trial y vencido).
const isPayableStatus = subStatus === 'trial' const isPayableStatus = subStatus === 'trial'
|| subStatus === 'trial_expired' || subStatus === 'trial_expired'
|| subStatus === 'pending'
|| hasActiveSub; || hasActiveSub;
const isCurrentPlanPaid = currentPlan === planInfo?.subscription?.plan const isCurrentPlanPaid = currentPlan === planInfo?.subscription?.plan && subState?.isActive === true;
&& (subStatus === 'authorized' || subStatus === 'pending');
/** 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. */
@@ -112,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');
@@ -197,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>
); );
} }
@@ -325,7 +334,7 @@ export default function PlanesDespachoPage() {
)} )}
{/* Banner de suscripción activa */} {/* Banner de suscripción activa */}
{!loading && planInfo?.subscription && hasPaidPlan && (subStatus === 'authorized' || subStatus === 'pending') && (() => { {!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
@@ -352,6 +361,21 @@ export default function PlanesDespachoPage() {
); );
})()} })()}
{/* Banner de suscripción pendiente */}
{!loading && planInfo?.subscription && hasPaidPlan && subState?.isPending && (
<div className="flex items-start gap-3 bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
<Clock className="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
<div className="text-sm space-y-0.5">
<div className="font-semibold text-yellow-800 dark:text-yellow-300">
Suscripción pendiente de pago
</div>
<div className="text-yellow-700 dark:text-yellow-400">
Tu suscripción aún no está activa. Completa el pago para evitar la suspensión del servicio.
</div>
</div>
</div>
)}
{/* Banner de trial vencido */} {/* Banner de trial vencido */}
{!loading && subStatus === 'trial_expired' && hasPaidPlan && ( {!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"> <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">
@@ -423,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" />
@@ -457,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" />
@@ -494,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
@@ -529,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" />

View File

@@ -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...
</div>
) : obligacionesQ.error ? (
<p className="text-sm text-red-600 mt-1">Error al cargar obligaciones.</p>
) : obligacionesQ.data?.data.length === 0 ? (
<p className="text-sm text-muted-foreground mt-1">No hay obligaciones fiscales configuradas para este periodo.</p>
) : (
<div className="space-y-3 mt-2 max-h-60 overflow-y-auto rounded-md border p-3">
{Array.from(new Set((obligacionesQ.data?.data || []).map(o => o.categoria || 'Sin categoría'))).map((categoria) => (
<div key={categoria}>
<p className="text-xs font-semibold uppercase text-muted-foreground mb-1.5">{categoria}</p>
<div className="grid grid-cols-1 gap-2">
{(obligacionesQ.data?.data || [])
.filter(o => (o.categoria || 'Sin categoría') === categoria)
.map((o) => (
<label
key={o.id}
className={`flex items-start gap-2 px-3 py-2 rounded-md border cursor-pointer text-sm ${obligacionesIds.includes(o.id) ? 'bg-primary/10 border-primary' : 'hover:bg-muted'}`}
>
<input
type="checkbox"
checked={obligacionesIds.includes(o.id)}
onChange={() => toggleObligacion(o.id)}
className="accent-primary mt-0.5"
/>
<div className="flex-1">
<span className="font-medium">{o.nombre}</span>
<span className="text-xs text-muted-foreground ml-2 capitalize">({o.frecuencia || '—'})</span>
{o.requierePago && (
<span className="block text-[10px] text-muted-foreground">Requiere comprobante de pago</span>
)}
</div>
</label> </label>
))} ))}
</div> </div>
<p className="text-xs text-muted-foreground mt-1">Selecciona todos los impuestos que incluye esta declaración definen qué recordatorios se desactivan.</p> </div>
))}
</div>
)}
<p className="text-xs text-muted-foreground mt-1">Selecciona las obligaciones fiscales que cubre esta declaración. Al guardar se marcarán como presentadas y, si aplica, quedarán a la espera de su comprobante de pago.</p>
</div> </div>
<div> <div>

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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 ? (

View File

@@ -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' : ''}`}>

View File

@@ -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);

View File

@@ -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;

View 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);
}

View File

@@ -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
View 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

View File

@@ -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);
} }