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 { 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 { 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 { 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); // Backfill: si la org fue creada antes de este fix, sincronizar datos fiscales. await updateOrgLegalOnCreate(pool, existingId, contribuyenteId); 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); await updateOrgLegalOnCreate(pool, org.id, contribuyenteId); 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); await updateOrgLegalOnCreate(pool, org.id, contribuyenteId); 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 { 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); } interface ContribuyenteFiscalData { rfc: string; razon_social: string | null; regimen_fiscal: string | null; codigo_postal: string | null; domicilio: any; } async function fetchContribuyenteFiscalData( pool: Pool, contribuyenteId: string, ): Promise { const { rows } = await pool.query( `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'); return rows[0]; } async function buildLegalPayload( contrib: ContribuyenteFiscalData, chosenTaxSystem: string, currentLegal?: any, ) { const domicilio = (contrib.domicilio || {}) as any; return { 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 || '', }, }; } async function putOrgLegal(orgId: string, payload: any): Promise { const userKey = env.FACTURAPI_USER_KEY; if (!userKey) throw new Error('FACTURAPI_USER_KEY no configurada'); 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(payload), }); if (!putRes.ok) { const errText = await putRes.text(); throw new Error( `Error actualizando datos fiscales de la organización Facturapi (${putRes.status}): ${errText}`, ); } } /** * Actualiza los datos fiscales de una organización Facturapi recién creada * usando la información del contribuyente. Se usa el primer régimen fiscal * registrado. No-op si no hay régimen fiscal o razón social que setear. */ async function updateOrgLegalOnCreate( pool: Pool, orgId: string, contribuyenteId: string, ): Promise { try { const contrib = await fetchContribuyenteFiscalData(pool, contribuyenteId); const allowed = (contrib.regimen_fiscal || '') .split(',') .map(s => s.trim()) .filter(Boolean); if (!allowed.length || !contrib.razon_social) { // Datos incompletos: no fallar la creación de la org, solo loguear silenciosamente. return; } const payload = await buildLegalPayload(contrib, allowed[0]); await putOrgLegal(orgId, payload); } catch { // No bloquear la creación de la org si el update legal falla. } } 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 { 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 { 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 { 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 { 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 { 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 { const client = await getOrgClientContribuyente(pool, contribuyenteId); await client.invoices.sendByEmail(facturapiId, { email }); } export async function createInvoiceContribuyente( pool: Pool, contribuyenteId: string, data: any ): Promise { 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 { const userKey = env.FACTURAPI_USER_KEY; if (!userKey) throw new Error('FACTURAPI_USER_KEY no configurada'); const contrib = await fetchContribuyenteFiscalData(pool, contribuyenteId); // 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 payload = await buildLegalPayload(contrib, chosenTaxSystem, currentLegal); await putOrgLegal(orgId, payload); }