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;
|
||||
nombre: string;
|
||||
fundamento: string;
|
||||
frecuencia: 'mensual' | 'bimestral' | 'trimestral' | 'anual' | 'eventual';
|
||||
frecuencia: 'mensual' | 'bimestral' | 'trimestral' | 'cuatrimestral' | 'anual' | 'eventual';
|
||||
fechaLimite: string;
|
||||
aplica: 'PM' | 'PF' | 'ambos';
|
||||
regimenes: string[] | null; // null = all regimes
|
||||
condicion: string | null;
|
||||
categoria: string;
|
||||
recomendadaPorDefecto: boolean;
|
||||
/** Si true, la obligación requiere comprobante de pago para cerrarse. */
|
||||
requierePago: boolean;
|
||||
}
|
||||
|
||||
export const OBLIGACIONES_CATALOGO: ObligacionFiscal[] = [
|
||||
// === FEDERALES MENSUALES (día 17) ===
|
||||
{ id: 'isr-provisional', nombre: 'Pago provisional de ISR', fundamento: 'Art. 14 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', recomendadaPorDefecto: true },
|
||||
{ id: 'iva-mensual', nombre: 'Pago mensual definitivo de IVA', fundamento: 'Art. 5-D LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', recomendadaPorDefecto: true },
|
||||
{ id: 'ret-isr-sueldos', nombre: 'Retenciones de ISR por sueldos y salarios', fundamento: 'Art. 96 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', recomendadaPorDefecto: false },
|
||||
{ id: 'ret-isr-asimilados', nombre: 'Retenciones de ISR por asimilados a salarios', fundamento: 'Art. 94 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', recomendadaPorDefecto: false },
|
||||
{ id: 'ret-isr-honorarios', nombre: 'Retenciones de ISR por honorarios y arrendamiento a PF', fundamento: 'Art. 106/116 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'PM que contrate PF', categoria: 'Federal mensual', recomendadaPorDefecto: false },
|
||||
{ id: 'ret-iva', nombre: 'Retenciones de IVA (servicios, fletes, outsourcing)', fundamento: 'Art. 1-A LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Según supuesto', categoria: 'Federal mensual', recomendadaPorDefecto: false },
|
||||
{ id: 'ieps', nombre: 'Pago definitivo de IEPS', fundamento: 'Art. 5 LIEPS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Productores/importadores', categoria: 'Federal mensual', recomendadaPorDefecto: false },
|
||||
{ id: 'isr-provisional', nombre: 'Pago provisional de ISR', fundamento: 'Art. 14 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', requierePago: true, recomendadaPorDefecto: true },
|
||||
{ id: 'iva-mensual', nombre: 'Pago mensual definitivo de IVA', fundamento: 'Art. 5-D LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', requierePago: true, recomendadaPorDefecto: true },
|
||||
{ id: 'actividades-vulnerables', nombre: 'Aviso de actividades vulnerables', fundamento: 'LFPIORPI', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
|
||||
{ id: 'ret-isr-sueldos', nombre: 'Retenciones de ISR por sueldos y salarios', fundamento: 'Art. 96 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
|
||||
{ id: 'ret-isr-asimilados', nombre: 'Retenciones de ISR por asimilados a salarios', fundamento: 'Art. 94 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
|
||||
{ id: 'ret-isr-honorarios', nombre: 'Retenciones de ISR por honorarios y arrendamiento a PF', fundamento: 'Art. 106/116 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'PM que contrate PF', categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
|
||||
{ id: 'ret-iva', nombre: 'Retenciones de IVA (servicios, fletes, outsourcing)', fundamento: 'Art. 1-A LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Según supuesto', categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
|
||||
{ id: 'ieps', nombre: 'Pago definitivo de IEPS', fundamento: 'Art. 5 LIEPS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Productores/importadores', categoria: 'Federal mensual', requierePago: true, recomendadaPorDefecto: false },
|
||||
|
||||
// === INFORMATIVAS MENSUALES ===
|
||||
{ id: 'diot', nombre: 'DIOT (Declaración Informativa de Operaciones con Terceros)', fundamento: 'Art. 32 LIVA', frecuencia: 'mensual', fechaLimite: 'Último día del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false },
|
||||
{ id: 'cont-balanza', nombre: 'Contabilidad Electrónica - Balanza de comprobación', fundamento: 'CFF Art. 28', frecuencia: 'mensual', fechaLimite: 'Día 3 del segundo mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false },
|
||||
{ id: 'cont-catalogo', nombre: 'Contabilidad Electrónica - Catálogo de cuentas', fundamento: 'CFF Art. 28', frecuencia: 'eventual', fechaLimite: 'Cuando haya modificación', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false },
|
||||
{ id: 'diot', nombre: 'DIOT (Declaración Informativa de Operaciones con Terceros)', fundamento: 'Art. 32 LIVA', frecuencia: 'mensual', fechaLimite: 'Último día del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', requierePago: false, recomendadaPorDefecto: false },
|
||||
{ id: 'cont-balanza', nombre: 'Contabilidad Electrónica - Balanza de comprobación', fundamento: 'CFF Art. 28', frecuencia: 'mensual', fechaLimite: 'Día 3 del segundo mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', requierePago: false, recomendadaPorDefecto: false },
|
||||
{ id: 'cont-catalogo', nombre: 'Contabilidad Electrónica - Catálogo de cuentas', fundamento: 'CFF Art. 28', frecuencia: 'eventual', fechaLimite: 'Cuando haya modificación', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', requierePago: false, recomendadaPorDefecto: false },
|
||||
|
||||
// === FEDERALES TRIMESTRALES ===
|
||||
{ id: 'ieps-trimestral', nombre: 'Declaración Informativa Múltiple del IEPS', fundamento: 'LIEPS', frecuencia: 'trimestral', fechaLimite: 'Día 17 de abril, julio, octubre y enero', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal trimestral', requierePago: false, recomendadaPorDefecto: false },
|
||||
|
||||
// === RESICO PM ===
|
||||
{ id: 'isr-resico-pm', nombre: 'Pago provisional ISR RESICO-PM', fundamento: 'Art. 206 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: ['626'], condicion: null, categoria: 'RESICO PM', recomendadaPorDefecto: true },
|
||||
{ id: 'isr-resico-pm', nombre: 'Pago provisional ISR RESICO-PM', fundamento: 'Art. 206 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: ['626'], condicion: null, categoria: 'RESICO PM', requierePago: true, recomendadaPorDefecto: true },
|
||||
|
||||
// === RESICO PF ===
|
||||
{ id: 'isr-resico-pf', nombre: 'Pago mensual ISR RESICO PF (1%-2.5%)', fundamento: 'Art. 113-E LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PF', regimenes: ['626'], condicion: null, categoria: 'RESICO PF', recomendadaPorDefecto: true },
|
||||
{ id: 'isr-resico-pf', nombre: 'Pago mensual ISR RESICO PF (1%-2.5%)', fundamento: 'Art. 113-E LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PF', regimenes: ['626'], condicion: null, categoria: 'RESICO PF', requierePago: true, recomendadaPorDefecto: true },
|
||||
|
||||
// === ANUALES PM ===
|
||||
{ id: 'anual-isr-pm', nombre: 'Declaración Anual de ISR PM', fundamento: 'Art. 76 LISR', frecuencia: 'anual', fechaLimite: '31 de marzo', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: true },
|
||||
{ id: 'issif', nombre: 'ISSIF (Información sobre Situación Fiscal)', fundamento: 'CFF Art. 32-H', frecuencia: 'anual', fechaLimite: 'Con la declaración anual', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: false },
|
||||
{ id: 'dictamen-fiscal', nombre: 'Dictamen Fiscal', fundamento: 'CFF Art. 32-A', frecuencia: 'anual', fechaLimite: '15 de mayo', aplica: 'PM', regimenes: null, condicion: 'Ingresos > $1,855M o grupos', categoria: 'Anual', recomendadaPorDefecto: false },
|
||||
{ id: 'dim', nombre: 'DIM - Declaraciones Informativas Múltiples', fundamento: 'CFF', frecuencia: 'anual', fechaLimite: '15 de febrero', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: false },
|
||||
{ id: 'anual-isr-pm', nombre: 'Declaración Anual de ISR PM', fundamento: 'Art. 76 LISR', frecuencia: 'anual', fechaLimite: '31 de marzo', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', requierePago: true, recomendadaPorDefecto: true },
|
||||
{ id: 'declaracion-transparencia', nombre: 'Declaración Informativa de transparencia', fundamento: 'LFTAIPG', frecuencia: 'anual', fechaLimite: 'Día 31 de mayo', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Federal anual', requierePago: false, recomendadaPorDefecto: false },
|
||||
{ id: 'issif', nombre: 'ISSIF (Información sobre Situación Fiscal)', fundamento: 'CFF Art. 32-H', frecuencia: 'anual', fechaLimite: 'Con la declaración anual', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', requierePago: false, recomendadaPorDefecto: false },
|
||||
{ id: 'dictamen-fiscal', nombre: 'Dictamen Fiscal', fundamento: 'CFF Art. 32-A', frecuencia: 'anual', fechaLimite: '15 de mayo', aplica: 'PM', regimenes: null, condicion: 'Ingresos > $1,855M o grupos', categoria: 'Anual', requierePago: false, recomendadaPorDefecto: false },
|
||||
{ id: 'dim', nombre: 'DIM - Declaraciones Informativas Múltiples', fundamento: 'CFF', frecuencia: 'anual', fechaLimite: '15 de febrero', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', requierePago: false, recomendadaPorDefecto: false },
|
||||
|
||||
// === ANUALES PF ===
|
||||
{ id: 'anual-isr-pf', nombre: 'Declaración Anual PF', fundamento: 'Art. 150 LISR', frecuencia: 'anual', fechaLimite: '30 de abril', aplica: 'PF', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: true },
|
||||
{ id: 'anual-isr-pf', nombre: 'Declaración Anual PF', fundamento: 'Art. 150 LISR', frecuencia: 'anual', fechaLimite: '30 de abril', aplica: 'PF', regimenes: null, condicion: null, categoria: 'Anual', requierePago: true, recomendadaPorDefecto: true },
|
||||
|
||||
// === SEGURIDAD SOCIAL ===
|
||||
{ id: 'imss-cuotas', nombre: 'Cuotas obrero-patronales IMSS', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
|
||||
{ id: 'infonavit', nombre: 'Aportaciones INFONAVIT + amortizaciones', fundamento: 'LINFONAVIT', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
|
||||
{ id: 'sar-retiro', nombre: 'SAR / Retiro', fundamento: 'LSS', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
|
||||
{ id: 'prima-riesgo', nombre: 'Determinación Prima de Riesgo de Trabajo', fundamento: 'LSS Art. 74', frecuencia: 'anual', fechaLimite: 'Febrero', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
|
||||
{ id: 'imss-cuotas', nombre: 'Cuotas obrero-patronales IMSS', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
|
||||
{ id: 'sipare', nombre: 'SIPARE - Cuotas obrero-patronales', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 15 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
|
||||
{ id: 'infonavit', nombre: 'Aportaciones INFONAVIT + amortizaciones', fundamento: 'LINFONAVIT', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
|
||||
{ id: 'sar-retiro', nombre: 'SAR / Retiro', fundamento: 'LSS', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
|
||||
{ id: 'sisub', nombre: 'Sistema de Información de Subcontratación', fundamento: 'LFT', frecuencia: 'cuatrimestral', fechaLimite: 'Día 17 de enero, mayo y septiembre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: false, recomendadaPorDefecto: false },
|
||||
{ id: 'prima-riesgo', nombre: 'Determinación Prima de Riesgo de Trabajo', fundamento: 'LSS Art. 74', frecuencia: 'anual', fechaLimite: 'Febrero', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
|
||||
|
||||
// === CRÉDITOS DE LOS TRABAJADORES ===
|
||||
{ id: 'fonacot', nombre: 'Crédito FONACOT', fundamento: 'Ley FONACOT', frecuencia: 'mensual', fechaLimite: 'Día 5 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Créditos de los trabajadores', requierePago: true, recomendadaPorDefecto: false },
|
||||
|
||||
// === ESTATALES ===
|
||||
{ id: 'isn', nombre: 'ISN - Impuesto Sobre Nómina', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Varía por estado (CDMX día 17)', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Estatal', recomendadaPorDefecto: false },
|
||||
{ id: 'isn', nombre: 'ISN - Impuesto Sobre Nómina', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Varía por estado (CDMX día 17)', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Estatal', requierePago: true, recomendadaPorDefecto: false },
|
||||
{ id: 'isrtp', nombre: 'Impuesto sobre remuneración al trabajo', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Día 10 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Estatal', requierePago: true, recomendadaPorDefecto: false },
|
||||
{ id: 'ish', nombre: 'ISH - Impuesto Sobre Hospedaje', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Día 15 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Estatal', requierePago: true, recomendadaPorDefecto: false },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,10 @@ export async function getClavesUnidad(req: Request, res: Response, next: NextFun
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export async function searchClaveProdServ(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const q = (req.query.q as string || '').trim();
|
||||
@@ -44,11 +48,10 @@ export async function searchClaveProdServ(req: Request, res: Response, next: Nex
|
||||
}
|
||||
|
||||
// Buscar por clave o descripción
|
||||
// Primero buscar por clave, luego por texto
|
||||
const data = await prisma.catClaveProdServ.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ clave: { startsWith: q } },
|
||||
{ clave: { startsWith: q, mode: 'insensitive' } },
|
||||
{ descripcion: { contains: q, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
@@ -68,8 +71,8 @@ export async function searchClaveProdServ(req: Request, res: Response, next: Nex
|
||||
return res.json(fallback);
|
||||
}
|
||||
|
||||
// Buscar con variantes comunes de acentos
|
||||
const withAccents = normalized
|
||||
// Buscar con variantes comunes de acentos, escapando caracteres regex primero
|
||||
const withAccents = escapeRegex(normalized)
|
||||
.replace(/a/gi, '[aá]').replace(/e/gi, '[eé]')
|
||||
.replace(/i/gi, '[ií]').replace(/o/gi, '[oó]').replace(/u/gi, '[uú]')
|
||||
.replace(/n/gi, '[nñ]');
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getOpiniones, getOpinionPdf, consultarOpinion, consultarOpinionContribu
|
||||
import * as declaracionesService from '../services/declaraciones.service.js';
|
||||
import * as constanciaService from '../services/constancia.service.js';
|
||||
import * as extrasService from '../services/documentos-extras.service.js';
|
||||
import * as obligacionEvidenciasService from '../services/obligacion-evidencias.service.js';
|
||||
import { notifyDocumentoSubido } from '../services/notify-upload.service.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
|
||||
@@ -81,8 +82,9 @@ const createDeclaracionSchema = z.object({
|
||||
año: z.number().int().min(2020).max(2100),
|
||||
mes: z.number().int().min(1).max(12),
|
||||
tipo: z.enum(['normal', 'complementaria']),
|
||||
periodicidad: z.enum(['mensual', 'bimestral', 'trimestral', 'semestral', 'anual']).optional(),
|
||||
impuestos: z.array(z.enum(['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'])).min(1, 'Selecciona al menos un impuesto'),
|
||||
periodicidad: z.enum(['mensual', 'bimestral', 'trimestral', 'cuatrimestral', 'semestral', 'anual']).optional(),
|
||||
impuestos: z.array(z.enum(['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'])).optional(),
|
||||
obligacionesIds: z.array(z.string().uuid()).optional(),
|
||||
montoPago: z.number().min(0).optional(),
|
||||
pdfBase64: z.string().min(100),
|
||||
pdfFilename: z.string().min(1).max(255),
|
||||
@@ -92,6 +94,9 @@ const createDeclaracionSchema = z.object({
|
||||
}).refine(
|
||||
d => !d.ligaPagoBase64 || !!d.ligaPagoFilename,
|
||||
{ message: 'Si incluyes liga de pago, también debes mandar su nombre de archivo', path: ['ligaPagoFilename'] },
|
||||
).refine(
|
||||
d => (d.obligacionesIds && d.obligacionesIds.length > 0) || (d.impuestos && d.impuestos.length > 0),
|
||||
{ message: 'Selecciona al menos una obligación fiscal o un impuesto', path: ['obligacionesIds'] },
|
||||
);
|
||||
|
||||
export async function listarDeclaraciones(req: Request, res: Response, next: NextFunction) {
|
||||
@@ -119,6 +124,7 @@ export async function crearDeclaracion(req: Request, res: Response, next: NextFu
|
||||
});
|
||||
|
||||
// Notificación fire-and-forget a owners del despacho + supervisor del RFC.
|
||||
// Incluye como adjuntos el acuse de declaración y la liga de pago (si se subió).
|
||||
// No bloquea la respuesta ni falla la creación si SMTP no está configurado.
|
||||
notifyDocumentoSubido({
|
||||
pool: req.tenantPool!,
|
||||
@@ -126,6 +132,7 @@ export async function crearDeclaracion(req: Request, res: Response, next: NextFu
|
||||
contribuyenteId: contribuyenteId ?? null,
|
||||
subidoPor: req.user!.email,
|
||||
kind: 'declaracion',
|
||||
declaracionId: result.declaracion.id,
|
||||
declaracion: {
|
||||
periodo: `${MESES[data.mes - 1]} ${data.año}`,
|
||||
tipo: data.tipo,
|
||||
@@ -334,3 +341,91 @@ export async function listarCategoriasExtras(req: Request, res: Response, next:
|
||||
res.json(data);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Obligación evidencias — documentos que cierran obligaciones fiscales
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const createEvidenciaObligacionSchema = z.object({
|
||||
contribuyenteId: z.string().uuid('contribuyenteId inválido'),
|
||||
obligacionId: z.string().uuid('obligacionId inválido'),
|
||||
periodo: z.string().regex(/^\d{4}-\d{2}$/, 'periodo debe ser YYYY-MM'),
|
||||
tipoDocumento: z.enum(['declaracion', 'pago', 'acuse', 'complemento']),
|
||||
pdfBase64: z.string().min(100, 'PDF requerido'),
|
||||
pdfFilename: z.string().min(1).max(255),
|
||||
notas: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
export async function listarEvidenciasObligacion(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = req.query.contribuyenteId as string | undefined;
|
||||
if (!contribuyenteId) return next(new AppError(400, 'contribuyenteId requerido'));
|
||||
const periodo = req.query.periodo as string | undefined;
|
||||
const obligacionId = req.query.obligacionId as string | undefined;
|
||||
const data = await obligacionEvidenciasService.listEvidencias(req.tenantPool!, contribuyenteId, {
|
||||
periodo,
|
||||
obligacionId,
|
||||
});
|
||||
res.json(data);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function crearEvidenciaObligacion(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para subir documentos' });
|
||||
const data = createEvidenciaObligacionSchema.parse(req.body);
|
||||
const result = await obligacionEvidenciasService.createEvidencia(req.tenantPool!, {
|
||||
...data,
|
||||
subidoPor: req.user!.userId,
|
||||
subidoPorEmail: req.user!.email,
|
||||
});
|
||||
|
||||
// Notificación fire-and-forget a owners + supervisor del contribuyente.
|
||||
const { rows: obRows } = await req.tenantPool!.query<{ nombre: string }>(
|
||||
'SELECT nombre FROM obligaciones_contribuyente WHERE id = $1',
|
||||
[data.obligacionId],
|
||||
);
|
||||
notifyDocumentoSubido({
|
||||
pool: req.tenantPool!,
|
||||
tenantId: req.viewingTenantId ?? req.user!.tenantId,
|
||||
contribuyenteId: data.contribuyenteId,
|
||||
subidoPor: req.user!.email,
|
||||
kind: 'obligacion_evidencia',
|
||||
evidencia: {
|
||||
obligacionNombre: obRows[0]?.nombre || 'Obligación fiscal',
|
||||
periodo: data.periodo,
|
||||
tipoDocumento: data.tipoDocumento,
|
||||
filename: data.pdfFilename,
|
||||
},
|
||||
pdfBase64: data.pdfBase64,
|
||||
}).catch((err: any) => console.error('[notifyDocumentoSubido obligacion_evidencia]', err?.message || err));
|
||||
|
||||
res.status(201).json(result);
|
||||
} catch (error: any) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function descargarEvidenciaObligacion(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const id = parseInt(String(req.params.id));
|
||||
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
|
||||
const pdf = await obligacionEvidenciasService.getEvidenciaPdf(req.tenantPool!, id);
|
||||
if (!pdf) return next(new AppError(404, 'Evidencia no encontrada'));
|
||||
res.setHeader('Content-Type', pdf.mime);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${pdf.filename}"`);
|
||||
res.send(pdf.buffer);
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
export async function eliminarEvidenciaObligacion(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para eliminar documentos' });
|
||||
const id = parseInt(String(req.params.id));
|
||||
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
|
||||
const result = await obligacionEvidenciasService.deleteEvidencia(req.tenantPool!, id);
|
||||
if (!result) return next(new AppError(404, 'Evidencia no encontrada'));
|
||||
res.status(204).send();
|
||||
} catch (error) { next(error); }
|
||||
}
|
||||
|
||||
@@ -402,7 +402,7 @@ async function hasIncompleteCfdis(pool: Pool, contribuyenteId: string): Promise<
|
||||
FROM cfdis
|
||||
WHERE contribuyente_id = $1
|
||||
AND status = 'Vigente'
|
||||
AND tipo_comprobante IN ('I', 'E')
|
||||
AND tipo_comprobante IN ('I', 'E', 'P', 'N')
|
||||
AND xml_original IS NULL
|
||||
`, [contribuyenteId]);
|
||||
return Number(rows[0]?.count || 0) > 0;
|
||||
@@ -414,7 +414,7 @@ async function getOldestIncompleteCfdiDate(pool: Pool, contribuyenteId: string):
|
||||
FROM cfdis
|
||||
WHERE contribuyente_id = $1
|
||||
AND status = 'Vigente'
|
||||
AND tipo_comprobante IN ('I', 'E')
|
||||
AND tipo_comprobante IN ('I', 'E', 'P', 'N')
|
||||
AND xml_original IS NULL
|
||||
`, [contribuyenteId]);
|
||||
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) {
|
||||
console.log('[SAT Recovery] Ya en ejecución, omitiendo');
|
||||
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.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 };
|
||||
|
||||
@@ -119,6 +119,7 @@ function appliesToPeriod(frecuencia: string | null, periodo: string): boolean {
|
||||
case 'mensual': return true;
|
||||
case 'bimestral': return month % 2 === 1;
|
||||
case 'trimestral': return [1, 4, 7, 10].includes(month);
|
||||
case 'cuatrimestral': return [1, 5, 9].includes(month);
|
||||
case 'anual': return month === 3 || month === 4;
|
||||
case 'eventual': return false;
|
||||
default: return true;
|
||||
|
||||
@@ -214,6 +214,7 @@ export async function generarEventosDesdeObligaciones(
|
||||
if (freq === 'mensual') monthsToGenerate.push(m);
|
||||
else if (freq === 'bimestral' && m % 2 === 1) monthsToGenerate.push(m);
|
||||
else if (freq === 'trimestral' && [1, 4, 7, 10].includes(m)) monthsToGenerate.push(m);
|
||||
else if (freq === 'cuatrimestral' && [1, 5, 9].includes(m)) monthsToGenerate.push(m);
|
||||
else if (freq === 'anual' && (m === 3 || m === 4)) monthsToGenerate.push(m);
|
||||
// 'eventual' and unknown: skip auto-generation
|
||||
}
|
||||
|
||||
@@ -1,4 +1,38 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { createEvidencia } from './obligacion-evidencias.service.js';
|
||||
|
||||
function normalize(s: string): string {
|
||||
return s
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[.,;:()]/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dadas las obligaciones seleccionadas para una declaración, infiere los
|
||||
* impuestos que cubre. Se usa para mantener la resolución de alertas legacy
|
||||
* (decl-*, pago-*) sin exponer el campo en la UI.
|
||||
*/
|
||||
function inferirImpuestosDeObligaciones(
|
||||
obligaciones: Array<{ id: string; nombre: string; catalogoId?: string | null }>,
|
||||
): Impuesto[] {
|
||||
const set = new Set<Impuesto>();
|
||||
for (const ob of obligaciones) {
|
||||
const nombre = normalize(ob.nombre);
|
||||
const catalogoId = normalize(ob.catalogoId || '');
|
||||
if (nombre.includes('diot') || catalogoId.includes('diot')) {
|
||||
set.add('DIOT');
|
||||
} else if (nombre.includes('iva') || catalogoId.includes('iva')) {
|
||||
set.add('IVA');
|
||||
}
|
||||
if (nombre.includes('isr') || catalogoId.includes('isr')) set.add('ISR');
|
||||
if (nombre.includes('ieps') || catalogoId.includes('ieps')) set.add('IEPS');
|
||||
if (nombre.includes('isn') || catalogoId.includes('isn')) set.add('ISN');
|
||||
if (nombre.includes('ish') || catalogoId.includes('ish')) set.add('ISH');
|
||||
}
|
||||
return Array.from(set);
|
||||
}
|
||||
|
||||
// Mapeo: impuesto de la declaración → reglas para matchear obligaciones del
|
||||
// contribuyente. `include` son substrings que DEBE contener el nombre de la
|
||||
@@ -25,17 +59,28 @@ const IMPUESTO_A_OBLIGACION_KEYWORDS: Record<string, { include: string[]; exclud
|
||||
* periodo sigue marcado completado — el usuario decidirá si re-abrirlo
|
||||
* manualmente.
|
||||
*/
|
||||
async function completarObligacionesPorDeclaracion(
|
||||
/**
|
||||
* Al subir una declaración o comprobante de pago, registra una evidencia para
|
||||
* cada obligación del contribuyente que corresponda al impuesto declarado.
|
||||
*
|
||||
* - Obligaciones informativas (`requierePago = false`) se marcan completadas al
|
||||
* recibir cualquier documento de declaración/acuse.
|
||||
* - Obligaciones de pago (`requierePago = true`) se marcan completadas solo al
|
||||
* recibir un comprobante de pago (`tipo_documento = 'pago'`).
|
||||
*/
|
||||
async function registrarEvidenciasPorDeclaracion(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
impuestos: string[],
|
||||
periodo: string,
|
||||
/** UUID del usuario que subió la declaración (obligacion_periodos.completada_por es uuid). */
|
||||
completadaPor: string,
|
||||
declaracionId: number,
|
||||
/** Periodicidad de la declaración. Si no se provee, se asume 'mensual'. */
|
||||
/** UUID del usuario que subió el documento. */
|
||||
subidoPor: string,
|
||||
pdfBase64: string,
|
||||
pdfFilename: string,
|
||||
tipoDocumento: 'declaracion' | 'pago',
|
||||
/** Periodicidad de la declaración. Si no se provee, asume 'mensual'. */
|
||||
periodicidad: string = 'mensual',
|
||||
): Promise<number> {
|
||||
): Promise<{ count: number; obligacionesAfectadas: string[] }> {
|
||||
// Get active obligations for this contribuyente (incluye frecuencia para filtrar)
|
||||
const { rows: obligaciones } = await pool.query<{ id: string; nombre: string; frecuencia: string | null }>(
|
||||
`SELECT id, nombre, frecuencia FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND activa = true`,
|
||||
@@ -43,6 +88,7 @@ async function completarObligacionesPorDeclaracion(
|
||||
);
|
||||
|
||||
let count = 0;
|
||||
const obligacionesAfectadas: string[] = [];
|
||||
|
||||
for (const impuesto of impuestos) {
|
||||
const rules = IMPUESTO_A_OBLIGACION_KEYWORDS[impuesto];
|
||||
@@ -55,33 +101,109 @@ async function completarObligacionesPorDeclaracion(
|
||||
if (!matches) continue;
|
||||
|
||||
// Filtro por periodicidad/frecuencia: una declaración mensual no debe
|
||||
// cerrar obligaciones anuales del mismo impuesto (ej. ISR mensual no
|
||||
// cubre "Declaración anual de ISR"). Si la obligación tiene frecuencia
|
||||
// explícita y no coincide con la periodicidad de la declaración, skip.
|
||||
// `eventual` obligaciones no se tocan automáticamente.
|
||||
// cerrar obligaciones anuales del mismo impuesto.
|
||||
const obFrec = (ob.frecuencia || '').toLowerCase();
|
||||
if (obFrec === 'eventual') continue;
|
||||
if (obFrec && obFrec !== periodicidad.toLowerCase()) continue;
|
||||
|
||||
// Mark obligation as completed for this period, with FK a la declaración
|
||||
await pool.query(`
|
||||
INSERT INTO obligacion_periodos (obligacion_id, periodo, completada, completada_at, completada_por, notas, declaracion_id)
|
||||
VALUES ($1, $2, true, now(), $3, $4, $5)
|
||||
ON CONFLICT (obligacion_id, periodo)
|
||||
DO UPDATE SET completada = true, completada_at = now(), completada_por = $3, declaracion_id = $5
|
||||
`, [ob.id, periodo, completadaPor, `Declaración ${impuesto} subida`, declaracionId]);
|
||||
|
||||
// Resolve the ob-* alert for this obligation+period
|
||||
await pool.query(
|
||||
`UPDATE alertas SET resuelta = true WHERE tipo = $1 AND resuelta = false`,
|
||||
[`ob-${ob.id}-${periodo}`],
|
||||
);
|
||||
await createEvidencia(pool, {
|
||||
obligacionId: ob.id,
|
||||
periodo,
|
||||
contribuyenteId,
|
||||
tipoDocumento,
|
||||
pdfBase64,
|
||||
pdfFilename,
|
||||
notas: `${tipoDocumento === 'pago' ? 'Pago' : 'Declaración'} ${impuesto}`,
|
||||
subidoPor,
|
||||
});
|
||||
|
||||
if (!obligacionesAfectadas.includes(ob.id)) obligacionesAfectadas.push(ob.id);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
return { count, obligacionesAfectadas };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuando una declaración tiene monto $0, no se requiere comprobante de pago.
|
||||
* Esta función marca `pago_presentado = true` (y `completada = true`) en los
|
||||
* periodos de las obligaciones afectadas para reflejar que el pago está saldado.
|
||||
*/
|
||||
async function confirmarPagoPeriodoSinComprobante(
|
||||
pool: Pool,
|
||||
obligacionesAfectadas: string[],
|
||||
periodo: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
for (const obligacionId of obligacionesAfectadas) {
|
||||
await pool.query(
|
||||
`INSERT INTO obligacion_periodos
|
||||
(obligacion_id, periodo, declaracion_presentada, pago_presentado, completada, completada_at, completada_por)
|
||||
VALUES ($1, $2, true, true, true, $3, $4)
|
||||
ON CONFLICT (obligacion_id, periodo)
|
||||
DO UPDATE SET
|
||||
pago_presentado = true,
|
||||
completada = true,
|
||||
completada_at = COALESCE(obligacion_periodos.completada_at, $3),
|
||||
completada_por = COALESCE(obligacion_periodos.completada_por, $4)`,
|
||||
[obligacionId, periodo, now, userId],
|
||||
);
|
||||
|
||||
// Resolver alerta ob-* si existe
|
||||
await pool.query(
|
||||
`UPDATE alertas SET resuelta = true WHERE tipo = $1 AND resuelta = false`,
|
||||
[`ob-${obligacionId}-${periodo}`],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra una evidencia por cada obligación seleccionada.
|
||||
* - Obligaciones informativas se completan con `declaracion`/`acuse`/`complemento`.
|
||||
* - Obligaciones de pago requieren evidencia `pago` para cerrarse.
|
||||
*/
|
||||
async function registrarEvidenciasPorObligaciones(
|
||||
pool: Pool,
|
||||
obligaciones: Array<{ id: string; nombre: string; catalogoId?: string | null }>,
|
||||
contribuyenteId: string,
|
||||
periodo: string,
|
||||
subidoPor: string,
|
||||
pdfBase64: string,
|
||||
pdfFilename: string,
|
||||
tipoDocumento: 'declaracion' | 'pago',
|
||||
notas?: string,
|
||||
): Promise<string[]> {
|
||||
const afectadas: string[] = [];
|
||||
for (const ob of obligaciones) {
|
||||
await createEvidencia(pool, {
|
||||
obligacionId: ob.id,
|
||||
periodo,
|
||||
contribuyenteId,
|
||||
tipoDocumento,
|
||||
pdfBase64,
|
||||
pdfFilename,
|
||||
notas: notas || `${tipoDocumento === 'pago' ? 'Comprobante de pago' : 'Declaración'}: ${ob.nombre}`,
|
||||
subidoPor,
|
||||
});
|
||||
afectadas.push(ob.id);
|
||||
}
|
||||
return afectadas;
|
||||
}
|
||||
|
||||
async function getObligacionesPorIds(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
obligacionesIds: string[],
|
||||
): Promise<Array<{ id: string; nombre: string; catalogoId: string | null }>> {
|
||||
const { rows } = await pool.query<{ id: string; nombre: string; catalogo_id: string | null }>(
|
||||
`SELECT id, nombre, catalogo_id
|
||||
FROM obligaciones_contribuyente
|
||||
WHERE contribuyente_id = $1 AND id = ANY($2::uuid[]) AND activa = true`,
|
||||
[contribuyenteId, obligacionesIds],
|
||||
);
|
||||
return rows.map(r => ({ id: r.id, nombre: r.nombre, catalogoId: r.catalogo_id }));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,7 +218,7 @@ async function completarObligacionesPorDeclaracion(
|
||||
|
||||
export type Impuesto = 'IVA' | 'ISR' | 'IEPS' | 'ISN' | 'DIOT' | 'OTRO' | 'ISH';
|
||||
|
||||
export type Periodicidad = 'mensual' | 'bimestral' | 'trimestral' | 'semestral' | 'anual';
|
||||
export type Periodicidad = 'mensual' | 'bimestral' | 'trimestral' | 'cuatrimestral' | 'semestral' | 'anual';
|
||||
|
||||
export interface DeclaracionRow {
|
||||
id: number;
|
||||
@@ -232,7 +354,10 @@ export async function createDeclaracion(
|
||||
mes: number;
|
||||
tipo: 'normal' | 'complementaria';
|
||||
periodicidad?: Periodicidad;
|
||||
impuestos: string[];
|
||||
/** Legacy: se infiere de obligacionesIds si no se envía. */
|
||||
impuestos?: string[];
|
||||
/** Obligaciones fiscales que cubre esta declaración. */
|
||||
obligacionesIds?: string[];
|
||||
montoPago?: number | null;
|
||||
pdfBase64: string; // PDF de la declaración (base64)
|
||||
pdfFilename: string;
|
||||
@@ -253,6 +378,16 @@ export async function createDeclaracion(
|
||||
// If monto_pago is exactly 0, auto-mark as paid (no payment receipt needed)
|
||||
const pagadoAt = montoPago === 0 ? new Date() : null;
|
||||
|
||||
// Resolvemos obligaciones e impuestos.
|
||||
let obligacionesSeleccionadas: Array<{ id: string; nombre: string; catalogoId: string | null }> = [];
|
||||
let impuestos: string[] = data.impuestos ?? [];
|
||||
if (data.contribuyenteId && data.obligacionesIds && data.obligacionesIds.length > 0) {
|
||||
obligacionesSeleccionadas = await getObligacionesPorIds(pool, data.contribuyenteId, data.obligacionesIds);
|
||||
if (impuestos.length === 0) {
|
||||
impuestos = inferirImpuestosDeObligaciones(obligacionesSeleccionadas);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO declaraciones_provisionales
|
||||
@@ -262,46 +397,55 @@ export async function createDeclaracion(
|
||||
RETURNING id, año, mes, tipo, periodicidad, impuestos, monto_pago, pdf_filename,
|
||||
pdf_liga_pago_filename, pdf_pago_filename, pagado_at, creado_por, notas,
|
||||
created_at, updated_at`,
|
||||
[data.año, data.mes, data.tipo, periodicidad, data.impuestos, montoPago,
|
||||
[data.año, data.mes, data.tipo, periodicidad, impuestos, montoPago,
|
||||
buf, data.pdfFilename, ligaBuf, data.ligaPagoFilename ?? null,
|
||||
data.notas ?? null, data.creadoPor, pagadoAt, data.contribuyenteId ?? null],
|
||||
);
|
||||
|
||||
const declaracion = rowToDeclaracion(rows[0]);
|
||||
|
||||
// Auto-resolver alertas. Reglas:
|
||||
// - tipo='normal': resuelve alertas de declaración (decl-*) del mes.
|
||||
// El pago se resuelve por separado al subir comprobante.
|
||||
// - tipo='complementaria': sustituye a la normal en términos de
|
||||
// obligación de pago — al subirla se resuelven AMBAS (decl-* y
|
||||
// pago-*) porque el cliente pagará usando la complementaria,
|
||||
// no la normal. La alerta de declaración ya estaría resuelta
|
||||
// si la normal se subió antes; el resolver es idempotente.
|
||||
const prefijosDecl = data.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_DECL[i] || []);
|
||||
// Guardar relación con obligaciones para que el comprobante de pago
|
||||
// posterior se aplique a las mismas obligaciones.
|
||||
if (obligacionesSeleccionadas.length > 0) {
|
||||
const values = obligacionesSeleccionadas.map((_, i) => `($1, $${i + 2})`).join(',');
|
||||
await pool.query(
|
||||
`INSERT INTO declaracion_obligaciones (declaracion_id, obligacion_id) VALUES ${values}`,
|
||||
[declaracion.id, ...obligacionesSeleccionadas.map(o => o.id)],
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-resolver alertas legacy (decl-*, pago-*).
|
||||
const prefijosDecl = impuestos.flatMap(i => IMPUESTO_A_PREFIJO_DECL[i] || []);
|
||||
let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosDecl, data.año, data.mes);
|
||||
if (data.tipo === 'complementaria' || montoPago === 0) {
|
||||
// complementaria: sustituye normal para pago → resolver ambas
|
||||
// monto 0: nada que pagar → resolver alertas de pago también
|
||||
const prefijosPago = data.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []);
|
||||
const prefijosPago = impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []);
|
||||
alertasResueltas += await resolverAlertasPorPeriodo(pool, prefijosPago, data.año, data.mes);
|
||||
}
|
||||
|
||||
// Auto-complete obligaciones del contribuyente SOLO si la declaración
|
||||
// también cubre el pago (complementaria sustituye a la normal para el
|
||||
// pago; monto=0 significa "nada que pagar"). Una declaración normal con
|
||||
// monto>0 solo presenta el acuse — la obligación de pago sigue abierta
|
||||
// y se marca completada hasta que se suba el comprobante via
|
||||
// `uploadComprobantePago`. Esto mantiene las alertas `pago-*` y `ob-*`
|
||||
// visibles hasta que realmente se cierre el ciclo.
|
||||
const cubrePago = data.tipo === 'complementaria' || montoPago === 0;
|
||||
if (data.contribuyenteId && cubrePago) {
|
||||
if (!data.creadoPorUserId) {
|
||||
console.warn('[createDeclaracion] Sin creadoPorUserId — no se auto-completan obligaciones del contribuyente');
|
||||
} else {
|
||||
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,
|
||||
// Registrar evidencias de declaración en las obligaciones seleccionadas.
|
||||
// Fallback legacy: si no se enviaron obligaciones, se usa el keyword matching
|
||||
// anterior a partir de impuestos.
|
||||
let obligacionesAfectadas: string[] = obligacionesSeleccionadas.map(o => o.id);
|
||||
if (data.contribuyenteId && data.creadoPorUserId) {
|
||||
const periodo = `${data.año}-${String(data.mes).padStart(2, '0')}`;
|
||||
|
||||
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 declaracion = rowToDeclaracion(row);
|
||||
|
||||
// Auto-resolver alertas de pago para los impuestos del periodo
|
||||
// Auto-resolver alertas de pago legacy.
|
||||
const prefijosPago = declaracion.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []);
|
||||
let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosPago, declaracion.año, declaracion.mes);
|
||||
|
||||
// Al subirse el comprobante de pago, la obligación ahora SÍ está completada
|
||||
// (declaración + pago). Marcar `obligacion_periodos.completada=true` y
|
||||
// resolver los `ob-*` alerts. Requires contribuyenteId (guardado en la
|
||||
// declaración) y userId (del caller).
|
||||
// Registrar evidencias de pago en las obligaciones vinculadas a esta declaración.
|
||||
// Fallback legacy: si no hay relaciones, se usa keyword matching por impuestos.
|
||||
if (row.contribuyente_id && data.uploadedByUserId) {
|
||||
const periodo = `${declaracion.año}-${String(declaracion.mes).padStart(2, '0')}`;
|
||||
const periodicidad = row.periodicidad || 'mensual';
|
||||
alertasResueltas += await completarObligacionesPorDeclaracion(
|
||||
pool, row.contribuyente_id, declaracion.impuestos, periodo, data.uploadedByUserId, declaracion.id, periodicidad,
|
||||
|
||||
const { rows: relaciones } = await pool.query<{ obligacion_id: string }>(
|
||||
`SELECT obligacion_id FROM declaracion_obligaciones WHERE declaracion_id = $1`,
|
||||
[id],
|
||||
);
|
||||
|
||||
if (relaciones.length > 0) {
|
||||
const obligaciones = await getObligacionesPorIds(
|
||||
pool, row.contribuyente_id, relaciones.map(r => r.obligacion_id),
|
||||
);
|
||||
await registrarEvidenciasPorObligaciones(
|
||||
pool, obligaciones, row.contribuyente_id, periodo, data.uploadedByUserId,
|
||||
data.pdfBase64, data.pdfFilename, 'pago', declaracion.notas ?? undefined,
|
||||
);
|
||||
} else if (declaracion.impuestos.length > 0) {
|
||||
const periodicidad = row.periodicidad || 'mensual';
|
||||
await registrarEvidenciasPorDeclaracion(
|
||||
pool, row.contribuyente_id, declaracion.impuestos, periodo, data.uploadedByUserId,
|
||||
data.pdfBase64, data.pdfFilename, 'pago', periodicidad,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { declaracion, alertasResueltas };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEmailTransport } from '@horux/core';
|
||||
import { createEmailTransport, type EmailAttachment } from '@horux/core';
|
||||
import { env } from '../../config/env.js';
|
||||
|
||||
const transport = createEmailTransport(
|
||||
@@ -13,8 +13,8 @@ const transport = createEmailTransport(
|
||||
: null
|
||||
);
|
||||
|
||||
async function sendEmail(to: string, subject: string, html: string) {
|
||||
await transport.send(to, subject, html);
|
||||
async function sendEmail(to: string, subject: string, html: string, attachments?: EmailAttachment[]) {
|
||||
await transport.send(to, subject, html, attachments);
|
||||
}
|
||||
|
||||
export const emailService = {
|
||||
@@ -128,10 +128,14 @@ export const emailService = {
|
||||
* Notifica la subida de una declaración o documento extra al despacho.
|
||||
* `recipients` debe venir deduplicado por el caller. El subject se
|
||||
* genera a partir del kind y RFC del contribuyente.
|
||||
*
|
||||
* Para declaraciones, `attachments` puede contener los PDFs subidos
|
||||
* (acuse + liga de pago) para enviarlos adjuntos al correo.
|
||||
*/
|
||||
sendDocumentoSubido: async (
|
||||
recipients: string[],
|
||||
data: import('./templates/documento-subido.js').DocumentoSubidoData,
|
||||
attachments?: EmailAttachment[],
|
||||
) => {
|
||||
if (recipients.length === 0) return;
|
||||
const { documentoSubidoEmail } = await import('./templates/documento-subido.js');
|
||||
@@ -143,7 +147,7 @@ export const emailService = {
|
||||
// destinatario NO debe impedir enviar al siguiente.
|
||||
for (const to of recipients) {
|
||||
try {
|
||||
await sendEmail(to, subject, html);
|
||||
await sendEmail(to, subject, html, attachments);
|
||||
} catch (err: any) {
|
||||
console.error(`[Email] Fallo enviando documento-subido a ${to}:`, err?.message || err);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { baseTemplate, heading, infoBox, primaryButton, BRAND_COLORS as C } from
|
||||
|
||||
export interface DocumentoSubidoData {
|
||||
/** Kind: para el título/subject. */
|
||||
kind: 'declaracion' | 'extra';
|
||||
kind: 'declaracion' | 'extra' | 'obligacion_evidencia';
|
||||
/** Quién subió el documento (email). */
|
||||
subidoPor: string;
|
||||
/** RFC del contribuyente. */
|
||||
@@ -24,25 +24,38 @@ export interface DocumentoSubidoData {
|
||||
descripcion?: string | null;
|
||||
categoria?: string | null;
|
||||
};
|
||||
/** Si es evidencia de obligación fiscal. */
|
||||
evidencia?: {
|
||||
obligacionNombre: string;
|
||||
periodo: string;
|
||||
tipoDocumento: string;
|
||||
filename: string;
|
||||
};
|
||||
/** URL al sistema (ej. https://despachos.horuxfin.com/documentos). */
|
||||
link: string;
|
||||
/** Solo para declaraciones: los adjuntos se omitieron por exceder el límite de tamaño. */
|
||||
attachmentsOmitted?: boolean;
|
||||
}
|
||||
|
||||
export function documentoSubidoEmail(data: DocumentoSubidoData): string {
|
||||
const titulo = data.kind === 'declaracion'
|
||||
? 'Nueva declaración subida'
|
||||
: 'Nuevo documento subido';
|
||||
: data.kind === 'obligacion_evidencia'
|
||||
? 'Nueva evidencia de obligación fiscal'
|
||||
: 'Nuevo documento subido';
|
||||
|
||||
const contenidoEspecifico = data.kind === 'declaracion' && data.declaracion
|
||||
? declaracionBlock(data.declaracion)
|
||||
: data.extra
|
||||
? extraBlock(data.extra)
|
||||
: '';
|
||||
: data.kind === 'obligacion_evidencia' && data.evidencia
|
||||
? evidenciaBlock(data.evidencia)
|
||||
: data.extra
|
||||
? extraBlock(data.extra)
|
||||
: '';
|
||||
|
||||
return baseTemplate(`
|
||||
${heading(titulo)}
|
||||
<p style="color:${C.textPrimary};margin:0 0 16px;">
|
||||
<strong>${escapeHtml(data.subidoPor)}</strong> subió un ${data.kind === 'declaracion' ? 'acuse de declaración' : 'documento'}
|
||||
<strong>${escapeHtml(data.subidoPor)}</strong> subió ${data.kind === 'obligacion_evidencia' ? 'una evidencia de obligación fiscal' : data.kind === 'declaracion' ? 'un acuse de declaración' : 'un documento'}
|
||||
para <strong>${escapeHtml(data.contribuyenteNombre)}</strong>.
|
||||
</p>
|
||||
${infoBox(`
|
||||
@@ -57,6 +70,12 @@ export function documentoSubidoEmail(data: DocumentoSubidoData): string {
|
||||
<div style="margin-top:24px;">
|
||||
${primaryButton('Ver en el sistema', data.link)}
|
||||
</div>
|
||||
${data.kind === 'declaracion' && data.attachmentsOmitted ? `
|
||||
<p style="color:${C.textMuted};font-size:13px;margin-top:16px;">
|
||||
Los documentos no se adjuntaron porque exceden el tamaño permitido por correo.
|
||||
Puedes descargarlos desde el sistema.
|
||||
</p>
|
||||
` : ''}
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -76,6 +95,19 @@ function declaracionBlock(d: NonNullable<DocumentoSubidoData['declaracion']>): s
|
||||
`;
|
||||
}
|
||||
|
||||
function evidenciaBlock(e: NonNullable<DocumentoSubidoData['evidencia']>): string {
|
||||
return `
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Obligación</p>
|
||||
<p style="margin:0 0 12px;color:${C.textPrimary};font-weight:600;">${escapeHtml(e.obligacionNombre)}</p>
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Periodo</p>
|
||||
<p style="margin:0 0 12px;color:${C.textPrimary};">${escapeHtml(e.periodo)}</p>
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Tipo de documento</p>
|
||||
<p style="margin:0 0 12px;color:${C.textPrimary};text-transform:capitalize;">${escapeHtml(e.tipoDocumento)}</p>
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Archivo</p>
|
||||
<p style="margin:0 0 12px;color:${C.textPrimary};">${escapeHtml(e.filename)}</p>
|
||||
`;
|
||||
}
|
||||
|
||||
function extraBlock(e: NonNullable<DocumentoSubidoData['extra']>): string {
|
||||
return `
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Documento</p>
|
||||
|
||||
@@ -5,6 +5,10 @@ import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js'
|
||||
import { env } from '../config/env.js';
|
||||
import { filterRecipientsByRole, type RecipientWithRole } from './notification-preferences.service.js';
|
||||
import type { DocumentoSubidoData } from './email/templates/documento-subido.js';
|
||||
import type { EmailAttachment } from '@horux/core';
|
||||
|
||||
/** Límite total de adjuntos para evitar rechazos por SMTP (20 MB). */
|
||||
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Notifica a los destinatarios relevantes cuando se sube una declaración
|
||||
@@ -26,7 +30,11 @@ export async function notifyDocumentoSubido(params: {
|
||||
subidoPor: string;
|
||||
kind: DocumentoSubidoData['kind'];
|
||||
declaracion?: DocumentoSubidoData['declaracion'];
|
||||
declaracionId?: number;
|
||||
extra?: DocumentoSubidoData['extra'];
|
||||
evidencia?: DocumentoSubidoData['evidencia'];
|
||||
/** PDF en base64 para adjuntar en notificaciones de evidencia de obligación. */
|
||||
pdfBase64?: string;
|
||||
}): Promise<void> {
|
||||
const { pool, tenantId, contribuyenteId, subidoPor } = params;
|
||||
|
||||
@@ -77,6 +85,23 @@ export async function notifyDocumentoSubido(params: {
|
||||
// 4. Link al sistema. Usa FRONTEND_URL del env.
|
||||
const link = `${env.FRONTEND_URL}/documentos`;
|
||||
|
||||
// Adjuntar los PDFs cuando se trata de una declaración recién creada o de una evidencia de obligación.
|
||||
let attachments: EmailAttachment[] | undefined;
|
||||
let attachmentsOmitted = false;
|
||||
if (params.kind === 'declaracion' && params.declaracionId) {
|
||||
const built = await buildDeclaracionAttachments(pool, params.declaracionId);
|
||||
attachments = built.attachments;
|
||||
attachmentsOmitted = built.omitted;
|
||||
} else if (params.kind === 'obligacion_evidencia' && params.pdfBase64 && params.evidencia) {
|
||||
const content = Buffer.from(params.pdfBase64, 'base64');
|
||||
if (content.length > MAX_ATTACHMENT_BYTES) {
|
||||
attachmentsOmitted = true;
|
||||
console.warn(`[notifyDocumentoSubido] Evidencia de obligación excede ${MAX_ATTACHMENT_BYTES} bytes (${content.length}). Se envía sin adjunto.`);
|
||||
} else {
|
||||
attachments = [{ filename: params.evidencia.filename, content }];
|
||||
}
|
||||
}
|
||||
|
||||
await emailService.sendDocumentoSubido(Array.from(recipients), {
|
||||
kind: params.kind,
|
||||
subidoPor,
|
||||
@@ -85,6 +110,46 @@ export async function notifyDocumentoSubido(params: {
|
||||
despachoNombre: tenant?.nombre,
|
||||
declaracion: params.declaracion,
|
||||
extra: params.extra,
|
||||
evidencia: params.evidencia,
|
||||
link,
|
||||
});
|
||||
attachmentsOmitted,
|
||||
}, attachments);
|
||||
}
|
||||
|
||||
async function buildDeclaracionAttachments(
|
||||
pool: Pool,
|
||||
declaracionId: number,
|
||||
): Promise<{ attachments?: EmailAttachment[]; omitted: boolean }> {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT pdf_declaracion, pdf_filename,
|
||||
pdf_liga_pago, pdf_liga_pago_filename
|
||||
FROM declaraciones_provisionales
|
||||
WHERE id = $1`,
|
||||
[declaracionId],
|
||||
);
|
||||
|
||||
const row = rows[0];
|
||||
if (!row) return { omitted: false };
|
||||
|
||||
let totalSize = 0;
|
||||
const attachments: EmailAttachment[] = [];
|
||||
|
||||
if (row.pdf_declaracion && row.pdf_filename) {
|
||||
const content = Buffer.from(row.pdf_declaracion);
|
||||
totalSize += content.length;
|
||||
attachments.push({ filename: row.pdf_filename, content });
|
||||
}
|
||||
|
||||
if (row.pdf_liga_pago && row.pdf_liga_pago_filename) {
|
||||
const content = Buffer.from(row.pdf_liga_pago);
|
||||
totalSize += content.length;
|
||||
attachments.push({ filename: row.pdf_liga_pago_filename, content });
|
||||
}
|
||||
|
||||
if (totalSize > MAX_ATTACHMENT_BYTES) {
|
||||
console.warn(`[notifyDocumentoSubido] Adjuntos de declaración ${declaracionId} exceden ${MAX_ATTACHMENT_BYTES} bytes (${totalSize}). Se envía sin adjuntos.`);
|
||||
return { omitted: true };
|
||||
}
|
||||
|
||||
return { attachments, omitted: false };
|
||||
}
|
||||
|
||||
272
apps/api/src/services/obligacion-evidencias.service.ts
Normal file
272
apps/api/src/services/obligacion-evidencias.service.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { OBLIGACIONES_CATALOGO } from '../constants/obligaciones-fiscales.js';
|
||||
|
||||
export interface EvidenciaRow {
|
||||
id: number;
|
||||
obligacionId: string;
|
||||
periodo: string;
|
||||
contribuyenteId: string;
|
||||
tipoDocumento: 'declaracion' | 'pago' | 'acuse' | 'complemento';
|
||||
archivo: Buffer;
|
||||
archivoFilename: string;
|
||||
archivoMime: string;
|
||||
notas: string | null;
|
||||
subidoPor: string | null;
|
||||
subidoPorEmail: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateEvidenciaInput {
|
||||
obligacionId: string;
|
||||
periodo: string;
|
||||
contribuyenteId: string;
|
||||
tipoDocumento: 'declaracion' | 'pago' | 'acuse' | 'complemento';
|
||||
pdfBase64: string;
|
||||
pdfFilename: string;
|
||||
notas?: string;
|
||||
subidoPor: string; // userId UUID
|
||||
subidoPorEmail?: string;
|
||||
}
|
||||
|
||||
function rowToEvidencia(r: any): EvidenciaRow {
|
||||
return {
|
||||
id: r.id,
|
||||
obligacionId: r.obligacion_id,
|
||||
periodo: r.periodo,
|
||||
contribuyenteId: r.contribuyente_id,
|
||||
tipoDocumento: r.tipo_documento,
|
||||
archivo: Buffer.from(r.archivo),
|
||||
archivoFilename: r.archivo_filename,
|
||||
archivoMime: r.archivo_mime,
|
||||
notas: r.notas,
|
||||
subidoPor: r.subido_por,
|
||||
subidoPorEmail: r.subido_por_email,
|
||||
createdAt: r.created_at.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function getObligacionContribuyente(pool: Pool, obligacionId: string): Promise<{ contribuyenteId: string; catalogoId: string | null } | null> {
|
||||
const { rows } = await pool.query<{ contribuyente_id: string; catalogo_id: string | null }>(
|
||||
`SELECT contribuyente_id, catalogo_id FROM obligaciones_contribuyente WHERE id = $1`,
|
||||
[obligacionId],
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
return { contribuyenteId: row.contribuyente_id, catalogoId: row.catalogo_id };
|
||||
}
|
||||
|
||||
function requierePago(obligacion: { catalogoId: string | null }): boolean {
|
||||
if (!obligacion.catalogoId) return true; // conservador: sin catálogo, requiere pago
|
||||
const catalogo = OBLIGACIONES_CATALOGO.find((o) => o.id === obligacion.catalogoId);
|
||||
return catalogo?.requierePago ?? true;
|
||||
}
|
||||
|
||||
function esDocumentoDeclaracion(tipo: string): boolean {
|
||||
return tipo === 'declaracion' || tipo === 'acuse' || tipo === 'complemento';
|
||||
}
|
||||
|
||||
async function updatePeriodoStatus(
|
||||
pool: Pool,
|
||||
obligacionId: string,
|
||||
periodo: string,
|
||||
tipoDocumento: string,
|
||||
reqPago: boolean,
|
||||
completadaPor: string,
|
||||
notas?: string,
|
||||
): Promise<{ completada: boolean; declaracionPresentada: boolean; pagoPresentado: boolean }> {
|
||||
const { rows } = await pool.query<{
|
||||
declaracion_presentada: boolean;
|
||||
pago_presentado: boolean;
|
||||
completada: boolean;
|
||||
}>(
|
||||
`SELECT declaracion_presentada, pago_presentado, completada
|
||||
FROM obligacion_periodos
|
||||
WHERE obligacion_id = $1 AND periodo = $2`,
|
||||
[obligacionId, periodo],
|
||||
);
|
||||
|
||||
const existing = rows[0];
|
||||
let declaracionPresentada = existing?.declaracion_presentada ?? false;
|
||||
let pagoPresentado = existing?.pago_presentado ?? false;
|
||||
|
||||
if (esDocumentoDeclaracion(tipoDocumento)) declaracionPresentada = true;
|
||||
if (tipoDocumento === 'pago') pagoPresentado = true;
|
||||
|
||||
const completada = !reqPago || pagoPresentado;
|
||||
const now = new Date();
|
||||
|
||||
if (existing) {
|
||||
await pool.query(
|
||||
`UPDATE obligacion_periodos
|
||||
SET declaracion_presentada = $3,
|
||||
pago_presentado = $4,
|
||||
completada = $5,
|
||||
completada_at = CASE WHEN $5 THEN COALESCE(completada_at, $6) ELSE completada_at END,
|
||||
completada_por = CASE WHEN $5 THEN COALESCE(completada_por, $7) ELSE completada_por END,
|
||||
notas = COALESCE($8, notas)
|
||||
WHERE obligacion_id = $1 AND periodo = $2`,
|
||||
[obligacionId, periodo, declaracionPresentada, pagoPresentado, completada, now, completadaPor, notas ?? null],
|
||||
);
|
||||
} else {
|
||||
await pool.query(
|
||||
`INSERT INTO obligacion_periodos
|
||||
(obligacion_id, periodo, declaracion_presentada, pago_presentado, completada, completada_at, completada_por, notas)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[obligacionId, periodo, declaracionPresentada, pagoPresentado, completada, completada ? now : null, completada ? completadaPor : null, notas ?? null],
|
||||
);
|
||||
}
|
||||
|
||||
if (completada) {
|
||||
await pool.query(
|
||||
`UPDATE alertas SET resuelta = true WHERE tipo = $1 AND resuelta = false`,
|
||||
[`ob-${obligacionId}-${periodo}`],
|
||||
);
|
||||
}
|
||||
|
||||
return { completada, declaracionPresentada, pagoPresentado };
|
||||
}
|
||||
|
||||
async function recalcPeriodoStatus(
|
||||
pool: Pool,
|
||||
obligacionId: string,
|
||||
periodo: string,
|
||||
reqPago: boolean,
|
||||
): Promise<void> {
|
||||
const { rows } = await pool.query<{ tipo_documento: string }>(
|
||||
`SELECT tipo_documento FROM obligacion_evidencias WHERE obligacion_id = $1 AND periodo = $2`,
|
||||
[obligacionId, periodo],
|
||||
);
|
||||
|
||||
const declaracionPresentada = rows.some((r) => esDocumentoDeclaracion(r.tipo_documento));
|
||||
const pagoPresentado = rows.some((r) => r.tipo_documento === 'pago');
|
||||
const completada = !reqPago || pagoPresentado;
|
||||
|
||||
await pool.query(
|
||||
`UPDATE obligacion_periodos
|
||||
SET declaracion_presentada = $3,
|
||||
pago_presentado = $4,
|
||||
completada = $5,
|
||||
completada_at = CASE WHEN $5 THEN COALESCE(completada_at, NOW()) ELSE completada_at END
|
||||
WHERE obligacion_id = $1 AND periodo = $2`,
|
||||
[obligacionId, periodo, declaracionPresentada, pagoPresentado, completada],
|
||||
);
|
||||
}
|
||||
|
||||
export async function createEvidencia(
|
||||
pool: Pool,
|
||||
data: CreateEvidenciaInput,
|
||||
): Promise<{ evidencia: EvidenciaRow; completada: boolean; declaracionPresentada: boolean; pagoPresentado: boolean }> {
|
||||
const obligacion = await getObligacionContribuyente(pool, data.obligacionId);
|
||||
if (!obligacion) throw new Error('Obligación no encontrada');
|
||||
if (obligacion.contribuyenteId !== data.contribuyenteId) throw new Error('La obligación no pertenece al contribuyente');
|
||||
|
||||
const reqPago = requierePago(obligacion);
|
||||
const archivo = Buffer.from(data.pdfBase64, 'base64');
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO obligacion_evidencias
|
||||
(obligacion_id, periodo, contribuyente_id, tipo_documento, archivo, archivo_filename, archivo_mime, notas, subido_por, subido_por_email)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, obligacion_id, periodo, contribuyente_id, tipo_documento, archivo, archivo_filename, archivo_mime,
|
||||
notas, subido_por, subido_por_email, created_at`,
|
||||
[data.obligacionId, data.periodo, data.contribuyenteId, data.tipoDocumento, archivo, data.pdfFilename, 'application/pdf', data.notas ?? null, data.subidoPor, data.subidoPorEmail],
|
||||
);
|
||||
|
||||
const status = await updatePeriodoStatus(
|
||||
pool,
|
||||
data.obligacionId,
|
||||
data.periodo,
|
||||
data.tipoDocumento,
|
||||
reqPago,
|
||||
data.subidoPor,
|
||||
data.notas,
|
||||
);
|
||||
|
||||
return { evidencia: rowToEvidencia(rows[0]), ...status };
|
||||
}
|
||||
|
||||
export async function listEvidencias(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
filters?: { periodo?: string; obligacionId?: string },
|
||||
): Promise<EvidenciaRow[]> {
|
||||
const conditions: string[] = ['contribuyente_id = $1'];
|
||||
const params: unknown[] = [contribuyenteId];
|
||||
|
||||
if (filters?.periodo) {
|
||||
params.push(filters.periodo);
|
||||
conditions.push(`periodo = $${params.length}`);
|
||||
}
|
||||
if (filters?.obligacionId) {
|
||||
params.push(filters.obligacionId);
|
||||
conditions.push(`obligacion_id = $${params.length}`);
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, obligacion_id, periodo, contribuyente_id, tipo_documento, archivo, archivo_filename, archivo_mime,
|
||||
notas, subido_por, subido_por_email, created_at
|
||||
FROM obligacion_evidencias
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY created_at DESC`,
|
||||
params,
|
||||
);
|
||||
return rows.map(rowToEvidencia);
|
||||
}
|
||||
|
||||
export async function getEvidenciaPdf(
|
||||
pool: Pool,
|
||||
id: number,
|
||||
): Promise<{ buffer: Buffer; filename: string; mime: string } | null> {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT archivo, archivo_filename, archivo_mime FROM obligacion_evidencias WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
if (rows.length === 0 || !rows[0].archivo) return null;
|
||||
return {
|
||||
buffer: Buffer.from(rows[0].archivo),
|
||||
filename: rows[0].archivo_filename || `evidencia-${id}.pdf`,
|
||||
mime: rows[0].archivo_mime || 'application/pdf',
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteEvidencia(
|
||||
pool: Pool,
|
||||
id: number,
|
||||
): Promise<{ obligacionId: string; periodo: string } | null> {
|
||||
const { rows } = await pool.query<{ obligacion_id: string; periodo: string }>(
|
||||
`DELETE FROM obligacion_evidencias WHERE id = $1 RETURNING obligacion_id, periodo`,
|
||||
[id],
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const { obligacion_id: obligacionId, periodo } = rows[0];
|
||||
const obligacion = await getObligacionContribuyente(pool, obligacionId);
|
||||
if (obligacion) {
|
||||
const reqPago = requierePago(obligacion);
|
||||
await recalcPeriodoStatus(pool, obligacionId, periodo, reqPago);
|
||||
}
|
||||
return { obligacionId, periodo };
|
||||
}
|
||||
|
||||
export async function getPeriodoStatus(
|
||||
pool: Pool,
|
||||
obligacionId: string,
|
||||
periodo: string,
|
||||
): Promise<{ completada: boolean; declaracionPresentada: boolean; pagoPresentado: boolean } | null> {
|
||||
const { rows } = await pool.query<{
|
||||
completada: boolean;
|
||||
declaracion_presentada: boolean;
|
||||
pago_presentado: boolean;
|
||||
}>(
|
||||
`SELECT completada, declaracion_presentada, pago_presentado
|
||||
FROM obligacion_periodos
|
||||
WHERE obligacion_id = $1 AND periodo = $2`,
|
||||
[obligacionId, periodo],
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
return {
|
||||
completada: rows[0].completada,
|
||||
declaracionPresentada: rows[0].declaracion_presentada,
|
||||
pagoPresentado: rows[0].pago_presentado,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { OBLIGACIONES_CATALOGO, getRecomendaciones, type ObligacionFiscal } from '../constants/obligaciones-fiscales.js';
|
||||
|
||||
function requierePagoPorCatalogo(catalogoId: string | null): boolean {
|
||||
if (!catalogoId) return true;
|
||||
return OBLIGACIONES_CATALOGO.find((o) => o.id === catalogoId)?.requierePago ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyword-based matching: each catalog entry has discriminant keywords
|
||||
* that must ALL appear in the SAT description (normalized, lowercase, no accents).
|
||||
@@ -255,6 +260,7 @@ export async function initRecomendaciones(
|
||||
function inferirFrecuencia(vencimiento: string): string {
|
||||
const lower = vencimiento.toLowerCase();
|
||||
if (lower.includes('mensual') || lower.includes('mes')) return 'mensual';
|
||||
if (lower.includes('cuatrimest')) return 'cuatrimestral';
|
||||
if (lower.includes('bimest')) return 'bimestral';
|
||||
if (lower.includes('trimest')) return 'trimestral';
|
||||
if (lower.includes('anual') || lower.includes('ejercicio') || lower.includes('tres meses siguientes')) return 'anual';
|
||||
@@ -351,13 +357,22 @@ export async function getObligacionesPorPeriodo(
|
||||
|
||||
const [year, month] = periodo.split('-').map(Number);
|
||||
const currentPeriodo = new Date().toISOString().substring(0, 7);
|
||||
const results: Array<ObligacionContribuyente & { periodStatus: string; periodoAplica: string; declaracion: DeclaracionLink | null }> = [];
|
||||
const results: Array<ObligacionContribuyente & {
|
||||
periodStatus: string;
|
||||
periodoAplica: string;
|
||||
declaracion: DeclaracionLink | null;
|
||||
declaracionPresentada: boolean;
|
||||
pagoPresentado: boolean;
|
||||
requierePago: boolean;
|
||||
}> = [];
|
||||
|
||||
// Get all completion records + associated declaration info for this contribuyente
|
||||
const { rows: completions } = await pool.query<{
|
||||
obligacion_id: string;
|
||||
periodo: string;
|
||||
completada: boolean;
|
||||
declaracion_presentada: boolean;
|
||||
pago_presentado: boolean;
|
||||
declaracion_id: number | null;
|
||||
decl_año: number | null;
|
||||
decl_mes: number | null;
|
||||
@@ -365,6 +380,7 @@ export async function getObligacionesPorPeriodo(
|
||||
decl_pdf_filename: string | null;
|
||||
}>(`
|
||||
SELECT op.obligacion_id, op.periodo, op.completada,
|
||||
op.declaracion_presentada, op.pago_presentado,
|
||||
op.declaracion_id,
|
||||
dp.año AS decl_año,
|
||||
dp.mes AS decl_mes,
|
||||
@@ -377,10 +393,14 @@ export async function getObligacionesPorPeriodo(
|
||||
`, [contribuyenteId]);
|
||||
|
||||
const completionMap = new Map<string, boolean>();
|
||||
const declaracionPresentadaMap = new Map<string, boolean>();
|
||||
const pagoPresentadoMap = new Map<string, boolean>();
|
||||
const declaracionMap = new Map<string, DeclaracionLink | null>();
|
||||
for (const c of completions) {
|
||||
const key = `${c.obligacion_id}:${c.periodo}`;
|
||||
completionMap.set(key, c.completada);
|
||||
declaracionPresentadaMap.set(key, c.declaracion_presentada);
|
||||
pagoPresentadoMap.set(key, c.pago_presentado);
|
||||
if (c.declaracion_id && c.decl_año != null && c.decl_mes != null && c.decl_tipo) {
|
||||
declaracionMap.set(key, {
|
||||
id: c.declaracion_id,
|
||||
@@ -407,6 +427,9 @@ export async function getObligacionesPorPeriodo(
|
||||
periodStatus: isCompleted ? 'completada' : 'pendiente',
|
||||
periodoAplica: periodo,
|
||||
declaracion: declaracionMap.get(key) ?? null,
|
||||
declaracionPresentada: declaracionPresentadaMap.get(key) === true,
|
||||
pagoPresentado: pagoPresentadoMap.get(key) === true,
|
||||
requierePago: requierePagoPorCatalogo(ob.catalogoId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -434,6 +457,9 @@ export async function getObligacionesPorPeriodo(
|
||||
periodStatus: 'atrasada',
|
||||
periodoAplica: pastPeriodo,
|
||||
declaracion: null,
|
||||
declaracionPresentada: declaracionPresentadaMap.get(pastKey) === true,
|
||||
pagoPresentado: pagoPresentadoMap.get(pastKey) === true,
|
||||
requierePago: requierePagoPorCatalogo(ob.catalogoId),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -448,7 +474,14 @@ export async function getObligacionesPorPeriodo(
|
||||
return a.nombre.localeCompare(b.nombre);
|
||||
});
|
||||
|
||||
return results as Array<ObligacionContribuyente & { periodStatus: 'pendiente' | 'completada' | 'atrasada'; periodoAplica: string; declaracion: DeclaracionLink | null }>;
|
||||
return results as Array<ObligacionContribuyente & {
|
||||
periodStatus: 'pendiente' | 'completada' | 'atrasada';
|
||||
periodoAplica: string;
|
||||
declaracion: DeclaracionLink | null;
|
||||
declaracionPresentada: boolean;
|
||||
pagoPresentado: boolean;
|
||||
requierePago: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
function appliesTo(frecuencia: string | null, periodo: string): boolean {
|
||||
@@ -457,6 +490,7 @@ function appliesTo(frecuencia: string | null, periodo: string): boolean {
|
||||
case 'mensual': return true;
|
||||
case 'bimestral': return month % 2 === 1; // Jan, Mar, May...
|
||||
case 'trimestral': return [1, 4, 7, 10].includes(month);
|
||||
case 'cuatrimestral': return [1, 5, 9].includes(month);
|
||||
case 'anual': return month === 3 || month === 4; // March (PM) or April (PF) — show in both
|
||||
case 'eventual': return false; // Don't auto-show
|
||||
default: return true;
|
||||
|
||||
@@ -299,7 +299,7 @@ async function saveCfdis(
|
||||
cfdi_tipo_relacion=$88, cfdis_relacionados=$89,
|
||||
last_sat_sync=NOW(), sat_sync_job_id=$90::uuid,
|
||||
actualizado_en=NOW()
|
||||
WHERE uuid = $1`,
|
||||
WHERE LOWER(uuid) = LOWER($1)`,
|
||||
[cfdi.uuid, ...vals]
|
||||
);
|
||||
// Re-insert conceptos for updated CFDI
|
||||
@@ -355,7 +355,7 @@ async function saveCfdis(
|
||||
[...vals, contribuyenteId]
|
||||
);
|
||||
// Get the inserted cfdi id and save conceptos
|
||||
const { rows: [newRow] } = await pool.query(`SELECT id FROM cfdis WHERE uuid = $1`, [cfdi.uuid]);
|
||||
const { rows: [newRow] } = await pool.query(`SELECT id FROM cfdis WHERE LOWER(uuid) = LOWER($1)`, [cfdi.uuid]);
|
||||
if (newRow) await saveConceptosWithRetry(pool, newRow.id, cfdi);
|
||||
inserted++;
|
||||
}
|
||||
@@ -609,30 +609,35 @@ async function requestAndDownload(
|
||||
});
|
||||
let existingMap = (jobRow?.satRequestIds as Record<string, string> | null) || {};
|
||||
|
||||
// NOTA: se desactivó la reutilización de requestIds de jobs previos porque el SAT
|
||||
// limita las descargas por solicitud. Reusar un requestId de un job anterior puede
|
||||
// agotar el límite y devolver "Máximo de descargas permitidas", dejando el recovery
|
||||
// sin poder descargar. Cada job nuevo crea sus propias solicitudes.
|
||||
//
|
||||
// Si no existe en el job actual, buscar en el job más reciente del mismo tenant/contribuyente
|
||||
// SOLO si el rango de fechas es idéntico (mismo dateFrom/dateTo).
|
||||
if (!existingMap[kindKey]) {
|
||||
const previousJob = await prisma.satSyncJob.findFirst({
|
||||
where: {
|
||||
tenantId: jobRow?.tenantId,
|
||||
contribuyenteId: jobRow?.contribuyenteId ?? null,
|
||||
id: { not: jobId },
|
||||
dateFrom: jobRow?.dateFrom,
|
||||
dateTo: jobRow?.dateTo,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { satRequestIds: true },
|
||||
});
|
||||
if (previousJob?.satRequestIds) {
|
||||
const prevMap = previousJob.satRequestIds as Record<string, string>;
|
||||
if (prevMap[kindKey]) {
|
||||
console.log(`[SAT] Reutilizando requestId de job previo (${label}): ${prevMap[kindKey]}`);
|
||||
// Copiar al job actual para futuros usos
|
||||
await persistSatRequestId(jobId, kindKey, prevMap[kindKey]);
|
||||
existingMap = { ...existingMap, [kindKey]: prevMap[kindKey] };
|
||||
}
|
||||
}
|
||||
}
|
||||
// if (!existingMap[kindKey]) {
|
||||
// const previousJob = await prisma.satSyncJob.findFirst({
|
||||
// where: {
|
||||
// tenantId: jobRow?.tenantId,
|
||||
// contribuyenteId: jobRow?.contribuyenteId ?? null,
|
||||
// id: { not: jobId },
|
||||
// dateFrom: jobRow?.dateFrom,
|
||||
// dateTo: jobRow?.dateTo,
|
||||
// },
|
||||
// orderBy: { createdAt: 'desc' },
|
||||
// select: { satRequestIds: true },
|
||||
// });
|
||||
// if (previousJob?.satRequestIds) {
|
||||
// const prevMap = previousJob.satRequestIds as Record<string, string>;
|
||||
// if (prevMap[kindKey]) {
|
||||
// console.log(`[SAT] Reutilizando requestId de job previo (${label}): ${prevMap[kindKey]}`);
|
||||
// // Copiar al job actual para futuros usos
|
||||
// await persistSatRequestId(jobId, kindKey, prevMap[kindKey]);
|
||||
// existingMap = { ...existingMap, [kindKey]: prevMap[kindKey] };
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
let requestId: string | null = existingMap[kindKey] || null;
|
||||
let verifyResult: Awaited<ReturnType<typeof verifySatRequest>> | undefined;
|
||||
|
||||
@@ -189,6 +189,7 @@ export default function ObligacionesPage() {
|
||||
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
||||
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
||||
cuatrimestral: 'bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300',
|
||||
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||
eventual: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { apiClient } from '@/lib/api/client';
|
||||
import { subscribeMe, changeMyPlan, cancelMySubscription, upgradeMe, generatePaymentLink } from '@/lib/api/subscription';
|
||||
import { getPendingInvitation, acceptInvitation } from '@/lib/api/trial-invitations';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { getSubscriptionState } from '@horux/shared';
|
||||
|
||||
type Despachoplan = 'trial' | 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus' | 'custom';
|
||||
type PaidPlan = 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus';
|
||||
@@ -89,15 +90,14 @@ export default function PlanesDespachoPage() {
|
||||
// El usuario puede cancelar si tiene una suscripción que aún corre (paid, trial,
|
||||
// custom). Si ya está cancelada o expirada, no hay nada que cancelar.
|
||||
const subStatus = planInfo?.subscription?.status ?? null;
|
||||
const hasActiveSub = subStatus != null
|
||||
&& subStatus !== 'cancelled'
|
||||
&& subStatus !== 'trial_expired';
|
||||
// Estados en los que se puede generar un link de pago (incluye trial y vencido).
|
||||
const subState = planInfo?.subscription ? getSubscriptionState(planInfo.subscription) : null;
|
||||
const hasActiveSub = subState?.isActive || subState?.isTrial || subState?.isCancelledInPeriod || false;
|
||||
// Estados en los que se puede generar un link de pago (incluye trial, vencido y pending).
|
||||
const isPayableStatus = subStatus === 'trial'
|
||||
|| subStatus === 'trial_expired'
|
||||
|| subStatus === 'pending'
|
||||
|| hasActiveSub;
|
||||
const isCurrentPlanPaid = currentPlan === planInfo?.subscription?.plan
|
||||
&& (subStatus === 'authorized' || subStatus === 'pending');
|
||||
const isCurrentPlanPaid = currentPlan === planInfo?.subscription?.plan && subState?.isActive === true;
|
||||
|
||||
/** Resuelve la frecuencia para un plan. Mi Empresa y Mi Empresa+ leen su
|
||||
* propio toggle; el resto (business_*) siempre annual. */
|
||||
@@ -112,6 +112,15 @@ export default function PlanesDespachoPage() {
|
||||
setBusy(plan);
|
||||
setMessage(null);
|
||||
try {
|
||||
// Si el plan actual está pendiente de pago, solo regeneramos el link de pago.
|
||||
if (currentPlan === plan && subState?.isPending) {
|
||||
return await handlePagarAhora();
|
||||
}
|
||||
// Si tiene una sub pendiente en otro plan, no permitir cambiar hasta pagar.
|
||||
if (subState?.isPending) {
|
||||
setMessage({ kind: 'err', text: 'Completa el pago del plan actual antes de cambiar de plan.' });
|
||||
return;
|
||||
}
|
||||
// Sin sub activa: subscribe directo → MP (preapproval del plan completo).
|
||||
const result = await subscribeMe({ plan, frequency });
|
||||
window.open(result.paymentUrl, '_blank');
|
||||
@@ -197,10 +206,10 @@ export default function PlanesDespachoPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function ActiveBadge() {
|
||||
function CurrentPlanBadge({ pending }: { pending?: boolean }) {
|
||||
return (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-green-600 text-white text-xs px-3 py-1 rounded-full font-medium whitespace-nowrap">
|
||||
Plan actual
|
||||
<div className={`absolute -top-3 left-1/2 -translate-x-1/2 text-white text-xs px-3 py-1 rounded-full font-medium whitespace-nowrap ${pending ? 'bg-yellow-600' : 'bg-green-600'}`}>
|
||||
{pending ? 'Plan actual — pendiente' : 'Plan actual'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -325,7 +334,7 @@ export default function PlanesDespachoPage() {
|
||||
)}
|
||||
|
||||
{/* Banner de suscripción activa */}
|
||||
{!loading && planInfo?.subscription && hasPaidPlan && (subStatus === 'authorized' || subStatus === 'pending') && (() => {
|
||||
{!loading && planInfo?.subscription && hasPaidPlan && subState?.isActive && (() => {
|
||||
const sub = planInfo.subscription;
|
||||
const periodEndDate = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null;
|
||||
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 */}
|
||||
{!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">
|
||||
@@ -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">
|
||||
{/* Mi Empresa */}
|
||||
<Card className={`relative flex flex-col${currentPlan === 'mi_empresa' ? ' ring-2 ring-green-500' : ''}`}>
|
||||
{currentPlan === 'mi_empresa' && <ActiveBadge />}
|
||||
{currentPlan === 'mi_empresa' && <CurrentPlanBadge pending={subState?.isPending} />}
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto bg-emerald-100 dark:bg-emerald-900 rounded-full p-3 w-fit mb-2">
|
||||
<Cloud className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
|
||||
@@ -457,7 +481,7 @@ export default function PlanesDespachoPage() {
|
||||
|
||||
{/* Mi Empresa + */}
|
||||
<Card className={`relative flex flex-col${currentPlan === 'mi_empresa_plus' ? ' ring-2 ring-green-500' : ''}`}>
|
||||
{currentPlan === 'mi_empresa_plus' && <ActiveBadge />}
|
||||
{currentPlan === 'mi_empresa_plus' && <CurrentPlanBadge pending={subState?.isPending} />}
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto bg-teal-100 dark:bg-teal-900 rounded-full p-3 w-fit mb-2">
|
||||
<Cloud className="h-6 w-6 text-teal-600 dark:text-teal-400" />
|
||||
@@ -494,7 +518,7 @@ export default function PlanesDespachoPage() {
|
||||
{/* Business Control */}
|
||||
<Card className={`relative flex flex-col${currentPlan === 'business_control' ? ' ring-2 ring-green-500' : ' border-primary ring-2 ring-primary/20'}`}>
|
||||
{currentPlan === 'business_control'
|
||||
? <ActiveBadge />
|
||||
? <CurrentPlanBadge pending={subState?.isPending} />
|
||||
: (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground text-xs px-3 py-1 rounded-full">
|
||||
Más popular
|
||||
@@ -529,7 +553,7 @@ export default function PlanesDespachoPage() {
|
||||
|
||||
{/* Enterprise (key interna: business_cloud) */}
|
||||
<Card className={`relative flex flex-col${currentPlan === 'business_cloud' ? ' ring-2 ring-green-500' : ''}`}>
|
||||
{currentPlan === 'business_cloud' && <ActiveBadge />}
|
||||
{currentPlan === 'business_cloud' && <CurrentPlanBadge pending={subState?.isPending} />}
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto bg-purple-100 dark:bg-purple-900 rounded-full p-3 w-fit mb-2">
|
||||
<Cloud className="h-6 w-6 text-purple-600 dark:text-purple-400" />
|
||||
|
||||
@@ -23,9 +23,11 @@ import {
|
||||
import { PapeleriaTab } from '@/components/documentos/papeleria-tab';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import * as docsApi from '@/lib/api/documentos';
|
||||
import { getObligacionesPorPeriodo, type ObligacionPeriodo } from '@/lib/api/obligaciones';
|
||||
|
||||
const MESES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
||||
const IMPUESTOS: Impuesto[] = ['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'];
|
||||
const OBLIGACIONES_ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'];
|
||||
const PERIODICIDADES: { value: Periodicidad; label: string }[] = [
|
||||
{ value: 'mensual', label: 'Mensual' },
|
||||
{ value: 'bimestral', label: 'Bimestral' },
|
||||
@@ -504,7 +506,7 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
const [tipo, setTipo] = useState<'normal' | 'complementaria'>('normal');
|
||||
const [periodicidad, setPeriodicidad] = useState<Periodicidad>('mensual');
|
||||
const yearsOptions = Array.from({ length: 6 }, (_, i) => currentYear - i);
|
||||
const [impuestos, setImpuestos] = useState<Impuesto[]>([]);
|
||||
const [obligacionesIds, setObligacionesIds] = useState<string[]>([]);
|
||||
const [montoPago, setMontoPago] = useState('');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [ligaFile, setLigaFile] = useState<File | null>(null);
|
||||
@@ -512,6 +514,15 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const periodOptions = getPeriodOptions(periodicidad);
|
||||
const periodo = `${año}-${String(mes).padStart(2, '0')}`;
|
||||
|
||||
const obligacionesQ = useQuery({
|
||||
queryKey: ['obligaciones-periodo-declaracion', selectedContribuyenteId, periodo],
|
||||
queryFn: () => selectedContribuyenteId
|
||||
? getObligacionesPorPeriodo(selectedContribuyenteId, periodo, false)
|
||||
: Promise.resolve({ data: [], periodo }),
|
||||
enabled: !!selectedContribuyenteId,
|
||||
});
|
||||
|
||||
const handlePeriodicidadChange = (p: Periodicidad) => {
|
||||
setPeriodicidad(p);
|
||||
@@ -522,21 +533,21 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleImpuesto = (i: Impuesto) => {
|
||||
setImpuestos(prev => prev.includes(i) ? prev.filter(x => x !== i) : [...prev, i]);
|
||||
const toggleObligacion = (id: string) => {
|
||||
setObligacionesIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
|
||||
};
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
if (!file) return setErr('Selecciona el PDF de la declaración');
|
||||
if (impuestos.length === 0) return setErr('Selecciona al menos un impuesto');
|
||||
if (obligacionesIds.length === 0) return setErr('Selecciona al menos una obligación fiscal');
|
||||
try {
|
||||
const pdfBase64 = await fileToBase64(file);
|
||||
const ligaPagoBase64 = ligaFile ? await fileToBase64(ligaFile) : undefined;
|
||||
const montoNum = montoPago.trim() !== '' ? parseFloat(montoPago) : undefined;
|
||||
await create.mutateAsync({
|
||||
año, mes, tipo, periodicidad, impuestos,
|
||||
año, mes, tipo, periodicidad, obligacionesIds,
|
||||
montoPago: montoNum,
|
||||
pdfBase64, pdfFilename: file.name,
|
||||
ligaPagoBase64,
|
||||
@@ -606,16 +617,51 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Impuestos cubiertos</Label>
|
||||
<div className="grid grid-cols-3 gap-2 mt-1">
|
||||
{IMPUESTOS.map(i => (
|
||||
<label key={i} className={`flex items-center gap-2 px-3 py-2 rounded-md border cursor-pointer text-sm ${impuestos.includes(i) ? 'bg-primary/10 border-primary' : 'hover:bg-muted'}`}>
|
||||
<input type="checkbox" checked={impuestos.includes(i)} onChange={() => toggleImpuesto(i)} className="accent-primary" />
|
||||
{i}
|
||||
</label>
|
||||
))}
|
||||
</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>
|
||||
<Label>Obligaciones fiscales cubiertas</Label>
|
||||
{!selectedContribuyenteId ? (
|
||||
<p className="text-sm text-muted-foreground mt-1">Selecciona un contribuyente para ver sus obligaciones.</p>
|
||||
) : obligacionesQ.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Cargando obligaciones...
|
||||
</div>
|
||||
) : obligacionesQ.error ? (
|
||||
<p className="text-sm text-red-600 mt-1">Error al cargar obligaciones.</p>
|
||||
) : obligacionesQ.data?.data.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground mt-1">No hay obligaciones fiscales configuradas para este periodo.</p>
|
||||
) : (
|
||||
<div className="space-y-3 mt-2 max-h-60 overflow-y-auto rounded-md border p-3">
|
||||
{Array.from(new Set((obligacionesQ.data?.data || []).map(o => o.categoria || 'Sin categoría'))).map((categoria) => (
|
||||
<div key={categoria}>
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground mb-1.5">{categoria}</p>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{(obligacionesQ.data?.data || [])
|
||||
.filter(o => (o.categoria || 'Sin categoría') === categoria)
|
||||
.map((o) => (
|
||||
<label
|
||||
key={o.id}
|
||||
className={`flex items-start gap-2 px-3 py-2 rounded-md border cursor-pointer text-sm ${obligacionesIds.includes(o.id) ? 'bg-primary/10 border-primary' : 'hover:bg-muted'}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={obligacionesIds.includes(o.id)}
|
||||
onChange={() => toggleObligacion(o.id)}
|
||||
className="accent-primary mt-0.5"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="font-medium">{o.nombre}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2 capitalize">({o.frecuencia || '—'})</span>
|
||||
{o.requierePago && (
|
||||
<span className="block text-[10px] text-muted-foreground">Requiere comprobante de pago</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { formatCurrency, toCfdiDate } from '@/lib/utils';
|
||||
import { exportToExcel } from '@/lib/export-excel';
|
||||
import { useTableSort } from '@horux/shared-ui';
|
||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||
import { getCfdiById } from '@/lib/api/cfdi';
|
||||
import { Eye, Download } from 'lucide-react';
|
||||
import type { Cfdi } from '@horux/shared';
|
||||
|
||||
@@ -44,6 +45,7 @@ export default function DrillDownPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const titulo = searchParams.get('titulo') || 'Detalle de CFDIs';
|
||||
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
|
||||
const [loadingCfdiId, setLoadingCfdiId] = useState<number | null>(null);
|
||||
const { selectedContribuyenteId } = useContribuyenteStore();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
@@ -154,7 +156,23 @@ export default function DrillDownPage() {
|
||||
<td className="py-2 text-xs font-mono">{cfdi.regimenEmisor || '-'}</td>
|
||||
<td className="py-2 text-xs font-mono">{cfdi.regimenReceptor || '-'}</td>
|
||||
<td className="py-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={loadingCfdiId === cfdi.id}
|
||||
onClick={async () => {
|
||||
setLoadingCfdiId(cfdi.id);
|
||||
try {
|
||||
const fullCfdi = await getCfdiById(String(cfdi.id));
|
||||
setSelectedCfdi(fullCfdi);
|
||||
} catch (err) {
|
||||
console.error('Error cargando CFDI completo:', err);
|
||||
} finally {
|
||||
setLoadingCfdiId(null);
|
||||
}
|
||||
}}
|
||||
title="Ver factura"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
|
||||
@@ -554,12 +554,26 @@ export default function FacturacionPage() {
|
||||
? clavesUnidad?.filter(u => !SERVICE_UNITS.includes(u.clave))
|
||||
: clavesUnidad;
|
||||
|
||||
const prodSearchAbort = useRef<AbortController | null>(null);
|
||||
|
||||
const handleSearchProduct = async (q: string, idx: number) => {
|
||||
setProdSearch(q);
|
||||
setSearchingIdx(idx);
|
||||
if (q.length < 2) { setProdResults([]); return; }
|
||||
const results = await searchClaveProdServ(q);
|
||||
setProdResults(results);
|
||||
setProdResults([]);
|
||||
if (q.length < 2) return;
|
||||
|
||||
prodSearchAbort.current?.abort();
|
||||
prodSearchAbort.current = new AbortController();
|
||||
|
||||
try {
|
||||
const results = await searchClaveProdServ(q, prodSearchAbort.current.signal);
|
||||
setProdResults(results ?? []);
|
||||
} catch (err: any) {
|
||||
if (err.name !== 'AbortError' && err.code !== 'ERR_CANCELED') {
|
||||
console.error('Error buscando clave SAT:', err);
|
||||
}
|
||||
setProdResults([]);
|
||||
}
|
||||
};
|
||||
|
||||
const selectProduct = (idx: number, clave: string, descripcion: string) => {
|
||||
@@ -1418,6 +1432,7 @@ export default function FacturacionPage() {
|
||||
onChange={e => handleSearchProduct(e.target.value, idx)}
|
||||
onFocus={() => { setSearchingIdx(idx); setProdSearch(c.productKey); }}
|
||||
placeholder="Buscar clave SAT..."
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
<Search className="absolute right-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
|
||||
@@ -147,6 +147,7 @@ export default function PendientesPage() {
|
||||
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
||||
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
||||
cuatrimestral: 'bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300',
|
||||
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||
};
|
||||
return f ? (
|
||||
|
||||
@@ -123,20 +123,6 @@ export function TareasTab({ contribuyenteId }: { contribuyenteId: string | null
|
||||
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) => {
|
||||
setEditingId(t.id);
|
||||
setForm({
|
||||
@@ -206,16 +192,11 @@ export function TareasTab({ contribuyenteId }: { contribuyenteId: string | null
|
||||
return (
|
||||
<Card key={t.id}>
|
||||
<CardContent className="py-3 flex items-center gap-3">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<div className="flex-shrink-0" title={p?.completada ? 'Completada' : atrasada ? 'Atrasada' : 'Pendiente'}>
|
||||
{p?.completada
|
||||
? <CheckCircle2 className="h-5 w-5 text-success" />
|
||||
: <Circle className={`h-5 w-5 ${atrasada ? 'text-destructive' : 'text-muted-foreground'}`} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<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 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 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);
|
||||
|
||||
@@ -28,7 +28,10 @@ export interface CreateDeclaracionData {
|
||||
mes: number;
|
||||
tipo: 'normal' | 'complementaria';
|
||||
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;
|
||||
pdfBase64: 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",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@horux/shared": "workspace:*",
|
||||
|
||||
Reference in New Issue
Block a user