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:
@@ -9,8 +9,8 @@ generates those payloads for:
|
||||
- Factura global mensual
|
||||
"""
|
||||
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from datetime import datetime
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
# SAT defaults
|
||||
RFC_PUBLICO_GENERAL = "XAXX010101000"
|
||||
@@ -148,9 +148,7 @@ def build_egreso_payload(sale, tenant_config, customer, original_uuid):
|
||||
"""Build Facturapi payload for a credit note (Comprobante tipo Egreso)."""
|
||||
payload = build_ingreso_payload(sale, tenant_config, customer)
|
||||
payload["type"] = "E"
|
||||
payload["related_documents"] = [
|
||||
{"relationship": "01", "documents": [original_uuid]}
|
||||
]
|
||||
payload["related_documents"] = [{"relationship": "01", "documents": [original_uuid]}]
|
||||
payload["payment_method"] = "PUE"
|
||||
return payload
|
||||
|
||||
@@ -162,15 +160,12 @@ def build_pago_payload(payment, tenant_config, customer, original_uuid):
|
||||
|
||||
amount = _to_dec(payment.get("amount", 0))
|
||||
base = (amount / Decimal("1.16")).quantize(TWO, ROUND_HALF_UP)
|
||||
iva = (amount - base).quantize(TWO, ROUND_HALF_UP)
|
||||
|
||||
payment_date = payment.get("date") or datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
||||
if "T" not in str(payment_date):
|
||||
payment_date = f"{payment_date}T12:00:00"
|
||||
|
||||
forma_pago = FORMA_PAGO_MAP.get(
|
||||
(payment.get("payment_method") or "").lower().strip(), "01"
|
||||
)
|
||||
forma_pago = FORMA_PAGO_MAP.get((payment.get("payment_method") or "").lower().strip(), "01")
|
||||
|
||||
payload = {
|
||||
"type": "P",
|
||||
|
||||
@@ -17,7 +17,7 @@ Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries)
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
|
||||
from services import facturapi_service
|
||||
|
||||
@@ -34,7 +34,7 @@ def _generate_provisional_folio(conn):
|
||||
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
|
||||
seq = cur.fetchone()[0]
|
||||
cur.close()
|
||||
return f'PRE-{seq:05d}'
|
||||
return f"PRE-{seq:05d}"
|
||||
|
||||
|
||||
def enqueue_cfdi(conn, sale_id, cfdi_type, payload):
|
||||
@@ -54,22 +54,25 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, payload):
|
||||
|
||||
payload_json = payload if isinstance(payload, str) else json.dumps(payload)
|
||||
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO cfdi_queue
|
||||
(sale_id, type, payload_unsigned, status, provisional_folio)
|
||||
VALUES (%s, %s, %s, 'pending', %s)
|
||||
RETURNING id, created_at
|
||||
""", (sale_id, cfdi_type, payload_json, provisional_folio))
|
||||
""",
|
||||
(sale_id, cfdi_type, payload_json, provisional_folio),
|
||||
)
|
||||
cfdi_id, created_at = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
return {
|
||||
'id': cfdi_id,
|
||||
'sale_id': sale_id,
|
||||
'type': cfdi_type,
|
||||
'status': 'pending',
|
||||
'provisional_folio': provisional_folio,
|
||||
'created_at': str(created_at),
|
||||
"id": cfdi_id,
|
||||
"sale_id": sale_id,
|
||||
"type": cfdi_type,
|
||||
"status": "pending",
|
||||
"provisional_folio": provisional_folio,
|
||||
"created_at": str(created_at),
|
||||
}
|
||||
|
||||
|
||||
@@ -90,34 +93,40 @@ def process_queue(conn, tenant_config, dry_run=False):
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, sale_id, type, payload_unsigned, retry_count
|
||||
FROM cfdi_queue
|
||||
WHERE status IN ('pending', 'failed')
|
||||
AND retry_count < %s
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 50
|
||||
""", (MAX_RETRIES,))
|
||||
""",
|
||||
(MAX_RETRIES,),
|
||||
)
|
||||
items = cur.fetchall()
|
||||
|
||||
results = {'processed': 0, 'stamped': 0, 'failed': 0, 'details': []}
|
||||
results = {"processed": 0, "stamped": 0, "failed": 0, "details": []}
|
||||
|
||||
api_key = tenant_config.get('facturapi_key')
|
||||
api_key = tenant_config.get("facturapi_key")
|
||||
if not api_key:
|
||||
cur.close()
|
||||
raise ValueError("Facturapi key not configured for tenant")
|
||||
|
||||
for cfdi_id, sale_id, cfdi_type, payload_unsigned, retry_count in items:
|
||||
results['processed'] += 1
|
||||
for cfdi_id, _sale_id, _cfdi_type, payload_unsigned, _retry_count in items:
|
||||
results["processed"] += 1
|
||||
|
||||
# Update status to 'sending'
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE cfdi_queue SET status = 'sending' WHERE id = %s
|
||||
""", (cfdi_id,))
|
||||
""",
|
||||
(cfdi_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
payload = json.loads(payload_unsigned or '{}')
|
||||
payload = json.loads(payload_unsigned or "{}")
|
||||
if not payload:
|
||||
raise ValueError("Empty payload in queue item")
|
||||
|
||||
@@ -127,18 +136,19 @@ def process_queue(conn, tenant_config, dry_run=False):
|
||||
raise ValueError("dry_run is not supported with Facturapi")
|
||||
|
||||
invoice = facturapi_service.create_invoice(tenant_config, payload)
|
||||
invoice_id = invoice.get('id')
|
||||
uuid_fiscal = invoice.get('uuid')
|
||||
invoice_id = invoice.get("id")
|
||||
uuid_fiscal = invoice.get("uuid")
|
||||
|
||||
# Download signed XML for storage
|
||||
try:
|
||||
xml_signed = facturapi_service.download_xml(tenant_config, invoice_id)
|
||||
xml_signed_str = xml_signed.decode('utf-8') if isinstance(xml_signed, bytes) else str(xml_signed)
|
||||
xml_signed_str = xml_signed.decode("utf-8") if isinstance(xml_signed, bytes) else str(xml_signed)
|
||||
except Exception as xml_err:
|
||||
logger.warning("Could not download signed XML for %s: %s", invoice_id, xml_err)
|
||||
xml_signed_str = ''
|
||||
xml_signed_str = ""
|
||||
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE cfdi_queue
|
||||
SET status = 'stamped',
|
||||
xml_signed = %s,
|
||||
@@ -147,30 +157,37 @@ def process_queue(conn, tenant_config, dry_run=False):
|
||||
stamped_at = NOW(),
|
||||
error_message = NULL
|
||||
WHERE id = %s
|
||||
""", (xml_signed_str, uuid_fiscal, invoice_id, cfdi_id))
|
||||
""",
|
||||
(xml_signed_str, uuid_fiscal, invoice_id, cfdi_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
results['stamped'] += 1
|
||||
results['details'].append({
|
||||
'id': cfdi_id, 'status': 'stamped',
|
||||
'uuid': uuid_fiscal, 'external_id': invoice_id,
|
||||
})
|
||||
results["stamped"] += 1
|
||||
results["details"].append(
|
||||
{
|
||||
"id": cfdi_id,
|
||||
"status": "stamped",
|
||||
"uuid": uuid_fiscal,
|
||||
"external_id": invoice_id,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f'{type(e).__name__}: {str(e)[:500]}'
|
||||
cur.execute("""
|
||||
error_msg = f"{type(e).__name__}: {str(e)[:500]}"
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE cfdi_queue
|
||||
SET status = 'failed',
|
||||
retry_count = retry_count + 1,
|
||||
error_message = %s
|
||||
WHERE id = %s
|
||||
""", (error_msg, cfdi_id))
|
||||
""",
|
||||
(error_msg, cfdi_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
results['failed'] += 1
|
||||
results['details'].append({
|
||||
'id': cfdi_id, 'status': 'failed', 'error': error_msg
|
||||
})
|
||||
results["failed"] += 1
|
||||
results["details"].append({"id": cfdi_id, "status": "failed", "error": error_msg})
|
||||
|
||||
cur.close()
|
||||
return results
|
||||
@@ -184,30 +201,33 @@ def retry_failed(conn):
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, retry_count, created_at
|
||||
FROM cfdi_queue
|
||||
WHERE status = 'failed' AND retry_count < %s
|
||||
ORDER BY created_at ASC
|
||||
""", (MAX_RETRIES,))
|
||||
""",
|
||||
(MAX_RETRIES,),
|
||||
)
|
||||
items = cur.fetchall()
|
||||
|
||||
reset_count = 0
|
||||
now = datetime.utcnow()
|
||||
|
||||
for cfdi_id, retry_count, created_at in items:
|
||||
if retry_count < len(BACKOFF_INTERVALS):
|
||||
wait_seconds = BACKOFF_INTERVALS[retry_count]
|
||||
else:
|
||||
wait_seconds = BACKOFF_INTERVALS[-1]
|
||||
wait_seconds = BACKOFF_INTERVALS[retry_count] if retry_count < len(BACKOFF_INTERVALS) else BACKOFF_INTERVALS[-1]
|
||||
|
||||
# Use created_at as approximation for last attempt.
|
||||
# In production, track last_attempt_at separately.
|
||||
elapsed = (now - created_at).total_seconds()
|
||||
if elapsed >= wait_seconds:
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE cfdi_queue SET status = 'pending' WHERE id = %s
|
||||
""", (cfdi_id,))
|
||||
""",
|
||||
(cfdi_id,),
|
||||
)
|
||||
reset_count += 1
|
||||
|
||||
conn.commit()
|
||||
@@ -215,8 +235,7 @@ def retry_failed(conn):
|
||||
return reset_count
|
||||
|
||||
|
||||
def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
||||
tenant_config=None):
|
||||
def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, tenant_config=None):
|
||||
"""Cancel a stamped CFDI via Facturapi.
|
||||
|
||||
SAT cancellation motives:
|
||||
@@ -238,38 +257,44 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
||||
Raises:
|
||||
ValueError: on validation errors
|
||||
"""
|
||||
if motive not in ('01', '02', '03', '04'):
|
||||
if motive not in ("01", "02", "03", "04"):
|
||||
raise ValueError(f"Invalid SAT cancellation motive: {motive}")
|
||||
|
||||
if motive == '01' and not replacement_uuid:
|
||||
if motive == "01" and not replacement_uuid:
|
||||
raise ValueError("Motive 01 requires a replacement UUID")
|
||||
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, uuid_fiscal, external_id, status FROM cfdi_queue WHERE id = %s
|
||||
""", (cfdi_id,))
|
||||
""",
|
||||
(cfdi_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"CFDI queue item {cfdi_id} not found")
|
||||
|
||||
_, uuid_fiscal, external_id, current_status = row
|
||||
|
||||
if current_status == 'cancelled':
|
||||
if current_status == "cancelled":
|
||||
raise ValueError("CFDI is already cancelled")
|
||||
|
||||
if current_status != 'stamped':
|
||||
if current_status != "stamped":
|
||||
# If not stamped, we can just mark as cancelled locally
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE cfdi_queue
|
||||
SET status = 'cancelled', cancel_motive = %s
|
||||
WHERE id = %s
|
||||
""", (motive, cfdi_id))
|
||||
""",
|
||||
(motive, cfdi_id),
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return {'id': cfdi_id, 'status': 'cancelled', 'message': 'Cancelled locally (was not stamped)'}
|
||||
return {"id": cfdi_id, "status": "cancelled", "message": "Cancelled locally (was not stamped)"}
|
||||
|
||||
if not tenant_config or not tenant_config.get('facturapi_key'):
|
||||
if not tenant_config or not tenant_config.get("facturapi_key"):
|
||||
cur.close()
|
||||
raise ValueError("Facturapi key not configured for tenant")
|
||||
|
||||
@@ -279,36 +304,44 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
||||
|
||||
try:
|
||||
facturapi_service.cancel_invoice(
|
||||
tenant_config, external_id, motive,
|
||||
tenant_config,
|
||||
external_id,
|
||||
motive,
|
||||
replacement_uuid=replacement_uuid,
|
||||
)
|
||||
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE cfdi_queue
|
||||
SET status = 'cancelled',
|
||||
cancel_motive = %s,
|
||||
cancel_replacement_uuid = %s,
|
||||
error_message = NULL
|
||||
WHERE id = %s
|
||||
""", (motive, replacement_uuid, cfdi_id))
|
||||
""",
|
||||
(motive, replacement_uuid, cfdi_id),
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return {
|
||||
'id': cfdi_id,
|
||||
'status': 'cancelled',
|
||||
'message': f'Cancelled with SAT (motive {motive})',
|
||||
"id": cfdi_id,
|
||||
"status": "cancelled",
|
||||
"message": f"Cancelled with SAT (motive {motive})",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f'Cancel failed: {str(e)[:500]}'
|
||||
cur.execute("""
|
||||
error_msg = f"Cancel failed: {str(e)[:500]}"
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE cfdi_queue
|
||||
SET error_message = %s
|
||||
WHERE id = %s
|
||||
""", (error_msg, cfdi_id))
|
||||
""",
|
||||
(error_msg, cfdi_id),
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
raise ValueError(error_msg)
|
||||
raise ValueError(error_msg) from e
|
||||
|
||||
|
||||
def get_queue_status(conn, filters=None):
|
||||
@@ -316,30 +349,31 @@ def get_queue_status(conn, filters=None):
|
||||
filters = filters or {}
|
||||
cur = conn.cursor()
|
||||
|
||||
page = int(filters.get('page', 1))
|
||||
per_page = min(int(filters.get('per_page', 50)), 200)
|
||||
page = int(filters.get("page", 1))
|
||||
per_page = min(int(filters.get("per_page", 50)), 200)
|
||||
|
||||
where_clauses = ["1=1"]
|
||||
params = []
|
||||
|
||||
if filters.get('status'):
|
||||
if filters.get("status"):
|
||||
where_clauses.append("q.status = %s")
|
||||
params.append(filters['status'])
|
||||
params.append(filters["status"])
|
||||
|
||||
if filters.get('sale_id'):
|
||||
if filters.get("sale_id"):
|
||||
where_clauses.append("q.sale_id = %s")
|
||||
params.append(int(filters['sale_id']))
|
||||
params.append(int(filters["sale_id"]))
|
||||
|
||||
if filters.get('type'):
|
||||
if filters.get("type"):
|
||||
where_clauses.append("q.type = %s")
|
||||
params.append(filters['type'])
|
||||
params.append(filters["type"])
|
||||
|
||||
where = " AND ".join(where_clauses)
|
||||
|
||||
cur.execute(f"SELECT count(*) FROM cfdi_queue q WHERE {where}", params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
cur.execute(f"""
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
|
||||
q.retry_count, q.provisional_folio, q.error_message,
|
||||
q.cancel_motive, q.created_at, q.stamped_at, q.external_id
|
||||
@@ -347,26 +381,37 @@ def get_queue_status(conn, filters=None):
|
||||
WHERE {where}
|
||||
ORDER BY q.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""", params + [per_page, (page - 1) * per_page])
|
||||
""",
|
||||
params + [per_page, (page - 1) * per_page],
|
||||
)
|
||||
|
||||
items = []
|
||||
for r in cur.fetchall():
|
||||
items.append({
|
||||
'id': r[0], 'sale_id': r[1], 'type': r[2],
|
||||
'uuid_fiscal': r[3], 'status': r[4],
|
||||
'retry_count': r[5], 'provisional_folio': r[6],
|
||||
'error_message': r[7], 'cancel_motive': r[8],
|
||||
'created_at': str(r[9]) if r[9] else None,
|
||||
'stamped_at': str(r[10]) if r[10] else None,
|
||||
'external_id': r[11],
|
||||
})
|
||||
items.append(
|
||||
{
|
||||
"id": r[0],
|
||||
"sale_id": r[1],
|
||||
"type": r[2],
|
||||
"uuid_fiscal": r[3],
|
||||
"status": r[4],
|
||||
"retry_count": r[5],
|
||||
"provisional_folio": r[6],
|
||||
"error_message": r[7],
|
||||
"cancel_motive": r[8],
|
||||
"created_at": str(r[9]) if r[9] else None,
|
||||
"stamped_at": str(r[10]) if r[10] else None,
|
||||
"external_id": r[11],
|
||||
}
|
||||
)
|
||||
|
||||
cur.close()
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
return {
|
||||
'data': items,
|
||||
'pagination': {
|
||||
'page': page, 'per_page': per_page,
|
||||
'total': total, 'total_pages': total_pages,
|
||||
}
|
||||
"data": items,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": total,
|
||||
"total_pages": total_pages,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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