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 {
id String @id @default(uuid())
nombre String
rfc String @unique
plan Plan @default(starter)
schemaName String @unique @map("schema_name")
cfdiLimit Int @map("cfdi_limit")
usersLimit Int @map("users_limit")
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime? @map("expires_at")
id String @id @default(uuid())
nombre String
rfc String @unique
plan Plan @default(starter)
databaseName String @unique @map("database_name")
cfdiLimit Int @default(100) @map("cfdi_limit")
usersLimit Int @default(1) @map("users_limit")
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime? @map("expires_at")
users User[]
fielCredential FielCredential?
satSyncJobs SatSyncJob[]
subscriptions Subscription[]
payments Payment[]
@@map("tenants")
}
@@ -76,8 +78,12 @@ model FielCredential {
cerData Bytes @map("cer_data")
keyData Bytes @map("key_data")
keyPasswordEncrypted Bytes @map("key_password_encrypted")
encryptionIv Bytes @map("encryption_iv")
encryptionTag Bytes @map("encryption_tag")
cerIv Bytes @map("cer_iv")
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)
validFrom DateTime @map("valid_from")
validUntil DateTime @map("valid_until")
@@ -90,6 +96,46 @@ model FielCredential {
@@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 {
id String @id @default(uuid())
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({
where: { id: tenantId },
select: { schemaName: true, active: true },
select: { databaseName: true, active: true },
});
if (!tenant || !tenant.active) {
return next(new AppError(403, 'Tenant no encontrado o inactivo'));
}
req.tenantSchema = tenant.schemaName;
req.tenantSchema = tenant.databaseName;
// Set search_path for this request
await prisma.$executeRawUnsafe(`SET search_path TO "${tenant.schemaName}", public`);
// Set search_path for this request (will be replaced by pool-based approach)
await prisma.$executeRawUnsafe(`SET search_path TO "${tenant.databaseName}", public`);
next();
} 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');
}
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({
data: {
nombre: data.empresa.nombre,
rfc: data.empresa.rfc.toUpperCase(),
plan: 'starter',
schemaName,
databaseName,
cfdiLimit: PLANS.starter.cfdiLimit,
usersLimit: PLANS.starter.usersLimit,
},
});
await createTenantSchema(schemaName);
await createTenantSchema(databaseName);
const passwordHash = await hashPassword(data.usuario.password);
const user = await prisma.user.create({
@@ -54,7 +54,7 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
email: user.email,
role: user.role,
tenantId: tenant.id,
schemaName: tenant.schemaName,
databaseName: tenant.databaseName,
};
const accessToken = generateAccessToken(tokenPayload);
@@ -117,7 +117,7 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
email: user.email,
role: user.role,
tenantId: user.tenantId,
schemaName: user.tenant.schemaName,
databaseName: user.tenant.databaseName,
};
const accessToken = generateAccessToken(tokenPayload);
@@ -181,7 +181,7 @@ export async function refreshTokens(token: string): Promise<{ accessToken: strin
email: user.email,
role: user.role,
tenantId: user.tenantId,
schemaName: user.tenant.schemaName,
databaseName: user.tenant.databaseName,
};
const accessToken = generateAccessToken(newTokenPayload);

View File

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

View File

@@ -6,10 +6,10 @@ const IV_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 {
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(
cerData: Buffer,
@@ -62,61 +62,51 @@ export function encryptFielCredentials(
encryptedCer: Buffer;
encryptedKey: Buffer;
encryptedPassword: Buffer;
iv: Buffer;
tag: Buffer;
cerIv: Buffer;
cerTag: Buffer;
keyIv: Buffer;
keyTag: Buffer;
passwordIv: Buffer;
passwordTag: Buffer;
} {
// Usamos el mismo IV y tag para simplificar, concatenando los datos
const combined = Buffer.concat([
Buffer.from(cerData.length.toString().padStart(10, '0')),
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;
const cer = encrypt(cerData);
const key = encrypt(keyData);
const pwd = encrypt(Buffer.from(password, 'utf-8'));
return {
encryptedCer: encrypted.subarray(0, 10 + cerLength),
encryptedKey: encrypted.subarray(10 + cerLength, 20 + cerLength + keyLength),
encryptedPassword: encrypted.subarray(20 + cerLength + keyLength),
iv,
tag,
encryptedCer: cer.encrypted,
encryptedKey: key.encrypted,
encryptedPassword: pwd.encrypted,
cerIv: cer.iv,
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(
encryptedCer: Buffer,
encryptedKey: Buffer,
encryptedPassword: Buffer,
iv: Buffer,
tag: Buffer
cerIv: Buffer,
cerTag: Buffer,
keyIv: Buffer,
keyTag: Buffer,
passwordIv: Buffer,
passwordTag: Buffer
): {
cerData: Buffer;
keyData: Buffer;
password: string;
} {
const combined = Buffer.concat([encryptedCer, encryptedKey, encryptedPassword]);
const decrypted = decrypt(combined, iv, tag);
// 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');
const cerData = decrypt(encryptedCer, cerIv, cerTag);
const keyData = decrypt(encryptedKey, keyIv, keyTag);
const password = decrypt(encryptedPassword, passwordIv, passwordTag).toString('utf-8');
return { cerData, keyData, password };
}

View File

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

View File

@@ -8,7 +8,7 @@ export async function getAllTenants() {
nombre: true,
rfc: true,
plan: true,
schemaName: true,
databaseName: true,
createdAt: true,
_count: {
select: { users: true }
@@ -26,7 +26,7 @@ export async function getTenantById(id: string) {
nombre: true,
rfc: true,
plan: true,
schemaName: true,
databaseName: true,
cfdiLimit: true,
usersLimit: true,
createdAt: true,
@@ -41,7 +41,7 @@ export async function createTenant(data: {
cfdiLimit?: 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
const tenant = await prisma.tenant.create({
@@ -49,18 +49,18 @@ export async function createTenant(data: {
nombre: data.nombre,
rfc: data.rfc.toUpperCase(),
plan: data.plan || 'starter',
schemaName,
databaseName,
cfdiLimit: data.cfdiLimit || 500,
usersLimit: data.usersLimit || 3,
}
});
// 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
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(),
uuid_fiscal VARCHAR(36) UNIQUE NOT NULL,
tipo VARCHAR(20) NOT NULL,
@@ -92,7 +92,7 @@ export async function createTenant(data: {
// Create IVA monthly table
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."iva_mensual" (
CREATE TABLE IF NOT EXISTS "${databaseName}"."iva_mensual" (
id SERIAL PRIMARY KEY,
año INT NOT NULL,
mes INT NOT NULL,
@@ -109,7 +109,7 @@ export async function createTenant(data: {
// Create alerts table
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."alertas" (
CREATE TABLE IF NOT EXISTS "${databaseName}"."alertas" (
id SERIAL PRIMARY KEY,
tipo VARCHAR(50) NOT NULL,
titulo VARCHAR(200) NOT NULL,
@@ -124,7 +124,7 @@ export async function createTenant(data: {
// Create calendario_fiscal table
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."calendario_fiscal" (
CREATE TABLE IF NOT EXISTS "${databaseName}"."calendario_fiscal" (
id SERIAL PRIMARY KEY,
titulo VARCHAR(200) NOT NULL,
descripcion TEXT,
@@ -163,7 +163,7 @@ export async function updateTenant(id: string, data: {
nombre: true,
rfc: true,
plan: true,
schemaName: true,
databaseName: true,
cfdiLimit: true,
usersLimit: true,
active: true,