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:
2026-06-15 04:58:42 +00:00
parent 6aff32f93b
commit d67887284d
15 changed files with 1559 additions and 481 deletions

View File

@@ -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,
},
}