- Auto-update fiscal data on org creation via updateOrgLegalOnCreate - Add Carta Manifiesto embedded iframe in CSD config page - Fix CSF scraper: 60s timeout + manual RFC fallback when SAT doesn't auto-populate - Fix contribuyenteId propagation in constancia frontend hooks/API - Fix needsInitialSync to check per-contribuyente, not just per-tenant - Fix documento notifications for global_admin using viewingTenantId - Extract CSF manually for Carlos Husberto Torres Romero - Trigger initial SAT sync for Carlos Husberto Torres Romero - Update org legal data in Facturapi for Carlos Husberto (tax_system 612 + address) Files changed: - apps/api/src/controllers/documentos.controller.ts - apps/api/src/jobs/sat-sync.job.ts - apps/api/src/services/constancia.service.ts - apps/api/src/services/contribuyente-facturapi.service.ts - apps/api/src/services/sat/sat-csf-login.ts - apps/web/app/(dashboard)/configuracion/csd/page.tsx - apps/web/lib/api/constancias.ts - apps/web/lib/hooks/use-constancias.ts - docs/sessions/2026-05-17-facturapi-csf-sync-notifications.md
545 lines
20 KiB
TypeScript
545 lines
20 KiB
TypeScript
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);
|
|
// 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<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);
|
|
}
|
|
|
|
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<ContribuyenteFiscalData> {
|
|
const { rows } = await pool.query<ContribuyenteFiscalData>(
|
|
`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<void> {
|
|
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<void> {
|
|
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<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');
|
|
|
|
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);
|
|
}
|