Update: nueva version Horux Despachos

This commit is contained in:
consultoria-as
2026-04-27 22:09:36 -06:00
commit 6b36db1403
614 changed files with 125926 additions and 0 deletions

559
apps/api/prisma/seed.ts Normal file
View File

@@ -0,0 +1,559 @@
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`);
// Seed precios de planes (editables vía BD — custom no se incluye, se fija por tenant)
const PLAN_PRICES = [
{ plan: 'starter' as const, frequency: 'monthly', amount: 199 },
{ plan: 'starter' as const, frequency: 'annual', amount: 1990 },
{ plan: 'business' as const, frequency: 'monthly', amount: 480 },
{ plan: 'business' as const, frequency: 'annual', amount: 4800 },
{ plan: 'business_ia' as const, frequency: 'monthly', amount: 780 },
{ plan: 'business_ia' as const, frequency: 'annual', amount: 7800 },
{ plan: 'enterprise' as const, frequency: 'monthly', amount: 900 },
{ plan: 'enterprise' as const, frequency: 'annual', amount: 9000 },
];
for (const p of PLAN_PRICES) {
await prisma.planPrice.upsert({
where: { plan_frequency: { plan: p.plan, frequency: p.frequency } },
update: { amount: p.amount },
create: p,
});
}
console.log(`${PLAN_PRICES.length} precios de planes cargados`);
// 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: 'business',
databaseName,
cfdiLimit: 500,
usersLimit: 3,
},
});
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.
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
ON CONFLICT (rfc) DO NOTHING
`);
// Backfill de user_platform_roles: los owners del tenant HTS240708LJA se
// convierten automáticamente en platform_admin. Esto preserva el comportamiento
// anterior (admin global por RFC) al mismo tiempo que abre la puerta al modelo
// de roles granulares. Idempotente.
await prisma.$executeRawUnsafe(`
INSERT INTO user_platform_roles (user_id, role, created_at)
SELECT u.id, 'platform_admin'::"PlatformRole", NOW()
FROM users u
JOIN tenants t ON u.tenant_id = t.id
JOIN roles r ON u.rol_id = r.id
WHERE t.rfc = 'HTS240708LJA' AND r.nombre = 'owner'
ON CONFLICT (user_id, role) DO NOTHING
`);
// Backfill de tenant_memberships: cada user existente genera una membership
// con su tenant y rol actuales. isOwner = true si su rol es 'owner' (u 'cfo',
// que es equivalente en permisos). Esto es el fundamento del modelo multi-tenant
// — durante la transición, User.tenantId sigue siendo el "default tenant" para
// login UX, pero las autorizaciones verdaderas vienen de esta tabla.
// Idempotente: ON CONFLICT evita duplicados al re-correr seed.
await prisma.$executeRawUnsafe(`
INSERT INTO tenant_memberships (user_id, tenant_id, rol_id, is_owner, active, joined_at)
SELECT u.id, u.tenant_id, u.rol_id, (r.nombre IN ('owner', 'cfo')), u.active, u.created_at
FROM users u
JOIN roles r ON u.rol_id = r.id
ON CONFLICT (user_id, tenant_id) DO NOTHING
`);
// 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 user = await prisma.user.upsert({
where: { email: userData.email },
update: {},
create: {
tenantId: tenant.id,
email: userData.email,
passwordHash,
nombre: userData.nombre,
rolId: rolMap.get(userData.rolNombre)!,
},
include: { rol: true },
});
console.log(`✅ User created: ${user.email} (${user.rol.nombre})`);
}
// 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<string, string> = { 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');
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)
ON CONFLICT (uuid) DO NOTHING
`, [
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();
}
// Seed plan catalog for CONTABLE vertical
const planCatalogoData = [
{
codename: 'trial_contable',
nombre: 'Trial Contable',
verticalProfile: 'CONTABLE' as const,
precioBase: 0,
frecuencia: 'mensual',
limits: { maxRfcs: 3, maxUsers: 1, timbresIncluidosMes: 20, features: ['dashboard', 'cfdi_basic', 'iva_isr'] },
},
{
codename: 'starter_contable',
nombre: 'Starter Contable',
verticalProfile: 'CONTABLE' as const,
precioBase: 490,
frecuencia: 'mensual',
limits: { maxRfcs: 10, maxUsers: 3, timbresIncluidosMes: 50, features: ['dashboard', 'cfdi_basic', 'iva_isr', 'alertas', 'calendario'] },
},
{
codename: 'business_contable',
nombre: 'Business Contable',
verticalProfile: 'CONTABLE' as const,
precioBase: 1290,
frecuencia: 'mensual',
limits: { maxRfcs: 50, maxUsers: 10, timbresIncluidosMes: 200, features: ['dashboard', 'cfdi_basic', 'iva_isr', 'alertas', 'calendario', 'reportes', 'conciliacion', 'documentos', 'facturacion'] },
},
{
codename: 'enterprise_contable',
nombre: 'Enterprise Contable',
verticalProfile: 'CONTABLE' as const,
precioBase: 2990,
frecuencia: 'mensual',
limits: { maxRfcs: -1, maxUsers: -1, timbresIncluidosMes: 600, features: ['dashboard', 'cfdi_basic', 'iva_isr', 'alertas', 'calendario', 'reportes', 'conciliacion', 'documentos', 'facturacion', 'api'] },
},
];
for (const p of planCatalogoData) {
await prisma.planCatalogo.upsert({
where: { codename: p.codename },
update: { nombre: p.nombre, precioBase: p.precioBase, limits: p.limits },
create: p,
});
}
console.log('✓ Plan catalog seeded (4 plans CONTABLE)');
// 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();
});