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:
75
apps/api/scripts/change-user-email.ts
Normal file
75
apps/api/scripts/change-user-email.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Script: change-user-email
|
||||||
|
*
|
||||||
|
* Cambia el correo de un usuario, resetea su contraseña a una temporal
|
||||||
|
* y reenvía el correo de bienvenida con las nuevas credenciales.
|
||||||
|
*
|
||||||
|
* Ejecución:
|
||||||
|
* cd apps/api && npx tsx scripts/change-user-email.ts
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { emailService } from '../src/services/email/email.service.js';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const OLD_EMAIL = 'eduardo.corona@corpcyl.com';
|
||||||
|
const NEW_EMAIL = 'miguel.corona@corpcyl.com';
|
||||||
|
|
||||||
|
function generateTempPassword(length = 12): string {
|
||||||
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
|
||||||
|
let result = '';
|
||||||
|
const bytes = randomBytes(length);
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += chars[bytes[i] % chars.length];
|
||||||
|
}
|
||||||
|
return result + '!';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const user = await prisma.user.findUnique({ where: { email: OLD_EMAIL } });
|
||||||
|
if (!user) {
|
||||||
|
console.error(`❌ No existe un usuario con el correo ${OLD_EMAIL}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({ where: { email: NEW_EMAIL } });
|
||||||
|
if (existing) {
|
||||||
|
console.error(`❌ Ya existe un usuario con el correo ${NEW_EMAIL}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempPassword = generateTempPassword();
|
||||||
|
const passwordHash = await bcrypt.hash(tempPassword, 12);
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
email: NEW_EMAIL,
|
||||||
|
passwordHash,
|
||||||
|
tokenVersion: { increment: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await emailService.sendWelcome(NEW_EMAIL, {
|
||||||
|
nombre: user.nombre,
|
||||||
|
email: NEW_EMAIL,
|
||||||
|
tempPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Correo actualizado:', OLD_EMAIL, '→', NEW_EMAIL);
|
||||||
|
console.log('✅ Contraseña temporal generada y enviada por correo');
|
||||||
|
console.log(' Nombre:', user.nombre);
|
||||||
|
console.log(' Email:', NEW_EMAIL);
|
||||||
|
console.log(' Contraseña temporal:', tempPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('\n❌ Error:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
457
apps/api/scripts/create-demo-ventas.ts
Normal file
457
apps/api/scripts/create-demo-ventas.ts
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
/**
|
||||||
|
* Script: create-demo-ventas
|
||||||
|
*
|
||||||
|
* Crea una cuenta demo completa para ventas:
|
||||||
|
* - Tenant "Demo Ventas SA de CV" (plan custom, sin cobro)
|
||||||
|
* - Usuario owner: demo@horuxfin.com / Demo12345!
|
||||||
|
* - Base de datos propia con datos ficticios de contabilidad
|
||||||
|
* - Contribuyente, clientes/proveedores, CFDIs, bancos, conciliaciones,
|
||||||
|
* obligaciones fiscales y cartera.
|
||||||
|
*
|
||||||
|
* Ejecución:
|
||||||
|
* cd apps/api && npx tsx scripts/create-demo-ventas.ts
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { tenantDb } from '../src/config/database.ts';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const DEMO = {
|
||||||
|
rfc: 'DEMO2501019X2',
|
||||||
|
nombre: 'Demo Ventas SA de CV',
|
||||||
|
email: 'demo@horuxfin.com',
|
||||||
|
password: 'Demo12345!',
|
||||||
|
databaseName: 'horux_demoventas',
|
||||||
|
codigoPostal: '01000',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLIENTES = [
|
||||||
|
{ rfc: 'CLI123456AB1', nombre: 'Cliente Alfa SA' },
|
||||||
|
{ rfc: 'CLI123456AB2', nombre: 'Cliente Beta SA' },
|
||||||
|
{ rfc: 'CLI123456AB3', nombre: 'Cliente Gamma SA' },
|
||||||
|
{ rfc: 'CLI123456AB4', nombre: 'Cliente Delta SA' },
|
||||||
|
{ rfc: 'CLI123456AB5', nombre: 'Cliente Epsilon SA' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROVEEDORES = [
|
||||||
|
{ rfc: 'PRO123456AB1', nombre: 'Proveedor Materiales SA' },
|
||||||
|
{ rfc: 'PRO123456AB2', nombre: 'Proveedor Servicios SA' },
|
||||||
|
{ rfc: 'PRO123456AB3', nombre: 'Proveedor Logistica SA' },
|
||||||
|
{ rfc: 'PRO123456AB4', nombre: 'Proveedor Tecnologia SA' },
|
||||||
|
{ rfc: 'PRO123456AB5', nombre: 'Proveedor Papeleria SA' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRODUCTOS = [
|
||||||
|
{ clave: '84111506', descripcion: 'Servicio de consultoria', unidad: 'Servicio' },
|
||||||
|
{ clave: '43232408', descripcion: 'Licencia de software', unidad: 'Licencia' },
|
||||||
|
{ clave: '81141500', descripcion: 'Soporte tecnico', unidad: 'Servicio' },
|
||||||
|
{ clave: '81121700', descripcion: 'Desarrollo web', unidad: 'Servicio' },
|
||||||
|
{ clave: '86101500', descripcion: 'Capacitacion', unidad: 'Servicio' },
|
||||||
|
{ clave: '50151500', descripcion: 'Materiales de oficina', unidad: 'Pieza' },
|
||||||
|
{ clave: '80181600', descripcion: 'Publicidad', unidad: 'Servicio' },
|
||||||
|
{ clave: '81112200', descripcion: 'Diseno grafico', unidad: 'Servicio' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseDatabaseUrl(url: string) {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return {
|
||||||
|
host: parsed.hostname,
|
||||||
|
port: parseInt(parsed.port || '5432'),
|
||||||
|
user: decodeURIComponent(parsed.username),
|
||||||
|
password: decodeURIComponent(parsed.password),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🌱 Creando cuenta demo "Demo Ventas"...\n');
|
||||||
|
|
||||||
|
const ownerRole = await prisma.rol.findUnique({ where: { nombre: 'owner' } });
|
||||||
|
if (!ownerRole) throw new Error('Rol owner no encontrado en BD central');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 1. Tenant
|
||||||
|
// ============================================================
|
||||||
|
let tenant = await prisma.tenant.findUnique({ where: { rfc: DEMO.rfc } });
|
||||||
|
if (!tenant) {
|
||||||
|
tenant = await prisma.tenant.create({
|
||||||
|
data: {
|
||||||
|
nombre: DEMO.nombre,
|
||||||
|
rfc: DEMO.rfc,
|
||||||
|
plan: 'custom',
|
||||||
|
databaseName: DEMO.databaseName,
|
||||||
|
verticalProfile: 'CONTABLE',
|
||||||
|
dbMode: 'MANAGED',
|
||||||
|
dbSchemaVersion: 0,
|
||||||
|
codigoPostal: DEMO.codigoPostal,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('✅ Tenant creado:', tenant.nombre, `(${tenant.rfc})`);
|
||||||
|
} else {
|
||||||
|
await prisma.tenant.update({
|
||||||
|
where: { id: tenant.id },
|
||||||
|
data: { plan: 'custom', active: true, verticalProfile: 'CONTABLE' },
|
||||||
|
});
|
||||||
|
console.log('✅ Tenant actualizado:', tenant.nombre, `(${tenant.rfc})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 2. Usuario owner
|
||||||
|
// ============================================================
|
||||||
|
let user = await prisma.user.findUnique({ where: { email: DEMO.email } });
|
||||||
|
const passwordHash = await bcrypt.hash(DEMO.password, 12);
|
||||||
|
if (!user) {
|
||||||
|
user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: DEMO.email,
|
||||||
|
passwordHash,
|
||||||
|
nombre: 'Usuario Demo',
|
||||||
|
lastTenantId: tenant.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('✅ Usuario creado:', user.email);
|
||||||
|
} else {
|
||||||
|
user = await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { passwordHash, lastTenantId: tenant.id },
|
||||||
|
});
|
||||||
|
console.log('✅ Usuario actualizado:', user.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 3. Membership
|
||||||
|
// ============================================================
|
||||||
|
await prisma.tenantMembership.upsert({
|
||||||
|
where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } },
|
||||||
|
update: { rolId: ownerRole.id, isOwner: true, active: true },
|
||||||
|
create: {
|
||||||
|
userId: user.id,
|
||||||
|
tenantId: tenant.id,
|
||||||
|
rolId: ownerRole.id,
|
||||||
|
isOwner: true,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('✅ Membership owner asignada');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 4. Suscripción custom gratis/ilimitada (status authorized)
|
||||||
|
// ============================================================
|
||||||
|
const now = new Date();
|
||||||
|
const periodEnd = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
|
||||||
|
const existingSub = await prisma.subscription.findFirst({
|
||||||
|
where: { tenantId: tenant.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingSub) {
|
||||||
|
await prisma.subscription.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
plan: 'custom',
|
||||||
|
status: 'authorized',
|
||||||
|
amount: 0,
|
||||||
|
frequency: 'monthly',
|
||||||
|
currentPeriodStart: now,
|
||||||
|
currentPeriodEnd: periodEnd,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.subscription.update({
|
||||||
|
where: { id: existingSub.id },
|
||||||
|
data: {
|
||||||
|
plan: 'custom',
|
||||||
|
status: 'authorized',
|
||||||
|
amount: 0,
|
||||||
|
currentPeriodStart: now,
|
||||||
|
currentPeriodEnd: periodEnd,
|
||||||
|
pendingPlan: null,
|
||||||
|
pendingFrequency: null,
|
||||||
|
pendingEffectiveAt: null,
|
||||||
|
upgradePreferenceId: null,
|
||||||
|
upgradeTargetPlan: null,
|
||||||
|
upgradeTargetAmount: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log('✅ Suscripción custom activa (gratis)');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 5. Régimen fiscal activo del tenant
|
||||||
|
// ============================================================
|
||||||
|
const regimen = await prisma.regimen.findUnique({ where: { clave: '601' } });
|
||||||
|
if (regimen) {
|
||||||
|
await prisma.tenantRegimenActivo.upsert({
|
||||||
|
where: { tenantId_regimenId: { tenantId: tenant.id, regimenId: regimen.id } },
|
||||||
|
update: {},
|
||||||
|
create: { tenantId: tenant.id, regimenId: regimen.id },
|
||||||
|
});
|
||||||
|
console.log('✅ Régimen 601 activado para el tenant');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 6. Base de datos del tenant
|
||||||
|
// ============================================================
|
||||||
|
await tenantDb.provisionDatabase(DEMO.rfc, DEMO.databaseName);
|
||||||
|
const pool = await tenantDb.getPool(tenant.id, DEMO.databaseName);
|
||||||
|
console.log('✅ Base de datos del tenant provisionada:', DEMO.databaseName);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 7. Datos ficticios en BD del tenant
|
||||||
|
// ============================================================
|
||||||
|
await seedTenantData(pool, tenant.id, user.id);
|
||||||
|
|
||||||
|
console.log('\n🎉 Demo Ventas lista');
|
||||||
|
console.log(' Login:', DEMO.email, '/', DEMO.password);
|
||||||
|
console.log(' Tenant:', DEMO.nombre, `(${DEMO.rfc})`);
|
||||||
|
console.log(' BD:', DEMO.databaseName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedTenantData(pool: Pool, tenantId: string, ownerId: string) {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Contribuyente principal
|
||||||
|
const { rows: [entidad] } = await client.query<{ id: string }>(`
|
||||||
|
INSERT INTO entidades_gestionadas (tipo, nombre, identificador, supervisor_user_id)
|
||||||
|
VALUES ('CONTRIBUYENTE', $1, $2, $3)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
RETURNING id
|
||||||
|
`, [DEMO.nombre, DEMO.rfc, ownerId]);
|
||||||
|
|
||||||
|
let contribuyenteId: string;
|
||||||
|
if (entidad) {
|
||||||
|
contribuyenteId = entidad.id;
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO contribuyentes (entidad_id, rfc, regimen_fiscal, codigo_postal)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (entidad_id) DO NOTHING
|
||||||
|
`, [contribuyenteId, DEMO.rfc, '601', DEMO.codigoPostal]);
|
||||||
|
} else {
|
||||||
|
const { rows: [existing] } = await client.query<{ id: string }>(`
|
||||||
|
SELECT e.id FROM entidades_gestionadas e
|
||||||
|
JOIN contribuyentes c ON c.entidad_id = e.id
|
||||||
|
WHERE e.identificador = $1
|
||||||
|
`, [DEMO.rfc]);
|
||||||
|
contribuyenteId = existing.id;
|
||||||
|
}
|
||||||
|
console.log('✅ Contribuyente principal creado:', DEMO.rfc);
|
||||||
|
|
||||||
|
// RFCs de clientes y proveedores
|
||||||
|
const rfcs = new Map<string, number>();
|
||||||
|
for (const c of [...CLIENTES, ...PROVEEDORES]) {
|
||||||
|
const { rows: [r] } = await client.query<{ id: number }>(`
|
||||||
|
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
|
||||||
|
RETURNING id
|
||||||
|
`, [c.rfc, c.nombre, c.rfc.startsWith('CLI') ? '601' : '601']);
|
||||||
|
rfcs.set(c.rfc, r.id);
|
||||||
|
}
|
||||||
|
// RFC del contribuyente principal
|
||||||
|
const { rows: [rfcPrincipal] } = await client.query<{ id: number }>(`
|
||||||
|
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
|
||||||
|
RETURNING id
|
||||||
|
`, [DEMO.rfc, DEMO.nombre, '601']);
|
||||||
|
rfcs.set(DEMO.rfc, rfcPrincipal.id);
|
||||||
|
|
||||||
|
// Bancos del contribuyente
|
||||||
|
const { rows: [banco1] } = await client.query<{ id: number }>(`
|
||||||
|
INSERT INTO bancos (banco, terminacion_cuenta, contribuyente_id)
|
||||||
|
VALUES ($1, $2, $3) RETURNING id
|
||||||
|
`, ['BBVA', '1234', contribuyenteId]);
|
||||||
|
const { rows: [banco2] } = await client.query<{ id: number }>(`
|
||||||
|
INSERT INTO bancos (banco, terminacion_cuenta, contribuyente_id)
|
||||||
|
VALUES ($1, $2, $3) RETURNING id
|
||||||
|
`, ['Santander', '5678', contribuyenteId]);
|
||||||
|
console.log('✅ Bancos creados');
|
||||||
|
|
||||||
|
// Generar CFDIs
|
||||||
|
const tipos: Array<'EMITIDO' | 'RECIBIDO'> = ['EMITIDO', 'RECIBIDO'];
|
||||||
|
const cfdiIds: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
const tipo = tipos[i % 2];
|
||||||
|
const esEmitido = tipo === 'EMITIDO';
|
||||||
|
const contraparte = esEmitido
|
||||||
|
? CLIENTES[i % CLIENTES.length]
|
||||||
|
: PROVEEDORES[i % PROVEEDORES.length];
|
||||||
|
|
||||||
|
const subtotal = Math.floor(Math.random() * 40000) + 2000;
|
||||||
|
const iva = Math.round(subtotal * 0.16 * 100) / 100;
|
||||||
|
const total = Math.round((subtotal + iva) * 100) / 100;
|
||||||
|
|
||||||
|
const daysAgo = Math.floor(Math.random() * 540); // hasta ~18 meses atrás
|
||||||
|
const fecha = new Date();
|
||||||
|
fecha.setDate(fecha.getDate() - daysAgo);
|
||||||
|
fecha.setHours(10 + (i % 8), 0, 0, 0);
|
||||||
|
|
||||||
|
const year = String(fecha.getFullYear());
|
||||||
|
const month = String(fecha.getMonth() + 1).padStart(2, '0');
|
||||||
|
const fechaStr = fecha.toISOString();
|
||||||
|
|
||||||
|
const metodoPago = Math.random() > 0.3 ? 'PUE' : 'PPD';
|
||||||
|
const formasPago = ['01', '02', '03', '04'];
|
||||||
|
const formaPago = formasPago[i % formasPago.length];
|
||||||
|
const usoCfdi = esEmitido ? 'G03' : 'G01';
|
||||||
|
|
||||||
|
const rfcEmisor = esEmitido ? DEMO.rfc : contraparte.rfc;
|
||||||
|
const nombreEmisor = esEmitido ? DEMO.nombre : contraparte.nombre;
|
||||||
|
const rfcReceptor = esEmitido ? contraparte.rfc : DEMO.rfc;
|
||||||
|
const nombreReceptor = esEmitido ? contraparte.nombre : DEMO.nombre;
|
||||||
|
|
||||||
|
const { rows: [cfdi] } = await client.query<{ id: number }>(`
|
||||||
|
INSERT INTO cfdis (
|
||||||
|
year, month, type, uuid, serie, folio, status, fecha_emision,
|
||||||
|
rfc_emisor_id, rfc_emisor, nombre_emisor,
|
||||||
|
rfc_receptor_id, rfc_receptor, nombre_receptor,
|
||||||
|
subtotal, subtotal_mxn, descuento, descuento_mxn,
|
||||||
|
total, total_mxn, moneda, tipo_cambio, tipo_comprobante,
|
||||||
|
metodo_pago, forma_pago, uso_cfdi,
|
||||||
|
iva_traslado, iva_traslado_mxn,
|
||||||
|
regimen_fiscal_emisor, regimen_fiscal_receptor,
|
||||||
|
contribuyente_id, fecha_efectiva, meses_global, año_global
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8,
|
||||||
|
$9, $10, $11,
|
||||||
|
$12, $13, $14,
|
||||||
|
$15, $16, $17, $18,
|
||||||
|
$19, $20, $21, $22, $23,
|
||||||
|
$24, $25, $26,
|
||||||
|
$27, $28,
|
||||||
|
$29, $30,
|
||||||
|
$31, $32, $33, $34
|
||||||
|
) RETURNING id
|
||||||
|
`, [
|
||||||
|
year, month, tipo, randomUUID(), 'DEMO', String(1000 + i),
|
||||||
|
'Vigente', fechaStr,
|
||||||
|
rfcs.get(rfcEmisor), rfcEmisor, nombreEmisor,
|
||||||
|
rfcs.get(rfcReceptor), rfcReceptor, nombreReceptor,
|
||||||
|
subtotal, subtotal, 0, 0,
|
||||||
|
total, total, 'MXN', 1, 'I',
|
||||||
|
metodoPago, formaPago, usoCfdi,
|
||||||
|
iva, iva,
|
||||||
|
'601', '601',
|
||||||
|
contribuyenteId, fechaStr, month, year,
|
||||||
|
]);
|
||||||
|
cfdiIds.push(cfdi.id);
|
||||||
|
|
||||||
|
// Conceptos
|
||||||
|
const numConceptos = Math.floor(Math.random() * 3) + 1;
|
||||||
|
for (let j = 0; j < numConceptos; j++) {
|
||||||
|
const prod = PRODUCTOS[(i + j) % PRODUCTOS.length];
|
||||||
|
const cantidad = Math.floor(Math.random() * 5) + 1;
|
||||||
|
const valorUnitario = Math.floor(Math.random() * 4000) + 500;
|
||||||
|
const importe = Math.round(cantidad * valorUnitario * 100) / 100;
|
||||||
|
const ivaConcepto = Math.round(importe * 0.16 * 100) / 100;
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO cfdi_conceptos (
|
||||||
|
cfdi_id, clave_prod_serv, descripcion, cantidad, clave_unidad, unidad,
|
||||||
|
valor_unitario, valor_unitario_mxn, importe, importe_mxn,
|
||||||
|
iva_traslado, iva_traslado_mxn
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
`, [
|
||||||
|
cfdi.id, prod.clave, prod.descripcion, cantidad, 'E48', prod.unidad,
|
||||||
|
valorUnitario, valorUnitario, importe, importe,
|
||||||
|
ivaConcepto, ivaConcepto,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('✅ 60 CFDIs y conceptos creados');
|
||||||
|
|
||||||
|
// Conciliaciones para algunos CFDIs PPD pagados con transferencia (forma 02/03)
|
||||||
|
const { rows: cfdisPpd } = await client.query<{ id: number; year: string; month: string }>(`
|
||||||
|
SELECT id, year, month FROM cfdis
|
||||||
|
WHERE metodo_pago = 'PPD' AND forma_pago IN ('02', '03')
|
||||||
|
ORDER BY id LIMIT 15
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const c of cfdisPpd) {
|
||||||
|
const bancoId = Math.random() > 0.5 ? banco1.id : banco2.id;
|
||||||
|
const fechaPago = new Date();
|
||||||
|
fechaPago.setDate(fechaPago.getDate() - Math.floor(Math.random() * 30));
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO conciliaciones (anio, mes, id_cfdi, fecha_de_pago, id_banco)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (id_cfdi) DO NOTHING
|
||||||
|
`, [c.year, c.month, c.id, fechaPago.toISOString().split('T')[0], bancoId]);
|
||||||
|
}
|
||||||
|
console.log('✅ Conciliaciones creadas');
|
||||||
|
|
||||||
|
// Obligaciones fiscales asignadas al contribuyente
|
||||||
|
const obligaciones = [
|
||||||
|
{ id: 'isr-provisional', nombre: 'Pago provisional de ISR', categoria: 'Federal mensual' },
|
||||||
|
{ id: 'iva-mensual', nombre: 'Pago mensual definitivo de IVA', categoria: 'Federal mensual' },
|
||||||
|
{ id: 'ret-isr-honorarios', nombre: 'Retenciones de ISR por honorarios y arrendamiento a PF', categoria: 'Federal mensual' },
|
||||||
|
{ id: 'diot', nombre: 'DIOT', categoria: 'Informativa mensual' },
|
||||||
|
{ id: 'imss-cuotas', nombre: 'Cuotas obrero-patronales IMSS', categoria: 'Seguridad social' },
|
||||||
|
{ id: 'anual-isr-pm', nombre: 'Declaración Anual de ISR PM', categoria: 'Anual' },
|
||||||
|
{ id: 'isn', nombre: 'ISN - Impuesto Sobre Nómina', categoria: 'Estatal' },
|
||||||
|
{ id: 'isrtp', nombre: 'Impuesto sobre remuneración al trabajo', categoria: 'Estatal' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const o of obligaciones) {
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO obligaciones_contribuyente (
|
||||||
|
contribuyente_id, catalogo_id, nombre, frecuencia, fecha_limite, categoria, activa, es_recomendada
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, true, true)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`, [contribuyenteId, o.id, o.nombre, 'mensual', 'Día 17 del mes siguiente', o.categoria]);
|
||||||
|
}
|
||||||
|
console.log('✅ Obligaciones fiscales asignadas');
|
||||||
|
|
||||||
|
// Cartera principal con el contribuyente
|
||||||
|
const { rows: [cartera] } = await client.query<{ id: string }>(`
|
||||||
|
INSERT INTO carteras (supervisor_user_id, nombre, descripcion)
|
||||||
|
VALUES ($1, $2, $3) RETURNING id
|
||||||
|
`, [ownerId, 'Cartera Principal', 'Clientes y prospectos de Demo Ventas']);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO cartera_entidades (cartera_id, entidad_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`, [cartera.id, contribuyenteId]);
|
||||||
|
console.log('✅ Cartera principal creada');
|
||||||
|
|
||||||
|
// Alertas y recordatorios de ejemplo
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
|
||||||
|
VALUES
|
||||||
|
('obligacion', 'Declaración mensual de IVA', 'Pago de IVA correspondiente a mayo 2026', 'alta', NOW() + INTERVAL '10 days'),
|
||||||
|
('obligacion', 'Pago provisional ISR', 'Pago provisional de ISR de mayo 2026', 'alta', NOW() + INTERVAL '10 days'),
|
||||||
|
('sat', 'Sincronización SAT pendiente', 'Última sincronización hace más de 7 días', 'media', NOW() + INTERVAL '3 days')
|
||||||
|
`);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO recordatorios (titulo, descripcion, fecha_limite, notas, completado, privado, creado_por)
|
||||||
|
VALUES
|
||||||
|
('Revisar estados de cuenta', 'Conciliar pagos de clientes', NOW() + INTERVAL '5 days', 'Prioridad alta', false, false, $1),
|
||||||
|
('Enviar facturas del mes', 'Facturación recurrente a clientes', NOW() + INTERVAL '7 days', 'Clientes Alfa y Beta', false, false, $1)
|
||||||
|
`, [ownerId]);
|
||||||
|
console.log('✅ Alertas y recordatorios de ejemplo creados');
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('\n❌ Error creando demo:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await tenantDb.shutdown();
|
||||||
|
});
|
||||||
126
apps/api/scripts/fix-demo-carteras-asignaciones.ts
Normal file
126
apps/api/scripts/fix-demo-carteras-asignaciones.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Script: fix-demo-carteras-asignaciones
|
||||||
|
*
|
||||||
|
* Corrige la estructura de carteras de Demo Ventas para que las asignaciones
|
||||||
|
* de obligaciones/tareas al auxiliar sean válidas:
|
||||||
|
* - La cartera principal queda solo para el supervisor.
|
||||||
|
* - Se crea una subcartera asignada al auxiliar.
|
||||||
|
* - Los contribuyentes se mueven a la subcartera del auxiliar.
|
||||||
|
* - Se mantiene la relación auxiliar → supervisor.
|
||||||
|
*
|
||||||
|
* Ejecución:
|
||||||
|
* cd apps/api && npx tsx scripts/fix-demo-carteras-asignaciones.ts
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { tenantDb } from '../src/config/database.ts';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const DEMO_RFC = 'DEMO2501019X2';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🔧 Corrigiendo carteras y asignaciones de Demo Ventas...\n');
|
||||||
|
|
||||||
|
const tenant = await prisma.tenant.findUnique({ where: { rfc: DEMO_RFC } });
|
||||||
|
if (!tenant) throw new Error(`Tenant ${DEMO_RFC} no encontrado`);
|
||||||
|
|
||||||
|
const [supervisor, auxiliar] = await Promise.all([
|
||||||
|
prisma.user.findUnique({ where: { email: 'supervisor@horuxfin.com' } }),
|
||||||
|
prisma.user.findUnique({ where: { email: 'auxiliar@horuxfin.com' } }),
|
||||||
|
]);
|
||||||
|
if (!supervisor) throw new Error('Usuario supervisor no encontrado');
|
||||||
|
if (!auxiliar) throw new Error('Usuario auxiliar no encontrado');
|
||||||
|
|
||||||
|
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Obtener cartera principal
|
||||||
|
const { rows: [carteraPrincipal] } = await client.query<{ id: string }>(`
|
||||||
|
SELECT id FROM carteras WHERE parent_id IS NULL ORDER BY created_at LIMIT 1
|
||||||
|
`);
|
||||||
|
if (!carteraPrincipal) throw new Error('No existe cartera principal');
|
||||||
|
|
||||||
|
// Crear subcartera para el auxiliar
|
||||||
|
const { rows: [subcartera] } = await client.query<{ id: string }>(`
|
||||||
|
INSERT INTO carteras (supervisor_user_id, auxiliar_user_id, nombre, descripcion, parent_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
RETURNING id
|
||||||
|
`, [supervisor.id, auxiliar.id, 'Cartera Auxiliar Demo', 'RFCs asignados al auxiliar de demo', carteraPrincipal.id]);
|
||||||
|
|
||||||
|
const subcarteraId = subcartera?.id;
|
||||||
|
if (!subcarteraId) {
|
||||||
|
// Si ya existía, recuperarla
|
||||||
|
const { rows: [existing] } = await client.query<{ id: string }>(`
|
||||||
|
SELECT id FROM carteras WHERE parent_id = $1 AND auxiliar_user_id = $2 LIMIT 1
|
||||||
|
`, [carteraPrincipal.id, auxiliar.id]);
|
||||||
|
if (!existing) throw new Error('No se pudo crear ni recuperar la subcartera del auxiliar');
|
||||||
|
// Asegurar que tenga supervisor
|
||||||
|
await client.query(`UPDATE carteras SET supervisor_user_id = $1 WHERE id = $2`, [supervisor.id, existing.id]);
|
||||||
|
}
|
||||||
|
const finalSubcarteraId = subcarteraId || (await client.query<{ id: string }>(`SELECT id FROM carteras WHERE parent_id = $1 AND auxiliar_user_id = $2 LIMIT 1`, [carteraPrincipal.id, auxiliar.id])).rows[0].id;
|
||||||
|
|
||||||
|
console.log('✅ Subcartera del auxiliar creada/recuperada');
|
||||||
|
|
||||||
|
// Mover contribuyentes de la cartera principal a la subcartera del auxiliar
|
||||||
|
const { rows: entidades } = await client.query<{ entidad_id: string }>(`
|
||||||
|
SELECT entidad_id FROM cartera_entidades WHERE cartera_id = $1
|
||||||
|
`, [carteraPrincipal.id]);
|
||||||
|
|
||||||
|
for (const e of entidades) {
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO cartera_entidades (cartera_id, entidad_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`, [finalSubcarteraId, e.entidad_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quitar contribuyentes de la cartera principal (ahora están en la subcartera)
|
||||||
|
await client.query(`DELETE FROM cartera_entidades WHERE cartera_id = $1`, [carteraPrincipal.id]);
|
||||||
|
|
||||||
|
// La cartera principal ya no tiene auxiliar asignado
|
||||||
|
await client.query(`UPDATE carteras SET auxiliar_user_id = NULL WHERE id = $1`, [carteraPrincipal.id]);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
console.log(`✅ ${entidades.length} contribuyentes movidos a la subcartera del auxiliar`);
|
||||||
|
console.log('✅ Cartera principal limpia (sin auxiliar)');
|
||||||
|
|
||||||
|
// Asegurar relación auxiliar → supervisor
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO auxiliar_supervisores (auxiliar_user_id, supervisor_user_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (auxiliar_user_id) DO UPDATE SET supervisor_user_id = EXCLUDED.supervisor_user_id
|
||||||
|
`, [auxiliar.id, supervisor.id]);
|
||||||
|
console.log('✅ Relación auxiliar → supervisor registrada');
|
||||||
|
|
||||||
|
// Validar: el auxiliar debe ser elegible para todos los contribuyentes
|
||||||
|
const { rows: elegibles } = await pool.query<{ entidad_id: string }>(`
|
||||||
|
SELECT DISTINCT ce.entidad_id
|
||||||
|
FROM carteras c
|
||||||
|
JOIN cartera_entidades ce ON ce.cartera_id = c.id
|
||||||
|
WHERE c.auxiliar_user_id = $1
|
||||||
|
`, [auxiliar.id]);
|
||||||
|
console.log(`✅ Auxiliar elegible para ${elegibles.length} contribuyentes`);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎉 Estructura de carteras corregida');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('\n❌ Error:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await tenantDb.shutdown();
|
||||||
|
});
|
||||||
56
apps/api/scripts/import-clave-prod-serv.ts
Normal file
56
apps/api/scripts/import-clave-prod-serv.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import readline from 'readline';
|
||||||
|
import { prisma } from '../src/config/database.js';
|
||||||
|
|
||||||
|
const BATCH_SIZE = 2000;
|
||||||
|
const CSV_PATH = process.argv[2] || '/tmp/claves_prod_serv.csv';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!fs.existsSync(CSV_PATH)) {
|
||||||
|
console.error(`Archivo no encontrado: ${CSV_PATH}`);
|
||||||
|
console.error('Uso: npx tsx scripts/import-clave-prod-serv.ts [ruta/al/csv]');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.catClaveProdServ.count();
|
||||||
|
console.log(`Registros existentes: ${existing}`);
|
||||||
|
if (existing > 0) {
|
||||||
|
console.log('El catálogo ya tiene datos. No se importará nada.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileStream = fs.createReadStream(CSV_PATH, { encoding: 'utf-8' });
|
||||||
|
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
|
||||||
|
|
||||||
|
let batch: { clave: string; descripcion: string }[] = [];
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
for await (const line of rl) {
|
||||||
|
const idx = line.indexOf(',');
|
||||||
|
if (idx === -1) continue;
|
||||||
|
const clave = line.slice(0, idx).trim();
|
||||||
|
const descripcion = line.slice(idx + 1).trim();
|
||||||
|
if (!clave || !descripcion) continue;
|
||||||
|
batch.push({ clave, descripcion });
|
||||||
|
|
||||||
|
if (batch.length >= BATCH_SIZE) {
|
||||||
|
await prisma.catClaveProdServ.createMany({ data: batch, skipDuplicates: true });
|
||||||
|
total += batch.length;
|
||||||
|
console.log(`Importados: ${total}`);
|
||||||
|
batch = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batch.length > 0) {
|
||||||
|
await prisma.catClaveProdServ.createMany({ data: batch, skipDuplicates: true });
|
||||||
|
total += batch.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Importación completada. Total: ${total}`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
67
apps/api/scripts/resend-welcome.ts
Normal file
67
apps/api/scripts/resend-welcome.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Script: resend-welcome
|
||||||
|
*
|
||||||
|
* Genera una nueva contraseña temporal para el usuario y reenvía el correo
|
||||||
|
* de bienvenida. Útil cuando el envío anterior falló o se perdió.
|
||||||
|
*
|
||||||
|
* Ejecución:
|
||||||
|
* cd apps/api && npx tsx scripts/resend-welcome.ts
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { emailService } from '../src/services/email/email.service.js';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const EMAIL = 'miguel.corona@corpcyl.com';
|
||||||
|
|
||||||
|
function generateTempPassword(length = 12): string {
|
||||||
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
|
||||||
|
let result = '';
|
||||||
|
const bytes = randomBytes(length);
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += chars[bytes[i] % chars.length];
|
||||||
|
}
|
||||||
|
return result + '!';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const user = await prisma.user.findUnique({ where: { email: EMAIL } });
|
||||||
|
if (!user) {
|
||||||
|
console.error(`❌ No existe un usuario con el correo ${EMAIL}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempPassword = generateTempPassword();
|
||||||
|
const passwordHash = await bcrypt.hash(tempPassword, 12);
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
passwordHash,
|
||||||
|
tokenVersion: { increment: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('⏳ Enviando correo de bienvenida a', EMAIL, '...');
|
||||||
|
await emailService.sendWelcome(EMAIL, {
|
||||||
|
nombre: user.nombre,
|
||||||
|
email: EMAIL,
|
||||||
|
tempPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Correo de bienvenida enviado');
|
||||||
|
console.log(' Nombre:', user.nombre);
|
||||||
|
console.log(' Email:', EMAIL);
|
||||||
|
console.log(' Contraseña temporal:', tempPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('\n❌ Error enviando correo:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
112
apps/api/scripts/reset-demo-asignaciones.ts
Normal file
112
apps/api/scripts/reset-demo-asignaciones.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Script: reset-demo-asignaciones
|
||||||
|
*
|
||||||
|
* Deja el tenant Demo Ventas listo para que el usuario haga manualmente
|
||||||
|
* el flujo de asignación de carteras, obligaciones y tareas (útiles para tutoriales):
|
||||||
|
* - Elimina la subcartera del auxiliar.
|
||||||
|
* - Deja todos los contribuyentes en la cartera principal (sin auxiliar).
|
||||||
|
* - Elimina asignaciones de obligaciones y tareas.
|
||||||
|
* - Elimina la relación auxiliar → supervisor.
|
||||||
|
*
|
||||||
|
* Ejecución:
|
||||||
|
* cd apps/api && npx tsx scripts/reset-demo-asignaciones.ts
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { tenantDb } from '../src/config/database.ts';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const DEMO_RFC = 'DEMO2501019X2';
|
||||||
|
|
||||||
|
async function findUserIdByEmail(email: string): Promise<string | null> {
|
||||||
|
const rows = await prisma.$queryRawUnsafe<{ id: string }[]>(
|
||||||
|
`SELECT id FROM users WHERE email = $1 LIMIT 1`,
|
||||||
|
email,
|
||||||
|
);
|
||||||
|
return rows[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🔄 Reseteando asignaciones de Demo Ventas para tutoriales...\n');
|
||||||
|
|
||||||
|
const tenants = await prisma.$queryRawUnsafe<{ id: string; database_name: string }[]>(
|
||||||
|
`SELECT id, database_name FROM tenants WHERE rfc = $1 LIMIT 1`,
|
||||||
|
DEMO_RFC,
|
||||||
|
);
|
||||||
|
const tenant = tenants[0];
|
||||||
|
if (!tenant) throw new Error(`Tenant ${DEMO_RFC} no encontrado`);
|
||||||
|
|
||||||
|
const supervisorId = await findUserIdByEmail('supervisor@horuxfin.com');
|
||||||
|
if (!supervisorId) throw new Error('Usuario supervisor no encontrado');
|
||||||
|
|
||||||
|
const auxiliarId = await findUserIdByEmail('auxiliar@horuxfin.com');
|
||||||
|
|
||||||
|
const pool = await tenantDb.getPool(tenant.id, tenant.database_name);
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Eliminar asignaciones de obligaciones y tareas
|
||||||
|
await client.query('DELETE FROM obligacion_asignaciones');
|
||||||
|
await client.query('DELETE FROM tarea_asignaciones');
|
||||||
|
console.log('✅ Asignaciones de obligaciones y tareas eliminadas');
|
||||||
|
|
||||||
|
// Obtener cartera principal
|
||||||
|
const { rows: [carteraPrincipal] } = await client.query<{ id: string }>(`
|
||||||
|
SELECT id FROM carteras WHERE parent_id IS NULL ORDER BY created_at LIMIT 1
|
||||||
|
`);
|
||||||
|
if (!carteraPrincipal) throw new Error('No existe cartera principal');
|
||||||
|
|
||||||
|
// Eliminar subcarteras (borra también cartera_entidades en cascade si hay FK)
|
||||||
|
await client.query('DELETE FROM cartera_entidades WHERE cartera_id != $1', [carteraPrincipal.id]);
|
||||||
|
await client.query('DELETE FROM carteras WHERE parent_id = $1', [carteraPrincipal.id]);
|
||||||
|
console.log('✅ Subcarteras eliminadas');
|
||||||
|
|
||||||
|
// Limpiar cartera principal: sin auxiliar, supervisor demo
|
||||||
|
await client.query(`
|
||||||
|
UPDATE carteras SET auxiliar_user_id = NULL, supervisor_user_id = $1 WHERE id = $2
|
||||||
|
`, [supervisorId, carteraPrincipal.id]);
|
||||||
|
|
||||||
|
// Agregar todos los contribuyentes a la cartera principal
|
||||||
|
const { rows: contribuyentes } = await client.query<{ entidad_id: string }>(`
|
||||||
|
SELECT entidad_id FROM contribuyentes
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const c of contribuyentes) {
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO cartera_entidades (cartera_id, entidad_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`, [carteraPrincipal.id, c.entidad_id]);
|
||||||
|
}
|
||||||
|
console.log(`✅ ${contribuyentes.length} contribuyentes dejados en Cartera Principal`);
|
||||||
|
|
||||||
|
// Eliminar relación auxiliar → supervisor para que se cree en el tutorial
|
||||||
|
if (auxiliarId) {
|
||||||
|
await client.query('DELETE FROM auxiliar_supervisores WHERE auxiliar_user_id = $1', [auxiliarId]);
|
||||||
|
console.log('✅ Relación auxiliar → supervisor eliminada');
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎉 Demo Ventas listo para tutoriales');
|
||||||
|
console.log(' - Cartera Principal con 6 contribuyentes, sin auxiliar');
|
||||||
|
console.log(' - 48 obligaciones y 24 tareas sin asignar');
|
||||||
|
console.log(' - Usuarios: owner, supervisor, auxiliar, cliente');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('\n❌ Error:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await tenantDb.shutdown();
|
||||||
|
});
|
||||||
124
apps/api/scripts/seed-demo-obligaciones-tareas.ts
Normal file
124
apps/api/scripts/seed-demo-obligaciones-tareas.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Script: seed-demo-obligaciones-tareas
|
||||||
|
*
|
||||||
|
* Crea obligaciones fiscales y tareas recurrentes para todos los contribuyentes
|
||||||
|
* del tenant Demo Ventas. Además asigna el usuario auxiliar a las tareas y
|
||||||
|
* obligaciones, y lo vincula a la cartera principal.
|
||||||
|
*
|
||||||
|
* Ejecución:
|
||||||
|
* cd apps/api && npx tsx scripts/seed-demo-obligaciones-tareas.ts
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { tenantDb } from '../src/config/database.ts';
|
||||||
|
import { seedTareasDefault, materializarPeriodos } from '../src/services/tareas.service.ts';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const DEMO_RFC = 'DEMO2501019X2';
|
||||||
|
|
||||||
|
const OBLIGACIONES = [
|
||||||
|
{ id: 'isr-provisional', nombre: 'Pago provisional de ISR', fundamento: 'Art. 14 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', categoria: 'Federal mensual' },
|
||||||
|
{ id: 'iva-mensual', nombre: 'Pago mensual definitivo de IVA', fundamento: 'Art. 5-D LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', categoria: 'Federal mensual' },
|
||||||
|
{ id: 'ret-isr-honorarios', nombre: 'Retenciones de ISR por honorarios y arrendamiento a PF', fundamento: 'Art. 106/116 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', categoria: 'Federal mensual' },
|
||||||
|
{ id: 'diot', nombre: 'DIOT (Declaración Informativa de Operaciones con Terceros)', fundamento: 'Art. 32 LIVA', frecuencia: 'mensual', fechaLimite: 'Último día del mes siguiente', categoria: 'Informativa mensual' },
|
||||||
|
{ id: 'imss-cuotas', nombre: 'Cuotas obrero-patronales IMSS', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', categoria: 'Seguridad social' },
|
||||||
|
{ id: 'anual-isr-pm', nombre: 'Declaración Anual de ISR PM', fundamento: 'Art. 76 LISR', frecuencia: 'anual', fechaLimite: '31 de marzo', categoria: 'Anual' },
|
||||||
|
{ id: 'isn', nombre: 'ISN - Impuesto Sobre Nómina', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Varía por estado (CDMX día 17)', categoria: 'Estatal' },
|
||||||
|
{ id: 'isrtp', nombre: 'Impuesto sobre remuneración al trabajo', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Día 10 del mes siguiente', categoria: 'Estatal' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🌱 Sembrando obligaciones y tareas en Demo Ventas...\n');
|
||||||
|
|
||||||
|
const tenant = await prisma.tenant.findUnique({ where: { rfc: DEMO_RFC } });
|
||||||
|
if (!tenant) throw new Error(`Tenant ${DEMO_RFC} no encontrado`);
|
||||||
|
|
||||||
|
const auxUser = await prisma.user.findUnique({ where: { email: 'auxiliar@horuxfin.com' } });
|
||||||
|
if (!auxUser) throw new Error('Usuario auxiliar no encontrado');
|
||||||
|
|
||||||
|
const supervisorUser = await prisma.user.findUnique({ where: { email: 'supervisor@horuxfin.com' } });
|
||||||
|
if (!supervisorUser) throw new Error('Usuario supervisor no encontrado');
|
||||||
|
|
||||||
|
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||||
|
|
||||||
|
const { rows: contribuyentes } = await pool.query<{ id: string; rfc: string }>(`
|
||||||
|
SELECT entidad_id AS id, rfc FROM contribuyentes ORDER BY rfc
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (contribuyentes.length === 0) throw new Error('No hay contribuyentes en el tenant demo');
|
||||||
|
|
||||||
|
for (const c of contribuyentes) {
|
||||||
|
// Obligaciones fiscales (idempotente: evita duplicados por contribuyente + catalogo_id)
|
||||||
|
let obligacionesCreadas = 0;
|
||||||
|
for (const o of OBLIGACIONES) {
|
||||||
|
const { rows: existing } = await pool.query(
|
||||||
|
`SELECT 1 FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND catalogo_id = $2 LIMIT 1`,
|
||||||
|
[c.id, o.id],
|
||||||
|
);
|
||||||
|
if (existing.length > 0) continue;
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO obligaciones_contribuyente (
|
||||||
|
contribuyente_id, catalogo_id, nombre, fundamento, frecuencia, fecha_limite, categoria, activa, es_recomendada
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, true, true)
|
||||||
|
`, [c.id, o.id, o.nombre, o.fundamento, o.frecuencia, o.fechaLimite, o.categoria]);
|
||||||
|
obligacionesCreadas++;
|
||||||
|
}
|
||||||
|
console.log(`✅ ${c.rfc}: ${obligacionesCreadas} obligaciones creadas`);
|
||||||
|
|
||||||
|
// Tareas default
|
||||||
|
const tareasCreadas = await seedTareasDefault(pool, c.id);
|
||||||
|
if (tareasCreadas > 0) {
|
||||||
|
await materializarPeriodos(pool, c.id);
|
||||||
|
console.log(`✅ ${c.rfc}: ${tareasCreadas} tareas creadas y periodos materializados`);
|
||||||
|
} else {
|
||||||
|
console.log(`ℹ️ ${c.rfc}: tareas default ya existían`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asignar auxiliar a todas las obligaciones y tareas activas
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO obligacion_asignaciones (obligacion_id, auxiliar_user_id, asignado_por)
|
||||||
|
SELECT oc.id, $1, $2
|
||||||
|
FROM obligaciones_contribuyente oc
|
||||||
|
WHERE oc.activa = true
|
||||||
|
ON CONFLICT (obligacion_id) DO UPDATE SET auxiliar_user_id = EXCLUDED.auxiliar_user_id, asignado_por = EXCLUDED.asignado_por
|
||||||
|
`, [auxUser.id, supervisorUser.id]);
|
||||||
|
console.log('✅ Auxiliar asignado a obligaciones');
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO tarea_asignaciones (tarea_id, auxiliar_user_id, asignado_por)
|
||||||
|
SELECT tc.id, $1, $2
|
||||||
|
FROM tareas_catalogo tc
|
||||||
|
WHERE tc.active = true
|
||||||
|
ON CONFLICT (tarea_id) DO UPDATE SET auxiliar_user_id = EXCLUDED.auxiliar_user_id, asignado_por = EXCLUDED.asignado_por
|
||||||
|
`, [auxUser.id, supervisorUser.id]);
|
||||||
|
console.log('✅ Auxiliar asignado a tareas');
|
||||||
|
|
||||||
|
// Asignar auxiliar a la cartera principal
|
||||||
|
await pool.query(`
|
||||||
|
UPDATE carteras SET auxiliar_user_id = $1
|
||||||
|
WHERE parent_id IS NULL
|
||||||
|
`, [auxUser.id]);
|
||||||
|
console.log('✅ Auxiliar asignado a la cartera principal');
|
||||||
|
|
||||||
|
// Asegurar relación auxiliar-supervisor
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO auxiliar_supervisores (auxiliar_user_id, supervisor_user_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (auxiliar_user_id) DO UPDATE SET supervisor_user_id = EXCLUDED.supervisor_user_id
|
||||||
|
`, [auxUser.id, supervisorUser.id]);
|
||||||
|
console.log('✅ Relación auxiliar → supervisor registrada');
|
||||||
|
|
||||||
|
console.log('\n🎉 Obligaciones y tareas listas en Demo Ventas');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('\n❌ Error:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await tenantDb.shutdown();
|
||||||
|
});
|
||||||
337
apps/api/scripts/update-demo-ventas.ts
Normal file
337
apps/api/scripts/update-demo-ventas.ts
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* Script: update-demo-ventas
|
||||||
|
*
|
||||||
|
* Agrega al tenant Demo Ventas:
|
||||||
|
* - 5 contribuyentes adicionales
|
||||||
|
* - Usuarios supervisor, auxiliar y cliente con sus memberships
|
||||||
|
* - CFDIs de ejemplo para los nuevos contribuyentes
|
||||||
|
* - Accesos de cliente a los contribuyentes
|
||||||
|
* - Ajusta el plan custom para soportar más RFCs/usuarios
|
||||||
|
*
|
||||||
|
* Ejecución:
|
||||||
|
* cd apps/api && npx tsx scripts/update-demo-ventas.ts
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { tenantDb } from '../src/config/database.ts';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const DEMO_RFC = 'DEMO2501019X2';
|
||||||
|
const DEFAULT_PASSWORD = 'Demo12345!';
|
||||||
|
|
||||||
|
const NUEVOS_CONTRIBUYENTES = [
|
||||||
|
{ rfc: 'COM2501019X1', nombre: 'Comercial del Norte SA de CV', cp: '64000' },
|
||||||
|
{ rfc: 'DIS2501019X1', nombre: 'Distribuidora del Centro SA de CV', cp: '44100' },
|
||||||
|
{ rfc: 'SIS2501019X1', nombre: 'Servicios Integrales del Sur SA de CV', cp: '86000' },
|
||||||
|
{ rfc: 'IMP2501019X1', nombre: 'Importadora del Pacifico SA de CV', cp: '82140' },
|
||||||
|
{ rfc: 'EXA2501019X1', nombre: 'Exportadora del Atlantico SA de CV', cp: '94270' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const USUARIOS = [
|
||||||
|
{ email: 'supervisor@horuxfin.com', nombre: 'Supervisor Demo', rol: 'supervisor' },
|
||||||
|
{ email: 'auxiliar@horuxfin.com', nombre: 'Auxiliar Demo', rol: 'auxiliar' },
|
||||||
|
{ email: 'cliente@horuxfin.com', nombre: 'Cliente Demo', rol: 'cliente' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CLIENTES = [
|
||||||
|
{ rfc: 'CLI123456AB1', nombre: 'Cliente Alfa SA' },
|
||||||
|
{ rfc: 'CLI123456AB2', nombre: 'Cliente Beta SA' },
|
||||||
|
{ rfc: 'CLI123456AB3', nombre: 'Cliente Gamma SA' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROVEEDORES = [
|
||||||
|
{ rfc: 'PRO123456AB1', nombre: 'Proveedor Materiales SA' },
|
||||||
|
{ rfc: 'PRO123456AB2', nombre: 'Proveedor Servicios SA' },
|
||||||
|
{ rfc: 'PRO123456AB3', nombre: 'Proveedor Logistica SA' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRODUCTOS = [
|
||||||
|
{ clave: '84111506', descripcion: 'Servicio de consultoria', unidad: 'Servicio' },
|
||||||
|
{ clave: '43232408', descripcion: 'Licencia de software', unidad: 'Licencia' },
|
||||||
|
{ clave: '81141500', descripcion: 'Soporte tecnico', unidad: 'Servicio' },
|
||||||
|
{ clave: '81121700', descripcion: 'Desarrollo web', unidad: 'Servicio' },
|
||||||
|
{ clave: '86101500', descripcion: 'Capacitacion', unidad: 'Servicio' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🌱 Actualizando Demo Ventas...\n');
|
||||||
|
|
||||||
|
const tenant = await prisma.tenant.findUnique({ where: { rfc: DEMO_RFC } });
|
||||||
|
if (!tenant) throw new Error(`Tenant ${DEMO_RFC} no encontrado`);
|
||||||
|
|
||||||
|
// Ajustar catálogo del plan custom para soportar la demo completa
|
||||||
|
await prisma.despachoPlanPrice.update({
|
||||||
|
where: { plan: 'custom' },
|
||||||
|
data: { maxRfcs: 10, maxUsers: 10 },
|
||||||
|
});
|
||||||
|
console.log('✅ Plan custom actualizado: maxRfcs=10, maxUsers=10');
|
||||||
|
|
||||||
|
// Crear/actualizar usuarios y memberships
|
||||||
|
const createdUsers: Record<string, { id: string; rolId: number }> = {};
|
||||||
|
for (const u of USUARIOS) {
|
||||||
|
const rol = await prisma.rol.findUnique({ where: { nombre: u.rol } });
|
||||||
|
if (!rol) throw new Error(`Rol ${u.rol} no encontrado`);
|
||||||
|
|
||||||
|
let user = await prisma.user.findUnique({ where: { email: u.email } });
|
||||||
|
const passwordHash = await bcrypt.hash(DEFAULT_PASSWORD, 12);
|
||||||
|
if (!user) {
|
||||||
|
user = await prisma.user.create({
|
||||||
|
data: { email: u.email, passwordHash, nombre: u.nombre, lastTenantId: tenant.id },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
user = await prisma.user.update({ where: { id: user.id }, data: { passwordHash, lastTenantId: tenant.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.tenantMembership.upsert({
|
||||||
|
where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } },
|
||||||
|
update: { rolId: rol.id, active: true, isOwner: false },
|
||||||
|
create: { userId: user.id, tenantId: tenant.id, rolId: rol.id, active: true, isOwner: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
createdUsers[u.rol] = { id: user.id, rolId: rol.id };
|
||||||
|
console.log(`✅ Usuario ${u.rol}:`, u.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supervisorId = createdUsers.supervisor.id;
|
||||||
|
const clienteId = createdUsers.cliente.id;
|
||||||
|
|
||||||
|
// Conectar a BD del tenant
|
||||||
|
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||||
|
|
||||||
|
// Crear contribuyentes, CFDIs y accesos
|
||||||
|
const contribuyenteIds: string[] = [];
|
||||||
|
for (const c of NUEVOS_CONTRIBUYENTES) {
|
||||||
|
const id = await crearContribuyente(pool, c, supervisorId, tenant.id);
|
||||||
|
contribuyenteIds.push(id);
|
||||||
|
console.log(`✅ Contribuyente creado: ${c.rfc}`);
|
||||||
|
|
||||||
|
await crearCfdis(pool, id, c.rfc, c.nombre);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asignar accesos de cliente a todos los contribuyentes (incluido el original)
|
||||||
|
const { rows: todasEntidades } = await pool.query<{ id: string }>(`
|
||||||
|
SELECT entidad_id AS id FROM contribuyentes
|
||||||
|
`);
|
||||||
|
for (const e of todasEntidades) {
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`, [clienteId, e.id]);
|
||||||
|
}
|
||||||
|
console.log('✅ Accesos de cliente asignados a', todasEntidades.length, 'contribuyentes');
|
||||||
|
|
||||||
|
// Agregar nuevos contribuyentes a la cartera principal
|
||||||
|
const { rows: [cartera] } = await pool.query<{ id: string }>(`
|
||||||
|
SELECT id FROM carteras ORDER BY created_at LIMIT 1
|
||||||
|
`);
|
||||||
|
if (cartera) {
|
||||||
|
for (const id of contribuyenteIds) {
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO cartera_entidades (cartera_id, entidad_id) VALUES ($1, $2)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`, [cartera.id, id]);
|
||||||
|
}
|
||||||
|
console.log('✅ Nuevos contribuyentes agregados a cartera principal');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎉 Demo Ventas actualizada');
|
||||||
|
console.log(' Nuevos contribuyentes:', NUEVOS_CONTRIBUYENTES.length);
|
||||||
|
console.log(' Usuos adicionales:');
|
||||||
|
for (const u of USUARIOS) {
|
||||||
|
console.log(` ${u.rol}: ${u.email} / ${DEFAULT_PASSWORD}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function crearContribuyente(pool: Pool, data: { rfc: string; nombre: string; cp: string }, supervisorId: string, tenantId: string): Promise<string> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Buscar si ya existe la entidad para este RFC
|
||||||
|
const { rows: existingEntidad } = await client.query<{ id: string }>(`
|
||||||
|
SELECT e.id FROM entidades_gestionadas e
|
||||||
|
WHERE e.identificador = $1 AND e.tipo = 'CONTRIBUYENTE'
|
||||||
|
`, [data.rfc]);
|
||||||
|
|
||||||
|
let entidadId: string;
|
||||||
|
if (existingEntidad.length > 0) {
|
||||||
|
entidadId = existingEntidad[0].id;
|
||||||
|
await client.query(`
|
||||||
|
UPDATE entidades_gestionadas
|
||||||
|
SET nombre = $1, supervisor_user_id = $2, updated_at = now()
|
||||||
|
WHERE id = $3
|
||||||
|
`, [data.nombre, supervisorId, entidadId]);
|
||||||
|
} else {
|
||||||
|
const { rows: [entidad] } = await client.query<{ id: string }>(`
|
||||||
|
INSERT INTO entidades_gestionadas (tipo, nombre, identificador, supervisor_user_id)
|
||||||
|
VALUES ('CONTRIBUYENTE', $1, $2, $3)
|
||||||
|
RETURNING id
|
||||||
|
`, [data.nombre, data.rfc, supervisorId]);
|
||||||
|
entidadId = entidad.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows: existingContrib } = await client.query<{ entidad_id: string }>(`
|
||||||
|
SELECT entidad_id FROM contribuyentes WHERE entidad_id = $1
|
||||||
|
`, [entidadId]);
|
||||||
|
|
||||||
|
if (existingContrib.length > 0) {
|
||||||
|
await client.query(`
|
||||||
|
UPDATE contribuyentes
|
||||||
|
SET rfc = $1, regimen_fiscal = $2, codigo_postal = $3
|
||||||
|
WHERE entidad_id = $4
|
||||||
|
`, [data.rfc, '601', data.cp, entidadId]);
|
||||||
|
} else {
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO contribuyentes (entidad_id, rfc, regimen_fiscal, codigo_postal)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
`, [entidadId, data.rfc, '601', data.cp]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
|
||||||
|
`, [data.rfc, data.nombre, '601']);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return entidadId;
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function crearCfdis(pool: Pool, contribuyenteId: string, rfcContribuyente: string, nombreContribuyente: string) {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Asegurar RFCs de clientes/proveedores
|
||||||
|
const rfcs = new Map<string, number>();
|
||||||
|
for (const c of [...CLIENTES, ...PROVEEDORES]) {
|
||||||
|
const { rows: [r] } = await client.query<{ id: number }>(`
|
||||||
|
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
|
||||||
|
RETURNING id
|
||||||
|
`, [c.rfc, c.nombre, '601']);
|
||||||
|
rfcs.set(c.rfc, r.id);
|
||||||
|
}
|
||||||
|
const { rows: [rfcPrincipal] } = await client.query<{ id: number }>(`
|
||||||
|
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
|
||||||
|
RETURNING id
|
||||||
|
`, [rfcContribuyente, nombreContribuyente, '601']);
|
||||||
|
rfcs.set(rfcContribuyente, rfcPrincipal.id);
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const esEmitido = i < 5;
|
||||||
|
const contraparte = esEmitido
|
||||||
|
? CLIENTES[i % CLIENTES.length]
|
||||||
|
: PROVEEDORES[i % PROVEEDORES.length];
|
||||||
|
|
||||||
|
const subtotal = Math.floor(Math.random() * 30000) + 1500;
|
||||||
|
const iva = Math.round(subtotal * 0.16 * 100) / 100;
|
||||||
|
const total = Math.round((subtotal + iva) * 100) / 100;
|
||||||
|
|
||||||
|
const daysAgo = Math.floor(Math.random() * 360);
|
||||||
|
const fecha = new Date();
|
||||||
|
fecha.setDate(fecha.getDate() - daysAgo);
|
||||||
|
fecha.setHours(9 + (i % 8), 0, 0, 0);
|
||||||
|
|
||||||
|
const year = String(fecha.getFullYear());
|
||||||
|
const month = String(fecha.getMonth() + 1).padStart(2, '0');
|
||||||
|
const fechaStr = fecha.toISOString();
|
||||||
|
const metodoPago = Math.random() > 0.4 ? 'PUE' : 'PPD';
|
||||||
|
const formasPago = ['01', '02', '03'];
|
||||||
|
const formaPago = formasPago[i % formasPago.length];
|
||||||
|
const usoCfdi = esEmitido ? 'G03' : 'G01';
|
||||||
|
const tipo = esEmitido ? 'EMITIDO' : 'RECIBIDO';
|
||||||
|
|
||||||
|
const rfcEmisor = esEmitido ? rfcContribuyente : contraparte.rfc;
|
||||||
|
const nombreEmisor = esEmitido ? nombreContribuyente : contraparte.nombre;
|
||||||
|
const rfcReceptor = esEmitido ? contraparte.rfc : rfcContribuyente;
|
||||||
|
const nombreReceptor = esEmitido ? contraparte.nombre : nombreContribuyente;
|
||||||
|
|
||||||
|
const { rows: [cfdi] } = await client.query<{ id: number }>(`
|
||||||
|
INSERT INTO cfdis (
|
||||||
|
year, month, type, uuid, serie, folio, status, fecha_emision,
|
||||||
|
rfc_emisor_id, rfc_emisor, nombre_emisor,
|
||||||
|
rfc_receptor_id, rfc_receptor, nombre_receptor,
|
||||||
|
subtotal, subtotal_mxn, descuento, descuento_mxn,
|
||||||
|
total, total_mxn, moneda, tipo_cambio, tipo_comprobante,
|
||||||
|
metodo_pago, forma_pago, uso_cfdi,
|
||||||
|
iva_traslado, iva_traslado_mxn,
|
||||||
|
regimen_fiscal_emisor, regimen_fiscal_receptor,
|
||||||
|
contribuyente_id, fecha_efectiva, meses_global, año_global
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8,
|
||||||
|
$9, $10, $11,
|
||||||
|
$12, $13, $14,
|
||||||
|
$15, $16, $17, $18,
|
||||||
|
$19, $20, $21, $22, $23,
|
||||||
|
$24, $25, $26,
|
||||||
|
$27, $28,
|
||||||
|
$29, $30,
|
||||||
|
$31, $32, $33, $34
|
||||||
|
) RETURNING id
|
||||||
|
`, [
|
||||||
|
year, month, tipo, randomUUID(), 'DEMO', String(2000 + i),
|
||||||
|
'Vigente', fechaStr,
|
||||||
|
rfcs.get(rfcEmisor), rfcEmisor, nombreEmisor,
|
||||||
|
rfcs.get(rfcReceptor), rfcReceptor, nombreReceptor,
|
||||||
|
subtotal, subtotal, 0, 0,
|
||||||
|
total, total, 'MXN', 1, 'I',
|
||||||
|
metodoPago, formaPago, usoCfdi,
|
||||||
|
iva, iva,
|
||||||
|
'601', '601',
|
||||||
|
contribuyenteId, fechaStr, month, year,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const numConceptos = Math.floor(Math.random() * 2) + 1;
|
||||||
|
for (let j = 0; j < numConceptos; j++) {
|
||||||
|
const prod = PRODUCTOS[(i + j) % PRODUCTOS.length];
|
||||||
|
const cantidad = Math.floor(Math.random() * 4) + 1;
|
||||||
|
const valorUnitario = Math.floor(Math.random() * 3000) + 500;
|
||||||
|
const importe = Math.round(cantidad * valorUnitario * 100) / 100;
|
||||||
|
const ivaConcepto = Math.round(importe * 0.16 * 100) / 100;
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO cfdi_conceptos (
|
||||||
|
cfdi_id, clave_prod_serv, descripcion, cantidad, clave_unidad, unidad,
|
||||||
|
valor_unitario, valor_unitario_mxn, importe, importe_mxn,
|
||||||
|
iva_traslado, iva_traslado_mxn
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
`, [
|
||||||
|
cfdi.id, prod.clave, prod.descripcion, cantidad, 'E48', prod.unidad,
|
||||||
|
valorUnitario, valorUnitario, importe, importe,
|
||||||
|
ivaConcepto, ivaConcepto,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
console.log(` 📄 10 CFDIs creados para ${rfcContribuyente}`);
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('\n❌ Error actualizando demo:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await tenantDb.shutdown();
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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ñ]');
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Extender periodicidad para soportar declaraciones cuatrimestrales (ej. SISUB)
|
||||||
|
ALTER TABLE declaraciones_provisionales
|
||||||
|
DROP CONSTRAINT IF EXISTS declaraciones_provisionales_periodicidad_check;
|
||||||
|
|
||||||
|
ALTER TABLE declaraciones_provisionales
|
||||||
|
ADD CONSTRAINT declaraciones_provisionales_periodicidad_check
|
||||||
|
CHECK (periodicidad IN ('mensual', 'bimestral', 'trimestral', 'cuatrimestral', 'semestral', 'anual'));
|
||||||
25
apps/api/src/migrations/tenant/053_obligacion_evidencias.sql
Normal file
25
apps/api/src/migrations/tenant/053_obligacion_evidencias.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-- Evidencias de cumplimiento para obligaciones fiscales.
|
||||||
|
-- Permite subir cualquier documento (declaración, pago, acuse, complemento)
|
||||||
|
-- vinculado a una obligación y periodo específicos.
|
||||||
|
CREATE TABLE IF NOT EXISTS obligacion_evidencias (
|
||||||
|
id serial PRIMARY KEY,
|
||||||
|
obligacion_id uuid NOT NULL REFERENCES obligaciones_contribuyente(id) ON DELETE CASCADE,
|
||||||
|
periodo varchar(7) NOT NULL, -- "2026-04"
|
||||||
|
contribuyente_id uuid NOT NULL REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE,
|
||||||
|
tipo_documento varchar(30) NOT NULL CHECK (tipo_documento IN (
|
||||||
|
'declaracion', 'pago', 'acuse', 'complemento'
|
||||||
|
)),
|
||||||
|
archivo bytea NOT NULL,
|
||||||
|
archivo_filename varchar(255) NOT NULL,
|
||||||
|
archivo_mime varchar(100) DEFAULT 'application/pdf',
|
||||||
|
notas text,
|
||||||
|
subido_por uuid, -- UUID del usuario en horux360 (sin FK local)
|
||||||
|
subido_por_email varchar(255),
|
||||||
|
created_at timestamptz DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_obligacion_evidencias_obligacion_periodo
|
||||||
|
ON obligacion_evidencias (obligacion_id, periodo);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_obligacion_evidencias_contribuyente
|
||||||
|
ON obligacion_evidencias (contribuyente_id);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Estados de declaración y pago por separado para obligaciones que requieren ambos.
|
||||||
|
ALTER TABLE obligacion_periodos
|
||||||
|
ADD COLUMN IF NOT EXISTS declaracion_presentada boolean DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS pago_presentado boolean DEFAULT false;
|
||||||
|
|
||||||
|
-- Backfill: periodos ya completados se consideran con declaración y pago presentados.
|
||||||
|
UPDATE obligacion_periodos
|
||||||
|
SET declaracion_presentada = true,
|
||||||
|
pago_presentado = true
|
||||||
|
WHERE completada = true
|
||||||
|
AND (declaracion_presentada IS NULL OR pago_presentado IS NULL);
|
||||||
|
|
||||||
|
-- Asegurar que declaracion_presentada y pago_presentado no sean NULL.
|
||||||
|
ALTER TABLE obligacion_periodos
|
||||||
|
ALTER COLUMN declaracion_presentada SET NOT NULL,
|
||||||
|
ALTER COLUMN pago_presentado SET NOT NULL;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Relación entre declaraciones provisionales y obligaciones fiscales.
|
||||||
|
-- Permite saber exactamente qué obligaciones cierra una declaración
|
||||||
|
-- y aplicar el comprobante de pago a las mismas obligaciones.
|
||||||
|
CREATE TABLE IF NOT EXISTS declaracion_obligaciones (
|
||||||
|
declaracion_id INT NOT NULL REFERENCES declaraciones_provisionales(id) ON DELETE CASCADE,
|
||||||
|
obligacion_id UUID NOT NULL REFERENCES obligaciones_contribuyente(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (declaracion_id, obligacion_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_declaracion_obligaciones_obligacion
|
||||||
|
ON declaracion_obligaciones (obligacion_id);
|
||||||
@@ -35,4 +35,10 @@ router.post('/extras', documentosController.crearExtra);
|
|||||||
router.get('/extras/:id/pdf', documentosController.descargarExtraPdf);
|
router.get('/extras/:id/pdf', documentosController.descargarExtraPdf);
|
||||||
router.delete('/extras/:id', documentosController.eliminarExtra);
|
router.delete('/extras/:id', documentosController.eliminarExtra);
|
||||||
|
|
||||||
|
// Evidencias de obligaciones fiscales
|
||||||
|
router.get('/obligacion-evidencias', documentosController.listarEvidenciasObligacion);
|
||||||
|
router.post('/obligacion-evidencias', documentosController.crearEvidenciaObligacion);
|
||||||
|
router.get('/obligacion-evidencias/:id/pdf', documentosController.descargarEvidenciaObligacion);
|
||||||
|
router.delete('/obligacion-evidencias/:id', documentosController.eliminarEvidenciaObligacion);
|
||||||
|
|
||||||
export { router as documentosRoutes };
|
export { router as documentosRoutes };
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ function appliesToPeriod(frecuencia: string | null, periodo: string): boolean {
|
|||||||
case 'mensual': return true;
|
case 'mensual': return true;
|
||||||
case 'bimestral': return month % 2 === 1;
|
case 'bimestral': return month % 2 === 1;
|
||||||
case 'trimestral': return [1, 4, 7, 10].includes(month);
|
case 'trimestral': return [1, 4, 7, 10].includes(month);
|
||||||
|
case 'cuatrimestral': return [1, 5, 9].includes(month);
|
||||||
case 'anual': return month === 3 || month === 4;
|
case 'anual': return month === 3 || month === 4;
|
||||||
case 'eventual': return false;
|
case 'eventual': return false;
|
||||||
default: return true;
|
default: return true;
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ export async function generarEventosDesdeObligaciones(
|
|||||||
if (freq === 'mensual') monthsToGenerate.push(m);
|
if (freq === 'mensual') monthsToGenerate.push(m);
|
||||||
else if (freq === 'bimestral' && m % 2 === 1) monthsToGenerate.push(m);
|
else if (freq === 'bimestral' && m % 2 === 1) monthsToGenerate.push(m);
|
||||||
else if (freq === 'trimestral' && [1, 4, 7, 10].includes(m)) monthsToGenerate.push(m);
|
else if (freq === 'trimestral' && [1, 4, 7, 10].includes(m)) monthsToGenerate.push(m);
|
||||||
|
else if (freq === 'cuatrimestral' && [1, 5, 9].includes(m)) monthsToGenerate.push(m);
|
||||||
else if (freq === 'anual' && (m === 3 || m === 4)) monthsToGenerate.push(m);
|
else if (freq === 'anual' && (m === 3 || m === 4)) monthsToGenerate.push(m);
|
||||||
// 'eventual' and unknown: skip auto-generation
|
// 'eventual' and unknown: skip auto-generation
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createEmailTransport } from '@horux/core';
|
import { createEmailTransport, type EmailAttachment } from '@horux/core';
|
||||||
import { env } from '../../config/env.js';
|
import { env } from '../../config/env.js';
|
||||||
|
|
||||||
const transport = createEmailTransport(
|
const transport = createEmailTransport(
|
||||||
@@ -13,8 +13,8 @@ const transport = createEmailTransport(
|
|||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
async function sendEmail(to: string, subject: string, html: string) {
|
async function sendEmail(to: string, subject: string, html: string, attachments?: EmailAttachment[]) {
|
||||||
await transport.send(to, subject, html);
|
await transport.send(to, subject, html, attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const emailService = {
|
export const emailService = {
|
||||||
@@ -128,10 +128,14 @@ export const emailService = {
|
|||||||
* Notifica la subida de una declaración o documento extra al despacho.
|
* Notifica la subida de una declaración o documento extra al despacho.
|
||||||
* `recipients` debe venir deduplicado por el caller. El subject se
|
* `recipients` debe venir deduplicado por el caller. El subject se
|
||||||
* genera a partir del kind y RFC del contribuyente.
|
* genera a partir del kind y RFC del contribuyente.
|
||||||
|
*
|
||||||
|
* Para declaraciones, `attachments` puede contener los PDFs subidos
|
||||||
|
* (acuse + liga de pago) para enviarlos adjuntos al correo.
|
||||||
*/
|
*/
|
||||||
sendDocumentoSubido: async (
|
sendDocumentoSubido: async (
|
||||||
recipients: string[],
|
recipients: string[],
|
||||||
data: import('./templates/documento-subido.js').DocumentoSubidoData,
|
data: import('./templates/documento-subido.js').DocumentoSubidoData,
|
||||||
|
attachments?: EmailAttachment[],
|
||||||
) => {
|
) => {
|
||||||
if (recipients.length === 0) return;
|
if (recipients.length === 0) return;
|
||||||
const { documentoSubidoEmail } = await import('./templates/documento-subido.js');
|
const { documentoSubidoEmail } = await import('./templates/documento-subido.js');
|
||||||
@@ -143,7 +147,7 @@ export const emailService = {
|
|||||||
// destinatario NO debe impedir enviar al siguiente.
|
// destinatario NO debe impedir enviar al siguiente.
|
||||||
for (const to of recipients) {
|
for (const to of recipients) {
|
||||||
try {
|
try {
|
||||||
await sendEmail(to, subject, html);
|
await sendEmail(to, subject, html, attachments);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`[Email] Fallo enviando documento-subido a ${to}:`, err?.message || err);
|
console.error(`[Email] Fallo enviando documento-subido a ${to}:`, err?.message || err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { baseTemplate, heading, infoBox, primaryButton, BRAND_COLORS as C } from
|
|||||||
|
|
||||||
export interface DocumentoSubidoData {
|
export interface DocumentoSubidoData {
|
||||||
/** Kind: para el título/subject. */
|
/** Kind: para el título/subject. */
|
||||||
kind: 'declaracion' | 'extra';
|
kind: 'declaracion' | 'extra' | 'obligacion_evidencia';
|
||||||
/** Quién subió el documento (email). */
|
/** Quién subió el documento (email). */
|
||||||
subidoPor: string;
|
subidoPor: string;
|
||||||
/** RFC del contribuyente. */
|
/** RFC del contribuyente. */
|
||||||
@@ -24,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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
272
apps/api/src/services/obligacion-evidencias.service.ts
Normal file
272
apps/api/src/services/obligacion-evidencias.service.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import type { Pool } from 'pg';
|
||||||
|
import { OBLIGACIONES_CATALOGO } from '../constants/obligaciones-fiscales.js';
|
||||||
|
|
||||||
|
export interface EvidenciaRow {
|
||||||
|
id: number;
|
||||||
|
obligacionId: string;
|
||||||
|
periodo: string;
|
||||||
|
contribuyenteId: string;
|
||||||
|
tipoDocumento: 'declaracion' | 'pago' | 'acuse' | 'complemento';
|
||||||
|
archivo: Buffer;
|
||||||
|
archivoFilename: string;
|
||||||
|
archivoMime: string;
|
||||||
|
notas: string | null;
|
||||||
|
subidoPor: string | null;
|
||||||
|
subidoPorEmail: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateEvidenciaInput {
|
||||||
|
obligacionId: string;
|
||||||
|
periodo: string;
|
||||||
|
contribuyenteId: string;
|
||||||
|
tipoDocumento: 'declaracion' | 'pago' | 'acuse' | 'complemento';
|
||||||
|
pdfBase64: string;
|
||||||
|
pdfFilename: string;
|
||||||
|
notas?: string;
|
||||||
|
subidoPor: string; // userId UUID
|
||||||
|
subidoPorEmail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToEvidencia(r: any): EvidenciaRow {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
obligacionId: r.obligacion_id,
|
||||||
|
periodo: r.periodo,
|
||||||
|
contribuyenteId: r.contribuyente_id,
|
||||||
|
tipoDocumento: r.tipo_documento,
|
||||||
|
archivo: Buffer.from(r.archivo),
|
||||||
|
archivoFilename: r.archivo_filename,
|
||||||
|
archivoMime: r.archivo_mime,
|
||||||
|
notas: r.notas,
|
||||||
|
subidoPor: r.subido_por,
|
||||||
|
subidoPorEmail: r.subido_por_email,
|
||||||
|
createdAt: r.created_at.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getObligacionContribuyente(pool: Pool, obligacionId: string): Promise<{ contribuyenteId: string; catalogoId: string | null } | null> {
|
||||||
|
const { rows } = await pool.query<{ contribuyente_id: string; catalogo_id: string | null }>(
|
||||||
|
`SELECT contribuyente_id, catalogo_id FROM obligaciones_contribuyente WHERE id = $1`,
|
||||||
|
[obligacionId],
|
||||||
|
);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
return { contribuyenteId: row.contribuyente_id, catalogoId: row.catalogo_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
function requierePago(obligacion: { catalogoId: string | null }): boolean {
|
||||||
|
if (!obligacion.catalogoId) return true; // conservador: sin catálogo, requiere pago
|
||||||
|
const catalogo = OBLIGACIONES_CATALOGO.find((o) => o.id === obligacion.catalogoId);
|
||||||
|
return catalogo?.requierePago ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esDocumentoDeclaracion(tipo: string): boolean {
|
||||||
|
return tipo === 'declaracion' || tipo === 'acuse' || tipo === 'complemento';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePeriodoStatus(
|
||||||
|
pool: Pool,
|
||||||
|
obligacionId: string,
|
||||||
|
periodo: string,
|
||||||
|
tipoDocumento: string,
|
||||||
|
reqPago: boolean,
|
||||||
|
completadaPor: string,
|
||||||
|
notas?: string,
|
||||||
|
): Promise<{ completada: boolean; declaracionPresentada: boolean; pagoPresentado: boolean }> {
|
||||||
|
const { rows } = await pool.query<{
|
||||||
|
declaracion_presentada: boolean;
|
||||||
|
pago_presentado: boolean;
|
||||||
|
completada: boolean;
|
||||||
|
}>(
|
||||||
|
`SELECT declaracion_presentada, pago_presentado, completada
|
||||||
|
FROM obligacion_periodos
|
||||||
|
WHERE obligacion_id = $1 AND periodo = $2`,
|
||||||
|
[obligacionId, periodo],
|
||||||
|
);
|
||||||
|
|
||||||
|
const existing = rows[0];
|
||||||
|
let declaracionPresentada = existing?.declaracion_presentada ?? false;
|
||||||
|
let pagoPresentado = existing?.pago_presentado ?? false;
|
||||||
|
|
||||||
|
if (esDocumentoDeclaracion(tipoDocumento)) declaracionPresentada = true;
|
||||||
|
if (tipoDocumento === 'pago') pagoPresentado = true;
|
||||||
|
|
||||||
|
const completada = !reqPago || pagoPresentado;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE obligacion_periodos
|
||||||
|
SET declaracion_presentada = $3,
|
||||||
|
pago_presentado = $4,
|
||||||
|
completada = $5,
|
||||||
|
completada_at = CASE WHEN $5 THEN COALESCE(completada_at, $6) ELSE completada_at END,
|
||||||
|
completada_por = CASE WHEN $5 THEN COALESCE(completada_por, $7) ELSE completada_por END,
|
||||||
|
notas = COALESCE($8, notas)
|
||||||
|
WHERE obligacion_id = $1 AND periodo = $2`,
|
||||||
|
[obligacionId, periodo, declaracionPresentada, pagoPresentado, completada, now, completadaPor, notas ?? null],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO obligacion_periodos
|
||||||
|
(obligacion_id, periodo, declaracion_presentada, pago_presentado, completada, completada_at, completada_por, notas)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[obligacionId, periodo, declaracionPresentada, pagoPresentado, completada, completada ? now : null, completada ? completadaPor : null, notas ?? null],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completada) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE alertas SET resuelta = true WHERE tipo = $1 AND resuelta = false`,
|
||||||
|
[`ob-${obligacionId}-${periodo}`],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { completada, declaracionPresentada, pagoPresentado };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recalcPeriodoStatus(
|
||||||
|
pool: Pool,
|
||||||
|
obligacionId: string,
|
||||||
|
periodo: string,
|
||||||
|
reqPago: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
const { rows } = await pool.query<{ tipo_documento: string }>(
|
||||||
|
`SELECT tipo_documento FROM obligacion_evidencias WHERE obligacion_id = $1 AND periodo = $2`,
|
||||||
|
[obligacionId, periodo],
|
||||||
|
);
|
||||||
|
|
||||||
|
const declaracionPresentada = rows.some((r) => esDocumentoDeclaracion(r.tipo_documento));
|
||||||
|
const pagoPresentado = rows.some((r) => r.tipo_documento === 'pago');
|
||||||
|
const completada = !reqPago || pagoPresentado;
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE obligacion_periodos
|
||||||
|
SET declaracion_presentada = $3,
|
||||||
|
pago_presentado = $4,
|
||||||
|
completada = $5,
|
||||||
|
completada_at = CASE WHEN $5 THEN COALESCE(completada_at, NOW()) ELSE completada_at END
|
||||||
|
WHERE obligacion_id = $1 AND periodo = $2`,
|
||||||
|
[obligacionId, periodo, declaracionPresentada, pagoPresentado, completada],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEvidencia(
|
||||||
|
pool: Pool,
|
||||||
|
data: CreateEvidenciaInput,
|
||||||
|
): Promise<{ evidencia: EvidenciaRow; completada: boolean; declaracionPresentada: boolean; pagoPresentado: boolean }> {
|
||||||
|
const obligacion = await getObligacionContribuyente(pool, data.obligacionId);
|
||||||
|
if (!obligacion) throw new Error('Obligación no encontrada');
|
||||||
|
if (obligacion.contribuyenteId !== data.contribuyenteId) throw new Error('La obligación no pertenece al contribuyente');
|
||||||
|
|
||||||
|
const reqPago = requierePago(obligacion);
|
||||||
|
const archivo = Buffer.from(data.pdfBase64, 'base64');
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO obligacion_evidencias
|
||||||
|
(obligacion_id, periodo, contribuyente_id, tipo_documento, archivo, archivo_filename, archivo_mime, notas, subido_por, subido_por_email)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING id, obligacion_id, periodo, contribuyente_id, tipo_documento, archivo, archivo_filename, archivo_mime,
|
||||||
|
notas, subido_por, subido_por_email, created_at`,
|
||||||
|
[data.obligacionId, data.periodo, data.contribuyenteId, data.tipoDocumento, archivo, data.pdfFilename, 'application/pdf', data.notas ?? null, data.subidoPor, data.subidoPorEmail],
|
||||||
|
);
|
||||||
|
|
||||||
|
const status = await updatePeriodoStatus(
|
||||||
|
pool,
|
||||||
|
data.obligacionId,
|
||||||
|
data.periodo,
|
||||||
|
data.tipoDocumento,
|
||||||
|
reqPago,
|
||||||
|
data.subidoPor,
|
||||||
|
data.notas,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { evidencia: rowToEvidencia(rows[0]), ...status };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEvidencias(
|
||||||
|
pool: Pool,
|
||||||
|
contribuyenteId: string,
|
||||||
|
filters?: { periodo?: string; obligacionId?: string },
|
||||||
|
): Promise<EvidenciaRow[]> {
|
||||||
|
const conditions: string[] = ['contribuyente_id = $1'];
|
||||||
|
const params: unknown[] = [contribuyenteId];
|
||||||
|
|
||||||
|
if (filters?.periodo) {
|
||||||
|
params.push(filters.periodo);
|
||||||
|
conditions.push(`periodo = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (filters?.obligacionId) {
|
||||||
|
params.push(filters.obligacionId);
|
||||||
|
conditions.push(`obligacion_id = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, obligacion_id, periodo, contribuyente_id, tipo_documento, archivo, archivo_filename, archivo_mime,
|
||||||
|
notas, subido_por, subido_por_email, created_at
|
||||||
|
FROM obligacion_evidencias
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return rows.map(rowToEvidencia);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEvidenciaPdf(
|
||||||
|
pool: Pool,
|
||||||
|
id: number,
|
||||||
|
): Promise<{ buffer: Buffer; filename: string; mime: string } | null> {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT archivo, archivo_filename, archivo_mime FROM obligacion_evidencias WHERE id = $1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (rows.length === 0 || !rows[0].archivo) return null;
|
||||||
|
return {
|
||||||
|
buffer: Buffer.from(rows[0].archivo),
|
||||||
|
filename: rows[0].archivo_filename || `evidencia-${id}.pdf`,
|
||||||
|
mime: rows[0].archivo_mime || 'application/pdf',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEvidencia(
|
||||||
|
pool: Pool,
|
||||||
|
id: number,
|
||||||
|
): Promise<{ obligacionId: string; periodo: string } | null> {
|
||||||
|
const { rows } = await pool.query<{ obligacion_id: string; periodo: string }>(
|
||||||
|
`DELETE FROM obligacion_evidencias WHERE id = $1 RETURNING obligacion_id, periodo`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const { obligacion_id: obligacionId, periodo } = rows[0];
|
||||||
|
const obligacion = await getObligacionContribuyente(pool, obligacionId);
|
||||||
|
if (obligacion) {
|
||||||
|
const reqPago = requierePago(obligacion);
|
||||||
|
await recalcPeriodoStatus(pool, obligacionId, periodo, reqPago);
|
||||||
|
}
|
||||||
|
return { obligacionId, periodo };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPeriodoStatus(
|
||||||
|
pool: Pool,
|
||||||
|
obligacionId: string,
|
||||||
|
periodo: string,
|
||||||
|
): Promise<{ completada: boolean; declaracionPresentada: boolean; pagoPresentado: boolean } | null> {
|
||||||
|
const { rows } = await pool.query<{
|
||||||
|
completada: boolean;
|
||||||
|
declaracion_presentada: boolean;
|
||||||
|
pago_presentado: boolean;
|
||||||
|
}>(
|
||||||
|
`SELECT completada, declaracion_presentada, pago_presentado
|
||||||
|
FROM obligacion_periodos
|
||||||
|
WHERE obligacion_id = $1 AND periodo = $2`,
|
||||||
|
[obligacionId, periodo],
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
return {
|
||||||
|
completada: rows[0].completada,
|
||||||
|
declaracionPresentada: rows[0].declaracion_presentada,
|
||||||
|
pagoPresentado: rows[0].pago_presentado,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
import { OBLIGACIONES_CATALOGO, getRecomendaciones, type ObligacionFiscal } from '../constants/obligaciones-fiscales.js';
|
import { OBLIGACIONES_CATALOGO, getRecomendaciones, type ObligacionFiscal } from '../constants/obligaciones-fiscales.js';
|
||||||
|
|
||||||
|
function requierePagoPorCatalogo(catalogoId: string | null): boolean {
|
||||||
|
if (!catalogoId) return true;
|
||||||
|
return OBLIGACIONES_CATALOGO.find((o) => o.id === catalogoId)?.requierePago ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keyword-based matching: each catalog entry has discriminant keywords
|
* Keyword-based matching: each catalog entry has discriminant keywords
|
||||||
* that must ALL appear in the SAT description (normalized, lowercase, no accents).
|
* that must ALL appear in the SAT description (normalized, lowercase, no accents).
|
||||||
@@ -255,6 +260,7 @@ export async function initRecomendaciones(
|
|||||||
function inferirFrecuencia(vencimiento: string): string {
|
function inferirFrecuencia(vencimiento: string): string {
|
||||||
const lower = vencimiento.toLowerCase();
|
const lower = vencimiento.toLowerCase();
|
||||||
if (lower.includes('mensual') || lower.includes('mes')) return 'mensual';
|
if (lower.includes('mensual') || lower.includes('mes')) return 'mensual';
|
||||||
|
if (lower.includes('cuatrimest')) return 'cuatrimestral';
|
||||||
if (lower.includes('bimest')) return 'bimestral';
|
if (lower.includes('bimest')) return 'bimestral';
|
||||||
if (lower.includes('trimest')) return 'trimestral';
|
if (lower.includes('trimest')) return 'trimestral';
|
||||||
if (lower.includes('anual') || lower.includes('ejercicio') || lower.includes('tres meses siguientes')) return 'anual';
|
if (lower.includes('anual') || lower.includes('ejercicio') || lower.includes('tres meses siguientes')) return 'anual';
|
||||||
@@ -351,13 +357,22 @@ export async function getObligacionesPorPeriodo(
|
|||||||
|
|
||||||
const [year, month] = periodo.split('-').map(Number);
|
const [year, month] = periodo.split('-').map(Number);
|
||||||
const currentPeriodo = new Date().toISOString().substring(0, 7);
|
const currentPeriodo = new Date().toISOString().substring(0, 7);
|
||||||
const results: Array<ObligacionContribuyente & { periodStatus: string; periodoAplica: string; declaracion: DeclaracionLink | null }> = [];
|
const results: Array<ObligacionContribuyente & {
|
||||||
|
periodStatus: string;
|
||||||
|
periodoAplica: string;
|
||||||
|
declaracion: DeclaracionLink | null;
|
||||||
|
declaracionPresentada: boolean;
|
||||||
|
pagoPresentado: boolean;
|
||||||
|
requierePago: boolean;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
// Get all completion records + associated declaration info for this contribuyente
|
// Get all completion records + associated declaration info for this contribuyente
|
||||||
const { rows: completions } = await pool.query<{
|
const { rows: completions } = await pool.query<{
|
||||||
obligacion_id: string;
|
obligacion_id: string;
|
||||||
periodo: string;
|
periodo: string;
|
||||||
completada: boolean;
|
completada: boolean;
|
||||||
|
declaracion_presentada: boolean;
|
||||||
|
pago_presentado: boolean;
|
||||||
declaracion_id: number | null;
|
declaracion_id: number | null;
|
||||||
decl_año: number | null;
|
decl_año: number | null;
|
||||||
decl_mes: number | null;
|
decl_mes: number | null;
|
||||||
@@ -365,6 +380,7 @@ export async function getObligacionesPorPeriodo(
|
|||||||
decl_pdf_filename: string | null;
|
decl_pdf_filename: string | null;
|
||||||
}>(`
|
}>(`
|
||||||
SELECT op.obligacion_id, op.periodo, op.completada,
|
SELECT op.obligacion_id, op.periodo, op.completada,
|
||||||
|
op.declaracion_presentada, op.pago_presentado,
|
||||||
op.declaracion_id,
|
op.declaracion_id,
|
||||||
dp.año AS decl_año,
|
dp.año AS decl_año,
|
||||||
dp.mes AS decl_mes,
|
dp.mes AS decl_mes,
|
||||||
@@ -377,10 +393,14 @@ export async function getObligacionesPorPeriodo(
|
|||||||
`, [contribuyenteId]);
|
`, [contribuyenteId]);
|
||||||
|
|
||||||
const completionMap = new Map<string, boolean>();
|
const completionMap = new Map<string, boolean>();
|
||||||
|
const declaracionPresentadaMap = new Map<string, boolean>();
|
||||||
|
const pagoPresentadoMap = new Map<string, boolean>();
|
||||||
const declaracionMap = new Map<string, DeclaracionLink | null>();
|
const declaracionMap = new Map<string, DeclaracionLink | null>();
|
||||||
for (const c of completions) {
|
for (const c of completions) {
|
||||||
const key = `${c.obligacion_id}:${c.periodo}`;
|
const key = `${c.obligacion_id}:${c.periodo}`;
|
||||||
completionMap.set(key, c.completada);
|
completionMap.set(key, c.completada);
|
||||||
|
declaracionPresentadaMap.set(key, c.declaracion_presentada);
|
||||||
|
pagoPresentadoMap.set(key, c.pago_presentado);
|
||||||
if (c.declaracion_id && c.decl_año != null && c.decl_mes != null && c.decl_tipo) {
|
if (c.declaracion_id && c.decl_año != null && c.decl_mes != null && c.decl_tipo) {
|
||||||
declaracionMap.set(key, {
|
declaracionMap.set(key, {
|
||||||
id: c.declaracion_id,
|
id: c.declaracion_id,
|
||||||
@@ -407,6 +427,9 @@ export async function getObligacionesPorPeriodo(
|
|||||||
periodStatus: isCompleted ? 'completada' : 'pendiente',
|
periodStatus: isCompleted ? 'completada' : 'pendiente',
|
||||||
periodoAplica: periodo,
|
periodoAplica: periodo,
|
||||||
declaracion: declaracionMap.get(key) ?? null,
|
declaracion: declaracionMap.get(key) ?? null,
|
||||||
|
declaracionPresentada: declaracionPresentadaMap.get(key) === true,
|
||||||
|
pagoPresentado: pagoPresentadoMap.get(key) === true,
|
||||||
|
requierePago: requierePagoPorCatalogo(ob.catalogoId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,6 +457,9 @@ export async function getObligacionesPorPeriodo(
|
|||||||
periodStatus: 'atrasada',
|
periodStatus: 'atrasada',
|
||||||
periodoAplica: pastPeriodo,
|
periodoAplica: pastPeriodo,
|
||||||
declaracion: null,
|
declaracion: null,
|
||||||
|
declaracionPresentada: declaracionPresentadaMap.get(pastKey) === true,
|
||||||
|
pagoPresentado: pagoPresentadoMap.get(pastKey) === true,
|
||||||
|
requierePago: requierePagoPorCatalogo(ob.catalogoId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -448,7 +474,14 @@ export async function getObligacionesPorPeriodo(
|
|||||||
return a.nombre.localeCompare(b.nombre);
|
return a.nombre.localeCompare(b.nombre);
|
||||||
});
|
});
|
||||||
|
|
||||||
return results as Array<ObligacionContribuyente & { periodStatus: 'pendiente' | 'completada' | 'atrasada'; periodoAplica: string; declaracion: DeclaracionLink | null }>;
|
return results as Array<ObligacionContribuyente & {
|
||||||
|
periodStatus: 'pendiente' | 'completada' | 'atrasada';
|
||||||
|
periodoAplica: string;
|
||||||
|
declaracion: DeclaracionLink | null;
|
||||||
|
declaracionPresentada: boolean;
|
||||||
|
pagoPresentado: boolean;
|
||||||
|
requierePago: boolean;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function appliesTo(frecuencia: string | null, periodo: string): boolean {
|
function appliesTo(frecuencia: string | null, periodo: string): boolean {
|
||||||
@@ -457,6 +490,7 @@ function appliesTo(frecuencia: string | null, periodo: string): boolean {
|
|||||||
case 'mensual': return true;
|
case 'mensual': return true;
|
||||||
case 'bimestral': return month % 2 === 1; // Jan, Mar, May...
|
case 'bimestral': return month % 2 === 1; // Jan, Mar, May...
|
||||||
case 'trimestral': return [1, 4, 7, 10].includes(month);
|
case 'trimestral': return [1, 4, 7, 10].includes(month);
|
||||||
|
case 'cuatrimestral': return [1, 5, 9].includes(month);
|
||||||
case 'anual': return month === 3 || month === 4; // March (PM) or April (PF) — show in both
|
case 'anual': return month === 3 || month === 4; // March (PM) or April (PF) — show in both
|
||||||
case 'eventual': return false; // Don't auto-show
|
case 'eventual': return false; // Don't auto-show
|
||||||
default: return true;
|
default: return true;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ export default function ObligacionesPage() {
|
|||||||
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||||
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
||||||
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
||||||
|
cuatrimestral: 'bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300',
|
||||||
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||||
eventual: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
eventual: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { apiClient } from '@/lib/api/client';
|
|||||||
import { subscribeMe, changeMyPlan, cancelMySubscription, upgradeMe, generatePaymentLink } from '@/lib/api/subscription';
|
import { subscribeMe, changeMyPlan, cancelMySubscription, upgradeMe, generatePaymentLink } from '@/lib/api/subscription';
|
||||||
import { getPendingInvitation, acceptInvitation } from '@/lib/api/trial-invitations';
|
import { getPendingInvitation, acceptInvitation } from '@/lib/api/trial-invitations';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { getSubscriptionState } from '@horux/shared';
|
||||||
|
|
||||||
type Despachoplan = 'trial' | 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus' | 'custom';
|
type Despachoplan = 'trial' | 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus' | 'custom';
|
||||||
type PaidPlan = 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus';
|
type PaidPlan = 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus';
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { formatCurrency, toCfdiDate } from '@/lib/utils';
|
|||||||
import { exportToExcel } from '@/lib/export-excel';
|
import { exportToExcel } from '@/lib/export-excel';
|
||||||
import { useTableSort } from '@horux/shared-ui';
|
import { useTableSort } from '@horux/shared-ui';
|
||||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||||
|
import { getCfdiById } from '@/lib/api/cfdi';
|
||||||
import { Eye, Download } from 'lucide-react';
|
import { Eye, Download } from 'lucide-react';
|
||||||
import type { Cfdi } from '@horux/shared';
|
import type { Cfdi } from '@horux/shared';
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ export default function DrillDownPage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const titulo = searchParams.get('titulo') || 'Detalle de CFDIs';
|
const titulo = searchParams.get('titulo') || 'Detalle de CFDIs';
|
||||||
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
|
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
|
||||||
|
const [loadingCfdiId, setLoadingCfdiId] = useState<number | null>(null);
|
||||||
const { selectedContribuyenteId } = useContribuyenteStore();
|
const { selectedContribuyenteId } = useContribuyenteStore();
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -154,7 +156,23 @@ export default function DrillDownPage() {
|
|||||||
<td className="py-2 text-xs font-mono">{cfdi.regimenEmisor || '-'}</td>
|
<td className="py-2 text-xs font-mono">{cfdi.regimenEmisor || '-'}</td>
|
||||||
<td className="py-2 text-xs font-mono">{cfdi.regimenReceptor || '-'}</td>
|
<td className="py-2 text-xs font-mono">{cfdi.regimenReceptor || '-'}</td>
|
||||||
<td className="py-2">
|
<td className="py-2">
|
||||||
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={loadingCfdiId === cfdi.id}
|
||||||
|
onClick={async () => {
|
||||||
|
setLoadingCfdiId(cfdi.id);
|
||||||
|
try {
|
||||||
|
const fullCfdi = await getCfdiById(String(cfdi.id));
|
||||||
|
setSelectedCfdi(fullCfdi);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error cargando CFDI completo:', err);
|
||||||
|
} finally {
|
||||||
|
setLoadingCfdiId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Ver factura"
|
||||||
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -554,12 +554,26 @@ export default function FacturacionPage() {
|
|||||||
? clavesUnidad?.filter(u => !SERVICE_UNITS.includes(u.clave))
|
? clavesUnidad?.filter(u => !SERVICE_UNITS.includes(u.clave))
|
||||||
: clavesUnidad;
|
: clavesUnidad;
|
||||||
|
|
||||||
|
const prodSearchAbort = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const handleSearchProduct = async (q: string, idx: number) => {
|
const handleSearchProduct = async (q: string, idx: number) => {
|
||||||
setProdSearch(q);
|
setProdSearch(q);
|
||||||
setSearchingIdx(idx);
|
setSearchingIdx(idx);
|
||||||
if (q.length < 2) { setProdResults([]); return; }
|
setProdResults([]);
|
||||||
const results = await searchClaveProdServ(q);
|
if (q.length < 2) return;
|
||||||
setProdResults(results);
|
|
||||||
|
prodSearchAbort.current?.abort();
|
||||||
|
prodSearchAbort.current = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await searchClaveProdServ(q, prodSearchAbort.current.signal);
|
||||||
|
setProdResults(results ?? []);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name !== 'AbortError' && err.code !== 'ERR_CANCELED') {
|
||||||
|
console.error('Error buscando clave SAT:', err);
|
||||||
|
}
|
||||||
|
setProdResults([]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectProduct = (idx: number, clave: string, descripcion: string) => {
|
const selectProduct = (idx: number, clave: string, descripcion: string) => {
|
||||||
@@ -1418,6 +1432,7 @@ export default function FacturacionPage() {
|
|||||||
onChange={e => handleSearchProduct(e.target.value, idx)}
|
onChange={e => handleSearchProduct(e.target.value, idx)}
|
||||||
onFocus={() => { setSearchingIdx(idx); setProdSearch(c.productKey); }}
|
onFocus={() => { setSearchingIdx(idx); setProdSearch(c.productKey); }}
|
||||||
placeholder="Buscar clave SAT..."
|
placeholder="Buscar clave SAT..."
|
||||||
|
autoComplete="off"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Search className="absolute right-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute right-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ export default function PendientesPage() {
|
|||||||
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||||
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
||||||
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
||||||
|
cuatrimestral: 'bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300',
|
||||||
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||||
};
|
};
|
||||||
return f ? (
|
return f ? (
|
||||||
|
|||||||
@@ -123,20 +123,6 @@ export function TareasTab({ contribuyenteId }: { contribuyenteId: string | null
|
|||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const completarMutation = useMutation({
|
|
||||||
mutationFn: async (periodoId: string) => apiClient.post(`/tareas/periodo/${periodoId}/completar`),
|
|
||||||
onSuccess: invalidate,
|
|
||||||
onError: (err: unknown) => {
|
|
||||||
const e = err as { response?: { data?: { message?: string } } };
|
|
||||||
alert(e.response?.data?.message || 'No se pudo marcar como completada');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const descompletarMutation = useMutation({
|
|
||||||
mutationFn: async (periodoId: string) => apiClient.delete(`/tareas/periodo/${periodoId}/completar`),
|
|
||||||
onSuccess: invalidate,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleEdit = (t: Tarea) => {
|
const handleEdit = (t: Tarea) => {
|
||||||
setEditingId(t.id);
|
setEditingId(t.id);
|
||||||
setForm({
|
setForm({
|
||||||
@@ -206,16 +192,11 @@ export function TareasTab({ contribuyenteId }: { contribuyenteId: string | null
|
|||||||
return (
|
return (
|
||||||
<Card key={t.id}>
|
<Card key={t.id}>
|
||||||
<CardContent className="py-3 flex items-center gap-3">
|
<CardContent className="py-3 flex items-center gap-3">
|
||||||
<button
|
<div className="flex-shrink-0" title={p?.completada ? 'Completada' : atrasada ? 'Atrasada' : 'Pendiente'}>
|
||||||
onClick={() => p && (p.completada ? descompletarMutation.mutate(p.id) : completarMutation.mutate(p.id))}
|
|
||||||
disabled={!p || completarMutation.isPending}
|
|
||||||
title={p?.completada ? 'Marcar pendiente' : 'Marcar completada'}
|
|
||||||
className="flex-shrink-0"
|
|
||||||
>
|
|
||||||
{p?.completada
|
{p?.completada
|
||||||
? <CheckCircle2 className="h-5 w-5 text-success" />
|
? <CheckCircle2 className="h-5 w-5 text-success" />
|
||||||
: <Circle className={`h-5 w-5 ${atrasada ? 'text-destructive' : 'text-muted-foreground'}`} />}
|
: <Circle className={`h-5 w-5 ${atrasada ? 'text-destructive' : 'text-muted-foreground'}`} />}
|
||||||
</button>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className={`text-sm font-medium ${p?.completada ? 'line-through text-muted-foreground' : ''}`}>
|
<span className={`text-sm font-medium ${p?.completada ? 'line-through text-muted-foreground' : ''}`}>
|
||||||
|
|||||||
@@ -20,5 +20,6 @@ export const getMetodosPago = () => apiClient.get<CatalogoItem[]>('/catalogos/me
|
|||||||
export const getUsosCfdi = () => apiClient.get<UsoCfdiItem[]>('/catalogos/uso-cfdi').then(r => r.data);
|
export const getUsosCfdi = () => apiClient.get<UsoCfdiItem[]>('/catalogos/uso-cfdi').then(r => r.data);
|
||||||
export const getMonedas = () => apiClient.get<MonedaItem[]>('/catalogos/moneda').then(r => r.data);
|
export const getMonedas = () => apiClient.get<MonedaItem[]>('/catalogos/moneda').then(r => r.data);
|
||||||
export const getClavesUnidad = () => apiClient.get<CatalogoItem[]>('/catalogos/clave-unidad').then(r => r.data);
|
export const getClavesUnidad = () => apiClient.get<CatalogoItem[]>('/catalogos/clave-unidad').then(r => r.data);
|
||||||
export const searchClaveProdServ = (q: string) => apiClient.get<CatalogoItem[]>(`/catalogos/clave-prod-serv?q=${encodeURIComponent(q)}`).then(r => r.data);
|
export const searchClaveProdServ = (q: string, signal?: AbortSignal) =>
|
||||||
|
apiClient.get<CatalogoItem[]>(`/catalogos/clave-prod-serv?q=${encodeURIComponent(q)}`, { signal }).then(r => r.data);
|
||||||
export const getObjetosImp = () => apiClient.get<CatalogoItem[]>('/catalogos/objeto-imp').then(r => r.data);
|
export const getObjetosImp = () => apiClient.get<CatalogoItem[]>('/catalogos/objeto-imp').then(r => r.data);
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ export interface CreateDeclaracionData {
|
|||||||
mes: number;
|
mes: number;
|
||||||
tipo: 'normal' | 'complementaria';
|
tipo: 'normal' | 'complementaria';
|
||||||
periodicidad?: Periodicidad;
|
periodicidad?: Periodicidad;
|
||||||
impuestos: Impuesto[];
|
/** Legacy: se infiere en backend si se envían obligacionesIds. */
|
||||||
|
impuestos?: Impuesto[];
|
||||||
|
/** Obligaciones fiscales que cubre esta declaración. */
|
||||||
|
obligacionesIds?: string[];
|
||||||
montoPago?: number;
|
montoPago?: number;
|
||||||
pdfBase64: string;
|
pdfBase64: string;
|
||||||
pdfFilename: string;
|
pdfFilename: string;
|
||||||
|
|||||||
47
apps/web/lib/api/obligaciones.ts
Normal file
47
apps/web/lib/api/obligaciones.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
export interface DeclaracionLink {
|
||||||
|
id: number;
|
||||||
|
año: number;
|
||||||
|
mes: number;
|
||||||
|
tipo: 'normal' | 'complementaria';
|
||||||
|
pdfFilename: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObligacionPeriodo {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
frecuencia: string | null;
|
||||||
|
fechaLimite: string | null;
|
||||||
|
categoria: string | null;
|
||||||
|
activa: boolean;
|
||||||
|
esRecomendada: boolean;
|
||||||
|
completada: boolean;
|
||||||
|
completadaAt: string | null;
|
||||||
|
completadaPor: string | null;
|
||||||
|
periodoCompletado: string | null;
|
||||||
|
periodStatus: 'pendiente' | 'completada' | 'atrasada';
|
||||||
|
periodoAplica: string;
|
||||||
|
declaracion: DeclaracionLink | null;
|
||||||
|
declaracionPresentada: boolean;
|
||||||
|
pagoPresentado: boolean;
|
||||||
|
requierePago: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObligacionesPorPeriodoResponse {
|
||||||
|
data: ObligacionPeriodo[];
|
||||||
|
periodo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getObligacionesPorPeriodo(
|
||||||
|
contribuyenteId: string,
|
||||||
|
periodo: string,
|
||||||
|
atrasados = false,
|
||||||
|
): Promise<ObligacionesPorPeriodoResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('periodo', periodo);
|
||||||
|
params.set('atrasados', String(atrasados));
|
||||||
|
return apiClient
|
||||||
|
.get<ObligacionesPorPeriodoResponse>(`/contribuyentes/${contribuyenteId}/obligaciones/periodo?${params}`)
|
||||||
|
.then((r) => r.data);
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@horux/shared": "workspace:*",
|
"@horux/shared": "workspace:*",
|
||||||
|
|||||||
346
docs/CAMBIOS-2026-05-04.md
Normal file
346
docs/CAMBIOS-2026-05-04.md
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
# Resumen de cambios - 4 de mayo de 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Catálogo de obligaciones fiscales: nuevas obligaciones predefinidas
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
|
||||||
|
Se agregaron 3 obligaciones fiscales predefinidas al catálogo maestro.
|
||||||
|
|
||||||
|
### Obligaciones agregadas
|
||||||
|
|
||||||
|
| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| `isrtp` | Impuesto sobre remuneración al trabajo | mensual | Día 10 del mes siguiente | PM y PF | Estatal | Ninguna | No |
|
||||||
|
| `ish` | ISH - Impuesto Sobre Hospedaje | mensual | Día 15 del mes siguiente | PM y PF | Estatal | Ninguna | No |
|
||||||
|
| `sipare` | SIPARE - Cuotas obrero-patronales | mensual | Día 15 del mes siguiente | PM y PF | Seguridad social | Con empleados | No |
|
||||||
|
|
||||||
|
### Archivo modificado
|
||||||
|
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/api/src/constants/obligaciones-fiscales.ts` | Se agregaron las 3 entradas al array `OBLIGACIONES_CATALOGO` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Fix: Suscripciones `pending` se mostraban como activas en /configuracion/planes-despacho
|
||||||
|
|
||||||
|
**Fecha:** 2026-06-18
|
||||||
|
|
||||||
|
### Problema
|
||||||
|
En la página **Configuración › Planes**, las suscripciones con estado `pending` (primer pago aún no completado) mostraban el banner verde **"Suscripción activa"** y el badge **"Plan actual"** en verde, dando la impresión de que el plan estaba pagado y vigente.
|
||||||
|
|
||||||
|
### Causa
|
||||||
|
El frontend evaluaba `subStatus === 'authorized' || subStatus === 'pending'` para mostrar el banner de activa, y consideraba `pending` como "plan actual pagado" (`isCurrentPlanPaid`).
|
||||||
|
|
||||||
|
### Solución
|
||||||
|
- Se derivó el estado real de la suscripción con `getSubscriptionState()` de `@horux/shared`.
|
||||||
|
- El banner **"Suscripción activa"** ahora solo aparece cuando la suscripción está realmente `authorized` y dentro de su período.
|
||||||
|
- Se agregó un banner amarillo **"Suscripción pendiente de pago"** para estados `pending`.
|
||||||
|
- El badge del plan actual cambia a amarillo y muestra **"Plan actual — pendiente"** cuando la suscripción está pendiente.
|
||||||
|
- El botón **"Cancelar suscripción"** ya no se muestra para suscripciones `pending`.
|
||||||
|
|
||||||
|
### Archivos modificados
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx` | Lógica de estado de suscripción, banners y badges |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Fix: Botón "Pagar este plan" fallaba para suscripciones `pending`
|
||||||
|
|
||||||
|
**Fecha:** 2026-06-18
|
||||||
|
|
||||||
|
### Problema
|
||||||
|
Al hacer clic en **"Pagar este plan"** en una suscripción con estado `pending`, se mostraba el error:
|
||||||
|
**"No hay suscripción activa para cambiar"** en lugar de abrir MercadoPago.
|
||||||
|
|
||||||
|
### Causa
|
||||||
|
El flujo `handleContratar` intentaba crear una nueva suscripción (`subscribeMe`), pero el backend rechazaba porque ya existía una `pending`. El frontend entonces caía en `upgradeMe` y luego `changeMyPlan`, ambos validan que haya una suscripción `authorized` o `trial` — `pending` no califica, por eso el error.
|
||||||
|
|
||||||
|
### Solución
|
||||||
|
En `handleContratar`:
|
||||||
|
- Si el usuario selecciona el plan actual y la suscripción está `pending`, se llama directamente a `generatePaymentLink` para regenerar el link de pago de MercadoPago.
|
||||||
|
- Si el usuario intenta cambiar a otro plan estando `pending`, se muestra:
|
||||||
|
*"Completa el pago del plan actual antes de cambiar de plan."*
|
||||||
|
|
||||||
|
### Archivos modificados
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx` | Lógica de `handleContratar` para estados `pending` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Adjuntar PDFs en el correo de declaración subida
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
|
||||||
|
### Cambio
|
||||||
|
Cuando se sube una declaración provisional (`POST /api/documentos/declaraciones`), el correo de notificación a owners y supervisor ahora incluye como adjuntos:
|
||||||
|
|
||||||
|
- El **acuse de declaración** (`pdf_declaracion`).
|
||||||
|
- La **liga de pago** (`pdf_liga_pago`), si se subió.
|
||||||
|
|
||||||
|
### Archivos modificados
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `packages/core/src/email/transport.ts` | `EmailTransport.send` acepta un arreglo opcional de `EmailAttachment` y lo pasa a `nodemailer.sendMail` |
|
||||||
|
| `apps/api/src/services/email/email.service.ts` | `sendEmail` y `sendDocumentoSubido` aceptan y reenvían `attachments` |
|
||||||
|
| `apps/api/src/services/notify-upload.service.ts` | Nueva función `buildDeclaracionAttachments` que lee los PDFs de `declaraciones_provisionales` y los pasa al correo |
|
||||||
|
| `apps/api/src/controllers/documentos.controller.ts` | Se pasa `declaracionId` a `notifyDocumentoSubido` para poder recuperar los PDFs |
|
||||||
|
|
||||||
|
### Notas
|
||||||
|
- Los documentos extra (`POST /api/documentos/extras`) **no** incluyen adjuntos; solo cambia el flujo de declaraciones.
|
||||||
|
- Si los adjuntos superan los 20 MB, se omiten y se deja un aviso en el cuerpo del correo para evitar rechazos por límite de SMTP.
|
||||||
|
|
||||||
|
## 5. Nueva obligación: FONACOT
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
|
||||||
|
### Cambio
|
||||||
|
Se agregó la obligación `fonacot` al catálogo maestro de obligaciones fiscales.
|
||||||
|
|
||||||
|
| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| `fonacot` | Crédito FONACOT | Mensual | Día 5 del mes siguiente | PM/PF | Créditos de los trabajadores | Con empleados | ❌ |
|
||||||
|
|
||||||
|
### Archivo modificado
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/api/src/constants/obligaciones-fiscales.ts` | Se agregó la entrada `fonacot` en la sección **Créditos de los trabajadores** |
|
||||||
|
|
||||||
|
## 6. Nueva obligación: Aviso de actividades vulnerables
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
|
||||||
|
### Cambio
|
||||||
|
Se agregó la obligación `actividades-vulnerables` al catálogo maestro.
|
||||||
|
|
||||||
|
| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| `actividades-vulnerables` | Aviso de actividades vulnerables | Mensual | Día 17 del mes siguiente | PM/PF | Federal mensual | — | ❌ |
|
||||||
|
|
||||||
|
### Archivo modificado
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/api/src/constants/obligaciones-fiscales.ts` | Se agregó la entrada `actividades-vulnerables` en la sección **Federales mensuales** |
|
||||||
|
|
||||||
|
## 7. Nueva obligación: Declaración Informativa de transparencia
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
|
||||||
|
### Cambio
|
||||||
|
Se agregó la obligación `declaracion-transparencia` al catálogo maestro.
|
||||||
|
|
||||||
|
| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| `declaracion-transparencia` | Declaración Informativa de transparencia | Anual | Día 31 de mayo | PM | Federal anual | — | ❌ |
|
||||||
|
|
||||||
|
### Archivo modificado
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/api/src/constants/obligaciones-fiscales.ts` | Se agregó la entrada `declaracion-transparencia` en la sección **Anuales PM** |
|
||||||
|
|
||||||
|
## 8. Nueva obligación: Declaración Informativa Múltiple del IEPS (trimestral)
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
|
||||||
|
### Cambio
|
||||||
|
Se agregó la obligación `ieps-trimestral` al catálogo maestro.
|
||||||
|
|
||||||
|
| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| `ieps-trimestral` | Declaración Informativa Múltiple del IEPS | Trimestral | Día 17 de abril, julio, octubre y enero | PM/PF | Federal trimestral | — | ❌ |
|
||||||
|
|
||||||
|
### Archivo modificado
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/api/src/constants/obligaciones-fiscales.ts` | Se agregó la entrada `ieps-trimestral` en la nueva sección **Federales trimestrales** |
|
||||||
|
|
||||||
|
## 9. Nueva obligación: SISUB y soporte de frecuencia cuatrimestral
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
|
||||||
|
### Cambio
|
||||||
|
Se agregó la obligación `sisub` al catálogo y se extendió el sistema para soportar obligaciones con frecuencia **cuatrimestral**.
|
||||||
|
|
||||||
|
| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| `sisub` | Sistema de Información de Subcontratación | Cuatrimestral | Día 17 de enero, mayo y septiembre | PM/PF | Seguridad social | Con empleados | ❌ |
|
||||||
|
|
||||||
|
### Archivos modificados
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/api/src/constants/obligaciones-fiscales.ts` | Agregada `sisub` y `cuatrimestral` al union type de `frecuencia` |
|
||||||
|
| `apps/api/src/services/obligaciones.service.ts` | `inferirFrecuencia` y `appliesTo` soportan `cuatrimestral` |
|
||||||
|
| `apps/api/src/services/calendario-fiscal.service.ts` | Generación de eventos para meses cuatrimestrales (`1, 5, 9`) |
|
||||||
|
| `apps/api/src/services/alertas-manuales.service.ts` | `appliesToPeriod` soporta `cuatrimestral` |
|
||||||
|
| `apps/api/src/services/declaraciones.service.ts` | `Periodicidad` incluye `cuatrimestral` |
|
||||||
|
| `apps/api/src/controllers/documentos.controller.ts` | Schema de declaraciones acepta `cuatrimestral` |
|
||||||
|
| `apps/api/src/migrations/tenant/052_declaraciones_cuatrimestral.sql` | CHECK de `periodicidad` permite `cuatrimestral` |
|
||||||
|
| `apps/web/app/(dashboard)/configuracion/obligaciones/page.tsx` | Badge de frecuencia `cuatrimestral` |
|
||||||
|
| `apps/web/app/(dashboard)/pendientes/page.tsx` | Badge de frecuencia `cuatrimestral` |
|
||||||
|
|
||||||
|
## 10. Fix: sincronización SAT — tipos de CFDI, UUID case-insensitive y reutilización de requestIds
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
|
||||||
|
### Cambios
|
||||||
|
- La verificación de CFDIs incompletos (`hasIncompleteCfdis` / `getOldestIncompleteCfdiDate`) ahora incluye los tipos de comprobante **P** (pago) y **N** (nómina), además de **I** (ingreso) y **E** (egreso).
|
||||||
|
- Al guardar/actualizar CFDIs, la comparación de `uuid` se hace con `LOWER()` para evitar duplicados por diferencias de mayúsculas/minúsculas.
|
||||||
|
- Se desactivó la reutilización de `requestId` de jobs SAT previos. Reusarlos puede agotar el límite de descargas del SAT y devolver **"Máximo de descargas permitidas"**, bloqueando el recovery.
|
||||||
|
- Se exportó `runRecoverySyncJob` para permitir su invocación manual desde scripts.
|
||||||
|
|
||||||
|
### Archivos modificados
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/api/src/jobs/sat-sync.job.ts` | Incluir `P` y `N` en consultas de CFDIs incompletos; exportar `runRecoverySyncJob` |
|
||||||
|
| `apps/api/src/services/sat/sat.service.ts` | Comparación `LOWER(uuid)`; comentar reutilización de `requestId` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Fix: drill-down de CFDIs carga el CFDI completo al visualizar
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
|
||||||
|
### Problema
|
||||||
|
En la vista de drill-down, al hacer clic en el ojo para ver un CFDI se usaba únicamente el objeto resumen de la lista, que no incluye conceptos ni todos los detalles.
|
||||||
|
|
||||||
|
### Solución
|
||||||
|
Ahora se llama a `getCfdiById(id)` para obtener el CFDI completo antes de abrir el visor, y se muestra un estado de carga mientras se resuelve la petición.
|
||||||
|
|
||||||
|
### Archivo modificado
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/web/app/(dashboard)/drill-down/page.tsx` | Carga completa del CFDI al hacer clic en "Ver factura" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Scripts de soporte: Demo Ventas y operaciones
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
|
||||||
|
Se crearon varios scripts de utilería bajo `apps/api/scripts/` para tareas de soporte y configuración de la cuenta Demo Ventas.
|
||||||
|
|
||||||
|
### Scripts principales
|
||||||
|
| Script | Propósito |
|
||||||
|
|---|---|
|
||||||
|
| `create-demo-ventas.ts` | Crea el tenant Demo Ventas, su BD, usuario owner y suscripción custom gratuita |
|
||||||
|
| `update-demo-ventas.ts` | Agrega usuarios supervisor/auxiliar/cliente y 5 contribuyentes adicionales a Demo Ventas |
|
||||||
|
| `seed-demo-obligaciones-tareas.ts` | Siembra obligaciones fiscales y tareas recurrentes para todos los contribuyentes de Demo Ventas |
|
||||||
|
| `fix-demo-carteras-asignaciones.ts` | Crea la subcartera del auxiliar y asigna contribuyentes, obligaciones y tareas de forma válida |
|
||||||
|
| `reset-demo-asignaciones.ts` | Deja Demo Ventas en estado "tutorial": elimina subcarteras, asignaciones y relación auxiliar-supervisor |
|
||||||
|
| `change-user-email.ts` | Cambia el correo de un usuario, genera contraseña temporal e invalida sesiones |
|
||||||
|
| `resend-welcome.ts` | Reenvía el correo de bienvenida a un usuario |
|
||||||
|
|
||||||
|
> Estos scripts no son parte del flujo productivo; se ejecutan manualmente vía `npx tsx`.
|
||||||
|
|
||||||
|
## 13. Automatización de cierre de obligaciones fiscales
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
|
||||||
|
### Cambio
|
||||||
|
Se automatiza el cierre de **todas las obligaciones fiscales** desde la sección existente **Documentos › Declaraciones**. Al subir una declaración o su comprobante de pago, el sistema crea automáticamente evidencias en `obligacion_evidencias` y actualiza el estado de cada obligación fiscal en `obligacion_periodos`.
|
||||||
|
|
||||||
|
### Reglas de cierre deterministas
|
||||||
|
- `requierePago = false` (informativas): se marcan completadas al subir la declaración (`declaracion`).
|
||||||
|
- `requierePago = true` (pago + declaración): la declaración marca `declaracion_presentada = true`; el periodo se cierra al subir el comprobante de pago (`pago`).
|
||||||
|
- Al subir una declaración con **monto $0**, se marca el pago como presentado automáticamente.
|
||||||
|
|
||||||
|
### Nuevas tablas y columnas
|
||||||
|
| Migración | Descripción |
|
||||||
|
|---|---|
|
||||||
|
| `053_obligacion_evidencias.sql` | Tabla genérica para evidencias de obligaciones (declaración, pago, acuse, complemento) |
|
||||||
|
| `054_obligacion_periodos_estados.sql` | Agrega `declaracion_presentada`, `pago_presentado` y `evidencia_id` a `obligacion_periodos` |
|
||||||
|
| `055_declaracion_obligaciones.sql` | Relaciona declaraciones provisionales con las obligaciones fiscales que cierran |
|
||||||
|
|
||||||
|
### Nuevos endpoints (uso interno / futuro)
|
||||||
|
| Método | Endpoint | Descripción |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/api/documentos/obligacion-evidencias` | Listar evidencias por contribuyente/periodo/obligación |
|
||||||
|
| `POST` | `/api/documentos/obligacion-evidencias` | Subir nueva evidencia |
|
||||||
|
| `GET` | `/api/documentos/obligacion-evidencias/:id/pdf` | Descargar PDF de evidencia |
|
||||||
|
| `DELETE` | `/api/documentos/obligacion-evidencias/:id` | Eliminar evidencia y recalcular estado del periodo |
|
||||||
|
|
||||||
|
### Archivos creados
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/api/src/services/obligacion-evidencias.service.ts` | Servicio para crear/listar/descargar/eliminar evidencias y actualizar `obligacion_periodos` |
|
||||||
|
| `apps/api/src/migrations/tenant/053_obligacion_evidencias.sql` | Tabla `obligacion_evidencias` |
|
||||||
|
| `apps/api/src/migrations/tenant/054_obligacion_periodos_estados.sql` | Columnas de estado en `obligacion_periodos` |
|
||||||
|
| `apps/api/src/migrations/tenant/055_declaracion_obligaciones.sql` | Relación declaración ↔ obligación |
|
||||||
|
| `apps/web/lib/api/obligaciones.ts` | Cliente API para obtener obligaciones por periodo |
|
||||||
|
|
||||||
|
### Archivos modificados
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/api/src/constants/obligaciones-fiscales.ts` | Campo `requierePago` en todas las obligaciones del catálogo |
|
||||||
|
| `apps/api/src/services/declaraciones.service.ts` | Crea evidencias en las obligaciones seleccionadas; vincula declaración con obligaciones; mantiene fallback legacy por impuestos |
|
||||||
|
| `apps/api/src/services/obligaciones.service.ts` | `getObligacionesPorPeriodo` devuelve `requierePago`, `declaracionPresentada`, `pagoPresentado` |
|
||||||
|
| `apps/api/src/services/notify-upload.service.ts` | Soporte para notificaciones de `obligacion_evidencia` |
|
||||||
|
| `apps/api/src/services/email/templates/documento-subido.ts` | Template para evidencias de obligación |
|
||||||
|
| `apps/api/src/controllers/documentos.controller.ts` | Schema de declaraciones acepta `obligacionesIds` |
|
||||||
|
| `apps/api/src/routes/documentos.routes.ts` | Rutas de evidencias |
|
||||||
|
| `apps/web/lib/api/declaraciones.ts` | `CreateDeclaracionData` acepta `obligacionesIds` |
|
||||||
|
| `apps/web/app/(dashboard)/documentos/page.tsx` | Diálogo de subida reemplaza “Impuestos cubiertos” por selector de obligaciones fiscales del periodo |
|
||||||
|
|
||||||
|
## 15. Fix: quitar toggle de completado en Configuración › Obligaciones fiscales › Tareas
|
||||||
|
|
||||||
|
**Fecha:** 2026-06-22
|
||||||
|
|
||||||
|
### Problema
|
||||||
|
En **Configuración › Obligaciones fiscales › Tareas** seguía apareciendo el botón para marcar tareas como completadas/pendientes manualmente, pero el estado de las obligaciones fiscales ahora se actualiza automáticamente desde **Documentos › Declaraciones**.
|
||||||
|
|
||||||
|
### Solución
|
||||||
|
- Se convirtió el icono de check/círculo en un indicador visual de estado (completada, pendiente, atrasada) sin interacción.
|
||||||
|
- Se eliminaron las mutaciones de completar/descompletar periodo del frontend.
|
||||||
|
|
||||||
|
### Archivo modificado
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/web/components/obligaciones/tareas-tab.tsx` | Icono de estado estático; eliminados `completarMutation` y `descompletarMutation` |
|
||||||
|
|
||||||
|
## 14. Fix: sugerencias de Clave Producto SAT en facturación
|
||||||
|
|
||||||
|
**Fecha:** 2026-06-22
|
||||||
|
|
||||||
|
### Problema
|
||||||
|
En **Facturación › Conceptos**, el campo **Clave Producto SAT** no mostraba sugerencias al escribir.
|
||||||
|
|
||||||
|
### Causa
|
||||||
|
La tabla `cat_clave_prod_serv` de la BD central estaba vacía; el catálogo nunca se había importado.
|
||||||
|
|
||||||
|
### Solución
|
||||||
|
- Se importó el catálogo oficial CFDI 4.0 (`c_ClaveProdServ`) desde los recursos de **phpcfdi/resources-sat-catalogs** (52,513 registros).
|
||||||
|
- Se creó el script `apps/api/scripts/import-clave-prod-serv.ts` para importaciones futuras.
|
||||||
|
- Se hizo más robusto el autocomplete del campo:
|
||||||
|
- `AbortController` para cancelar búsquedas anteriores.
|
||||||
|
- Manejo de errores y `autoComplete="off"`.
|
||||||
|
- Se sanitizó el fallback regex en el backend para evitar errores con caracteres especiales.
|
||||||
|
|
||||||
|
### Archivos creados
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/api/scripts/import-clave-prod-serv.ts` | Importa el catálogo desde CSV a PostgreSQL |
|
||||||
|
|
||||||
|
### Archivos modificados
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---|---|
|
||||||
|
| `apps/api/src/controllers/catalogos.controller.ts` | Escapa regex en búsqueda fallback; búsqueda por clave insensible a mayúsculas |
|
||||||
|
| `apps/web/lib/api/catalogos.ts` | `searchClaveProdServ` acepta `AbortSignal` |
|
||||||
|
| `apps/web/app/(dashboard)/facturacion/page.tsx` | `handleSearchProduct` con `AbortController`, try/catch y `autoComplete="off"` |
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/HoruxDespachosNuevo
|
||||||
|
pnpm --filter @horux/core build
|
||||||
|
pnpm --filter api build
|
||||||
|
pnpm --filter web build
|
||||||
|
npx tsx apps/api/scripts/migrate-tenants.ts
|
||||||
|
pm2 reload horux-api
|
||||||
|
pm2 reload horux-web
|
||||||
|
```
|
||||||
|
|
||||||
|
**Estado:** ✅ Exitoso
|
||||||
@@ -8,8 +8,13 @@ export interface SmtpConfig {
|
|||||||
from: string;
|
from: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmailAttachment {
|
||||||
|
filename: string;
|
||||||
|
content: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EmailTransport {
|
export interface EmailTransport {
|
||||||
send(to: string, subject: string, html: string): Promise<void>;
|
send(to: string, subject: string, html: string, attachments?: EmailAttachment[]): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEmailTransport(config: SmtpConfig | null): EmailTransport {
|
export function createEmailTransport(config: SmtpConfig | null): EmailTransport {
|
||||||
@@ -21,7 +26,11 @@ export function createEmailTransport(config: SmtpConfig | null): EmailTransport
|
|||||||
console.warn('[EMAIL] SMTP not configured. Emails will be logged to console.');
|
console.warn('[EMAIL] SMTP not configured. Emails will be logged to console.');
|
||||||
return {
|
return {
|
||||||
sendMail: async (opts: any) => {
|
sendMail: async (opts: any) => {
|
||||||
console.log('[EMAIL] Would send:', { to: opts.to, subject: opts.subject });
|
console.log('[EMAIL] Would send:', {
|
||||||
|
to: opts.to,
|
||||||
|
subject: opts.subject,
|
||||||
|
attachments: opts.attachments?.map((a: any) => a.filename ?? a.path),
|
||||||
|
});
|
||||||
return { messageId: 'mock' };
|
return { messageId: 'mock' };
|
||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
@@ -42,7 +51,7 @@ export function createEmailTransport(config: SmtpConfig | null): EmailTransport
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async send(to: string, subject: string, html: string) {
|
async send(to: string, subject: string, html: string, attachments?: EmailAttachment[]) {
|
||||||
const transport = getTransporter();
|
const transport = getTransporter();
|
||||||
try {
|
try {
|
||||||
await transport.sendMail({
|
await transport.sendMail({
|
||||||
@@ -51,7 +60,9 @@ export function createEmailTransport(config: SmtpConfig | null): EmailTransport
|
|||||||
subject,
|
subject,
|
||||||
html,
|
html,
|
||||||
text: html.replace(/<[^>]*>/g, ''),
|
text: html.replace(/<[^>]*>/g, ''),
|
||||||
|
attachments,
|
||||||
});
|
});
|
||||||
|
console.log(`[EMAIL] Sent email to ${to} with ${attachments?.length ?? 0} attachment(s)`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[EMAIL] Error sending email:', error);
|
console.error('[EMAIL] Error sending email:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user