feat(saas): update schema for db-per-tenant and per-component FIEL encryption

- Rename Tenant.schemaName to databaseName across all services
- Add Subscription and Payment models to Prisma schema
- Update FielCredential to per-component IV/tag encryption columns
- Switch FIEL encryption key from JWT_SECRET to FIEL_ENCRYPTION_KEY
- Add Subscription and Payment shared types
- Update JWTPayload to use databaseName

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Consultoria AS
2026-03-15 23:15:55 +00:00
parent 0d17fe3494
commit f96a9c55c5
10 changed files with 175 additions and 97 deletions

View File

@@ -8,20 +8,22 @@ datasource db {
} }
model Tenant { model Tenant {
id String @id @default(uuid()) id String @id @default(uuid())
nombre String nombre String
rfc String @unique rfc String @unique
plan Plan @default(starter) plan Plan @default(starter)
schemaName String @unique @map("schema_name") databaseName String @unique @map("database_name")
cfdiLimit Int @map("cfdi_limit") cfdiLimit Int @default(100) @map("cfdi_limit")
usersLimit Int @map("users_limit") usersLimit Int @default(1) @map("users_limit")
active Boolean @default(true) active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime? @map("expires_at") expiresAt DateTime? @map("expires_at")
users User[] users User[]
fielCredential FielCredential? fielCredential FielCredential?
satSyncJobs SatSyncJob[] satSyncJobs SatSyncJob[]
subscriptions Subscription[]
payments Payment[]
@@map("tenants") @@map("tenants")
} }
@@ -76,8 +78,12 @@ model FielCredential {
cerData Bytes @map("cer_data") cerData Bytes @map("cer_data")
keyData Bytes @map("key_data") keyData Bytes @map("key_data")
keyPasswordEncrypted Bytes @map("key_password_encrypted") keyPasswordEncrypted Bytes @map("key_password_encrypted")
encryptionIv Bytes @map("encryption_iv") cerIv Bytes @map("cer_iv")
encryptionTag Bytes @map("encryption_tag") cerTag Bytes @map("cer_tag")
keyIv Bytes @map("key_iv")
keyTag Bytes @map("key_tag")
passwordIv Bytes @map("password_iv")
passwordTag Bytes @map("password_tag")
serialNumber String? @map("serial_number") @db.VarChar(50) serialNumber String? @map("serial_number") @db.VarChar(50)
validFrom DateTime @map("valid_from") validFrom DateTime @map("valid_from")
validUntil DateTime @map("valid_until") validUntil DateTime @map("valid_until")
@@ -90,6 +96,46 @@ model FielCredential {
@@map("fiel_credentials") @@map("fiel_credentials")
} }
model Subscription {
id String @id @default(uuid())
tenantId String @map("tenant_id")
plan Plan
mpPreapprovalId String? @map("mp_preapproval_id")
status String @default("pending")
amount Decimal @db.Decimal(10, 2)
frequency String @default("monthly")
currentPeriodStart DateTime? @map("current_period_start")
currentPeriodEnd DateTime? @map("current_period_end")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
payments Payment[]
@@index([tenantId])
@@index([status])
@@map("subscriptions")
}
model Payment {
id String @id @default(uuid())
tenantId String @map("tenant_id")
subscriptionId String? @map("subscription_id")
mpPaymentId String? @map("mp_payment_id")
amount Decimal @db.Decimal(10, 2)
status String @default("pending")
paymentMethod String? @map("payment_method")
paidAt DateTime? @map("paid_at")
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
@@index([tenantId])
@@index([subscriptionId])
@@map("payments")
}
model SatSyncJob { model SatSyncJob {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String @map("tenant_id") tenantId String @map("tenant_id")

View File

@@ -29,17 +29,17 @@ export async function tenantMiddleware(req: Request, res: Response, next: NextFu
const tenant = await prisma.tenant.findUnique({ const tenant = await prisma.tenant.findUnique({
where: { id: tenantId }, where: { id: tenantId },
select: { schemaName: true, active: true }, select: { databaseName: true, active: true },
}); });
if (!tenant || !tenant.active) { if (!tenant || !tenant.active) {
return next(new AppError(403, 'Tenant no encontrado o inactivo')); return next(new AppError(403, 'Tenant no encontrado o inactivo'));
} }
req.tenantSchema = tenant.schemaName; req.tenantSchema = tenant.databaseName;
// Set search_path for this request // Set search_path for this request (will be replaced by pool-based approach)
await prisma.$executeRawUnsafe(`SET search_path TO "${tenant.schemaName}", public`); await prisma.$executeRawUnsafe(`SET search_path TO "${tenant.databaseName}", public`);
next(); next();
} catch (error) { } catch (error) {

View File

@@ -23,20 +23,20 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
throw new AppError(400, 'El RFC ya está registrado'); throw new AppError(400, 'El RFC ya está registrado');
} }
const schemaName = `tenant_${data.empresa.rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`; const databaseName = `horux_${data.empresa.rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
const tenant = await prisma.tenant.create({ const tenant = await prisma.tenant.create({
data: { data: {
nombre: data.empresa.nombre, nombre: data.empresa.nombre,
rfc: data.empresa.rfc.toUpperCase(), rfc: data.empresa.rfc.toUpperCase(),
plan: 'starter', plan: 'starter',
schemaName, databaseName,
cfdiLimit: PLANS.starter.cfdiLimit, cfdiLimit: PLANS.starter.cfdiLimit,
usersLimit: PLANS.starter.usersLimit, usersLimit: PLANS.starter.usersLimit,
}, },
}); });
await createTenantSchema(schemaName); await createTenantSchema(databaseName);
const passwordHash = await hashPassword(data.usuario.password); const passwordHash = await hashPassword(data.usuario.password);
const user = await prisma.user.create({ const user = await prisma.user.create({
@@ -54,7 +54,7 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
email: user.email, email: user.email,
role: user.role, role: user.role,
tenantId: tenant.id, tenantId: tenant.id,
schemaName: tenant.schemaName, databaseName: tenant.databaseName,
}; };
const accessToken = generateAccessToken(tokenPayload); const accessToken = generateAccessToken(tokenPayload);
@@ -117,7 +117,7 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
email: user.email, email: user.email,
role: user.role, role: user.role,
tenantId: user.tenantId, tenantId: user.tenantId,
schemaName: user.tenant.schemaName, databaseName: user.tenant.databaseName,
}; };
const accessToken = generateAccessToken(tokenPayload); const accessToken = generateAccessToken(tokenPayload);
@@ -181,7 +181,7 @@ export async function refreshTokens(token: string): Promise<{ accessToken: strin
email: user.email, email: user.email,
role: user.role, role: user.role,
tenantId: user.tenantId, tenantId: user.tenantId,
schemaName: user.tenant.schemaName, databaseName: user.tenant.databaseName,
}; };
const accessToken = generateAccessToken(newTokenPayload); const accessToken = generateAccessToken(newTokenPayload);

View File

@@ -58,13 +58,17 @@ export async function uploadFiel(
}; };
} }
// Encriptar credenciales (todas juntas con el mismo IV/tag) // Encriptar credenciales (per-component IV/tag)
const { const {
encryptedCer, encryptedCer,
encryptedKey, encryptedKey,
encryptedPassword, encryptedPassword,
iv, cerIv,
tag, cerTag,
keyIv,
keyTag,
passwordIv,
passwordTag,
} = encryptFielCredentials(cerData, keyData, password); } = encryptFielCredentials(cerData, keyData, password);
// Guardar o actualizar en BD // Guardar o actualizar en BD
@@ -76,8 +80,12 @@ export async function uploadFiel(
cerData: encryptedCer, cerData: encryptedCer,
keyData: encryptedKey, keyData: encryptedKey,
keyPasswordEncrypted: encryptedPassword, keyPasswordEncrypted: encryptedPassword,
encryptionIv: iv, cerIv,
encryptionTag: tag, cerTag,
keyIv,
keyTag,
passwordIv,
passwordTag,
serialNumber, serialNumber,
validFrom, validFrom,
validUntil, validUntil,
@@ -88,8 +96,12 @@ export async function uploadFiel(
cerData: encryptedCer, cerData: encryptedCer,
keyData: encryptedKey, keyData: encryptedKey,
keyPasswordEncrypted: encryptedPassword, keyPasswordEncrypted: encryptedPassword,
encryptionIv: iv, cerIv,
encryptionTag: tag, cerTag,
keyIv,
keyTag,
passwordIv,
passwordTag,
serialNumber, serialNumber,
validFrom, validFrom,
validUntil, validUntil,
@@ -198,13 +210,17 @@ export async function getDecryptedFiel(tenantId: string): Promise<{
} }
try { try {
// Desencriptar todas las credenciales juntas // Desencriptar credenciales (per-component IV/tag)
const { cerData, keyData, password } = decryptFielCredentials( const { cerData, keyData, password } = decryptFielCredentials(
Buffer.from(fiel.cerData), Buffer.from(fiel.cerData),
Buffer.from(fiel.keyData), Buffer.from(fiel.keyData),
Buffer.from(fiel.keyPasswordEncrypted), Buffer.from(fiel.keyPasswordEncrypted),
Buffer.from(fiel.encryptionIv), Buffer.from(fiel.cerIv),
Buffer.from(fiel.encryptionTag) Buffer.from(fiel.cerTag),
Buffer.from(fiel.keyIv),
Buffer.from(fiel.keyTag),
Buffer.from(fiel.passwordIv),
Buffer.from(fiel.passwordTag)
); );
return { return {

View File

@@ -6,10 +6,10 @@ const IV_LENGTH = 16;
const TAG_LENGTH = 16; const TAG_LENGTH = 16;
/** /**
* Deriva una clave de 256 bits del JWT_SECRET * Deriva una clave de 256 bits del FIEL_ENCRYPTION_KEY
*/ */
function deriveKey(): Buffer { function deriveKey(): Buffer {
return createHash('sha256').update(env.JWT_SECRET).digest(); return createHash('sha256').update(env.FIEL_ENCRYPTION_KEY).digest();
} }
/** /**
@@ -52,7 +52,7 @@ export function decryptToString(encrypted: Buffer, iv: Buffer, tag: Buffer): str
} }
/** /**
* Encripta credenciales FIEL (cer, key, password) * Encripta credenciales FIEL con IV/tag independiente por componente
*/ */
export function encryptFielCredentials( export function encryptFielCredentials(
cerData: Buffer, cerData: Buffer,
@@ -62,61 +62,51 @@ export function encryptFielCredentials(
encryptedCer: Buffer; encryptedCer: Buffer;
encryptedKey: Buffer; encryptedKey: Buffer;
encryptedPassword: Buffer; encryptedPassword: Buffer;
iv: Buffer; cerIv: Buffer;
tag: Buffer; cerTag: Buffer;
keyIv: Buffer;
keyTag: Buffer;
passwordIv: Buffer;
passwordTag: Buffer;
} { } {
// Usamos el mismo IV y tag para simplificar, concatenando los datos const cer = encrypt(cerData);
const combined = Buffer.concat([ const key = encrypt(keyData);
Buffer.from(cerData.length.toString().padStart(10, '0')), const pwd = encrypt(Buffer.from(password, 'utf-8'));
cerData,
Buffer.from(keyData.length.toString().padStart(10, '0')),
keyData,
Buffer.from(password, 'utf-8'),
]);
const { encrypted, iv, tag } = encrypt(combined);
// Extraemos las partes encriptadas
const cerLength = cerData.length;
const keyLength = keyData.length;
const passwordLength = Buffer.from(password, 'utf-8').length;
return { return {
encryptedCer: encrypted.subarray(0, 10 + cerLength), encryptedCer: cer.encrypted,
encryptedKey: encrypted.subarray(10 + cerLength, 20 + cerLength + keyLength), encryptedKey: key.encrypted,
encryptedPassword: encrypted.subarray(20 + cerLength + keyLength), encryptedPassword: pwd.encrypted,
iv, cerIv: cer.iv,
tag, cerTag: cer.tag,
keyIv: key.iv,
keyTag: key.tag,
passwordIv: pwd.iv,
passwordTag: pwd.tag,
}; };
} }
/** /**
* Desencripta credenciales FIEL * Desencripta credenciales FIEL (per-component IV/tag)
*/ */
export function decryptFielCredentials( export function decryptFielCredentials(
encryptedCer: Buffer, encryptedCer: Buffer,
encryptedKey: Buffer, encryptedKey: Buffer,
encryptedPassword: Buffer, encryptedPassword: Buffer,
iv: Buffer, cerIv: Buffer,
tag: Buffer cerTag: Buffer,
keyIv: Buffer,
keyTag: Buffer,
passwordIv: Buffer,
passwordTag: Buffer
): { ): {
cerData: Buffer; cerData: Buffer;
keyData: Buffer; keyData: Buffer;
password: string; password: string;
} { } {
const combined = Buffer.concat([encryptedCer, encryptedKey, encryptedPassword]); const cerData = decrypt(encryptedCer, cerIv, cerTag);
const decrypted = decrypt(combined, iv, tag); const keyData = decrypt(encryptedKey, keyIv, keyTag);
const password = decrypt(encryptedPassword, passwordIv, passwordTag).toString('utf-8');
// Parseamos las partes
const cerLengthStr = decrypted.subarray(0, 10).toString();
const cerLength = parseInt(cerLengthStr, 10);
const cerData = decrypted.subarray(10, 10 + cerLength);
const keyLengthStr = decrypted.subarray(10 + cerLength, 20 + cerLength).toString();
const keyLength = parseInt(keyLengthStr, 10);
const keyData = decrypted.subarray(20 + cerLength, 20 + cerLength + keyLength);
const password = decrypted.subarray(20 + cerLength + keyLength).toString('utf-8');
return { cerData, keyData, password }; return { cerData, keyData, password };
} }

View File

@@ -20,7 +20,7 @@ interface SyncContext {
service: Service; service: Service;
rfc: string; rfc: string;
tenantId: string; tenantId: string;
schemaName: string; databaseName: string;
} }
/** /**
@@ -54,7 +54,7 @@ async function updateJobProgress(
* Guarda los CFDIs en la base de datos del tenant * Guarda los CFDIs en la base de datos del tenant
*/ */
async function saveCfdis( async function saveCfdis(
schemaName: string, databaseName: string,
cfdis: CfdiParsed[], cfdis: CfdiParsed[],
jobId: string jobId: string
): Promise<{ inserted: number; updated: number }> { ): Promise<{ inserted: number; updated: number }> {
@@ -65,14 +65,14 @@ async function saveCfdis(
try { try {
// Usar raw query para el esquema del tenant // Usar raw query para el esquema del tenant
const existing = await prisma.$queryRawUnsafe<{ id: string }[]>( const existing = await prisma.$queryRawUnsafe<{ id: string }[]>(
`SELECT id FROM "${schemaName}".cfdis WHERE uuid_fiscal = $1`, `SELECT id FROM "${databaseName}".cfdis WHERE uuid_fiscal = $1`,
cfdi.uuidFiscal cfdi.uuidFiscal
); );
if (existing.length > 0) { if (existing.length > 0) {
// Actualizar CFDI existente // Actualizar CFDI existente
await prisma.$executeRawUnsafe( await prisma.$executeRawUnsafe(
`UPDATE "${schemaName}".cfdis SET `UPDATE "${databaseName}".cfdis SET
tipo = $2, tipo = $2,
serie = $3, serie = $3,
folio = $4, folio = $4,
@@ -128,7 +128,7 @@ async function saveCfdis(
} else { } else {
// Insertar nuevo CFDI // Insertar nuevo CFDI
await prisma.$executeRawUnsafe( await prisma.$executeRawUnsafe(
`INSERT INTO "${schemaName}".cfdis ( `INSERT INTO "${databaseName}".cfdis (
id, uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado, id, uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor, rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
subtotal, descuento, iva, isr_retenido, iva_retenido, total, subtotal, descuento, iva, isr_retenido, iva_retenido, total,
@@ -255,7 +255,7 @@ async function processDateRange(
console.log(`[SAT] Procesando ${cfdis.length} CFDIs del paquete`); console.log(`[SAT] Procesando ${cfdis.length} CFDIs del paquete`);
const { inserted, updated } = await saveCfdis(ctx.schemaName, cfdis, jobId); const { inserted, updated } = await saveCfdis(ctx.databaseName, cfdis, jobId);
totalInserted += inserted; totalInserted += inserted;
totalUpdated += updated; totalUpdated += updated;
@@ -409,7 +409,7 @@ export async function startSync(
// Obtener datos del tenant // Obtener datos del tenant
const tenant = await prisma.tenant.findUnique({ const tenant = await prisma.tenant.findUnique({
where: { id: tenantId }, where: { id: tenantId },
select: { schemaName: true }, select: { databaseName: true },
}); });
if (!tenant) { if (!tenant) {
@@ -446,7 +446,7 @@ export async function startSync(
service, service,
rfc: decryptedFiel.rfc, rfc: decryptedFiel.rfc,
tenantId, tenantId,
schemaName: tenant.schemaName, databaseName: tenant.databaseName,
}; };
// Ejecutar sincronización en background // Ejecutar sincronización en background

View File

@@ -8,7 +8,7 @@ export async function getAllTenants() {
nombre: true, nombre: true,
rfc: true, rfc: true,
plan: true, plan: true,
schemaName: true, databaseName: true,
createdAt: true, createdAt: true,
_count: { _count: {
select: { users: true } select: { users: true }
@@ -26,7 +26,7 @@ export async function getTenantById(id: string) {
nombre: true, nombre: true,
rfc: true, rfc: true,
plan: true, plan: true,
schemaName: true, databaseName: true,
cfdiLimit: true, cfdiLimit: true,
usersLimit: true, usersLimit: true,
createdAt: true, createdAt: true,
@@ -41,7 +41,7 @@ export async function createTenant(data: {
cfdiLimit?: number; cfdiLimit?: number;
usersLimit?: number; usersLimit?: number;
}) { }) {
const schemaName = `tenant_${data.rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`; const databaseName = `horux_${data.rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
// Create tenant record // Create tenant record
const tenant = await prisma.tenant.create({ const tenant = await prisma.tenant.create({
@@ -49,18 +49,18 @@ export async function createTenant(data: {
nombre: data.nombre, nombre: data.nombre,
rfc: data.rfc.toUpperCase(), rfc: data.rfc.toUpperCase(),
plan: data.plan || 'starter', plan: data.plan || 'starter',
schemaName, databaseName,
cfdiLimit: data.cfdiLimit || 500, cfdiLimit: data.cfdiLimit || 500,
usersLimit: data.usersLimit || 3, usersLimit: data.usersLimit || 3,
} }
}); });
// Create schema and tables for the tenant // Create schema and tables for the tenant
await prisma.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`); await prisma.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${databaseName}"`);
// Create CFDIs table // Create CFDIs table
await prisma.$executeRawUnsafe(` await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."cfdis" ( CREATE TABLE IF NOT EXISTS "${databaseName}"."cfdis" (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
uuid_fiscal VARCHAR(36) UNIQUE NOT NULL, uuid_fiscal VARCHAR(36) UNIQUE NOT NULL,
tipo VARCHAR(20) NOT NULL, tipo VARCHAR(20) NOT NULL,
@@ -92,7 +92,7 @@ export async function createTenant(data: {
// Create IVA monthly table // Create IVA monthly table
await prisma.$executeRawUnsafe(` await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."iva_mensual" ( CREATE TABLE IF NOT EXISTS "${databaseName}"."iva_mensual" (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
año INT NOT NULL, año INT NOT NULL,
mes INT NOT NULL, mes INT NOT NULL,
@@ -109,7 +109,7 @@ export async function createTenant(data: {
// Create alerts table // Create alerts table
await prisma.$executeRawUnsafe(` await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."alertas" ( CREATE TABLE IF NOT EXISTS "${databaseName}"."alertas" (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
tipo VARCHAR(50) NOT NULL, tipo VARCHAR(50) NOT NULL,
titulo VARCHAR(200) NOT NULL, titulo VARCHAR(200) NOT NULL,
@@ -124,7 +124,7 @@ export async function createTenant(data: {
// Create calendario_fiscal table // Create calendario_fiscal table
await prisma.$executeRawUnsafe(` await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."calendario_fiscal" ( CREATE TABLE IF NOT EXISTS "${databaseName}"."calendario_fiscal" (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
titulo VARCHAR(200) NOT NULL, titulo VARCHAR(200) NOT NULL,
descripcion TEXT, descripcion TEXT,
@@ -163,7 +163,7 @@ export async function updateTenant(id: string, data: {
nombre: true, nombre: true,
rfc: true, rfc: true,
plan: true, plan: true,
schemaName: true, databaseName: true,
cfdiLimit: true, cfdiLimit: true,
usersLimit: true, usersLimit: true,
active: true, active: true,

View File

@@ -5,7 +5,7 @@ export interface Tenant {
nombre: string; nombre: string;
rfc: string; rfc: string;
plan: string; plan: string;
schemaName: string; databaseName: string;
createdAt: string; createdAt: string;
_count?: { _count?: {
users: number; users: number;

View File

@@ -36,7 +36,7 @@ export interface JWTPayload {
email: string; email: string;
role: Role; role: Role;
tenantId: string; tenantId: string;
schemaName: string; databaseName: string;
iat?: number; iat?: number;
exp?: number; exp?: number;
} }

View File

@@ -5,7 +5,7 @@ export interface Tenant {
nombre: string; nombre: string;
rfc: string; rfc: string;
plan: Plan; plan: Plan;
schemaName: string; databaseName: string;
cfdiLimit: number; cfdiLimit: number;
usersLimit: number; usersLimit: number;
active: boolean; active: boolean;
@@ -20,3 +20,29 @@ export interface TenantUsage {
usersLimit: number; usersLimit: number;
plan: Plan; plan: Plan;
} }
export interface Subscription {
id: string;
tenantId: string;
plan: Plan;
mpPreapprovalId?: string;
status: 'pending' | 'authorized' | 'paused' | 'cancelled';
amount: number;
frequency: 'monthly' | 'yearly';
currentPeriodStart?: string;
currentPeriodEnd?: string;
createdAt: string;
updatedAt: string;
}
export interface Payment {
id: string;
tenantId: string;
subscriptionId?: string;
mpPaymentId?: string;
amount: number;
status: 'approved' | 'pending' | 'rejected' | 'refunded';
paymentMethod?: string;
paidAt?: string;
createdAt: string;
}