Initial commit - Horux Despachos NL
This commit is contained in:
202
apps/api/src/services/contribuyente-fiel.service.ts
Normal file
202
apps/api/src/services/contribuyente-fiel.service.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user