Initial commit - Horux Despachos NL
This commit is contained in:
313
apps/api/src/services/fiel.service.ts
Normal file
313
apps/api/src/services/fiel.service.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
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);
|
||||
|
||||
// Detectar si es la primera subida (no existe fielCredential previo activo)
|
||||
// — se usa abajo para disparar Opinión de Cumplimiento + CSF iniciales.
|
||||
const existingFiel = await prisma.fielCredential.findUnique({
|
||||
where: { tenantId },
|
||||
select: { isActive: true },
|
||||
});
|
||||
const esPrimeraSubida = !existingFiel || !existingFiel.isActive;
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
// Al primer upload de FIEL, disparar Opinión de Cumplimiento + CSF en
|
||||
// background. Fire-and-forget — no bloqueamos la respuesta porque ambos
|
||||
// procesos abren Playwright y tardan minutos. La CSF además autocompleta
|
||||
// domicilio y regímenes activos del tenant.
|
||||
if (esPrimeraSubida) {
|
||||
import('./opinion-cumplimiento.service.js').then(({ consultarOpinion }) =>
|
||||
consultarOpinion(tenantId),
|
||||
).catch(err => console.error(`[FIEL first-upload] Opinión falló para tenant ${tenantId}:`, err.message || err));
|
||||
|
||||
import('./constancia.service.js').then(({ consultarConstancia }) =>
|
||||
consultarConstancia(tenantId),
|
||||
).catch(err => console.error(`[FIEL first-upload] CSF falló para tenant ${tenantId}:`, err.message || 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;
|
||||
}
|
||||
Reference in New Issue
Block a user