diff --git a/pos/blueprints/invoicing_bp.py b/pos/blueprints/invoicing_bp.py index 65b7c4c..aeeac74 100644 --- a/pos/blueprints/invoicing_bp.py +++ b/pos/blueprints/invoicing_bp.py @@ -560,6 +560,60 @@ def facturapi_status(): return jsonify(status) +@invoicing_bp.route('/facturapi/setup', methods=['POST']) +@require_auth('invoicing.create') +def facturapi_setup(): + """Create or link a Facturapi organization for this tenant. + + Requires FACTURAPI_USER_KEY environment variable. + Stores cfdi_facturapi_org_id and cfdi_facturapi_key in tenant_config. + """ + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + try: + tenant_config = _get_issuer_config(cur) + if not tenant_config.get('rfc'): + return jsonify({'error': 'Tenant RFC not configured'}), 400 + + result = facturapi_service.create_organization(tenant_config) + + cur.execute(""" + INSERT INTO tenant_config (key, value) + VALUES ('cfdi_facturapi_org_id', %s) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + """, (result['org_id'],)) + + cur.execute(""" + INSERT INTO tenant_config (key, value) + VALUES ('cfdi_facturapi_key', %s) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + """, (result['api_key'],)) + + log_action(conn, 'FACTURAPI_SETUP', 'tenant_config', None, + new_value={'org_id': result['org_id']}) + + conn.commit() + cur.close() + conn.close() + + return jsonify({ + 'org_id': result['org_id'], + 'message': 'Facturapi organization created. Complete pending steps in Facturapi dashboard.', + }) + + except ValueError as e: + conn.rollback() + cur.close() + conn.close() + return jsonify({'error': str(e)}), 400 + except Exception as e: + conn.rollback() + cur.close() + conn.close() + return jsonify({'error': str(e)}), 500 + + @invoicing_bp.route('/facturapi/download//', methods=['GET']) @require_auth('invoicing.view') def facturapi_download(cfdi_id, doc_type): diff --git a/pos/services/facturapi_service.py b/pos/services/facturapi_service.py index 6f42958..5089906 100644 --- a/pos/services/facturapi_service.py +++ b/pos/services/facturapi_service.py @@ -186,28 +186,125 @@ def upload_csd(tenant_config: dict, cer_b64: str, key_b64: str, password: str) - return resp.json() +def _get_user_key_for_tenant(tenant_config: dict) -> str: + """Resolve the Facturapi user key to use for organization management. + + Priority: + 1. FACTURAPI_USER_KEY environment variable + 2. tenant_config.facturapi_key if it starts with sk_user_ + """ + user_key = _get_user_key() + if user_key: + return user_key + tenant_key = (tenant_config.get("facturapi_key") or "").strip() + if tenant_key.startswith("sk_user_"): + return tenant_key + raise FacturapiError( + "FACTURAPI_USER_KEY env or a Facturapi user key (sk_user_*) is required" + ) + + +def find_organization_by_rfc(tenant_config: dict) -> Optional[dict]: + """Search for an existing Facturapi organization by tenant RFC. + + Requires a user key (FACTURAPI_USER_KEY env or sk_user_* tenant key). + Returns the organization dict or None. + """ + user_key = _get_user_key_for_tenant(tenant_config) + + rfc = (tenant_config.get("rfc") or "").upper().strip() + if not rfc: + raise FacturapiError("Tenant RFC is required to search organizations") + + page = 1 + while True: + result = _request("GET", "/organizations", user_key, params={"page": page}, timeout=30) + for org in result.get("data", []): + legal = org.get("legal", {}) + if (legal.get("tax_id") or "").upper() == rfc: + return org + if page >= result.get("total_pages", 1): + break + page += 1 + return None + + +def create_organization(tenant_config: dict) -> dict: + """Create a new Facturapi organization for the tenant and return live key. + + Requires FACTURAPI_USER_KEY env or a user key (sk_user_*) in tenant_config. + Uses tenant RFC/razon_social if available. + """ + user_key = _get_user_key_for_tenant(tenant_config) + + rfc = (tenant_config.get("rfc") or "").upper().strip() + name = tenant_config.get("razon_social") or tenant_config.get("name") or rfc or "Nexus" + + # First try to find existing org by RFC + existing = find_organization_by_rfc(tenant_config) if rfc else None + if existing: + org_id = existing["id"] + else: + payload = {"name": name} + org = _request("POST", "/organizations", user_key, json_payload=payload, timeout=60) + org_id = org.get("id") + if not org_id: + raise FacturapiError("Could not create organization: no id returned") + + # Generate live secret key + key_resp = _request( + "PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60 + ) + live_key = key_resp.get("key") if isinstance(key_resp, dict) else str(key_resp) + if not live_key: + raise FacturapiError(f"Could not generate live key for org {org_id}") + + return {"org_id": org_id, "api_key": live_key} + + def get_org_status(tenant_config: dict) -> dict: - """Return organization status: configured, has_csd, org_id.""" + result = { + "configured": False, + "has_key": False, + "has_org_id": False, + "has_csd": False, + "org_id": None, + "legal_name": None, + "tax_id": None, + "pending_steps": [], + "error": None, + } + try: api_key = get_api_key(tenant_config) - except FacturapiError: - return {"configured": False, "has_csd": False, "org_id": None} + result["has_key"] = True + except FacturapiError as e: + result["error"] = str(e) + return result org_id = tenant_config.get("facturapi_org_id") if not org_id: - return {"configured": False, "has_csd": False, "org_id": None} + result["error"] = "No Facturapi organization configured" + return result + + result["has_org_id"] = True + result["org_id"] = org_id try: org = get_organization(org_id, api_key) - return { + legal = org.get("legal", {}) + cert = org.get("certificate", {}) + result.update({ "configured": True, - "org_id": org_id, - "has_csd": bool(org.get("certificate", {}).get("has_certificate")), - "legal_name": org.get("legal", {}).get("name"), - "tax_id": org.get("legal", {}).get("tax_id"), - } - except FacturapiError: - return {"configured": False, "has_csd": False, "org_id": org_id} + "has_csd": bool(cert.get("has_certificate")), + "legal_name": legal.get("name") or legal.get("legal_name"), + "tax_id": legal.get("tax_id"), + "pending_steps": org.get("pending_steps", []), + }) + except FacturapiError as e: + result["error"] = str(e) + + return result # ─── Customers ────────────────────────────────────────────────────────────── diff --git a/pos/static/js/invoicing.js b/pos/static/js/invoicing.js index 9b6e517..bef35ec 100644 --- a/pos/static/js/invoicing.js +++ b/pos/static/js/invoicing.js @@ -267,28 +267,68 @@ const Invoicing = (() => { container.innerHTML = 'Cargando...'; try { const status = await api('/facturapi/status'); - if (!status.configured) { - container.innerHTML = `

Facturapi no configurado. Configura la llave API en Configuración.

`; + + if (!status.has_key) { + container.innerHTML = `

Falta la llave API de Facturapi. Configura cfdi_facturapi_key o la variable FACTURAPI_USER_KEY.

`; return; } + + if (!status.has_org_id) { + container.innerHTML = ` +

No hay organización Facturapi vinculada.

+ + `; + return; + } + + let csdHtml = status.has_csd + ? 'Activo' + : 'Pendiente'; + + let pendingHtml = ''; + if (status.pending_steps && status.pending_steps.length) { + pendingHtml = ''; + } + container.innerHTML = `
Organización
-
${status.legal_name || '-'}
+
${escapeHtml(status.legal_name) || '-'}
${status.tax_id || ''}
+
${status.org_id || ''}
CSD
-
${status.has_csd ? 'Activo' : 'Pendiente'}
+
${csdHtml}
+
Pasos pendientes
+ ${pendingHtml || 'Ninguno'}
+ ${status.error ? `

Error: ${escapeHtml(status.error)}

` : ''} `; } catch (e) { container.innerHTML = `

Error: ${e.message}

`; } } + async function setupFacturapi(btn) { + if (!btn) return; + btn.disabled = true; + btn.textContent = 'Configurando...'; + try { + const res = await api('/facturapi/setup', { method: 'POST' }); + alert('Organización vinculada: ' + res.org_id); + loadFacturapiStatus(); + } catch (e) { + alert('Error: ' + e.message); + btn.disabled = false; + btn.textContent = 'Crear / Vincular Organización'; + } + } + // ---- Detail modal (uses modalDetalleOverlay) ---- async function showDetail(cfdiId) { const overlay = document.getElementById('modalDetalleOverlay'); @@ -571,7 +611,7 @@ const Invoicing = (() => { switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones, loadFacturapiStatus, showDetail, showCancelModal, confirmCancel, processQueue, showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder, - openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice, + openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice, setupFacturapi, }; // Register Cmd+K items if (typeof registerCmdKItem === "function") {