Files
Horux360/apps/api/src/services/fiel.service.ts
Consultoria AS c351b5aeda feat: integrate email + subscription into tenant provisioning, FIEL notification
createTenant now: provisions DB, creates admin user with temp password,
creates initial subscription, and sends welcome email.
FIEL upload sends admin notification email.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:42:55 +00:00

292 lines
8.4 KiB
TypeScript

import { Credential } from '@nodecfdi/credentials/node';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { prisma } from '../config/database.js';
import { env } from '../config/env.js';
import { encryptFielCredentials, encrypt, decryptFielCredentials } from './sat/sat-crypto.service.js';
import { emailService } from './email/email.service.js';
import type { FielStatus } from '@horux/shared';
/**
* Sube y valida credenciales FIEL
*/
export async function uploadFiel(
tenantId: string,
cerBase64: string,
keyBase64: string,
password: string
): Promise<{ success: boolean; message: string; status?: FielStatus }> {
try {
// Decodificar archivos de Base64
const cerData = Buffer.from(cerBase64, 'base64');
const keyData = Buffer.from(keyBase64, 'base64');
// Validar que los archivos sean válidos y coincidan
let credential: Credential;
try {
credential = Credential.create(
cerData.toString('binary'),
keyData.toString('binary'),
password
);
} catch (error: any) {
return {
success: false,
message: 'Los archivos de la FIEL no son válidos o la contraseña es incorrecta',
};
}
// Verificar que sea una FIEL (no CSD)
if (!credential.isFiel()) {
return {
success: false,
message: 'El certificado proporcionado no es una FIEL (e.firma). Parece ser un CSD.',
};
}
// Obtener información del certificado
const certificate = credential.certificate();
const rfc = certificate.rfc();
const serialNumber = certificate.serialNumber().bytes();
// validFromDateTime() y validToDateTime() retornan strings ISO o objetos DateTime
const validFromRaw = certificate.validFromDateTime();
const validUntilRaw = certificate.validToDateTime();
const validFrom = new Date(String(validFromRaw));
const validUntil = new Date(String(validUntilRaw));
// Verificar que no esté vencida
if (new Date() > validUntil) {
return {
success: false,
message: 'La FIEL está vencida desde ' + validUntil.toLocaleDateString(),
};
}
// Encriptar credenciales (per-component IV/tag)
const {
encryptedCer,
encryptedKey,
encryptedPassword,
cerIv,
cerTag,
keyIv,
keyTag,
passwordIv,
passwordTag,
} = encryptFielCredentials(cerData, keyData, password);
// Guardar o actualizar en BD
await prisma.fielCredential.upsert({
where: { tenantId },
create: {
tenantId,
rfc,
cerData: encryptedCer,
keyData: encryptedKey,
keyPasswordEncrypted: encryptedPassword,
cerIv,
cerTag,
keyIv,
keyTag,
passwordIv,
passwordTag,
serialNumber,
validFrom,
validUntil,
isActive: true,
},
update: {
rfc,
cerData: encryptedCer,
keyData: encryptedKey,
keyPasswordEncrypted: encryptedPassword,
cerIv,
cerTag,
keyIv,
keyTag,
passwordIv,
passwordTag,
serialNumber,
validFrom,
validUntil,
isActive: true,
updatedAt: new Date(),
},
});
// Save encrypted files to filesystem (dual storage)
try {
const fielDir = join(env.FIEL_STORAGE_PATH, rfc.toUpperCase());
await mkdir(fielDir, { recursive: true, mode: 0o700 });
// Re-encrypt for filesystem (independent keys from DB)
const fsEncrypted = encryptFielCredentials(cerData, keyData, password);
await writeFile(join(fielDir, 'certificate.cer.enc'), fsEncrypted.encryptedCer, { mode: 0o600 });
await writeFile(join(fielDir, 'certificate.cer.iv'), fsEncrypted.cerIv, { mode: 0o600 });
await writeFile(join(fielDir, 'certificate.cer.tag'), fsEncrypted.cerTag, { mode: 0o600 });
await writeFile(join(fielDir, 'private_key.key.enc'), fsEncrypted.encryptedKey, { mode: 0o600 });
await writeFile(join(fielDir, 'private_key.key.iv'), fsEncrypted.keyIv, { mode: 0o600 });
await writeFile(join(fielDir, 'private_key.key.tag'), fsEncrypted.keyTag, { mode: 0o600 });
// Encrypt and store metadata
const metadata = JSON.stringify({
serial: serialNumber,
validFrom: validFrom.toISOString(),
validUntil: validUntil.toISOString(),
uploadedAt: new Date().toISOString(),
rfc: rfc.toUpperCase(),
});
const metaEncrypted = encrypt(Buffer.from(metadata, 'utf-8'));
await writeFile(join(fielDir, 'metadata.json.enc'), metaEncrypted.encrypted, { mode: 0o600 });
await writeFile(join(fielDir, 'metadata.json.iv'), metaEncrypted.iv, { mode: 0o600 });
await writeFile(join(fielDir, 'metadata.json.tag'), metaEncrypted.tag, { mode: 0o600 });
} catch (fsError) {
console.error('[FIEL] Filesystem storage failed (DB storage OK):', fsError);
}
// Notify admin that client uploaded FIEL
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { nombre: true, rfc: true },
});
if (tenant) {
emailService.sendFielNotification({
clienteNombre: tenant.nombre,
clienteRfc: tenant.rfc,
}).catch(err => console.error('[EMAIL] FIEL notification failed:', err));
}
const daysUntilExpiration = Math.ceil(
(validUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
);
return {
success: true,
message: 'FIEL configurada correctamente',
status: {
configured: true,
rfc,
serialNumber,
validFrom: validFrom.toISOString(),
validUntil: validUntil.toISOString(),
isExpired: false,
daysUntilExpiration,
},
};
} catch (error: any) {
console.error('[FIEL Upload Error]', error);
return {
success: false,
message: error.message || 'Error al procesar la FIEL',
};
}
}
/**
* Obtiene el estado de la FIEL de un tenant
*/
export async function getFielStatus(tenantId: string): Promise<FielStatus> {
const fiel = await prisma.fielCredential.findUnique({
where: { tenantId },
select: {
rfc: true,
serialNumber: true,
validFrom: true,
validUntil: true,
isActive: true,
},
});
if (!fiel || !fiel.isActive) {
return { configured: false };
}
const now = new Date();
const isExpired = now > fiel.validUntil;
const daysUntilExpiration = Math.ceil(
(fiel.validUntil.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
);
return {
configured: true,
rfc: fiel.rfc,
serialNumber: fiel.serialNumber || undefined,
validFrom: fiel.validFrom.toISOString(),
validUntil: fiel.validUntil.toISOString(),
isExpired,
daysUntilExpiration: isExpired ? 0 : daysUntilExpiration,
};
}
/**
* Elimina la FIEL de un tenant
*/
export async function deleteFiel(tenantId: string): Promise<boolean> {
try {
await prisma.fielCredential.delete({
where: { tenantId },
});
return true;
} catch {
return false;
}
}
/**
* Obtiene las credenciales desencriptadas para usar en sincronización
* Solo debe usarse internamente por el servicio de SAT
*/
export async function getDecryptedFiel(tenantId: string): Promise<{
cerContent: string;
keyContent: string;
password: string;
rfc: string;
} | null> {
const fiel = await prisma.fielCredential.findUnique({
where: { tenantId },
});
if (!fiel || !fiel.isActive) {
return null;
}
// Verificar que no esté vencida
if (new Date() > fiel.validUntil) {
return null;
}
try {
// 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.cerIv),
Buffer.from(fiel.cerTag),
Buffer.from(fiel.keyIv),
Buffer.from(fiel.keyTag),
Buffer.from(fiel.passwordIv),
Buffer.from(fiel.passwordTag)
);
return {
cerContent: cerData.toString('binary'),
keyContent: keyData.toString('binary'),
password,
rfc: fiel.rfc,
};
} catch (error) {
console.error('[FIEL Decrypt Error]', error);
return null;
}
}
/**
* Verifica si un tenant tiene FIEL configurada y válida
*/
export async function hasFielConfigured(tenantId: string): Promise<boolean> {
const status = await getFielStatus(tenantId);
return status.configured && !status.isExpired;
}