Initial commit - Horux Despachos NL

This commit is contained in:
2026-05-03 16:47:53 -06:00
commit b00b677c54
647 changed files with 133843 additions and 0 deletions

View File

@@ -0,0 +1,493 @@
import Facturapi from 'facturapi';
import type { Pool } from 'pg';
import { Credential } from '@nodecfdi/credentials/node';
import { env } from '../config/env.js';
import { encryptString, decryptToString } from './sat/sat-crypto.service.js';
function getUserClient(): Facturapi {
if (!env.FACTURAPI_USER_KEY) throw new Error('FACTURAPI_USER_KEY no configurada');
return new Facturapi(env.FACTURAPI_USER_KEY);
}
/**
* Genera una Live Secret Key para una organización Facturapi via PUT idempotente.
* Si la org ya tiene live key, devuelve la existente; si no, crea una nueva.
* Endpoint oficial Facturapi: PUT /v2/organizations/{id}/apikeys/live
*/
async function generateLiveKey(orgId: string): Promise<string> {
const userKey = env.FACTURAPI_USER_KEY!;
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/live`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${userKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (!res.ok) {
const errBody = await res.text().catch(() => '');
throw new Error(`Facturapi PUT /apikeys/live falló (${res.status}): ${errBody}`);
}
const key = (await res.text()).replace(/"/g, '').trim();
if (!key.startsWith('sk_live_')) {
throw new Error(`Respuesta inesperada de Facturapi (no es sk_live_*): ${key.slice(0, 10)}...`);
}
return key;
}
/**
* Cifra y persiste la Live Secret Key de una organización.
* AES-256-GCM con la clave derivada de FIEL_ENCRYPTION_KEY.
*/
async function persistEncryptedKey(pool: Pool, orgId: string, plaintextKey: string): Promise<void> {
const { encrypted, iv, tag } = encryptString(plaintextKey);
await pool.query(
`UPDATE facturapi_orgs SET api_key_enc = $1, api_key_iv = $2, api_key_tag = $3 WHERE facturapi_org_id = $4`,
[encrypted, iv, tag, orgId],
);
}
/**
* Obtiene la Live Secret Key cacheada (descifra de BD) o la genera vía PUT
* y la persiste si no existe (caso de orgs legacy creadas antes del refactor live).
*/
async function getOrgApiKey(pool: Pool, orgId: string): Promise<string> {
const { rows } = await pool.query<{ api_key_enc: Buffer | null; api_key_iv: Buffer | null; api_key_tag: Buffer | null }>(
`SELECT api_key_enc, api_key_iv, api_key_tag FROM facturapi_orgs WHERE facturapi_org_id = $1 LIMIT 1`,
[orgId],
);
if (rows.length === 0) throw new Error(`Organización ${orgId} no encontrada en BD tenant`);
const row = rows[0];
if (row.api_key_enc && row.api_key_iv && row.api_key_tag) {
return decryptToString(row.api_key_enc, row.api_key_iv, row.api_key_tag);
}
// Org legacy sin live key cacheada — generar y guardar (idempotente).
const apiKey = await generateLiveKey(orgId);
await persistEncryptedKey(pool, orgId, apiKey);
return apiKey;
}
export async function createOrgContribuyente(
pool: Pool,
contribuyenteId: string,
nombre: string
): Promise<{ orgId: string; reused?: boolean; recreated?: boolean }> {
const { rows: existing } = await pool.query(
'SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1',
[contribuyenteId]
);
const client = getUserClient();
// Caso 1: hay fila local → verificar si la org sigue viva en Facturapi.
// Si existe en ambos lados, idempotente (devolver la existente).
// Si existe solo local pero Facturapi no la tiene (eliminada allá, API key
// cambió, etc.), recrear y actualizar el FK local — desbloquea el flujo
// de CSD que si no se quedaba trabado.
if (existing.length > 0) {
const existingId = existing[0].facturapi_org_id;
try {
await client.organizations.retrieve(existingId);
// Idempotente: si existe en ambos lados, asegurar que la live key está
// cacheada (puede faltar en orgs legacy creadas antes del refactor live).
await ensureLiveKeyCached(pool, existingId);
return { orgId: existingId, reused: true };
} catch {
const org = await client.organizations.create({ name: nombre });
await pool.query(
'UPDATE facturapi_orgs SET facturapi_org_id = $2, csd_uploaded = false, active = true, api_key_enc = NULL, api_key_iv = NULL, api_key_tag = NULL WHERE contribuyente_id = $1',
[contribuyenteId, org.id]
);
// Eager: generar y cachear live key para que la org quede lista para emitir.
await ensureLiveKeyCached(pool, org.id);
return { orgId: org.id, recreated: true };
}
}
// Caso 2: no hay fila local → crear fresh.
const org = await client.organizations.create({ name: nombre });
await pool.query(
'INSERT INTO facturapi_orgs (contribuyente_id, facturapi_org_id) VALUES ($1, $2)',
[contribuyenteId, org.id]
);
// Eager: generar y cachear live key inmediatamente tras crear la org.
await ensureLiveKeyCached(pool, org.id);
return { orgId: org.id };
}
/**
* Garantiza que la org tiene su Live Secret Key cifrada en BD. Si ya existe,
* no-op. Si no, hace PUT live y la persiste. Idempotente — el endpoint
* Facturapi PUT /apikeys/live es idempotente, devuelve la misma key si ya
* existe en su lado.
*/
async function ensureLiveKeyCached(pool: Pool, orgId: string): Promise<void> {
const { rows } = await pool.query(
`SELECT 1 FROM facturapi_orgs WHERE facturapi_org_id = $1 AND api_key_enc IS NOT NULL LIMIT 1`,
[orgId],
);
if (rows.length > 0) return;
const apiKey = await generateLiveKey(orgId);
await persistEncryptedKey(pool, orgId, apiKey);
}
export async function getOrgStatusContribuyente(
pool: Pool,
contribuyenteId: string
): Promise<{ configured: boolean; orgId?: string; legalName?: string; hasCsd?: boolean }> {
const { rows } = await pool.query(
'SELECT facturapi_org_id, csd_uploaded FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true',
[contribuyenteId]
);
if (rows.length === 0) return { configured: false };
try {
const client = getUserClient();
const org = await client.organizations.retrieve(rows[0].facturapi_org_id);
return {
configured: true,
orgId: org.id,
legalName: org.legal?.name || undefined,
hasCsd: !!org.certificate?.has_certificate,
};
} catch {
return { configured: false };
}
}
export async function uploadCsdContribuyente(
pool: Pool,
contribuyenteId: string,
cerFile: string,
keyFile: string,
password: string
): Promise<{ success: boolean; message: string }> {
const { rows } = await pool.query<{ facturapi_org_id: string; rfc: string }>(
`SELECT fo.facturapi_org_id, c.rfc
FROM facturapi_orgs fo
JOIN contribuyentes c ON c.entidad_id = fo.contribuyente_id
WHERE fo.contribuyente_id = $1 AND fo.active = true`,
[contribuyenteId]
);
if (rows.length === 0) throw new Error('Primero debe crearse la organización Facturapi del contribuyente');
const { facturapi_org_id, rfc: contribuyenteRfc } = rows[0];
// Validación preventiva: que el certificado sea CSD (no FIEL), que el RFC
// coincida con el contribuyente y que no esté vencido. Facturapi también
// valida, pero su mensaje de error es poco específico ("Certificado no
// válido") — el nuestro dice exactamente qué pasa.
const cerData = Buffer.from(cerFile, 'base64');
const keyData = Buffer.from(keyFile, 'base64');
let credential: Credential;
try {
credential = Credential.create(cerData.toString('binary'), keyData.toString('binary'), password);
} catch {
return { success: false, message: 'Los archivos .cer/.key no son válidos o la contraseña es incorrecta' };
}
// Debe ser CSD (sello digital para facturar), no FIEL (e.firma para trámites).
if (credential.isFiel()) {
return { success: false, message: 'El certificado es una FIEL (e.firma), no un CSD. Sube el Certificado de Sello Digital.' };
}
const certRfc = credential.certificate().rfc().toUpperCase();
if (certRfc !== contribuyenteRfc.toUpperCase()) {
return {
success: false,
message: `El RFC del CSD (${certRfc}) no coincide con el del contribuyente (${contribuyenteRfc}). Verifica que estés subiendo los archivos correctos.`,
};
}
const validUntil = new Date(String(credential.certificate().validToDateTime()));
if (new Date() > validUntil) {
return { success: false, message: `El CSD está vencido desde ${validUntil.toLocaleDateString('es-MX')}. Solicita al SAT uno nuevo.` };
}
const client = getUserClient();
try {
await client.organizations.uploadCertificate(
facturapi_org_id,
cerData,
keyData,
password,
);
await pool.query(
'UPDATE facturapi_orgs SET csd_uploaded = true WHERE contribuyente_id = $1',
[contribuyenteId]
);
return { success: true, message: 'CSD subido correctamente' };
} catch (error: any) {
return { success: false, message: error.message || 'Error al subir CSD a Facturapi' };
}
}
export async function getOrgClientContribuyente(
pool: Pool,
contribuyenteId: string
): Promise<Facturapi> {
const { rows } = await pool.query(
'SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true',
[contribuyenteId]
);
if (rows.length === 0) throw new Error('Contribuyente no tiene organización Facturapi configurada');
const apiKey = await getOrgApiKey(pool, rows[0].facturapi_org_id);
return new Facturapi(apiKey);
}
export async function cancelInvoiceContribuyente(
pool: Pool,
contribuyenteId: string,
facturapiId: string,
motive: '01' | '02' | '03' | '04' = '02',
substitution?: string,
): Promise<any> {
const client = await getOrgClientContribuyente(pool, contribuyenteId);
const cancelData: any = { motive };
if (motive === '01' && substitution) cancelData.substitution = substitution;
return client.invoices.cancel(facturapiId, cancelData);
}
function streamToBuffer(stream: any): Promise<Buffer> {
return new Promise((resolve, reject) => {
if (Buffer.isBuffer(stream)) return resolve(stream);
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
stream.on('end', () => resolve(Buffer.concat(chunks)));
stream.on('error', reject);
});
}
export async function downloadPdfContribuyente(
pool: Pool,
contribuyenteId: string,
facturapiId: string,
): Promise<Buffer> {
const client = await getOrgClientContribuyente(pool, contribuyenteId);
const stream = await client.invoices.downloadPdf(facturapiId);
return streamToBuffer(stream);
}
export async function downloadXmlContribuyente(
pool: Pool,
contribuyenteId: string,
facturapiId: string,
): Promise<Buffer> {
const client = await getOrgClientContribuyente(pool, contribuyenteId);
const stream = await client.invoices.downloadXml(facturapiId);
return streamToBuffer(stream);
}
export async function sendInvoiceByEmailContribuyente(
pool: Pool,
contribuyenteId: string,
facturapiId: string,
email: string,
): Promise<void> {
const client = await getOrgClientContribuyente(pool, contribuyenteId);
await client.invoices.sendByEmail(facturapiId, { email });
}
export async function createInvoiceContribuyente(
pool: Pool,
contribuyenteId: string,
data: any
): Promise<any> {
const client = await getOrgClientContribuyente(pool, contribuyenteId);
// Create/update customer in Facturapi
const isForiegn = !!data.customer?.country && data.customer.country !== 'MEX';
const customerData: any = {
legal_name: data.customer?.legalName,
tax_id: data.customer?.taxId,
email: data.customer?.email,
address: { zip: data.customer?.zip, ...(isForiegn ? { country: data.customer.country } : {}) },
};
if (!isForiegn && data.customer?.taxSystem) customerData.tax_system = data.customer.taxSystem;
let customerId: string;
try {
const existing = await client.customers.list({ search: data.customer?.taxId });
const match = existing.data?.find((c: any) => c.tax_id === data.customer?.taxId);
if (match) {
await client.customers.update(match.id, customerData);
customerId = match.id;
} else {
const created = await client.customers.create(customerData);
customerId = created.id;
}
} catch {
const created = await client.customers.create(customerData);
customerId = created.id;
}
// Build invoice payload (mirrors createInvoice logic in facturapi.service.ts)
const tipo = data.type || 'I';
const invoicePayload: any = { customer: customerId };
if (tipo !== 'I') invoicePayload.type = tipo;
if (data.items?.length) {
invoicePayload.items = data.items.map((item: any) => ({
quantity: item.quantity,
product: {
description: item.description,
product_key: item.productKey,
unit_key: item.unitKey || 'E48',
unit_name: item.unitName || 'Servicio',
price: item.price,
tax_included: item.taxIncluded ?? true,
taxes: item.taxes?.map((t: any) => ({
type: t.type,
rate: t.rate,
factor: t.factor || 'Tasa',
...(t.withholding ? { withholding: true } : {}),
})) || [{ type: 'IVA', rate: 0.16 }],
},
}));
}
if (tipo === 'I' || tipo === 'E') {
invoicePayload.use = data.use || 'G01';
invoicePayload.payment_form = data.paymentForm || '99';
invoicePayload.payment_method = data.paymentMethod || 'PUE';
invoicePayload.currency = data.currency || 'MXN';
if (data.exchangeRate && data.currency !== 'MXN') invoicePayload.exchange = data.exchangeRate;
if (data.conditions) invoicePayload.conditions = data.conditions;
}
if (data.series) invoicePayload.series = data.series;
if (data.folioNumber) invoicePayload.folio_number = data.folioNumber;
if (data.relatedDocuments?.length) {
// Estructura SAT 4.0: agrupa N uuids por tipo de relación. Acepta tanto
// el formato nuevo {relationship, uuids[]} como el legacy {relationship,
// uuid} para compat durante transición de callers frontend.
invoicePayload.related_documents = data.relatedDocuments.map((r: any) => ({
relationship: r.relationship,
documents: Array.isArray(r.uuids) ? r.uuids : (r.uuid ? [r.uuid] : []),
}));
}
if (data.complements?.length) invoicePayload.complements = data.complements;
if (data.global) invoicePayload.global = data.global;
// Régimen fiscal del emisor: Facturapi NO acepta override per-invoice via
// campo `issuer` (rechaza con "issuer is not allowed"). La única forma es
// actualizar el `legal.tax_system` de la organización antes del emit.
// Para contribuyentes con múltiples regímenes, esto significa un sync en
// cada emit cuando el seleccionado difiere del actual en la org.
if (data.issuerTaxSystem) {
const { rows } = await pool.query<{ facturapi_org_id: string }>(
`SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true`,
[contribuyenteId],
);
if (rows.length > 0) {
await ensureOrgLegalForEmit(pool, contribuyenteId, rows[0].facturapi_org_id, data.issuerTaxSystem);
}
}
return client.invoices.create(invoicePayload);
}
/**
* Sincroniza los datos fiscales de la organización Facturapi con la
* información del contribuyente, usando el régimen seleccionado. Se llama
* antes de cada emit cuando el user elige un régimen en el form, porque
* Facturapi toma el TaxSystem del CFDI del `legal.tax_system` de la org
* (no acepta override per-invoice). No-op si el `legal` ya coincide.
*/
async function ensureOrgLegalForEmit(
pool: Pool,
contribuyenteId: string,
orgId: string,
chosenTaxSystem: string,
): Promise<void> {
const userKey = env.FACTURAPI_USER_KEY;
if (!userKey) throw new Error('FACTURAPI_USER_KEY no configurada');
// Datos fiscales del contribuyente (razón social + domicilio)
const { rows } = await pool.query<{
rfc: string;
razon_social: string | null;
regimen_fiscal: string | null;
codigo_postal: string | null;
domicilio: any;
}>(
`SELECT c.rfc, r.razon_social, c.regimen_fiscal, c.codigo_postal, c.domicilio
FROM contribuyentes c
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
WHERE c.entidad_id = $1`,
[contribuyenteId],
);
if (rows.length === 0) throw new Error('Contribuyente no encontrado');
const contrib = rows[0];
// Validar que el régimen elegido esté entre los registrados del contrib
const allowed = (contrib.regimen_fiscal || '')
.split(',')
.map(s => s.trim())
.filter(Boolean);
if (allowed.length > 0 && !allowed.includes(chosenTaxSystem)) {
throw new Error(
`El régimen ${chosenTaxSystem} no está registrado para este contribuyente ` +
`(registrados: ${allowed.join(', ')})`,
);
}
// Leer el legal actual de la org en Facturapi
const getRes = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}`, {
headers: { 'Authorization': `Bearer ${userKey}` },
});
if (!getRes.ok) {
throw new Error(`No se pudo leer organización Facturapi (${getRes.status})`);
}
const org = (await getRes.json()) as any;
const currentLegal = org.legal || {};
// Si el tax_system ya coincide y la razón social está seteada, no tocar
// (evita updates innecesarios con latencia extra).
if (
currentLegal.tax_system === chosenTaxSystem &&
currentLegal.legal_name &&
currentLegal.legal_name === contrib.razon_social
) {
return;
}
const domicilio = (contrib.domicilio || {}) as any;
const legalPayload = {
name: contrib.razon_social || currentLegal.name || '',
legal_name: contrib.razon_social || currentLegal.legal_name || '',
tax_system: chosenTaxSystem,
address: {
street: domicilio.calle || currentLegal.address?.street || '',
exterior: domicilio.numExterior || currentLegal.address?.exterior || '',
interior: domicilio.numInterior || currentLegal.address?.interior || '',
neighborhood: domicilio.colonia || currentLegal.address?.neighborhood || '',
city: domicilio.ciudad || currentLegal.address?.city || '',
municipality: domicilio.municipio || currentLegal.address?.municipality || '',
state: domicilio.estado || currentLegal.address?.state || '',
zip: contrib.codigo_postal || domicilio.codigoPostal || currentLegal.address?.zip || '',
},
};
const putRes = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/legal`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${userKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(legalPayload),
});
if (!putRes.ok) {
const errText = await putRes.text();
throw new Error(
`Error actualizando datos fiscales de la organización Facturapi (${putRes.status}): ${errText}`,
);
}
}