import { PrismaClient } from '@prisma/client'; import bcrypt from 'bcryptjs'; import { Pool } from 'pg'; import { migrate } from '../src/config/tenant-migrations.js'; import { RESICO_TASAS, ISR_TARIFAS } from './isr-data.js'; import { EVENTOS_FISCALES, DIAS_INHABILES } from './eventos-fiscales-data.js'; import { FORMAS_PAGO, METODOS_PAGO, USOS_CFDI, MONEDAS, CLAVES_UNIDAD, OBJETOS_IMP, TIPOS_RELACION, EXPORTACIONES, } from './catalogos-sat-data.js'; const prisma = new PrismaClient(); 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), }; } const REGIMENES_SAT = [ { clave: '601', descripcion: 'General de Ley Personas Morales', tipoPersona: 'moral' }, { clave: '603', descripcion: 'Personas Morales con Fines no Lucrativos', tipoPersona: 'moral' }, { clave: '605', descripcion: 'Sueldos y Salarios e Ingresos Asimilados a Salarios', tipoPersona: 'fisica' }, { clave: '606', descripcion: 'Arrendamiento', tipoPersona: 'fisica' }, { clave: '607', descripcion: 'Régimen de Enajenación o Adquisición de Bienes', tipoPersona: 'fisica' }, { clave: '608', descripcion: 'Demás ingresos', tipoPersona: 'fisica' }, { clave: '610', descripcion: 'Residentes en el Extranjero sin Establecimiento Permanente en México', tipoPersona: 'ambos' }, { clave: '611', descripcion: 'Ingresos por Dividendos (socios y accionistas)', tipoPersona: 'fisica' }, { clave: '612', descripcion: 'Personas Físicas con Actividades Empresariales y Profesionales', tipoPersona: 'fisica' }, { clave: '614', descripcion: 'Ingresos por intereses', tipoPersona: 'fisica' }, { clave: '615', descripcion: 'Régimen de los ingresos por obtención de premios', tipoPersona: 'fisica' }, { clave: '616', descripcion: 'Sin obligaciones fiscales', tipoPersona: 'ambos' }, { clave: '620', descripcion: 'Sociedades Cooperativas de Producción que optan por diferir sus ingresos', tipoPersona: 'moral' }, { clave: '621', descripcion: 'Incorporación Fiscal', tipoPersona: 'fisica' }, { clave: '622', descripcion: 'Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras', tipoPersona: 'ambos' }, { clave: '623', descripcion: 'Opcional para Grupos de Sociedades', tipoPersona: 'moral' }, { clave: '624', descripcion: 'Coordinados', tipoPersona: 'moral' }, { clave: '625', descripcion: 'Régimen de las Actividades Empresariales con ingresos a través de Plataformas Tecnológicas', tipoPersona: 'fisica' }, { clave: '626', descripcion: 'Régimen Simplificado de Confianza', tipoPersona: 'ambos' }, ]; async function main() { console.log('🌱 Seeding database...'); // Seed regimenes catalog for (const r of REGIMENES_SAT) { await prisma.regimen.upsert({ where: { clave: r.clave }, update: { descripcion: r.descripcion, tipoPersona: r.tipoPersona }, create: r, }); } console.log(`✅ ${REGIMENES_SAT.length} regímenes fiscales SAT cargados`); // Seed ISR tables — limpiar y recrear await prisma.isrResicoTasa.deleteMany(); await prisma.isrTarifa.deleteMany(); for (const anio of [2020, 2021, 2022, 2023, 2024, 2025, 2026]) { if (anio >= 2022) { await prisma.isrResicoTasa.createMany({ data: RESICO_TASAS.map(t => ({ anio, montoMaximo: t.montoMaximo, porcentaje: t.porcentaje })), }); } const tarifas = ISR_TARIFAS[anio]; if (tarifas) { await prisma.isrTarifa.createMany({ data: tarifas.map(t => ({ anio, limiteInferior: t.li, limiteSuperior: t.ls, cuotaFija: t.cf, porcentajeExcedente: t.pe, })), }); } } console.log('✅ Tablas ISR 2020-2026 cargadas'); // Seed eventos fiscales catálogo await prisma.eventoFiscalCatalogo.deleteMany(); await prisma.eventoFiscalCatalogo.createMany({ data: EVENTOS_FISCALES.map(e => ({ titulo: e.titulo, tipo: e.tipo, diaBase: e.diaBase, mesRelativo: e.mesRelativo, mesFijo: (e as any).mesFijo || null, recurrencia: e.recurrencia, usaExtensionRfc: e.usaExtensionRfc, regimenes: e.regimenes, condicion: e.condicion || null, })), }); console.log(`✅ ${EVENTOS_FISCALES.length} eventos fiscales cargados`); // Seed días inhábiles await prisma.diaInhabil.deleteMany(); await prisma.diaInhabil.createMany({ data: DIAS_INHABILES.map(d => ({ fecha: new Date(d.fecha), nombre: d.nombre })), skipDuplicates: true, }); console.log(`✅ ${DIAS_INHABILES.length} días inhábiles cargados (2020-2027)`); // Seed catálogos SAT para facturación for (const fp of FORMAS_PAGO) { await prisma.catFormaPago.upsert({ where: { clave: fp.clave }, update: { descripcion: fp.descripcion }, create: fp }); } console.log(`✅ ${FORMAS_PAGO.length} formas de pago cargadas`); for (const mp of METODOS_PAGO) { await prisma.catMetodoPago.upsert({ where: { clave: mp.clave }, update: { descripcion: mp.descripcion }, create: mp }); } console.log(`✅ ${METODOS_PAGO.length} métodos de pago cargados`); for (const u of USOS_CFDI) { await prisma.catUsoCfdi.upsert({ where: { clave: u.clave }, update: { descripcion: u.descripcion, personaFisica: u.personaFisica, personaMoral: u.personaMoral }, create: u }); } console.log(`✅ ${USOS_CFDI.length} usos CFDI cargados`); for (const m of MONEDAS) { await prisma.catMoneda.upsert({ where: { clave: m.clave }, update: { descripcion: m.descripcion, decimales: m.decimales }, create: m }); } console.log(`✅ ${MONEDAS.length} monedas cargadas`); for (const cu of CLAVES_UNIDAD) { await prisma.catClaveUnidad.upsert({ where: { clave: cu.clave }, update: { descripcion: cu.descripcion }, create: cu }); } console.log(`✅ ${CLAVES_UNIDAD.length} claves de unidad cargadas`); for (const oi of OBJETOS_IMP) { await prisma.catObjetoImp.upsert({ where: { clave: oi.clave }, update: { descripcion: oi.descripcion }, create: oi }); } console.log(`✅ ${OBJETOS_IMP.length} objetos de impuesto cargados`); for (const tr of TIPOS_RELACION) { await prisma.catTipoRelacion.upsert({ where: { clave: tr.clave }, update: { descripcion: tr.descripcion }, create: tr }); } console.log(`✅ ${TIPOS_RELACION.length} tipos de relación cargados`); for (const ex of EXPORTACIONES) { await prisma.catExportacion.upsert({ where: { clave: ex.clave }, update: { descripcion: ex.descripcion }, create: ex }); } console.log(`✅ ${EXPORTACIONES.length} exportaciones cargadas`); // Tabla `plan_prices` (modelo PlanPrice) era el catálogo Horux 360 legacy. // Tras eliminar los planes legacy (2026-04-30), no se siembran filas. Los // precios despacho viven en `despacho_plan_prices` (modelo DespachoPlanPrice). // Catálogo despacho — precios + limits. UPSERT idempotente: precios y limits // se actualizan al re-correr seed (decisión: el seed es source of truth para // valores iniciales; el admin puede sobreescribir vía UI y NO debe re-correr // seed si no quiere perder ajustes manuales). Si quieres preservar edits del // admin, cambiar `update` a `{}` y aplicar manualmente. const DESPACHO_PLAN_CATALOGO = [ { plan: 'trial', nombre: 'Prueba', monthly: null, firstYear: null, renewal: null, permiteMonthly: false, maxRfcs: 3, maxUsers: 1, timbresIncluidosMes: 0, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: false }, { plan: 'custom', nombre: 'Custom', monthly: null, firstYear: null, renewal: null, permiteMonthly: false, maxRfcs: 1, maxUsers: 3, timbresIncluidosMes: 50, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: false }, { plan: 'mi_empresa', nombre: 'Mi Empresa', monthly: 580, firstYear: 5800, renewal: 5800, permiteMonthly: true, maxRfcs: 1, maxUsers: 3, timbresIncluidosMes: 50, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: false }, { plan: 'mi_empresa_plus', nombre: 'Mi Empresa +', monthly: 900, firstYear: 9000, renewal: 9000, permiteMonthly: true, maxRfcs: 1, maxUsers: 3, timbresIncluidosMes: 50, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: true }, { plan: 'business_control', nombre: 'Business Control', monthly: null, firstYear: 25850, renewal: 25850, permiteMonthly: false, maxRfcs: 100, maxUsers: -1, timbresIncluidosMes: 0, dbMode: 'BYO' as const, permiteServidorBackup: true, permiteSatIncremental: true }, { plan: 'business_cloud', nombre: 'Enterprise', monthly: null, firstYear: 43000, renewal: 43000, permiteMonthly: false, maxRfcs: 100, maxUsers: -1, timbresIncluidosMes: 0, dbMode: 'BYO' as const, permiteServidorBackup: true, permiteSatIncremental: true }, ]; for (const p of DESPACHO_PLAN_CATALOGO) { await prisma.despachoPlanPrice.upsert({ where: { plan: p.plan }, update: { ...p }, create: { ...p }, }); } console.log(`✅ ${DESPACHO_PLAN_CATALOGO.length} planes despacho cargados (precios + limits)`); // Catálogo de paquetes de timbres adicionales. Editables desde panel admin. // Se crean con upsert por `cantidad` (unique) — permite reejecutar seed sin // sobrescribir precios ya ajustados manualmente: si el row existe, update // NO toca el precio (solo active + updatedAt si hace falta), sólo lo crea // si no existía. Si se quiere forzar reset de precios, borrar las filas. const TIMBRE_PAQUETES = [ { cantidad: 100, precio: 200 }, { cantidad: 1000, precio: 1400 }, { cantidad: 10000, precio: 8600 }, ]; for (const p of TIMBRE_PAQUETES) { await prisma.timbrePaqueteCatalogo.upsert({ where: { cantidad: p.cantidad }, update: {}, // No tocamos `precio` si ya existe (admin pudo editarlo) create: { cantidad: p.cantidad, precio: p.precio, active: true }, }); } console.log(`✅ ${TIMBRE_PAQUETES.length} paquetes de timbres en catálogo`); const databaseName = 'horux_ede123456ab1'; // Create demo tenant const tenant = await prisma.tenant.upsert({ where: { rfc: 'EDE123456AB1' }, update: {}, create: { nombre: 'Empresa Demo SA de CV', rfc: 'EDE123456AB1', plan: 'mi_empresa_plus', databaseName, }, }); console.log('✅ Tenant created:', tenant.nombre); // Migración: renombra el rol legacy 'admin' a 'owner' si sobrevive de un seed viejo. // Idempotente (no-op si ya se renombró o nunca existió). await prisma.$executeRawUnsafe(`UPDATE roles SET nombre = 'owner' WHERE nombre = 'admin'`); // Backfill de trial_usages para tenants que ya consumieron su trial antes de que // existiera esta tabla. Idempotente: ON CONFLICT DO NOTHING. Filtramos por // longitud porque trial_usages.rfc es varchar(13) y los tenants despacho usan // slugs largos (DESPACHO_xxx) que no encajan — el padrón anti-abuso de trial // solo aplica a RFCs SAT reales de personas/empresas, no a slugs. await prisma.$executeRawUnsafe(` INSERT INTO trial_usages (rfc, tenant_id, started_at) SELECT UPPER(rfc), id, COALESCE(created_at, NOW()) FROM tenants WHERE trial_ends_at IS NOT NULL AND LENGTH(rfc) <= 13 ON CONFLICT (rfc) DO NOTHING `); // Backfill de user_platform_roles: los owners del tenant HTS240708LJA se // convierten automáticamente en platform_admin. Migrado a tenant_memberships // tras F6 (User.tenantId/rolId eliminados). Idempotente. await prisma.$executeRawUnsafe(` INSERT INTO user_platform_roles (user_id, role, created_at) SELECT tm.user_id, 'platform_admin'::"PlatformRole", NOW() FROM tenant_memberships tm JOIN tenants t ON tm.tenant_id = t.id WHERE t.rfc = 'HTS240708LJA' AND tm.is_owner = true AND tm.active = true ON CONFLICT (user_id, role) DO NOTHING `); // (Backfill de tenant_memberships eliminado — F6 ya migró todos los users // legacy y los campos `User.tenantId` y `User.rolId` ya no existen. Los // users nuevos se crean directamente con su membership.) // Seed roles const rolesData = [ { nombre: 'owner', descripcion: 'Dueño - acceso completo' }, { nombre: 'cfo', descripcion: 'CFO - acceso completo (mismo nivel que el dueño)' }, { nombre: 'contador', descripcion: 'Contador - dashboard, CFDI, impuestos, calendario, alertas, facturación' }, { nombre: 'auxiliar', descripcion: 'Auxiliar - mismos permisos que contador' }, { nombre: 'visor', descripcion: 'Visor - solo lectura de CFDI, impuestos, calendario, alertas' }, ]; for (const r of rolesData) { await prisma.rol.upsert({ where: { nombre: r.nombre }, update: { descripcion: r.descripcion }, create: r, }); } // Seed despacho roles await prisma.rol.upsert({ where: { nombre: 'supervisor' }, update: {}, create: { id: 9, nombre: 'supervisor', descripcion: 'Supervisor de despacho — titular de RFCs, crea carteras' }, }); await prisma.rol.upsert({ where: { nombre: 'cliente' }, update: {}, create: { id: 10, nombre: 'cliente', descripcion: 'Cliente visor externo — acceso read-only a sus RFCs' }, }); const roles = await prisma.rol.findMany(); const rolMap = new Map(roles.map(r => [r.nombre, r.id])); console.log(`✅ ${roles.length} roles cargados`); // Create demo users const passwordHash = await bcrypt.hash('demo123', 12); const users = [ { email: 'admin@demo.com', nombre: 'Dueño Demo', rolNombre: 'owner' }, { email: 'contador@demo.com', nombre: 'Contador Demo', rolNombre: 'contador' }, { email: 'visor@demo.com', nombre: 'Visor Demo', rolNombre: 'visor' }, ]; for (const userData of users) { const rolId = rolMap.get(userData.rolNombre)!; const user = await prisma.user.upsert({ where: { email: userData.email }, update: {}, create: { email: userData.email, passwordHash, nombre: userData.nombre, lastTenantId: tenant.id, }, }); // Membership al tenant demo (idempotente — F6 multi-tenant: la autorización // vive en tenant_memberships, no en User.tenantId/rolId). await prisma.tenantMembership.upsert({ where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } }, update: { rolId, isOwner: userData.rolNombre === 'owner' || userData.rolNombre === 'cfo', active: true }, create: { userId: user.id, tenantId: tenant.id, rolId, isOwner: userData.rolNombre === 'owner' || userData.rolNombre === 'cfo', active: true, }, }); console.log(`✅ User created: ${user.email} (${userData.rolNombre})`); } // Create tenant database const dbConfig = parseDatabaseUrl(process.env.DATABASE_URL!); const adminPool = new Pool({ ...dbConfig, database: 'postgres', max: 1 }); try { const exists = await adminPool.query( `SELECT 1 FROM pg_database WHERE datname = $1`, [databaseName] ); if (exists.rows.length === 0) { await adminPool.query(`CREATE DATABASE "${databaseName}"`); console.log(`✅ Tenant database created: ${databaseName}`); } else { console.log(`ℹ️ Tenant database already exists: ${databaseName}`); } } finally { await adminPool.end(); } // Create tables in tenant database const tenantPool = new Pool({ ...dbConfig, database: databaseName, max: 1 }); try { // Reset tenant tables so the re-seed parte de cero. Luego corremos las // migraciones (fuente única de verdad del schema tenant) para garantizar // que queden todas las tablas y columnas actuales. await tenantPool.query(` DROP TABLE IF EXISTS cfdi_conceptos CASCADE; DROP TABLE IF EXISTS cfdis CASCADE; DROP TABLE IF EXISTS conciliaciones CASCADE; DROP TABLE IF EXISTS bancos CASCADE; DROP TABLE IF EXISTS recordatorios CASCADE; DROP TABLE IF EXISTS alertas CASCADE; DROP TABLE IF EXISTS rfcs CASCADE; DROP TABLE IF EXISTS opiniones_cumplimiento CASCADE; DROP TABLE IF EXISTS schema_migrations; `); await migrate(tenantPool, tenant.rfc); console.log('✅ Tenant schema aplicado vía migraciones'); // Bloque legacy de CREATE TABLE / CREATE INDEX retirado: vive ahora en // `apps/api/src/migrations/tenant/*.sql` (fuente única de verdad). // Insert demo CFDIs with new structure const cfdiTypes = ['EMITIDO', 'RECIBIDO']; const tipoComprobantes: Record = { EMITIDO: 'I', RECIBIDO: 'I' }; const rfcs = ['XAXX010101000', 'MEXX020202000', 'AAXX030303000', 'BBXX040404000']; const nombres = ['Cliente Demo SA', 'Proveedor ABC', 'Servicios XYZ', 'Materiales 123']; for (let i = 0; i < 50; i++) { const tipo = cfdiTypes[i % 2]; const rfcIndex = i % 4; const subtotal = Math.floor(Math.random() * 50000) + 1000; const iva = subtotal * 0.16; const total = subtotal + iva; const daysAgo = Math.floor(Math.random() * 180); const fecha = new Date(); fecha.setDate(fecha.getDate() - daysAgo); const year = String(fecha.getFullYear()); const month = String(fecha.getMonth() + 1).padStart(2, '0'); // Sin ON CONFLICT: las tablas se dropean en línea 342-352 antes de seed // y los UUIDs son crypto.randomUUID() (probabilidad de colisión ~0). // El UNIQUE en cfdis es funcional (LOWER(uuid)), no acepta ON CONFLICT // por columna plana — ver migración 027_cfdi_uuid_unique_case_insensitive. await tenantPool.query(` INSERT INTO cfdis ( year, month, type, uuid, serie, folio, status, fecha_emision, rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor, subtotal, subtotal_mxn, descuento, descuento_mxn, total, total_mxn, moneda, tipo_cambio, tipo_comprobante, metodo_pago, iva_traslado, iva_traslado_mxn, regimen_fiscal_emisor, regimen_fiscal_receptor ) 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) `, [ year, month, tipo, crypto.randomUUID(), 'A', String(1000 + i), 'Vigente', fecha, tipo === 'EMITIDO' ? 'EDE123456AB1' : rfcs[rfcIndex], tipo === 'EMITIDO' ? 'Empresa Demo SA de CV' : nombres[rfcIndex], tipo === 'RECIBIDO' ? 'EDE123456AB1' : rfcs[rfcIndex], tipo === 'RECIBIDO' ? 'Empresa Demo SA de CV' : nombres[rfcIndex], subtotal, subtotal, 0, 0, total, total, 'MXN', 1, tipoComprobantes[tipo], 'PUE', iva, iva, '601', '601', ]); } console.log('✅ Demo CFDIs created (50)'); // Insert demo conceptos for each CFDI const { rows: allCfdis } = await tenantPool.query(`SELECT id FROM cfdis`); const productos = ['Servicio de consultoría', 'Licencia de software', 'Soporte técnico', 'Desarrollo web', 'Capacitación']; for (const c of allCfdis) { const numConceptos = Math.floor(Math.random() * 3) + 1; for (let j = 0; j < numConceptos; j++) { const cantidad = Math.floor(Math.random() * 5) + 1; const valorUnitario = Math.floor(Math.random() * 5000) + 500; const importe = cantidad * valorUnitario; const iva = importe * 0.16; await tenantPool.query(` INSERT INTO cfdi_conceptos ( cfdi_id, clave_prod_serv, descripcion, cantidad, clave_unidad, unidad, valor_unitario, valor_unitario_mxn, importe, importe_mxn, descuento, descuento_mxn, iva_traslado, iva_traslado_mxn ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) `, [ c.id, '84111506', productos[j % productos.length], cantidad, 'E48', 'Servicio', valorUnitario, valorUnitario, importe, importe, 0, 0, iva, iva, ]); } } console.log('✅ Demo conceptos created'); } finally { await tenantPool.end(); } // (PlanCatalogo seed eliminado — el modelo se dropeó en migración // 20260430200000_drop_plan_catalogo_orphan; el catálogo despacho vive en // `despacho_plan_prices` y se siembra arriba en DESPACHO_PLAN_CATALOGO.) // Seed addon catalog const addonCatalogoData = [ { codename: 'rfcs_extra_10', nombre: '+10 RFCs adicionales', verticalProfile: 'CONTABLE' as const, precio: 190, frecuencia: 'mensual', delta: { maxRfcs: 10 }, }, { codename: 'rfcs_extra_50', nombre: '+50 RFCs adicionales', verticalProfile: 'CONTABLE' as const, precio: 690, frecuencia: 'mensual', delta: { maxRfcs: 50 }, }, { codename: 'timbres_extra_500', nombre: '+500 timbres mensuales', precio: 490, frecuencia: 'mensual', delta: { timbresIncluidosMes: 500 }, }, { codename: 'modulo_ia', nombre: 'Módulo IA Fiscal', precio: 390, frecuencia: 'mensual', delta: { features: ['ia_lolita'] }, }, { // Lolita IA activable por contribuyente específico del despacho. // SubscriptionAddon.contribuyenteId apunta al RFC que lo contrata. // Cobro mensual en preapproval propio (la licencia del despacho es anual; // el add-on va en ciclo independiente). codename: 'lolita_ia_contribuyente', nombre: 'Lolita IA (por contribuyente)', verticalProfile: 'CONTABLE' as const, precio: 250, frecuencia: 'mensual', delta: { features: ['ia_lolita'] }, }, { // Contribuyente adicional para planes Business Control y Enterprise // (ambos incluyen 100 base). Se cobra automáticamente según overage; no // requiere opt-in, pero se modela como add-on para que el preapproval MP // lo cubra. El codename mantiene el sufijo "business_cloud" por compat // con suscripciones existentes; el nombre display ya es genérico. codename: 'contribuyente_extra_business_cloud', nombre: 'Contribuyente adicional (RFC extra)', verticalProfile: 'CONTABLE' as const, precio: 45, frecuencia: 'mensual', delta: { maxRfcs: 1 }, }, ]; for (const a of addonCatalogoData) { await prisma.planAddonCatalogo.upsert({ where: { codename: a.codename }, update: { nombre: a.nombre, precio: a.precio, delta: a.delta }, create: { ...a, verticalProfile: a.verticalProfile ?? null }, }); } console.log('✓ Addon catalog seeded (6 addons)'); console.log('\n🎉 Seed completed successfully!'); console.log('\n📝 Demo credentials:'); console.log(' Admin: admin@demo.com / demo123'); console.log(' Contador: contador@demo.com / demo123'); console.log(' Visor: visor@demo.com / demo123'); } main() .catch((e) => { console.error('Error seeding database:', e); process.exit(1); }) .finally(async () => { await prisma.$disconnect(); });