diff --git a/apps/api/scripts/change-user-email.ts b/apps/api/scripts/change-user-email.ts new file mode 100644 index 0000000..49a04a9 --- /dev/null +++ b/apps/api/scripts/change-user-email.ts @@ -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(); + }); diff --git a/apps/api/scripts/create-demo-ventas.ts b/apps/api/scripts/create-demo-ventas.ts new file mode 100644 index 0000000..03cd9b8 --- /dev/null +++ b/apps/api/scripts/create-demo-ventas.ts @@ -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(); + 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(); + }); diff --git a/apps/api/scripts/fix-demo-carteras-asignaciones.ts b/apps/api/scripts/fix-demo-carteras-asignaciones.ts new file mode 100644 index 0000000..61e16ef --- /dev/null +++ b/apps/api/scripts/fix-demo-carteras-asignaciones.ts @@ -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(); + }); diff --git a/apps/api/scripts/import-clave-prod-serv.ts b/apps/api/scripts/import-clave-prod-serv.ts new file mode 100644 index 0000000..9fbc7a2 --- /dev/null +++ b/apps/api/scripts/import-clave-prod-serv.ts @@ -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); +}); diff --git a/apps/api/scripts/resend-welcome.ts b/apps/api/scripts/resend-welcome.ts new file mode 100644 index 0000000..3f05698 --- /dev/null +++ b/apps/api/scripts/resend-welcome.ts @@ -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(); + }); diff --git a/apps/api/scripts/reset-demo-asignaciones.ts b/apps/api/scripts/reset-demo-asignaciones.ts new file mode 100644 index 0000000..c3de118 --- /dev/null +++ b/apps/api/scripts/reset-demo-asignaciones.ts @@ -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 { + 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(); + }); diff --git a/apps/api/scripts/seed-demo-obligaciones-tareas.ts b/apps/api/scripts/seed-demo-obligaciones-tareas.ts new file mode 100644 index 0000000..3b19208 --- /dev/null +++ b/apps/api/scripts/seed-demo-obligaciones-tareas.ts @@ -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(); + }); diff --git a/apps/api/scripts/update-demo-ventas.ts b/apps/api/scripts/update-demo-ventas.ts new file mode 100644 index 0000000..21436c9 --- /dev/null +++ b/apps/api/scripts/update-demo-ventas.ts @@ -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 = {}; + 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 { + 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(); + 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(); + }); diff --git a/apps/api/src/constants/obligaciones-fiscales.ts b/apps/api/src/constants/obligaciones-fiscales.ts index 6cb8662..c960f1b 100644 --- a/apps/api/src/constants/obligaciones-fiscales.ts +++ b/apps/api/src/constants/obligaciones-fiscales.ts @@ -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 }, ]; /** diff --git a/apps/api/src/controllers/catalogos.controller.ts b/apps/api/src/controllers/catalogos.controller.ts index c20a3a6..dbaef69 100644 --- a/apps/api/src/controllers/catalogos.controller.ts +++ b/apps/api/src/controllers/catalogos.controller.ts @@ -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ñ]'); diff --git a/apps/api/src/controllers/documentos.controller.ts b/apps/api/src/controllers/documentos.controller.ts index be9259c..53b8f1d 100644 --- a/apps/api/src/controllers/documentos.controller.ts +++ b/apps/api/src/controllers/documentos.controller.ts @@ -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); } +} diff --git a/apps/api/src/jobs/sat-sync.job.ts b/apps/api/src/jobs/sat-sync.job.ts index b36a97c..080a7cf 100644 --- a/apps/api/src/jobs/sat-sync.job.ts +++ b/apps/api/src/jobs/sat-sync.job.ts @@ -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 { } } -async function runRecoverySyncJob(): Promise { +export async function runRecoverySyncJob(): Promise { if (isRecoveryRunning) { console.log('[SAT Recovery] Ya en ejecución, omitiendo'); return; diff --git a/apps/api/src/migrations/tenant/052_declaraciones_cuatrimestral.sql b/apps/api/src/migrations/tenant/052_declaraciones_cuatrimestral.sql new file mode 100644 index 0000000..77d5aa7 --- /dev/null +++ b/apps/api/src/migrations/tenant/052_declaraciones_cuatrimestral.sql @@ -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')); diff --git a/apps/api/src/migrations/tenant/053_obligacion_evidencias.sql b/apps/api/src/migrations/tenant/053_obligacion_evidencias.sql new file mode 100644 index 0000000..aba13b2 --- /dev/null +++ b/apps/api/src/migrations/tenant/053_obligacion_evidencias.sql @@ -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); diff --git a/apps/api/src/migrations/tenant/054_obligacion_periodos_estados.sql b/apps/api/src/migrations/tenant/054_obligacion_periodos_estados.sql new file mode 100644 index 0000000..ed4fc72 --- /dev/null +++ b/apps/api/src/migrations/tenant/054_obligacion_periodos_estados.sql @@ -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; diff --git a/apps/api/src/migrations/tenant/055_declaracion_obligaciones.sql b/apps/api/src/migrations/tenant/055_declaracion_obligaciones.sql new file mode 100644 index 0000000..d97db05 --- /dev/null +++ b/apps/api/src/migrations/tenant/055_declaracion_obligaciones.sql @@ -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); diff --git a/apps/api/src/routes/documentos.routes.ts b/apps/api/src/routes/documentos.routes.ts index d264bcc..a834172 100644 --- a/apps/api/src/routes/documentos.routes.ts +++ b/apps/api/src/routes/documentos.routes.ts @@ -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 }; diff --git a/apps/api/src/services/alertas-manuales.service.ts b/apps/api/src/services/alertas-manuales.service.ts index 0353a5b..4cb4bad 100644 --- a/apps/api/src/services/alertas-manuales.service.ts +++ b/apps/api/src/services/alertas-manuales.service.ts @@ -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; diff --git a/apps/api/src/services/calendario-fiscal.service.ts b/apps/api/src/services/calendario-fiscal.service.ts index 326eb2c..2c3b97a 100644 --- a/apps/api/src/services/calendario-fiscal.service.ts +++ b/apps/api/src/services/calendario-fiscal.service.ts @@ -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 } diff --git a/apps/api/src/services/declaraciones.service.ts b/apps/api/src/services/declaraciones.service.ts index d506e4e..d5b351b 100644 --- a/apps/api/src/services/declaraciones.service.ts +++ b/apps/api/src/services/declaraciones.service.ts @@ -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(); + 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 { +): 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 { + 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 { + 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> { + 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 }; diff --git a/apps/api/src/services/email/email.service.ts b/apps/api/src/services/email/email.service.ts index f11ebf9..4998f48 100644 --- a/apps/api/src/services/email/email.service.ts +++ b/apps/api/src/services/email/email.service.ts @@ -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); } diff --git a/apps/api/src/services/email/templates/documento-subido.ts b/apps/api/src/services/email/templates/documento-subido.ts index 137960d..edfa866 100644 --- a/apps/api/src/services/email/templates/documento-subido.ts +++ b/apps/api/src/services/email/templates/documento-subido.ts @@ -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)}

- ${escapeHtml(data.subidoPor)} subió un ${data.kind === 'declaracion' ? 'acuse de declaración' : 'documento'} + ${escapeHtml(data.subidoPor)} subió ${data.kind === 'obligacion_evidencia' ? 'una evidencia de obligación fiscal' : data.kind === 'declaracion' ? 'un acuse de declaración' : 'un documento'} para ${escapeHtml(data.contribuyenteNombre)}.

${infoBox(` @@ -57,6 +70,12 @@ export function documentoSubidoEmail(data: DocumentoSubidoData): string {
${primaryButton('Ver en el sistema', data.link)}
+ ${data.kind === 'declaracion' && data.attachmentsOmitted ? ` +

+ Los documentos no se adjuntaron porque exceden el tamaño permitido por correo. + Puedes descargarlos desde el sistema. +

+ ` : ''} `); } @@ -76,6 +95,19 @@ function declaracionBlock(d: NonNullable): s `; } +function evidenciaBlock(e: NonNullable): string { + return ` +

Obligación

+

${escapeHtml(e.obligacionNombre)}

+

Periodo

+

${escapeHtml(e.periodo)}

+

Tipo de documento

+

${escapeHtml(e.tipoDocumento)}

+

Archivo

+

${escapeHtml(e.filename)}

+ `; +} + function extraBlock(e: NonNullable): string { return `

Documento

diff --git a/apps/api/src/services/notify-upload.service.ts b/apps/api/src/services/notify-upload.service.ts index 9456f67..61e78d9 100644 --- a/apps/api/src/services/notify-upload.service.ts +++ b/apps/api/src/services/notify-upload.service.ts @@ -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 { 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 }; } diff --git a/apps/api/src/services/obligacion-evidencias.service.ts b/apps/api/src/services/obligacion-evidencias.service.ts new file mode 100644 index 0000000..303d629 --- /dev/null +++ b/apps/api/src/services/obligacion-evidencias.service.ts @@ -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 { + 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 { + 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, + }; +} diff --git a/apps/api/src/services/obligaciones.service.ts b/apps/api/src/services/obligaciones.service.ts index 26e3501..d76f014 100644 --- a/apps/api/src/services/obligaciones.service.ts +++ b/apps/api/src/services/obligaciones.service.ts @@ -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 = []; + const results: Array = []; // 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(); + const declaracionPresentadaMap = new Map(); + const pagoPresentadoMap = new Map(); const declaracionMap = new Map(); 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; + return results as Array; } 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; diff --git a/apps/api/src/services/sat/sat.service.ts b/apps/api/src/services/sat/sat.service.ts index 3aa0ae2..510dfe9 100644 --- a/apps/api/src/services/sat/sat.service.ts +++ b/apps/api/src/services/sat/sat.service.ts @@ -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 | 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; - 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; + // 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> | undefined; diff --git a/apps/web/app/(dashboard)/configuracion/obligaciones/page.tsx b/apps/web/app/(dashboard)/configuracion/obligaciones/page.tsx index 49fd1a5..c97e987 100644 --- a/apps/web/app/(dashboard)/configuracion/obligaciones/page.tsx +++ b/apps/web/app/(dashboard)/configuracion/obligaciones/page.tsx @@ -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', }; diff --git a/apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx b/apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx index 75f3bce..2f8a430 100644 --- a/apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx +++ b/apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx @@ -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 ( -
- Plan actual +
+ {pending ? 'Plan actual — pendiente' : 'Plan actual'}
); } @@ -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 && ( +
+ +
+
+ Suscripción pendiente de pago +
+
+ Tu suscripción aún no está activa. Completa el pago para evitar la suspensión del servicio. +
+
+
+ )} + {/* Banner de trial vencido */} {!loading && subStatus === 'trial_expired' && hasPaidPlan && (
@@ -423,7 +447,7 @@ export default function PlanesDespachoPage() {
{/* Mi Empresa */} - {currentPlan === 'mi_empresa' && } + {currentPlan === 'mi_empresa' && }
@@ -457,7 +481,7 @@ export default function PlanesDespachoPage() { {/* Mi Empresa + */} - {currentPlan === 'mi_empresa_plus' && } + {currentPlan === 'mi_empresa_plus' && }
@@ -494,7 +518,7 @@ export default function PlanesDespachoPage() { {/* Business Control */} {currentPlan === 'business_control' - ? + ? : (
Más popular @@ -529,7 +553,7 @@ export default function PlanesDespachoPage() { {/* Enterprise (key interna: business_cloud) */} - {currentPlan === 'business_cloud' && } + {currentPlan === 'business_cloud' && }
diff --git a/apps/web/app/(dashboard)/documentos/page.tsx b/apps/web/app/(dashboard)/documentos/page.tsx index 2578dc0..7330da3 100644 --- a/apps/web/app/(dashboard)/documentos/page.tsx +++ b/apps/web/app/(dashboard)/documentos/page.tsx @@ -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('mensual'); const yearsOptions = Array.from({ length: 6 }, (_, i) => currentYear - i); - const [impuestos, setImpuestos] = useState([]); + const [obligacionesIds, setObligacionesIds] = useState([]); const [montoPago, setMontoPago] = useState(''); const [file, setFile] = useState(null); const [ligaFile, setLigaFile] = useState(null); @@ -512,6 +514,15 @@ function UploadDialog({ onClose }: { onClose: () => void }) { const [err, setErr] = useState(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 }) {
- -
- {IMPUESTOS.map(i => ( - - ))} -
-

Selecciona todos los impuestos que incluye esta declaración — definen qué recordatorios se desactivan.

+ + {!selectedContribuyenteId ? ( +

Selecciona un contribuyente para ver sus obligaciones.

+ ) : obligacionesQ.isLoading ? ( +
+ Cargando obligaciones... +
+ ) : obligacionesQ.error ? ( +

Error al cargar obligaciones.

+ ) : obligacionesQ.data?.data.length === 0 ? ( +

No hay obligaciones fiscales configuradas para este periodo.

+ ) : ( +
+ {Array.from(new Set((obligacionesQ.data?.data || []).map(o => o.categoria || 'Sin categoría'))).map((categoria) => ( +
+

{categoria}

+
+ {(obligacionesQ.data?.data || []) + .filter(o => (o.categoria || 'Sin categoría') === categoria) + .map((o) => ( + + ))} +
+
+ ))} +
+ )} +

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.

diff --git a/apps/web/app/(dashboard)/drill-down/page.tsx b/apps/web/app/(dashboard)/drill-down/page.tsx index 3d00b19..af26ebf 100644 --- a/apps/web/app/(dashboard)/drill-down/page.tsx +++ b/apps/web/app/(dashboard)/drill-down/page.tsx @@ -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(null); + const [loadingCfdiId, setLoadingCfdiId] = useState(null); const { selectedContribuyenteId } = useContribuyenteStore(); const params = new URLSearchParams(); @@ -154,7 +156,23 @@ export default function DrillDownPage() { {cfdi.regimenEmisor || '-'} {cfdi.regimenReceptor || '-'} - diff --git a/apps/web/app/(dashboard)/facturacion/page.tsx b/apps/web/app/(dashboard)/facturacion/page.tsx index f55fbcf..35066e7 100644 --- a/apps/web/app/(dashboard)/facturacion/page.tsx +++ b/apps/web/app/(dashboard)/facturacion/page.tsx @@ -554,12 +554,26 @@ export default function FacturacionPage() { ? clavesUnidad?.filter(u => !SERVICE_UNITS.includes(u.clave)) : clavesUnidad; + const prodSearchAbort = useRef(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 /> diff --git a/apps/web/app/(dashboard)/pendientes/page.tsx b/apps/web/app/(dashboard)/pendientes/page.tsx index 0199363..82ae12a 100644 --- a/apps/web/app/(dashboard)/pendientes/page.tsx +++ b/apps/web/app/(dashboard)/pendientes/page.tsx @@ -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 ? ( diff --git a/apps/web/components/obligaciones/tareas-tab.tsx b/apps/web/components/obligaciones/tareas-tab.tsx index 552017f..7f15153 100644 --- a/apps/web/components/obligaciones/tareas-tab.tsx +++ b/apps/web/components/obligaciones/tareas-tab.tsx @@ -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 ( - +
diff --git a/apps/web/lib/api/catalogos.ts b/apps/web/lib/api/catalogos.ts index 452ec78..f48f93f 100644 --- a/apps/web/lib/api/catalogos.ts +++ b/apps/web/lib/api/catalogos.ts @@ -20,5 +20,6 @@ export const getMetodosPago = () => apiClient.get('/catalogos/me export const getUsosCfdi = () => apiClient.get('/catalogos/uso-cfdi').then(r => r.data); export const getMonedas = () => apiClient.get('/catalogos/moneda').then(r => r.data); export const getClavesUnidad = () => apiClient.get('/catalogos/clave-unidad').then(r => r.data); -export const searchClaveProdServ = (q: string) => apiClient.get(`/catalogos/clave-prod-serv?q=${encodeURIComponent(q)}`).then(r => r.data); +export const searchClaveProdServ = (q: string, signal?: AbortSignal) => + apiClient.get(`/catalogos/clave-prod-serv?q=${encodeURIComponent(q)}`, { signal }).then(r => r.data); export const getObjetosImp = () => apiClient.get('/catalogos/objeto-imp').then(r => r.data); diff --git a/apps/web/lib/api/declaraciones.ts b/apps/web/lib/api/declaraciones.ts index 83bfeed..5337a80 100644 --- a/apps/web/lib/api/declaraciones.ts +++ b/apps/web/lib/api/declaraciones.ts @@ -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; diff --git a/apps/web/lib/api/obligaciones.ts b/apps/web/lib/api/obligaciones.ts new file mode 100644 index 0000000..e7863f7 --- /dev/null +++ b/apps/web/lib/api/obligaciones.ts @@ -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 { + const params = new URLSearchParams(); + params.set('periodo', periodo); + params.set('atrasados', String(atrasados)); + return apiClient + .get(`/contribuyentes/${contribuyenteId}/obligaciones/periodo?${params}`) + .then((r) => r.data); +} diff --git a/apps/web/package.json b/apps/web/package.json index 80c6a0d..340eddc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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:*", diff --git a/docs/CAMBIOS-2026-05-04.md b/docs/CAMBIOS-2026-05-04.md new file mode 100644 index 0000000..b61dc95 --- /dev/null +++ b/docs/CAMBIOS-2026-05-04.md @@ -0,0 +1,346 @@ +# Resumen de cambios - 4 de mayo de 2026 + +--- + +## 1. Catálogo de obligaciones fiscales: nuevas obligaciones predefinidas + +**Fecha:** 2026-05-04 + +Se agregaron 3 obligaciones fiscales predefinidas al catálogo maestro. + +### Obligaciones agregadas + +| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto | +|---|---|---|---|---|---|---|---| +| `isrtp` | Impuesto sobre remuneración al trabajo | mensual | Día 10 del mes siguiente | PM y PF | Estatal | Ninguna | No | +| `ish` | ISH - Impuesto Sobre Hospedaje | mensual | Día 15 del mes siguiente | PM y PF | Estatal | Ninguna | No | +| `sipare` | SIPARE - Cuotas obrero-patronales | mensual | Día 15 del mes siguiente | PM y PF | Seguridad social | Con empleados | No | + +### Archivo modificado + +| Archivo | Cambio | +|---|---| +| `apps/api/src/constants/obligaciones-fiscales.ts` | Se agregaron las 3 entradas al array `OBLIGACIONES_CATALOGO` | + +--- + +## 2. Fix: Suscripciones `pending` se mostraban como activas en /configuracion/planes-despacho + +**Fecha:** 2026-06-18 + +### Problema +En la página **Configuración › Planes**, las suscripciones con estado `pending` (primer pago aún no completado) mostraban el banner verde **"Suscripción activa"** y el badge **"Plan actual"** en verde, dando la impresión de que el plan estaba pagado y vigente. + +### Causa +El frontend evaluaba `subStatus === 'authorized' || subStatus === 'pending'` para mostrar el banner de activa, y consideraba `pending` como "plan actual pagado" (`isCurrentPlanPaid`). + +### Solución +- Se derivó el estado real de la suscripción con `getSubscriptionState()` de `@horux/shared`. +- El banner **"Suscripción activa"** ahora solo aparece cuando la suscripción está realmente `authorized` y dentro de su período. +- Se agregó un banner amarillo **"Suscripción pendiente de pago"** para estados `pending`. +- El badge del plan actual cambia a amarillo y muestra **"Plan actual — pendiente"** cuando la suscripción está pendiente. +- El botón **"Cancelar suscripción"** ya no se muestra para suscripciones `pending`. + +### Archivos modificados +| Archivo | Cambio | +|---|---| +| `apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx` | Lógica de estado de suscripción, banners y badges | + +--- + +## 3. Fix: Botón "Pagar este plan" fallaba para suscripciones `pending` + +**Fecha:** 2026-06-18 + +### Problema +Al hacer clic en **"Pagar este plan"** en una suscripción con estado `pending`, se mostraba el error: +**"No hay suscripción activa para cambiar"** en lugar de abrir MercadoPago. + +### Causa +El flujo `handleContratar` intentaba crear una nueva suscripción (`subscribeMe`), pero el backend rechazaba porque ya existía una `pending`. El frontend entonces caía en `upgradeMe` y luego `changeMyPlan`, ambos validan que haya una suscripción `authorized` o `trial` — `pending` no califica, por eso el error. + +### Solución +En `handleContratar`: +- Si el usuario selecciona el plan actual y la suscripción está `pending`, se llama directamente a `generatePaymentLink` para regenerar el link de pago de MercadoPago. +- Si el usuario intenta cambiar a otro plan estando `pending`, se muestra: + *"Completa el pago del plan actual antes de cambiar de plan."* + +### Archivos modificados +| Archivo | Cambio | +|---|---| +| `apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx` | Lógica de `handleContratar` para estados `pending` | + +--- + +## 4. Adjuntar PDFs en el correo de declaración subida + +**Fecha:** 2026-05-04 + +### Cambio +Cuando se sube una declaración provisional (`POST /api/documentos/declaraciones`), el correo de notificación a owners y supervisor ahora incluye como adjuntos: + +- El **acuse de declaración** (`pdf_declaracion`). +- La **liga de pago** (`pdf_liga_pago`), si se subió. + +### Archivos modificados +| Archivo | Cambio | +|---|---| +| `packages/core/src/email/transport.ts` | `EmailTransport.send` acepta un arreglo opcional de `EmailAttachment` y lo pasa a `nodemailer.sendMail` | +| `apps/api/src/services/email/email.service.ts` | `sendEmail` y `sendDocumentoSubido` aceptan y reenvían `attachments` | +| `apps/api/src/services/notify-upload.service.ts` | Nueva función `buildDeclaracionAttachments` que lee los PDFs de `declaraciones_provisionales` y los pasa al correo | +| `apps/api/src/controllers/documentos.controller.ts` | Se pasa `declaracionId` a `notifyDocumentoSubido` para poder recuperar los PDFs | + +### Notas +- Los documentos extra (`POST /api/documentos/extras`) **no** incluyen adjuntos; solo cambia el flujo de declaraciones. +- Si los adjuntos superan los 20 MB, se omiten y se deja un aviso en el cuerpo del correo para evitar rechazos por límite de SMTP. + +## 5. Nueva obligación: FONACOT + +**Fecha:** 2026-05-04 + +### Cambio +Se agregó la obligación `fonacot` al catálogo maestro de obligaciones fiscales. + +| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto | +|---|---|---|---|---|---|---|---| +| `fonacot` | Crédito FONACOT | Mensual | Día 5 del mes siguiente | PM/PF | Créditos de los trabajadores | Con empleados | ❌ | + +### Archivo modificado +| Archivo | Cambio | +|---|---| +| `apps/api/src/constants/obligaciones-fiscales.ts` | Se agregó la entrada `fonacot` en la sección **Créditos de los trabajadores** | + +## 6. Nueva obligación: Aviso de actividades vulnerables + +**Fecha:** 2026-05-04 + +### Cambio +Se agregó la obligación `actividades-vulnerables` al catálogo maestro. + +| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto | +|---|---|---|---|---|---|---|---| +| `actividades-vulnerables` | Aviso de actividades vulnerables | Mensual | Día 17 del mes siguiente | PM/PF | Federal mensual | — | ❌ | + +### Archivo modificado +| Archivo | Cambio | +|---|---| +| `apps/api/src/constants/obligaciones-fiscales.ts` | Se agregó la entrada `actividades-vulnerables` en la sección **Federales mensuales** | + +## 7. Nueva obligación: Declaración Informativa de transparencia + +**Fecha:** 2026-05-04 + +### Cambio +Se agregó la obligación `declaracion-transparencia` al catálogo maestro. + +| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto | +|---|---|---|---|---|---|---|---| +| `declaracion-transparencia` | Declaración Informativa de transparencia | Anual | Día 31 de mayo | PM | Federal anual | — | ❌ | + +### Archivo modificado +| Archivo | Cambio | +|---|---| +| `apps/api/src/constants/obligaciones-fiscales.ts` | Se agregó la entrada `declaracion-transparencia` en la sección **Anuales PM** | + +## 8. Nueva obligación: Declaración Informativa Múltiple del IEPS (trimestral) + +**Fecha:** 2026-05-04 + +### Cambio +Se agregó la obligación `ieps-trimestral` al catálogo maestro. + +| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto | +|---|---|---|---|---|---|---|---| +| `ieps-trimestral` | Declaración Informativa Múltiple del IEPS | Trimestral | Día 17 de abril, julio, octubre y enero | PM/PF | Federal trimestral | — | ❌ | + +### Archivo modificado +| Archivo | Cambio | +|---|---| +| `apps/api/src/constants/obligaciones-fiscales.ts` | Se agregó la entrada `ieps-trimestral` en la nueva sección **Federales trimestrales** | + +## 9. Nueva obligación: SISUB y soporte de frecuencia cuatrimestral + +**Fecha:** 2026-05-04 + +### Cambio +Se agregó la obligación `sisub` al catálogo y se extendió el sistema para soportar obligaciones con frecuencia **cuatrimestral**. + +| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto | +|---|---|---|---|---|---|---|---| +| `sisub` | Sistema de Información de Subcontratación | Cuatrimestral | Día 17 de enero, mayo y septiembre | PM/PF | Seguridad social | Con empleados | ❌ | + +### Archivos modificados +| Archivo | Cambio | +|---|---| +| `apps/api/src/constants/obligaciones-fiscales.ts` | Agregada `sisub` y `cuatrimestral` al union type de `frecuencia` | +| `apps/api/src/services/obligaciones.service.ts` | `inferirFrecuencia` y `appliesTo` soportan `cuatrimestral` | +| `apps/api/src/services/calendario-fiscal.service.ts` | Generación de eventos para meses cuatrimestrales (`1, 5, 9`) | +| `apps/api/src/services/alertas-manuales.service.ts` | `appliesToPeriod` soporta `cuatrimestral` | +| `apps/api/src/services/declaraciones.service.ts` | `Periodicidad` incluye `cuatrimestral` | +| `apps/api/src/controllers/documentos.controller.ts` | Schema de declaraciones acepta `cuatrimestral` | +| `apps/api/src/migrations/tenant/052_declaraciones_cuatrimestral.sql` | CHECK de `periodicidad` permite `cuatrimestral` | +| `apps/web/app/(dashboard)/configuracion/obligaciones/page.tsx` | Badge de frecuencia `cuatrimestral` | +| `apps/web/app/(dashboard)/pendientes/page.tsx` | Badge de frecuencia `cuatrimestral` | + +## 10. Fix: sincronización SAT — tipos de CFDI, UUID case-insensitive y reutilización de requestIds + +**Fecha:** 2026-05-04 + +### Cambios +- La verificación de CFDIs incompletos (`hasIncompleteCfdis` / `getOldestIncompleteCfdiDate`) ahora incluye los tipos de comprobante **P** (pago) y **N** (nómina), además de **I** (ingreso) y **E** (egreso). +- Al guardar/actualizar CFDIs, la comparación de `uuid` se hace con `LOWER()` para evitar duplicados por diferencias de mayúsculas/minúsculas. +- Se desactivó la reutilización de `requestId` de jobs SAT previos. Reusarlos puede agotar el límite de descargas del SAT y devolver **"Máximo de descargas permitidas"**, bloqueando el recovery. +- Se exportó `runRecoverySyncJob` para permitir su invocación manual desde scripts. + +### Archivos modificados +| Archivo | Cambio | +|---|---| +| `apps/api/src/jobs/sat-sync.job.ts` | Incluir `P` y `N` en consultas de CFDIs incompletos; exportar `runRecoverySyncJob` | +| `apps/api/src/services/sat/sat.service.ts` | Comparación `LOWER(uuid)`; comentar reutilización de `requestId` | + +--- + +## 11. Fix: drill-down de CFDIs carga el CFDI completo al visualizar + +**Fecha:** 2026-05-04 + +### Problema +En la vista de drill-down, al hacer clic en el ojo para ver un CFDI se usaba únicamente el objeto resumen de la lista, que no incluye conceptos ni todos los detalles. + +### Solución +Ahora se llama a `getCfdiById(id)` para obtener el CFDI completo antes de abrir el visor, y se muestra un estado de carga mientras se resuelve la petición. + +### Archivo modificado +| Archivo | Cambio | +|---|---| +| `apps/web/app/(dashboard)/drill-down/page.tsx` | Carga completa del CFDI al hacer clic en "Ver factura" | + +--- + +## 12. Scripts de soporte: Demo Ventas y operaciones + +**Fecha:** 2026-05-04 + +Se crearon varios scripts de utilería bajo `apps/api/scripts/` para tareas de soporte y configuración de la cuenta Demo Ventas. + +### Scripts principales +| Script | Propósito | +|---|---| +| `create-demo-ventas.ts` | Crea el tenant Demo Ventas, su BD, usuario owner y suscripción custom gratuita | +| `update-demo-ventas.ts` | Agrega usuarios supervisor/auxiliar/cliente y 5 contribuyentes adicionales a Demo Ventas | +| `seed-demo-obligaciones-tareas.ts` | Siembra obligaciones fiscales y tareas recurrentes para todos los contribuyentes de Demo Ventas | +| `fix-demo-carteras-asignaciones.ts` | Crea la subcartera del auxiliar y asigna contribuyentes, obligaciones y tareas de forma válida | +| `reset-demo-asignaciones.ts` | Deja Demo Ventas en estado "tutorial": elimina subcarteras, asignaciones y relación auxiliar-supervisor | +| `change-user-email.ts` | Cambia el correo de un usuario, genera contraseña temporal e invalida sesiones | +| `resend-welcome.ts` | Reenvía el correo de bienvenida a un usuario | + +> Estos scripts no son parte del flujo productivo; se ejecutan manualmente vía `npx tsx`. + +## 13. Automatización de cierre de obligaciones fiscales + +**Fecha:** 2026-05-04 + +### Cambio +Se automatiza el cierre de **todas las obligaciones fiscales** desde la sección existente **Documentos › Declaraciones**. Al subir una declaración o su comprobante de pago, el sistema crea automáticamente evidencias en `obligacion_evidencias` y actualiza el estado de cada obligación fiscal en `obligacion_periodos`. + +### Reglas de cierre deterministas +- `requierePago = false` (informativas): se marcan completadas al subir la declaración (`declaracion`). +- `requierePago = true` (pago + declaración): la declaración marca `declaracion_presentada = true`; el periodo se cierra al subir el comprobante de pago (`pago`). +- Al subir una declaración con **monto $0**, se marca el pago como presentado automáticamente. + +### Nuevas tablas y columnas +| Migración | Descripción | +|---|---| +| `053_obligacion_evidencias.sql` | Tabla genérica para evidencias de obligaciones (declaración, pago, acuse, complemento) | +| `054_obligacion_periodos_estados.sql` | Agrega `declaracion_presentada`, `pago_presentado` y `evidencia_id` a `obligacion_periodos` | +| `055_declaracion_obligaciones.sql` | Relaciona declaraciones provisionales con las obligaciones fiscales que cierran | + +### Nuevos endpoints (uso interno / futuro) +| Método | Endpoint | Descripción | +|---|---|---| +| `GET` | `/api/documentos/obligacion-evidencias` | Listar evidencias por contribuyente/periodo/obligación | +| `POST` | `/api/documentos/obligacion-evidencias` | Subir nueva evidencia | +| `GET` | `/api/documentos/obligacion-evidencias/:id/pdf` | Descargar PDF de evidencia | +| `DELETE` | `/api/documentos/obligacion-evidencias/:id` | Eliminar evidencia y recalcular estado del periodo | + +### Archivos creados +| Archivo | Cambio | +|---|---| +| `apps/api/src/services/obligacion-evidencias.service.ts` | Servicio para crear/listar/descargar/eliminar evidencias y actualizar `obligacion_periodos` | +| `apps/api/src/migrations/tenant/053_obligacion_evidencias.sql` | Tabla `obligacion_evidencias` | +| `apps/api/src/migrations/tenant/054_obligacion_periodos_estados.sql` | Columnas de estado en `obligacion_periodos` | +| `apps/api/src/migrations/tenant/055_declaracion_obligaciones.sql` | Relación declaración ↔ obligación | +| `apps/web/lib/api/obligaciones.ts` | Cliente API para obtener obligaciones por periodo | + +### Archivos modificados +| Archivo | Cambio | +|---|---| +| `apps/api/src/constants/obligaciones-fiscales.ts` | Campo `requierePago` en todas las obligaciones del catálogo | +| `apps/api/src/services/declaraciones.service.ts` | Crea evidencias en las obligaciones seleccionadas; vincula declaración con obligaciones; mantiene fallback legacy por impuestos | +| `apps/api/src/services/obligaciones.service.ts` | `getObligacionesPorPeriodo` devuelve `requierePago`, `declaracionPresentada`, `pagoPresentado` | +| `apps/api/src/services/notify-upload.service.ts` | Soporte para notificaciones de `obligacion_evidencia` | +| `apps/api/src/services/email/templates/documento-subido.ts` | Template para evidencias de obligación | +| `apps/api/src/controllers/documentos.controller.ts` | Schema de declaraciones acepta `obligacionesIds` | +| `apps/api/src/routes/documentos.routes.ts` | Rutas de evidencias | +| `apps/web/lib/api/declaraciones.ts` | `CreateDeclaracionData` acepta `obligacionesIds` | +| `apps/web/app/(dashboard)/documentos/page.tsx` | Diálogo de subida reemplaza “Impuestos cubiertos” por selector de obligaciones fiscales del periodo | + +## 15. Fix: quitar toggle de completado en Configuración › Obligaciones fiscales › Tareas + +**Fecha:** 2026-06-22 + +### Problema +En **Configuración › Obligaciones fiscales › Tareas** seguía apareciendo el botón para marcar tareas como completadas/pendientes manualmente, pero el estado de las obligaciones fiscales ahora se actualiza automáticamente desde **Documentos › Declaraciones**. + +### Solución +- Se convirtió el icono de check/círculo en un indicador visual de estado (completada, pendiente, atrasada) sin interacción. +- Se eliminaron las mutaciones de completar/descompletar periodo del frontend. + +### Archivo modificado +| Archivo | Cambio | +|---|---| +| `apps/web/components/obligaciones/tareas-tab.tsx` | Icono de estado estático; eliminados `completarMutation` y `descompletarMutation` | + +## 14. Fix: sugerencias de Clave Producto SAT en facturación + +**Fecha:** 2026-06-22 + +### Problema +En **Facturación › Conceptos**, el campo **Clave Producto SAT** no mostraba sugerencias al escribir. + +### Causa +La tabla `cat_clave_prod_serv` de la BD central estaba vacía; el catálogo nunca se había importado. + +### Solución +- Se importó el catálogo oficial CFDI 4.0 (`c_ClaveProdServ`) desde los recursos de **phpcfdi/resources-sat-catalogs** (52,513 registros). +- Se creó el script `apps/api/scripts/import-clave-prod-serv.ts` para importaciones futuras. +- Se hizo más robusto el autocomplete del campo: + - `AbortController` para cancelar búsquedas anteriores. + - Manejo de errores y `autoComplete="off"`. +- Se sanitizó el fallback regex en el backend para evitar errores con caracteres especiales. + +### Archivos creados +| Archivo | Cambio | +|---|---| +| `apps/api/scripts/import-clave-prod-serv.ts` | Importa el catálogo desde CSV a PostgreSQL | + +### Archivos modificados +| Archivo | Cambio | +|---|---| +| `apps/api/src/controllers/catalogos.controller.ts` | Escapa regex en búsqueda fallback; búsqueda por clave insensible a mayúsculas | +| `apps/web/lib/api/catalogos.ts` | `searchClaveProdServ` acepta `AbortSignal` | +| `apps/web/app/(dashboard)/facturacion/page.tsx` | `handleSearchProduct` con `AbortController`, try/catch y `autoComplete="off"` | + +## Deploy + +```bash +cd /root/HoruxDespachosNuevo +pnpm --filter @horux/core build +pnpm --filter api build +pnpm --filter web build +npx tsx apps/api/scripts/migrate-tenants.ts +pm2 reload horux-api +pm2 reload horux-web +``` + +**Estado:** ✅ Exitoso diff --git a/packages/core/src/email/transport.ts b/packages/core/src/email/transport.ts index 5d8b7d4..9c6f63d 100644 --- a/packages/core/src/email/transport.ts +++ b/packages/core/src/email/transport.ts @@ -8,8 +8,13 @@ export interface SmtpConfig { from: string; } +export interface EmailAttachment { + filename: string; + content: Buffer; +} + export interface EmailTransport { - send(to: string, subject: string, html: string): Promise; + send(to: string, subject: string, html: string, attachments?: EmailAttachment[]): Promise; } export function createEmailTransport(config: SmtpConfig | null): EmailTransport { @@ -21,7 +26,11 @@ export function createEmailTransport(config: SmtpConfig | null): EmailTransport console.warn('[EMAIL] SMTP not configured. Emails will be logged to console.'); return { sendMail: async (opts: any) => { - console.log('[EMAIL] Would send:', { to: opts.to, subject: opts.subject }); + console.log('[EMAIL] Would send:', { + to: opts.to, + subject: opts.subject, + attachments: opts.attachments?.map((a: any) => a.filename ?? a.path), + }); return { messageId: 'mock' }; }, } as any; @@ -42,7 +51,7 @@ export function createEmailTransport(config: SmtpConfig | null): EmailTransport } return { - async send(to: string, subject: string, html: string) { + async send(to: string, subject: string, html: string, attachments?: EmailAttachment[]) { const transport = getTransporter(); try { await transport.sendMail({ @@ -51,7 +60,9 @@ export function createEmailTransport(config: SmtpConfig | null): EmailTransport subject, html, text: html.replace(/<[^>]*>/g, ''), + attachments, }); + console.log(`[EMAIL] Sent email to ${to} with ${attachments?.length ?? 0} attachment(s)`); } catch (error) { console.error('[EMAIL] Error sending email:', error); }