feat(pos): migrate CFDI timbrado from Horux to Facturapi

- Add Facturapi REST service (invoices, customers, orgs, cancel, downloads)
- Add JSON payload builder for ingreso/egreso/pago/global invoices
- Replace XML queue with Facturapi JSON queue (payload_unsigned, external_id)
- Update invoicing blueprint with Facturapi config and download endpoints
- Update global invoice service to use Facturapi payloads
- Add migration v4.3_facturapi.sql and tenant rollout script
- Update invoicing UI: payload preview, PDF/XML downloads, PAC status panel
- Add FACTURAPI_USER_KEY to .env.example
This commit is contained in:
2026-06-14 09:26:42 +00:00
parent 3378d26a31
commit 8796cadb56
11 changed files with 956 additions and 179 deletions

View File

@@ -1,25 +1,25 @@
# /home/Autopartes/pos/services/cfdi_queue.py
"""CFDI queue service: manages the timbrado pipeline.
"""CFDI queue service: manages the Facturapi timbrado pipeline.
Flow:
1. enqueue_cfdi() — inserts XML into cfdi_queue with status='pending'
2. process_queue() — sends pending items to Horux API, updates status
1. enqueue_cfdi() — inserts Facturapi JSON payload into cfdi_queue with status='pending'
2. process_queue() — sends pending items to Facturapi, updates status
3. retry_failed() — retries failed items with exponential backoff
4. cancel_cfdi() — sends cancel request to Horux API
4. cancel_cfdi() — cancels a stamped CFDI via Facturapi
Horux API endpoints:
POST /api/nexus/cfdi/stamp — send unsigned XML, receive signed+timbrado
GET /api/nexus/cfdi/status/:uuid — check timbrado status
POST /api/nexus/cfdi/cancel — cancel CFDI with SAT motive code
Facturapi endpoints used:
POST /v2/invoices — create and stamp an invoice
GET /v2/invoices/:id — fetch invoice metadata
DELETE /v2/invoices/:id — cancel with SAT motive
Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries)
"""
import json
import logging
import time
from datetime import datetime, timedelta
import requests
from services import facturapi_service
logger = logging.getLogger(__name__)
@@ -29,10 +29,7 @@ MAX_RETRIES = len(BACKOFF_INTERVALS)
def _generate_provisional_folio(conn):
"""Generate a provisional folio like PRE-00001.
Uses the cfdi_queue table's max id to avoid collisions.
"""
"""Generate a provisional folio like PRE-00001."""
cur = conn.cursor()
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
seq = cur.fetchone()[0]
@@ -40,14 +37,14 @@ def _generate_provisional_folio(conn):
return f'PRE-{seq:05d}'
def enqueue_cfdi(conn, sale_id, cfdi_type, xml):
def enqueue_cfdi(conn, sale_id, cfdi_type, payload):
"""Add a CFDI to the timbrado queue.
Args:
conn: psycopg2 connection
sale_id: int (FK to sales)
sale_id: int (FK to sales), may be None for global invoices
cfdi_type: 'ingreso' | 'egreso' | 'pago'
xml: str (unsigned XML from cfdi_builder)
payload: dict (Facturapi JSON payload) or str (JSON string)
Returns:
dict: {id, sale_id, type, status, provisional_folio}
@@ -55,12 +52,14 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, xml):
provisional_folio = _generate_provisional_folio(conn)
cur = conn.cursor()
payload_json = payload if isinstance(payload, str) else json.dumps(payload)
cur.execute("""
INSERT INTO cfdi_queue
(sale_id, type, xml_unsigned, status, provisional_folio)
(sale_id, type, payload_unsigned, status, provisional_folio)
VALUES (%s, %s, %s, 'pending', %s)
RETURNING id, created_at
""", (sale_id, cfdi_type, xml, provisional_folio))
""", (sale_id, cfdi_type, payload_json, provisional_folio))
cfdi_id, created_at = cur.fetchone()
cur.close()
@@ -74,17 +73,17 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, xml):
}
def process_queue(conn, horux_api_url, api_key):
def process_queue(conn, tenant_config, dry_run=False):
"""Process all pending CFDI items in the queue.
Sends each pending XML to Horux for timbrado. On success, updates
Sends each pending payload to Facturapi for timbrado. On success, updates
the record with the signed XML and UUID fiscal. On failure, increments
retry_count and records the error.
Args:
conn: psycopg2 connection
horux_api_url: str base URL for Horux API (e.g. 'https://horux.example.com')
api_key: str Horux API key
tenant_config: dict with facturapi_key (and optional facturapi_org_id)
dry_run: if True, validates payload without stamping
Returns:
dict: {processed: int, stamped: int, failed: int, details: [...]}
@@ -92,7 +91,7 @@ def process_queue(conn, horux_api_url, api_key):
cur = conn.cursor()
cur.execute("""
SELECT id, sale_id, type, xml_unsigned, retry_count
SELECT id, sale_id, type, payload_unsigned, retry_count
FROM cfdi_queue
WHERE status IN ('pending', 'failed')
AND retry_count < %s
@@ -103,7 +102,12 @@ def process_queue(conn, horux_api_url, api_key):
results = {'processed': 0, 'stamped': 0, 'failed': 0, 'details': []}
for cfdi_id, sale_id, cfdi_type, xml_unsigned, retry_count in items:
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
# Update status to 'sending'
@@ -113,54 +117,47 @@ def process_queue(conn, horux_api_url, api_key):
conn.commit()
try:
response = requests.post(
f'{horux_api_url}/api/nexus/cfdi/stamp',
headers={
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/xml',
},
data=xml_unsigned.encode('utf-8'),
timeout=30,
)
payload = json.loads(payload_unsigned or '{}')
if not payload:
raise ValueError("Empty payload in queue item")
if response.status_code == 200:
data = response.json()
uuid_fiscal = data.get('uuid')
xml_signed = data.get('xml', '')
if dry_run:
# TODO: Facturapi dry-run validation (not officially supported)
# For now we just skip the API call and mark as stamped with a fake UUID
raise ValueError("dry_run is not supported with Facturapi")
cur.execute("""
UPDATE cfdi_queue
SET status = 'stamped',
xml_signed = %s,
uuid_fiscal = %s,
stamped_at = NOW(),
error_message = NULL
WHERE id = %s
""", (xml_signed, uuid_fiscal, cfdi_id))
conn.commit()
invoice = facturapi_service.create_invoice(tenant_config, payload)
invoice_id = invoice.get('id')
uuid_fiscal = invoice.get('uuid')
results['stamped'] += 1
results['details'].append({
'id': cfdi_id, 'status': 'stamped', 'uuid': uuid_fiscal
})
else:
error_msg = f'HTTP {response.status_code}: {response.text[:500]}'
cur.execute("""
UPDATE cfdi_queue
SET status = 'failed',
retry_count = retry_count + 1,
error_message = %s
WHERE id = %s
""", (error_msg, cfdi_id))
conn.commit()
# 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)
except Exception as xml_err:
logger.warning("Could not download signed XML for %s: %s", invoice_id, xml_err)
xml_signed_str = ''
results['failed'] += 1
results['details'].append({
'id': cfdi_id, 'status': 'failed', 'error': error_msg
})
cur.execute("""
UPDATE cfdi_queue
SET status = 'stamped',
xml_signed = %s,
uuid_fiscal = %s,
external_id = %s,
stamped_at = NOW(),
error_message = NULL
WHERE id = %s
""", (xml_signed_str, uuid_fiscal, invoice_id, cfdi_id))
conn.commit()
except requests.RequestException as e:
error_msg = f'Connection error: {str(e)[:500]}'
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("""
UPDATE cfdi_queue
SET status = 'failed',
@@ -180,20 +177,13 @@ def process_queue(conn, horux_api_url, api_key):
def retry_failed(conn):
"""Find failed items eligible for retry (based on backoff) and reset to pending.
"""Find failed items eligible for retry and reset to pending.
Uses exponential backoff: item is eligible for retry only if enough
time has passed since the last attempt based on retry_count.
Args:
conn: psycopg2 connection
Returns:
int: number of items reset to pending
"""
cur = conn.cursor()
# For each failed item, check if enough time has passed for its retry level
cur.execute("""
SELECT id, retry_count, created_at
FROM cfdi_queue
@@ -206,15 +196,15 @@ def retry_failed(conn):
now = datetime.utcnow()
for cfdi_id, retry_count, created_at in items:
# Calculate required wait time based on retry count
if retry_count < len(BACKOFF_INTERVALS):
wait_seconds = BACKOFF_INTERVALS[retry_count]
else:
wait_seconds = BACKOFF_INTERVALS[-1] # max backoff
wait_seconds = BACKOFF_INTERVALS[-1]
# Check if enough time has passed (use created_at as approximation)
# In production, you'd track last_attempt_at separately
if True: # Always eligible for manual retry trigger
# 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("""
UPDATE cfdi_queue SET status = 'pending' WHERE id = %s
""", (cfdi_id,))
@@ -226,8 +216,8 @@ def retry_failed(conn):
def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
horux_api_url=None, api_key=None):
"""Cancel a stamped CFDI via Horux API.
tenant_config=None):
"""Cancel a stamped CFDI via Facturapi.
SAT cancellation motives:
01: Comprobante emitido con errores con relacion (requires replacement UUID)
@@ -240,8 +230,7 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
cfdi_id: int (cfdi_queue.id)
motive: str ('01', '02', '03', '04')
replacement_uuid: str (required if motive == '01')
horux_api_url: str (optional, skips API call if None — for offline)
api_key: str (optional)
tenant_config: dict with facturapi_key
Returns:
dict: {id, status, message}
@@ -258,13 +247,13 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
cur = conn.cursor()
cur.execute("""
SELECT id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s
SELECT id, uuid_fiscal, external_id, status FROM cfdi_queue WHERE id = %s
""", (cfdi_id,))
row = cur.fetchone()
if not row:
raise ValueError(f"CFDI queue item {cfdi_id} not found")
_, uuid_fiscal, current_status = row
_, uuid_fiscal, external_id, current_status = row
if current_status == 'cancelled':
raise ValueError("CFDI is already cancelled")
@@ -280,64 +269,26 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
cur.close()
return {'id': cfdi_id, 'status': 'cancelled', 'message': 'Cancelled locally (was not stamped)'}
# Send cancel request to Horux
if horux_api_url and api_key:
try:
payload = {
'uuid': uuid_fiscal,
'motive': motive,
}
if replacement_uuid:
payload['replacement_uuid'] = replacement_uuid
if not tenant_config or not tenant_config.get('facturapi_key'):
cur.close()
raise ValueError("Facturapi key not configured for tenant")
response = requests.post(
f'{horux_api_url}/api/nexus/cfdi/cancel',
headers={
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
},
json=payload,
timeout=30,
)
if not external_id:
cur.close()
raise ValueError("Cannot cancel: no Facturapi invoice id stored")
if response.status_code == 200:
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))
conn.commit()
cur.close()
return {
'id': cfdi_id,
'status': 'cancelled',
'message': f'Cancelled with SAT (motive {motive})',
}
else:
error_msg = f'Cancel failed: HTTP {response.status_code}: {response.text[:500]}'
cur.execute("""
UPDATE cfdi_queue
SET error_message = %s
WHERE id = %s
""", (error_msg, cfdi_id))
conn.commit()
cur.close()
raise ValueError(error_msg)
try:
facturapi_service.cancel_invoice(
tenant_config, external_id, motive,
replacement_uuid=replacement_uuid,
)
except requests.RequestException as e:
cur.close()
raise ValueError(f'Connection error during cancel: {str(e)}')
else:
# Offline mode: mark as cancelled locally, will sync later
cur.execute("""
UPDATE cfdi_queue
SET status = 'cancelled',
cancel_motive = %s,
cancel_replacement_uuid = %s,
error_message = 'Cancelled offline, pending SAT sync'
error_message = NULL
WHERE id = %s
""", (motive, replacement_uuid, cfdi_id))
conn.commit()
@@ -345,24 +296,23 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
return {
'id': cfdi_id,
'status': 'cancelled',
'message': 'Cancelled offline, pending SAT sync',
'message': f'Cancelled with SAT (motive {motive})',
}
except Exception as e:
error_msg = f'Cancel failed: {str(e)[:500]}'
cur.execute("""
UPDATE cfdi_queue
SET error_message = %s
WHERE id = %s
""", (error_msg, cfdi_id))
conn.commit()
cur.close()
raise ValueError(error_msg)
def get_queue_status(conn, filters=None):
"""Get CFDI queue items with optional filters.
Args:
conn: psycopg2 connection
filters: dict with optional keys:
status: str filter by status
sale_id: int filter by sale
page: int (default 1)
per_page: int (default 50)
Returns:
dict: {data: [...], pagination: {...}}
"""
"""Get CFDI queue items with optional filters."""
filters = filters or {}
cur = conn.cursor()
@@ -392,7 +342,7 @@ def get_queue_status(conn, filters=None):
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.cancel_motive, q.created_at, q.stamped_at, q.external_id
FROM cfdi_queue q
WHERE {where}
ORDER BY q.created_at DESC
@@ -408,6 +358,7 @@ def get_queue_status(conn, filters=None):
'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()