diff --git a/apps/api/scripts/add-demo-cfdis.ts b/apps/api/scripts/add-demo-cfdis.ts new file mode 100644 index 0000000..fe3b05c --- /dev/null +++ b/apps/api/scripts/add-demo-cfdis.ts @@ -0,0 +1,279 @@ +/** + * Script: add-demo-cfdis.ts + * + * Agrega CFDIs sintéticos adicionales a los contribuyentes del tenant + * "Demo Ventas" (horux_demoventas). Los CFDIs se generan con UUIDs + * deterministas, por lo que el script es idempotente: volverlo a correr no + * duplica registros. + * + * Uso: + * cd apps/api && npx tsx scripts/add-demo-cfdis.ts + * + * Opciones via env: + * DEMO_CFDIS_POR_CONTRIBUYENTE=80 # default: 80 + * DEMO_DIAS_ATRAS=540 # default: 540 (~18 meses) + */ +import { PrismaClient } from '@prisma/client'; +import { Pool } from 'pg'; +import { createHash } from 'crypto'; +import { tenantDb } from '../src/config/database.ts'; +import { markForInvalidation } from '../src/services/metricas.service.js'; + +const prisma = new PrismaClient(); + +const DEMO_RFC = 'DEMO2501019X2'; +const CFDIS_POR_CONTRIBUYENTE = parseInt(process.env.DEMO_CFDIS_POR_CONTRIBUYENTE || '80', 10); +const DIAS_ATRAS = parseInt(process.env.DEMO_DIAS_ATRAS || '540', 10); + +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' }, + { rfc: 'CLI123456AB6', nombre: 'Cliente Zeta SA' }, + { rfc: 'CLI123456AB7', nombre: 'Cliente Eta 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' }, + { rfc: 'PRO123456AB6', nombre: 'Proveedor Telecom SA' }, + { rfc: 'PRO123456AB7', nombre: 'Proveedor Asesoria 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' }, + { clave: '72121000', descripcion: 'Renta de oficinas', unidad: 'Servicio' }, + { clave: '73101500', descripcion: 'Servicios de telecomunicaciones', unidad: 'Servicio' }, + { clave: '43231500', descripcion: 'Infraestructura en la nube', unidad: 'Servicio' }, + { clave: '81141800', descripcion: 'Mantenimiento de sistemas', unidad: 'Servicio' }, +]; + +const FORMAS_PAGO = ['01', '02', '03', '04', '28', '99']; + +function deterministicUuid(seed: string): string { + const hex = createHash('sha256').update(seed).digest('hex'); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +function randomDateWithin(daysBack: number): Date { + // Sesgar hacia fechas recientes: random^2 produce valores pequenos con mayor probabilidad + const daysAgo = Math.floor(Math.pow(Math.random(), 2) * daysBack); + const fecha = new Date(); + fecha.setDate(fecha.getDate() - daysAgo); + fecha.setHours(8 + Math.floor(Math.random() * 10), Math.floor(Math.random() * 60), 0, 0); + return fecha; +} + +async function main() { + console.log(`🌱 Agregando ${CFDIS_POR_CONTRIBUYENTE} CFDIs adicionales por contribuyente 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 pool = await tenantDb.getPool(tenant.id, tenant.databaseName); + + const { rows: contribuyentes } = await pool.query<{ entidad_id: string; rfc: string; nombre: string }>(` + SELECT c.entidad_id, c.rfc, eg.nombre + FROM contribuyentes c + JOIN entidades_gestionadas eg ON eg.id = c.entidad_id + ORDER BY c.rfc + `); + + if (contribuyentes.length === 0) throw new Error('No hay contribuyentes demo'); + + let totalCreados = 0; + let totalExistentes = 0; + + for (const c of contribuyentes) { + const { creados, existentes } = await agregarCfdisContribuyente(pool, c); + console.log(`✅ ${c.rfc}: ${creados} CFDIs creados, ${existentes} ya existian`); + totalCreados += creados; + totalExistentes += existentes; + } + + console.log(`\n🎉 Total: ${totalCreados} CFDIs nuevos, ${totalExistentes} ya existian`); +} + +async function agregarCfdisContribuyente( + pool: Pool, + contribuyente: { entidad_id: string; rfc: string; nombre: string }, +): Promise<{ creados: number; existentes: number }> { + const client = await pool.connect(); + const mesesAfectados = new Set(); + + try { + await client.query('BEGIN'); + + // Asegurar RFCs de clientes/proveedores y el contribuyente mismo + const rfcs = new Map(); + for (const p of [...CLIENTES, ...PROVEEDORES]) { + const { rows: [r] } = await client.query<{ id: number }>(` + INSERT INTO rfcs (rfc, razon_social, regimen_fiscal) + VALUES ($1, $2, '601') + ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social + RETURNING id + `, [p.rfc, p.nombre]); + rfcs.set(p.rfc, r.id); + } + const { rows: [principal] } = await client.query<{ id: number }>(` + INSERT INTO rfcs (rfc, razon_social, regimen_fiscal) + VALUES ($1, $2, '601') + ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social + RETURNING id + `, [contribuyente.rfc, contribuyente.nombre]); + rfcs.set(contribuyente.rfc, principal.id); + + let creados = 0; + let existentes = 0; + + for (let i = 0; i < CFDIS_POR_CONTRIBUYENTE; i++) { + const esEmitido = i % 2 === 0; + const contraparte = esEmitido + ? CLIENTES[i % CLIENTES.length] + : PROVEEDORES[i % PROVEEDORES.length]; + + // Distribucion sesgada: muchos CFDIs pequenos, pocos grandes + const raw = Math.random() * Math.random(); + const subtotal = Math.floor(raw * 60000) + 1500; + const iva = round2(subtotal * 0.16); + const total = round2(subtotal + iva); + + const fecha = randomDateWithin(DIAS_ATRAS); + const year = String(fecha.getFullYear()); + const month = String(fecha.getMonth() + 1).padStart(2, '0'); + const fechaStr = fecha.toISOString(); + + const metodoPago = Math.random() > 0.35 ? 'PUE' : 'PPD'; + const formaPago = FORMAS_PAGO[i % FORMAS_PAGO.length]; + const usoCfdi = esEmitido ? 'G03' : 'G01'; + const tipo = esEmitido ? 'EMITIDO' : 'RECIBIDO'; + + const rfcEmisor = esEmitido ? contribuyente.rfc : contraparte.rfc; + const nombreEmisor = esEmitido ? contribuyente.nombre : contraparte.nombre; + const rfcReceptor = esEmitido ? contraparte.rfc : contribuyente.rfc; + const nombreReceptor = esEmitido ? contraparte.nombre : contribuyente.nombre; + + const uuid = deterministicUuid(`${contribuyente.rfc}-add-demo-cfdis-${i}`); + + // Idempotencia: si el UUID ya existe, lo contamos y saltamos + const { rows: duplicados } = await client.query(`SELECT 1 FROM cfdis WHERE lower(uuid) = lower($1) LIMIT 1`, [uuid]); + if (duplicados.length > 0) { + existentes++; + continue; + } + + 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, uuid, 'DEMO', String(100000 + 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', + contribuyente.entidad_id, fechaStr, month, year, + ]); + + // Conceptos: de 1 a 3, repartiendo exactamente el subtotal + const numConceptos = Math.floor(Math.random() * 3) + 1; + let importeRestante = round2(subtotal); + for (let j = 0; j < numConceptos; j++) { + const prod = PRODUCTOS[(i + j) % PRODUCTOS.length]; + const esUltimo = j === numConceptos - 1; + const cantidad = Math.floor(Math.random() * 5) + 1; + + let importe: number; + if (esUltimo) { + importe = importeRestante; + } else { + const promedio = importeRestante / (numConceptos - j); + const factor = 0.7 + Math.random() * 0.6; // 70% - 130% del promedio + importe = round2(promedio * factor); + importe = Math.min(importe, importeRestante - 0.01); + } + importeRestante = round2(importeRestante - importe); + + const valorUnitario = round2(importe / cantidad); + const ivaConcepto = round2(importe * 0.16); + + 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, + ]); + } + + creados++; + mesesAfectados.add(`${year}-${month}`); + } + + // Marcar meses afectados para recomputo de metricas + for (const ym of mesesAfectados) { + const [anio, mes] = ym.split('-').map(Number); + await markForInvalidation(pool, contribuyente.entidad_id, anio, mes, 'add-demo-cfdis'); + } + + await client.query('COMMIT'); + return { creados, existentes }; + } catch (err) { + await client.query('ROLLBACK').catch(() => {}); + throw err; + } finally { + client.release(); + } +} + +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/add-demo-notas-credito.ts b/apps/api/scripts/add-demo-notas-credito.ts new file mode 100644 index 0000000..c0edd1e --- /dev/null +++ b/apps/api/scripts/add-demo-notas-credito.ts @@ -0,0 +1,259 @@ +/** + * Script: add-demo-notas-credito.ts + * + * Agrega notas de crédito (NC) sintéticas a los contribuyentes del tenant + * "Demo Ventas" (horux_demoventas). Cada NC se relaciona con una factura + * existente (tipo_comprobante = 'I', metodo_pago = 'PUE') mediante + * cfdi_tipo_relacion = '01' y cfdis_relacionados = uuid de la factura origen. + * + * El script es idempotente: usa UUIDs deterministas, por lo que volverlo a + * correr no duplica registros. + * + * Uso: + * cd apps/api && npx tsx scripts/add-demo-notas-credito.ts + * + * Opciones via env: + * DEMO_NC_POR_CONTRIBUYENTE=4 # default: 4 (2 emitidas + 2 recibidas) + * DEMO_NC_DIAS_DESPUES=90 # default: 90 (max dias despues de la factura) + */ +import { PrismaClient } from '@prisma/client'; +import { Pool, type PoolClient } from 'pg'; +import { createHash } from 'crypto'; +import { tenantDb } from '../src/config/database.ts'; +import { markForInvalidation } from '../src/services/metricas.service.js'; + +const prisma = new PrismaClient(); + +const DEMO_RFC = 'DEMO2501019X2'; +const NC_POR_CONTRIBUYENTE = parseInt(process.env.DEMO_NC_POR_CONTRIBUYENTE || '4', 10); +const MAX_DIAS_DESPUES = parseInt(process.env.DEMO_NC_DIAS_DESPUES || '90', 10); + +function deterministicUuid(seed: string): string { + const hex = createHash('sha256').update(seed).digest('hex'); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +function addDays(fecha: Date, dias: number): Date { + const r = new Date(fecha); + r.setDate(r.getDate() + dias); + r.setHours(8 + Math.floor(Math.random() * 10), Math.floor(Math.random() * 60), 0, 0); + return r; +} + +interface FacturaOrigen { + id: number; + uuid: string; + total: number; + fecha_emision: Date; + type: 'EMITIDO' | 'RECIBIDO'; + rfc_emisor_id: number; + rfc_emisor: string; + nombre_emisor: string; + rfc_receptor_id: number; + rfc_receptor: string; + nombre_receptor: string; + forma_pago: string; + uso_cfdi: string; + regimen_fiscal_emisor: string; + regimen_fiscal_receptor: string; +} + +async function main() { + console.log(`🌱 Agregando ${NC_POR_CONTRIBUYENTE} notas de crédito por contribuyente 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 pool = await tenantDb.getPool(tenant.id, tenant.databaseName); + + const { rows: contribuyentes } = await pool.query<{ entidad_id: string; rfc: string; nombre: string }>(` + SELECT c.entidad_id, c.rfc, eg.nombre + FROM contribuyentes c + JOIN entidades_gestionadas eg ON eg.id = c.entidad_id + ORDER BY c.rfc + `); + + if (contribuyentes.length === 0) throw new Error('No hay contribuyentes demo'); + + let totalCreadas = 0; + let totalExistentes = 0; + + for (const c of contribuyentes) { + const { creadas, existentes } = await agregarNcContribuyente(pool, c); + console.log(`✅ ${c.rfc}: ${creadas} NCs creadas, ${existentes} ya existian`); + totalCreadas += creadas; + totalExistentes += existentes; + } + + console.log(`\n🎉 Total: ${totalCreadas} notas de crédito nuevas, ${totalExistentes} ya existian`); +} + +async function agregarNcContribuyente( + pool: Pool, + contribuyente: { entidad_id: string; rfc: string; nombre: string }, +): Promise<{ creadas: number; existentes: number }> { + const client = await pool.connect(); + const mesesAfectados = new Set(); + + try { + await client.query('BEGIN'); + + const mitad = Math.ceil(NC_POR_CONTRIBUYENTE / 2); + const facturasEmitidas = await obtenerFacturasPUE(client, contribuyente.entidad_id, 'EMITIDO', mitad); + const facturasRecibidas = await obtenerFacturasPUE(client, contribuyente.entidad_id, 'RECIBIDO', NC_POR_CONTRIBUYENTE - mitad); + + let creadas = 0; + let existentes = 0; + const usadas = new Set(); + let idxEmitida = 0; + let idxRecibida = 0; + + for (let i = 0; i < NC_POR_CONTRIBUYENTE; i++) { + const esEmitida = i % 2 === 0; + const origen = esEmitida + ? facturasEmitidas[idxEmitida++ % facturasEmitidas.length] + : facturasRecibidas[idxRecibida++ % facturasRecibidas.length]; + + if (!origen || usadas.has(origen.uuid)) { + // Si no hay suficientes facturas distintas, saltar + continue; + } + usadas.add(origen.uuid); + + // Monto de la NC: entre 10% y 40% del total de la factura origen + const porcentaje = 0.1 + Math.random() * 0.3; + const ncTotal = round2(origen.total * porcentaje); + const ncSubtotal = round2(ncTotal / 1.16); + const ncIva = round2(ncTotal - ncSubtotal); + + // Fecha: entre 5 y MAX_DIAS_DESPUES dias despues de la factura origen, sin pasar de hoy + const diasDespues = 5 + Math.floor(Math.random() * (MAX_DIAS_DESPUES - 5)); + let ncFecha = addDays(origen.fecha_emision, diasDespues); + const ahora = new Date(); + if (ncFecha > ahora) ncFecha = ahora; + + const year = String(ncFecha.getFullYear()); + const month = String(ncFecha.getMonth() + 1).padStart(2, '0'); + const fechaStr = ncFecha.toISOString(); + + const uuid = deterministicUuid(`${contribuyente.rfc}-demo-nc-${esEmitida ? 'E' : 'R'}-${i}`); + const { rows: duplicados } = await client.query( + `SELECT 1 FROM cfdis WHERE lower(uuid) = lower($1) LIMIT 1`, + [uuid], + ); + if (duplicados.length > 0) { + existentes++; + continue; + } + + const { rows: [nc] } = 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, + cfdi_tipo_relacion, cfdis_relacionados + ) 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, + $35, $36 + ) RETURNING id + `, [ + year, month, esEmitida ? 'EMITIDO' : 'RECIBIDO', uuid, 'NC', String(200000 + i), + 'Vigente', fechaStr, + origen.rfc_emisor_id, origen.rfc_emisor, origen.nombre_emisor, + origen.rfc_receptor_id, origen.rfc_receptor, origen.nombre_receptor, + ncSubtotal, ncSubtotal, 0, 0, + ncTotal, ncTotal, 'MXN', 1, 'E', + 'PUE', origen.forma_pago, origen.uso_cfdi, + ncIva, ncIva, + origen.regimen_fiscal_emisor, origen.regimen_fiscal_receptor, + contribuyente.entidad_id, fechaStr, month, year, + '01', origen.uuid.toLowerCase(), + ]); + + 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) + `, [ + nc.id, '84111506', 'Descuento por nota de credito', 1, 'E48', 'Servicio', + ncSubtotal, ncSubtotal, ncSubtotal, ncSubtotal, + ncIva, ncIva, + ]); + + creadas++; + mesesAfectados.add(`${year}-${month}`); + } + + for (const ym of mesesAfectados) { + const [anio, mes] = ym.split('-').map(Number); + await markForInvalidation(pool, contribuyente.entidad_id, anio, mes, 'demo-nc'); + } + + await client.query('COMMIT'); + return { creadas, existentes }; + } catch (err) { + await client.query('ROLLBACK').catch(() => {}); + throw err; + } finally { + client.release(); + } +} + +async function obtenerFacturasPUE( + client: PoolClient, + contribuyenteId: string, + type: 'EMITIDO' | 'RECIBIDO', + limite: number, +): Promise { + const { rows } = await client.query(` + SELECT + id, uuid, total_mxn AS total, fecha_emision, type, + rfc_emisor_id, rfc_emisor, nombre_emisor, + rfc_receptor_id, rfc_receptor, nombre_receptor, + forma_pago, uso_cfdi, + regimen_fiscal_emisor, regimen_fiscal_receptor + FROM cfdis + WHERE contribuyente_id = $1 + AND type = $2 + AND tipo_comprobante = 'I' + AND metodo_pago = 'PUE' + AND status = 'Vigente' + AND total_mxn > 5000 + ORDER BY random() + LIMIT $3 + `, [contribuyenteId, type, Math.max(limite * 3, 20)]); + + // Mezclar y retornar hasta `limite` + const mezcladas = rows.sort(() => Math.random() - 0.5); + return mezcladas.slice(0, limite); +} + +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/create-vendedor-fernando.ts b/apps/api/scripts/create-vendedor-fernando.ts new file mode 100644 index 0000000..7f5fda0 --- /dev/null +++ b/apps/api/scripts/create-vendedor-fernando.ts @@ -0,0 +1,119 @@ +/** + * Script: create-vendedor-fernando.ts + * + * Crea la cuenta de Fernando (fernando@horuxfin.com) como Vendedor de Horux 360. + * Rol de plataforma: platform_sales (Vendedor). + * Membership en el tenant Horux 360 con rol cliente (minimo, solo para login + * y acceso a configuracion/cambio de contraseña). + * + * Si el usuario ya existe, le asigna/actualiza los permisos y envia un correo + * de notificacion. Si es nuevo, genera password temporal y envia bienvenida. + * + * Uso: + * cd apps/api && npx tsx scripts/create-vendedor-fernando.ts + */ +import { randomBytes } from 'crypto'; +import { prisma } from '../src/config/database.js'; +import { hashPassword } from '../src/auth/passwords.js'; +import { emailService } from '../src/services/email/email.service.js'; +import { invalidatePlatformRolesCache } from '../src/utils/platform-admin.js'; + +const EMAIL = 'fernando@horuxfin.com'; +const NOMBRE = 'Fernando'; +const HORUX_RFC = 'HTS240708LJA'; + +function generarPassword(): string { + return randomBytes(6).toString('hex'); // 12 caracteres hex +} + +async function main() { + console.log(`🌱 Creando cuenta de Vendedor para ${EMAIL}...\n`); + + // 1. Tenant raiz Horux 360 + const tenant = await prisma.tenant.findUnique({ where: { rfc: HORUX_RFC } }); + if (!tenant) throw new Error(`Tenant Horux 360 (${HORUX_RFC}) no encontrado. Ejecuta primero el bootstrap admin global.`); + + // 2. Rol "cliente" para la membership (minimo acceso) + const clienteRol = await prisma.rol.findUnique({ where: { nombre: 'cliente' } }); + if (!clienteRol) throw new Error('Rol "cliente" no encontrado en BD central'); + + // 3. Buscar o crear usuario + let user = await prisma.user.findUnique({ where: { email: EMAIL } }); + let tempPassword: string | null = null; + let esNuevo = false; + + if (!user) { + tempPassword = generarPassword(); + const passwordHash = await hashPassword(tempPassword); + user = await prisma.user.create({ + data: { + email: EMAIL, + passwordHash, + nombre: NOMBRE, + lastTenantId: tenant.id, + active: true, + }, + }); + esNuevo = true; + console.log(`✅ Usuario creado: ${user.email}`); + console.log(` Password temporal: ${tempPassword}`); + } else { + await prisma.user.update({ + where: { id: user.id }, + data: { lastTenantId: tenant.id, active: true }, + }); + console.log(`ℹ️ Usuario ya existia: ${user.email}`); + } + + // 4. Membership en Horux 360 con rol cliente + await prisma.tenantMembership.upsert({ + where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } }, + update: { rolId: clienteRol.id, active: true, isOwner: false }, + create: { + userId: user.id, + tenantId: tenant.id, + rolId: clienteRol.id, + active: true, + isOwner: false, + }, + }); + console.log(`✅ Membership "cliente" en ${tenant.nombre}`); + + // 5. Rol de plataforma Vendedor (platform_sales) + await prisma.userPlatformRole.upsert({ + where: { userId_role: { userId: user.id, role: 'platform_sales' } }, + update: {}, + create: { userId: user.id, role: 'platform_sales' }, + }); + console.log(`✅ Rol de plataforma "Vendedor" (platform_sales) asignado`); + + // 6. Invalidar cache de roles de plataforma + invalidatePlatformRolesCache(user.id); + + // 7. Enviar correo con accesos (solo si es nuevo; si ya existia, no se reenvia password) + if (esNuevo && tempPassword) { + await emailService.sendWelcome(EMAIL, { + nombre: NOMBRE, + email: EMAIL, + tempPassword, + }); + console.log(`✅ Correo de bienvenida con credenciales enviado a ${EMAIL}`); + } else { + console.log(`ℹ️ El usuario ya existia; no se envio correo con password`); + } + + console.log('\n🎉 Cuenta de Vendedor lista'); + console.log(` Email: ${EMAIL}`); + if (tempPassword) console.log(` Password temporal: ${tempPassword}`); + console.log(` Tenant: ${tenant.nombre} (${tenant.rfc})`); + console.log(` Rol de plataforma: platform_sales (Vendedor)`); +} + +main() + .catch((err) => { + console.error('\n❌ Error:', err.message || err); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/apps/api/src/controllers/client-invitations.controller.ts b/apps/api/src/controllers/client-invitations.controller.ts index e185105..c1c8f96 100644 --- a/apps/api/src/controllers/client-invitations.controller.ts +++ b/apps/api/src/controllers/client-invitations.controller.ts @@ -9,10 +9,10 @@ export async function createInvitation(req: Request, res: Response, next: NextFu return res.status(400).json({ message: 'El email es requerido' }); } - // Solo platform_admin puede crear invitaciones - const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin'); + // Admin y Vendedor (platform_sales) pueden crear invitaciones + const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin', 'platform_sales'); if (!isAdmin) { - return res.status(403).json({ message: 'Solo administradores pueden crear invitaciones' }); + return res.status(403).json({ message: 'Solo administradores o vendedores pueden crear invitaciones' }); } const invitation = await clientInvitationService.createInvitation({ @@ -70,7 +70,7 @@ export async function registerFromInvitation(req: Request, res: Response, next: export async function resendInvitation(req: Request, res: Response, next: NextFunction) { try { - const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin'); + const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin', 'platform_sales'); if (!isAdmin) { return res.status(403).json({ message: 'No autorizado' }); } @@ -88,7 +88,7 @@ export async function resendInvitation(req: Request, res: Response, next: NextFu export async function listInvitations(req: Request, res: Response, next: NextFunction) { try { - const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin'); + const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin', 'platform_sales'); if (!isAdmin) { return res.status(403).json({ message: 'No autorizado' }); } diff --git a/apps/api/src/controllers/trial-invitations.controller.ts b/apps/api/src/controllers/trial-invitations.controller.ts index 4daa12a..b02094b 100644 --- a/apps/api/src/controllers/trial-invitations.controller.ts +++ b/apps/api/src/controllers/trial-invitations.controller.ts @@ -1,19 +1,19 @@ import type { Request, Response, NextFunction } from 'express'; import * as trialInvitationService from '../services/trial-invitations.service.js'; -import { isGlobalAdmin } from '../utils/global-admin.js'; +import { hasAnyPlatformRole } from '../utils/platform-admin.js'; import { prisma } from '../config/database.js'; -async function requireGlobalAdmin(req: Request, res: Response): Promise { - const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId); +async function requireAdminOrSales(req: Request, res: Response): Promise { + const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin', 'platform_sales'); if (!isAdmin) { - res.status(403).json({ message: 'Solo el administrador global puede gestionar invitaciones de trial' }); + res.status(403).json({ message: 'Solo administradores o vendedores pueden gestionar invitaciones de trial' }); } return isAdmin; } export async function createInvitation(req: Request, res: Response, next: NextFunction) { try { - if (!(await requireGlobalAdmin(req, res))) return; + if (!(await requireAdminOrSales(req, res))) return; const { tenantId, plan, durationDays } = req.body; if (!tenantId || !durationDays || durationDays < 1 || durationDays > 365) { @@ -38,7 +38,7 @@ export async function createInvitation(req: Request, res: Response, next: NextFu export async function getAllInvitations(req: Request, res: Response, next: NextFunction) { try { - if (!(await requireGlobalAdmin(req, res))) return; + if (!(await requireAdminOrSales(req, res))) return; const { tenantId, status } = req.query; const invitations = await trialInvitationService.getInvitations({ @@ -85,7 +85,7 @@ export async function acceptInvitation(req: Request, res: Response, next: NextFu export async function cancelInvitation(req: Request, res: Response, next: NextFunction) { try { - if (!(await requireGlobalAdmin(req, res))) return; + if (!(await requireAdminOrSales(req, res))) return; const id = typeof req.params.id === 'string' ? req.params.id : ''; const result = await trialInvitationService.cancelInvitation(id); diff --git a/apps/web/app/(dashboard)/admin/staff/page.tsx b/apps/web/app/(dashboard)/admin/staff/page.tsx index 3e5e3f1..d69dc35 100644 --- a/apps/web/app/(dashboard)/admin/staff/page.tsx +++ b/apps/web/app/(dashboard)/admin/staff/page.tsx @@ -12,7 +12,7 @@ const ROLE_META: Record diff --git a/apps/web/app/(dashboard)/impuestos/page.tsx b/apps/web/app/(dashboard)/impuestos/page.tsx index c5d9442..07a1daa 100644 --- a/apps/web/app/(dashboard)/impuestos/page.tsx +++ b/apps/web/app/(dashboard)/impuestos/page.tsx @@ -186,6 +186,8 @@ export default function ImpuestosPage() { icon={} subtitle="Cobrado a clientes" href={drillUrl('IVA Trasladado - CFDIs Emitidos', { bucket: 'causado' })} + target="_blank" + rel="noopener noreferrer" /> } subtitle="Pagado a proveedores" href={drillUrl('IVA Acreditable - CFDIs Recibidos', { bucket: 'acreditable' })} + target="_blank" + rel="noopener noreferrer" /> {(() => { const val = regimenSeleccionado @@ -405,24 +409,32 @@ export default function ImpuestosPage() { value={ingSel} icon={} href={drillUrl('Ingresos ISR - CFDIs Emitidos', { bucket: 'ingresos' })} + target="_blank" + rel="noopener noreferrer" /> } href={drillUrl('NCs Emitidas - CFDIs', { bucket: 'ncs_emitidas' })} + target="_blank" + rel="noopener noreferrer" /> } href={drillUrl('Deducciones - CFDIs Recibidos', { bucket: 'gastos' })} + target="_blank" + rel="noopener noreferrer" /> } href={drillUrl('NCs Recibidas - CFDIs', { bucket: 'ncs_recibidas' })} + target="_blank" + rel="noopener noreferrer" /> } subtitle="Efectivo > $2,000" href={drillUrl('No Deducibles - Efectivo > $2,000', { bucket: 'no_deducibles_efectivo' })} + target="_blank" + rel="noopener noreferrer" /> ); diff --git a/apps/web/components/layouts/sidebar.tsx b/apps/web/components/layouts/sidebar.tsx index 5b2632a..28e9aaa 100644 --- a/apps/web/components/layouts/sidebar.tsx +++ b/apps/web/components/layouts/sidebar.tsx @@ -72,6 +72,11 @@ const adminNavigation: NavItem[] = [ { name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning }, ]; +const vendedorNavigation: NavItem[] = [ + { name: 'Invitar cliente', href: '/admin/invitar-cliente', icon: Send }, + { name: 'Configuracion', href: '/configuracion', icon: Settings }, +]; + export function Sidebar() { const pathname = usePathname(); const router = useRouter(); @@ -103,12 +108,15 @@ export function Sidebar() { const { data: contribuyentes } = useContribuyentes(); const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, role, user?.platformRoles); + const isVendedor = user?.platformRoles?.includes('platform_sales') && !isGlobalAdmin; // El admin global NO necesita "Configuración inicial" — su tenant raíz // (Horux 360) no tiene contribuyentes propios y nunca los tendrá. - const showOnboarding = (!contribuyentes || contribuyentes.length === 0) && role !== 'cliente' && !isGlobalAdmin; + const showOnboarding = (!contribuyentes || contribuyentes.length === 0) && role !== 'cliente' && !isGlobalAdmin && !isVendedor; const allNavigation = isGlobalAdmin ? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]] - : filteredNav; + : isVendedor + ? vendedorNavigation + : filteredNav; return (