Files
HoruxDespachosNuevo/apps/api/src/services/contribuyente-fiel.service.ts

203 lines
8.6 KiB
TypeScript

import { Credential } from '@nodecfdi/credentials/node';
import type { Pool } from 'pg';
import { encryptFielCredentials, decryptFielCredentials } from './sat/sat-crypto.service.js';
import type { FielStatus } from '@horux/shared';
export async function uploadFielContribuyente(
pool: Pool,
contribuyenteId: string,
cerBase64: string,
keyBase64: string,
password: string
): Promise<{ success: boolean; message: string; status?: FielStatus }> {
try {
const cerData = Buffer.from(cerBase64, 'base64');
const keyData = Buffer.from(keyBase64, 'base64');
let credential: Credential;
try {
credential = Credential.create(cerData.toString('binary'), keyData.toString('binary'), password);
} catch {
return { success: false, message: 'Los archivos de la FIEL no son válidos o la contraseña es incorrecta' };
}
if (!credential.isFiel()) {
return { success: false, message: 'El certificado proporcionado no es una FIEL (e.firma). Parece ser un CSD.' };
}
const certificate = credential.certificate();
const rfc = certificate.rfc();
const serialNumber = certificate.serialNumber().bytes();
const validFrom = new Date(String(certificate.validFromDateTime()));
const validUntil = new Date(String(certificate.validToDateTime()));
if (new Date() > validUntil) {
return { success: false, message: 'La FIEL está vencida desde ' + validUntil.toLocaleDateString() };
}
const enc = encryptFielCredentials(cerData, keyData, password);
// Check whether this contribuyente already had an active FIEL (to decide auto-sync)
const { rows: existingRows } = await pool.query(
`SELECT 1 FROM fiel_contribuyente WHERE contribuyente_id = $1 AND is_active = true`,
[contribuyenteId]
);
const isFirstUpload = existingRows.length === 0;
await pool.query(`
INSERT INTO fiel_contribuyente (
contribuyente_id, rfc, cer_data, key_data, key_password_enc,
cer_iv, cer_tag, key_iv, key_tag, password_iv, password_tag,
serial_number, valid_from, valid_until, is_active
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, true)
ON CONFLICT (contribuyente_id) DO UPDATE SET
rfc = $2, cer_data = $3, key_data = $4, key_password_enc = $5,
cer_iv = $6, cer_tag = $7, key_iv = $8, key_tag = $9,
password_iv = $10, password_tag = $11,
serial_number = $12, valid_from = $13, valid_until = $14,
is_active = true, updated_at = now()
`, [
contribuyenteId, rfc,
enc.encryptedCer, enc.encryptedKey, enc.encryptedPassword,
enc.cerIv, enc.cerTag, enc.keyIv, enc.keyTag, enc.passwordIv, enc.passwordTag,
serialNumber, validFrom, validUntil,
]);
// Trigger auto-sync on first upload (fire-and-forget)
if (isFirstUpload) {
import('./opinion-cumplimiento.service.js').then(async ({ consultarOpinionContribuyente }) => {
try {
await consultarOpinionContribuyente(pool, contribuyenteId);
} catch (err: any) {
console.error(`[FIEL first-upload] Opinión falló para contribuyente ${contribuyenteId}:`, err.message || err);
}
}).catch(() => {});
import('./constancia.service.js').then(async ({ consultarConstanciaContribuyente }) => {
try {
await consultarConstanciaContribuyente(pool, contribuyenteId);
} catch (err: any) {
console.error(`[FIEL first-upload] CSF falló para contribuyente ${contribuyenteId}:`, err.message || err);
}
}).catch(() => {});
}
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 Contribuyente Upload Error]', error);
return { success: false, message: error.message || 'Error al procesar la FIEL' };
}
}
export async function getFielStatusContribuyente(pool: Pool, contribuyenteId: string): Promise<FielStatus> {
// Try per-contribuyente first (tenant BD)
const { rows } = await pool.query(`
SELECT rfc, serial_number AS "serialNumber", valid_from AS "validFrom", valid_until AS "validUntil", is_active AS "isActive"
FROM fiel_contribuyente WHERE contribuyente_id = $1
`, [contribuyenteId]);
if (rows.length === 0 || !rows[0].isActive) {
// Fallback: check legacy tenant-level FIEL by matching RFC
const { rows: contribRows } = await pool.query('SELECT rfc FROM contribuyentes WHERE entidad_id = $1', [contribuyenteId]);
const rfc = contribRows[0]?.rfc;
if (rfc) {
const { getFielStatus } = await import('./fiel.service.js');
// getFielStatus reads by tenantId — check if the legacy FIEL matches this RFC
// We need prisma access, so import it
const { prisma } = await import('../config/database.js');
const legacyFiel = await prisma.fielCredential.findFirst({
where: { rfc, isActive: true },
select: { rfc: true, serialNumber: true, validFrom: true, validUntil: true, isActive: true },
});
if (legacyFiel) {
const now = new Date();
const isExpired = now > legacyFiel.validUntil;
const daysUntilExpiration = Math.ceil((legacyFiel.validUntil.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
return {
configured: true,
rfc: legacyFiel.rfc,
serialNumber: legacyFiel.serialNumber || undefined,
validFrom: legacyFiel.validFrom.toISOString(),
validUntil: legacyFiel.validUntil.toISOString(),
isExpired,
daysUntilExpiration: isExpired ? 0 : daysUntilExpiration,
};
}
}
return { configured: false };
}
const fiel = rows[0];
const now = new Date();
const isExpired = now > new Date(fiel.validUntil);
const daysUntilExpiration = Math.ceil((new Date(fiel.validUntil).getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
return {
configured: true,
rfc: fiel.rfc,
serialNumber: fiel.serialNumber || undefined,
validFrom: new Date(fiel.validFrom).toISOString(),
validUntil: new Date(fiel.validUntil).toISOString(),
isExpired,
daysUntilExpiration: isExpired ? 0 : daysUntilExpiration,
};
}
export async function getDecryptedFielContribuyente(pool: Pool, contribuyenteId: string): Promise<{
cerContent: string; keyContent: string; password: string; rfc: string;
} | null> {
const { rows } = await pool.query(`
SELECT * FROM fiel_contribuyente WHERE contribuyente_id = $1 AND is_active = true
`, [contribuyenteId]);
if (rows.length === 0) {
// Fallback: check legacy FIEL by matching RFC
const { rows: contribRows } = await pool.query('SELECT rfc FROM contribuyentes WHERE entidad_id = $1', [contribuyenteId]);
const rfc = contribRows[0]?.rfc;
if (rfc) {
const { prisma } = await import('../config/database.js');
const legacyFiel = await prisma.fielCredential.findFirst({
where: { rfc, isActive: true },
});
if (legacyFiel && new Date() <= legacyFiel.validUntil) {
try {
const { decryptFielCredentials } = await import('./sat/sat-crypto.service.js');
const { cerData, keyData, password } = decryptFielCredentials(
Buffer.from(legacyFiel.cerData), Buffer.from(legacyFiel.keyData), Buffer.from(legacyFiel.keyPasswordEncrypted),
Buffer.from(legacyFiel.cerIv), Buffer.from(legacyFiel.cerTag),
Buffer.from(legacyFiel.keyIv), Buffer.from(legacyFiel.keyTag),
Buffer.from(legacyFiel.passwordIv), Buffer.from(legacyFiel.passwordTag)
);
return { cerContent: cerData.toString('binary'), keyContent: keyData.toString('binary'), password, rfc: legacyFiel.rfc };
} catch (err) {
console.error('[FIEL Contribuyente] Legacy decrypt failed:', err);
return null;
}
}
}
return null;
}
const fiel = rows[0];
if (new Date() > new Date(fiel.valid_until)) return null;
try {
const { cerData, keyData, password } = decryptFielCredentials(
Buffer.from(fiel.cer_data), Buffer.from(fiel.key_data), Buffer.from(fiel.key_password_enc),
Buffer.from(fiel.cer_iv), Buffer.from(fiel.cer_tag),
Buffer.from(fiel.key_iv), Buffer.from(fiel.key_tag),
Buffer.from(fiel.password_iv), Buffer.from(fiel.password_tag)
);
return { cerContent: cerData.toString('binary'), keyContent: keyData.toString('binary'), password, rfc: fiel.rfc };
} catch (error) {
console.error('[FIEL Contribuyente Decrypt Error]', error);
return null;
}
}