feat(pos/facturapi): finalize Horux-to-Facturapi migration
- Normalize Facturapi key/org_id resolution (supports both cfdi_ prefixed tenant_config keys and short names used by invoicing_bp). - Add CSD upload end-to-end (backend + frontend). - Add helper scripts: setup_facturapi_orgs.py and check_facturapi_tenants.py. - Add 20 unit tests with mocks (pos/tests/test_facturapi_service.py). - Add CI workflow for lint + console tests on Python 3.11/3.13. - Add pyproject.toml and requirements-dev.txt with ruff/pytest config. - Update FASES_IMPLEMENTADAS.md with FASE 8 documentation. Tests: 81 passing (61 console + 20 Facturapi).
This commit is contained in:
@@ -12,11 +12,10 @@ Authentication modes:
|
||||
Reference: https://docs.facturapi.io/
|
||||
"""
|
||||
|
||||
import os
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
@@ -35,8 +34,8 @@ class FacturapiError(Exception):
|
||||
|
||||
# ─── HTTP helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
def _request(method: str, endpoint: str, api_key: str, json_payload=None, params=None,
|
||||
extra_headers=None, timeout=60):
|
||||
|
||||
def _request(method: str, endpoint: str, api_key: str, json_payload=None, params=None, extra_headers=None, timeout=60):
|
||||
"""Make a request to Facturapi REST API with Basic Auth."""
|
||||
url = f"{BASE_URL}{endpoint}"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
@@ -54,7 +53,7 @@ def _request(method: str, endpoint: str, api_key: str, json_payload=None, params
|
||||
timeout=timeout,
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
raise FacturapiError(f"Connection error: {e}", status_code=0)
|
||||
raise FacturapiError(f"Connection error: {e}", status_code=0) from e
|
||||
|
||||
if not resp.ok:
|
||||
raise FacturapiError(
|
||||
@@ -88,15 +87,24 @@ def _download(method: str, endpoint: str, api_key: str, params=None, timeout=60)
|
||||
|
||||
# ─── Tenant config helpers ──────────────────────────────────────────────────
|
||||
|
||||
def _get_secret_key(tenant_config: dict) -> Optional[str]:
|
||||
for key in ("facturapi_key", "facturapi_secret_key"):
|
||||
|
||||
def _get_secret_key(tenant_config: dict) -> str | None:
|
||||
for key in ("facturapi_secret_key", "facturapi_key", "cfdi_facturapi_key"):
|
||||
val = (tenant_config.get(key) or "").strip()
|
||||
if val:
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
def _get_user_key() -> Optional[str]:
|
||||
def _get_org_id(tenant_config: dict) -> str | None:
|
||||
for key in ("facturapi_org_id", "cfdi_facturapi_org_id"):
|
||||
val = (tenant_config.get(key) or "").strip()
|
||||
if val:
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
def _get_user_key() -> str | None:
|
||||
return USER_KEY.strip() or None
|
||||
|
||||
|
||||
@@ -117,42 +125,11 @@ def get_api_key(tenant_config: dict) -> str:
|
||||
user = _get_user_key()
|
||||
if user:
|
||||
return user
|
||||
raise FacturapiError(
|
||||
"Facturapi not configured. Set FACTURAPI_USER_KEY env or tenant_config.facturapi_secret_key"
|
||||
)
|
||||
raise FacturapiError("Facturapi not configured. Set FACTURAPI_USER_KEY env or tenant_config.facturapi_secret_key")
|
||||
|
||||
|
||||
# ─── Organizations ──────────────────────────────────────────────────────────
|
||||
|
||||
def create_organization(tenant_config: dict) -> dict:
|
||||
"""Create a new Facturapi organization for the tenant.
|
||||
|
||||
Requires FACTURAPI_USER_KEY.
|
||||
Returns dict with id, api_key.
|
||||
"""
|
||||
user_key = _get_user_key()
|
||||
if not user_key:
|
||||
raise FacturapiError("FACTURAPI_USER_KEY is required to create organizations")
|
||||
|
||||
payload = {"name": tenant_config.get("razon_social", tenant_config.get("name", "Nexus"))}
|
||||
legal = tenant_config.get("legal_name") or tenant_config.get("razon_social")
|
||||
if legal:
|
||||
payload["legal"] = {"name": legal}
|
||||
if tenant_config.get("rfc"):
|
||||
payload["legal"] = payload.get("legal", {})
|
||||
payload["legal"]["tax_id"] = tenant_config["rfc"]
|
||||
|
||||
org = _request("POST", "/organizations", user_key, json_payload=payload)
|
||||
org_id = org.get("id")
|
||||
|
||||
# Generate live secret key
|
||||
key_resp = _request("PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={})
|
||||
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_organization(org_id: str, api_key: str) -> dict:
|
||||
return _request("GET", f"/organizations/{org_id}", api_key)
|
||||
@@ -164,7 +141,7 @@ def upload_csd(tenant_config: dict, cer_b64: str, key_b64: str, password: str) -
|
||||
cer_b64 and key_b64 are base64-encoded strings.
|
||||
"""
|
||||
api_key = get_api_key(tenant_config)
|
||||
org_id = tenant_config.get("facturapi_org_id")
|
||||
org_id = _get_org_id(tenant_config)
|
||||
if not org_id:
|
||||
raise FacturapiError("No Facturapi organization configured for tenant")
|
||||
|
||||
@@ -196,15 +173,14 @@ def _get_user_key_for_tenant(tenant_config: dict) -> str:
|
||||
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"
|
||||
)
|
||||
for key in ("facturapi_key", "cfdi_facturapi_key"):
|
||||
tenant_key = (tenant_config.get(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]:
|
||||
def find_organization_by_rfc(tenant_config: dict) -> dict | None:
|
||||
"""Search for an existing Facturapi organization by tenant RFC.
|
||||
|
||||
Requires a user key (FACTURAPI_USER_KEY env or sk_user_* tenant key).
|
||||
@@ -252,9 +228,7 @@ def create_organization(tenant_config: dict) -> dict:
|
||||
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
|
||||
)
|
||||
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}")
|
||||
@@ -282,7 +256,7 @@ def get_org_status(tenant_config: dict) -> dict:
|
||||
result["error"] = str(e)
|
||||
return result
|
||||
|
||||
org_id = tenant_config.get("facturapi_org_id")
|
||||
org_id = _get_org_id(tenant_config)
|
||||
if not org_id:
|
||||
result["error"] = "No Facturapi organization configured"
|
||||
return result
|
||||
@@ -294,13 +268,15 @@ def get_org_status(tenant_config: dict) -> dict:
|
||||
org = get_organization(org_id, api_key)
|
||||
legal = org.get("legal", {})
|
||||
cert = org.get("certificate", {})
|
||||
result.update({
|
||||
"configured": True,
|
||||
"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", []),
|
||||
})
|
||||
result.update(
|
||||
{
|
||||
"configured": True,
|
||||
"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)
|
||||
|
||||
@@ -309,6 +285,7 @@ def get_org_status(tenant_config: dict) -> dict:
|
||||
|
||||
# ─── Customers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def create_or_update_customer(tenant_config: dict, customer_data: dict) -> str:
|
||||
"""Create or update a customer in Facturapi and return its id.
|
||||
|
||||
@@ -364,6 +341,7 @@ def create_or_update_customer(tenant_config: dict, customer_data: dict) -> str:
|
||||
|
||||
# ─── Invoices ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def create_invoice(tenant_config: dict, payload: dict) -> dict:
|
||||
"""Create and stamp an invoice in Facturapi.
|
||||
|
||||
@@ -373,8 +351,7 @@ def create_invoice(tenant_config: dict, payload: dict) -> dict:
|
||||
return _request("POST", "/invoices", api_key, json_payload=payload, timeout=90)
|
||||
|
||||
|
||||
def cancel_invoice(tenant_config: dict, invoice_id: str, motive: str,
|
||||
replacement_uuid: Optional[str] = None) -> dict:
|
||||
def cancel_invoice(tenant_config: dict, invoice_id: str, motive: str, replacement_uuid: str | None = None) -> dict:
|
||||
"""Cancel an invoice in Facturapi.
|
||||
|
||||
Motive codes:
|
||||
@@ -402,6 +379,7 @@ def download_pdf(tenant_config: dict, invoice_id: str) -> bytes:
|
||||
|
||||
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def is_lco_rejection(message: str) -> bool:
|
||||
"""Detect SAT LCO rejection (CSD not yet propagated)."""
|
||||
if not message:
|
||||
|
||||
Reference in New Issue
Block a user