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:
@@ -10,11 +10,14 @@ from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.cfdi_builder import build_ingreso_xml, build_egreso_xml, build_pago_xml
|
||||
from services.cfdi_facturapi_builder import (
|
||||
build_ingreso_payload, build_egreso_payload, build_pago_payload,
|
||||
)
|
||||
from services.cfdi_queue import (
|
||||
enqueue_cfdi, process_queue, retry_failed,
|
||||
cancel_cfdi, get_queue_status,
|
||||
)
|
||||
from services import facturapi_service
|
||||
from services.audit import log_action
|
||||
|
||||
invoicing_bp = Blueprint('invoicing', __name__, url_prefix='/pos/api/invoicing')
|
||||
@@ -38,8 +41,8 @@ def _get_issuer_config(cur, branch_id=None):
|
||||
'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'),
|
||||
'cp': config.get('tenant_cp', '00000'),
|
||||
'serie': config.get('cfdi_serie', 'A'),
|
||||
'horux_api_url': config.get('cfdi_horux_api_url', ''),
|
||||
'horux_api_key': config.get('cfdi_horux_api_key', ''),
|
||||
'facturapi_key': config.get('cfdi_facturapi_key', ''),
|
||||
'facturapi_org_id': config.get('cfdi_facturapi_org_id', ''),
|
||||
}
|
||||
|
||||
# Branch-level override
|
||||
@@ -177,19 +180,19 @@ def generate_invoice():
|
||||
'error': f'Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})'
|
||||
}), 409
|
||||
|
||||
# Build XML
|
||||
# Build Facturapi payload
|
||||
if cfdi_type == 'ingreso':
|
||||
xml = build_ingreso_xml(sale, tenant_config, customer)
|
||||
payload = build_ingreso_payload(sale, tenant_config, customer)
|
||||
elif cfdi_type == 'egreso':
|
||||
original_uuid = data.get('original_uuid')
|
||||
if not original_uuid:
|
||||
return jsonify({'error': 'original_uuid required for egreso'}), 400
|
||||
xml = build_egreso_xml(sale, tenant_config, customer, original_uuid)
|
||||
payload = build_egreso_payload(sale, tenant_config, customer, original_uuid)
|
||||
else:
|
||||
return jsonify({'error': f'Invalid CFDI type: {cfdi_type}'}), 400
|
||||
|
||||
# Enqueue
|
||||
result = enqueue_cfdi(conn, sale_id, cfdi_type, xml)
|
||||
result = enqueue_cfdi(conn, sale_id, cfdi_type, payload)
|
||||
|
||||
log_action(conn, 'CFDI_GENERATED', 'cfdi_queue', result['id'],
|
||||
new_value={'sale_id': sale_id, 'type': cfdi_type,
|
||||
@@ -244,10 +247,10 @@ def get_queue_item(cfdi_id):
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT q.id, q.sale_id, q.type, q.xml_unsigned, q.xml_signed,
|
||||
SELECT q.id, q.sale_id, q.type, q.payload_unsigned, q.xml_signed,
|
||||
q.uuid_fiscal, q.status, q.retry_count, q.provisional_folio,
|
||||
q.error_message, q.cancel_motive, q.cancel_replacement_uuid,
|
||||
q.created_at, q.stamped_at
|
||||
q.created_at, q.stamped_at, q.external_id
|
||||
FROM cfdi_queue q WHERE q.id = %s
|
||||
""", (cfdi_id,))
|
||||
row = cur.fetchone()
|
||||
@@ -258,13 +261,14 @@ def get_queue_item(cfdi_id):
|
||||
|
||||
item = {
|
||||
'id': row[0], 'sale_id': row[1], 'type': row[2],
|
||||
'xml_unsigned': row[3], 'xml_signed': row[4],
|
||||
'payload_unsigned': row[3], 'xml_signed': row[4],
|
||||
'uuid_fiscal': row[5], 'status': row[6],
|
||||
'retry_count': row[7], 'provisional_folio': row[8],
|
||||
'error_message': row[9], 'cancel_motive': row[10],
|
||||
'cancel_replacement_uuid': row[11],
|
||||
'created_at': str(row[12]) if row[12] else None,
|
||||
'stamped_at': str(row[13]) if row[13] else None,
|
||||
'external_id': row[14],
|
||||
}
|
||||
|
||||
cur.close()
|
||||
@@ -281,19 +285,16 @@ def trigger_process_queue():
|
||||
|
||||
try:
|
||||
tenant_config = _get_issuer_config(cur)
|
||||
horux_url = tenant_config.get('horux_api_url')
|
||||
horux_key = tenant_config.get('horux_api_key')
|
||||
|
||||
if not horux_url or not horux_key:
|
||||
if not tenant_config.get('facturapi_key'):
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'error': 'Horux API not configured'}), 400
|
||||
return jsonify({'error': 'Facturapi key not configured'}), 400
|
||||
|
||||
# Reset eligible failed items first
|
||||
reset_count = retry_failed(conn)
|
||||
|
||||
# Process the queue
|
||||
result = process_queue(conn, horux_url, horux_key)
|
||||
result = process_queue(conn, tenant_config)
|
||||
result['retries_reset'] = reset_count
|
||||
|
||||
cur.close()
|
||||
@@ -338,8 +339,7 @@ def cancel_invoice(cfdi_id):
|
||||
tenant_config = _get_issuer_config(cur)
|
||||
result = cancel_cfdi(
|
||||
conn, cfdi_id, motive, replacement_uuid,
|
||||
tenant_config.get('horux_api_url'),
|
||||
tenant_config.get('horux_api_key'),
|
||||
tenant_config=tenant_config,
|
||||
)
|
||||
|
||||
log_action(conn, 'CFDI_CANCELLED', 'cfdi_queue', cfdi_id,
|
||||
@@ -542,3 +542,69 @@ def get_eligible_sales_for_global():
|
||||
'total': sum(s['total'] for s in sales),
|
||||
'sales': [{'id': s['id'], 'total': s['total'], 'created_at': s['created_at']} for s in sales],
|
||||
})
|
||||
|
||||
|
||||
# ─── Facturapi extras ───────────────────────────────
|
||||
|
||||
@invoicing_bp.route('/facturapi/status', methods=['GET'])
|
||||
@require_auth('invoicing.view')
|
||||
def facturapi_status():
|
||||
"""Return Facturapi organization status for the tenant."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
tenant_config = _get_issuer_config(cur)
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
status = facturapi_service.get_org_status(tenant_config)
|
||||
return jsonify(status)
|
||||
|
||||
|
||||
@invoicing_bp.route('/facturapi/download/<int:cfdi_id>/<doc_type>', methods=['GET'])
|
||||
@require_auth('invoicing.view')
|
||||
def facturapi_download(cfdi_id, doc_type):
|
||||
"""Download PDF or XML for a stamped CFDI from Facturapi.
|
||||
|
||||
doc_type: 'pdf' | 'xml'
|
||||
"""
|
||||
if doc_type not in ('pdf', 'xml'):
|
||||
return jsonify({'error': "doc_type must be 'pdf' or 'xml'"}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT external_id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s
|
||||
""", (cfdi_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'CFDI not found'}), 404
|
||||
|
||||
external_id, uuid_fiscal, status = row
|
||||
if status != 'stamped' or not external_id:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'CFDI is not stamped or has no external id'}), 400
|
||||
|
||||
tenant_config = _get_issuer_config(cur)
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
try:
|
||||
if doc_type == 'pdf':
|
||||
content = facturapi_service.download_pdf(tenant_config, external_id)
|
||||
mime = 'application/pdf'
|
||||
filename = f'cfdi_{uuid_fiscal or external_id}.pdf'
|
||||
else:
|
||||
content = facturapi_service.download_xml(tenant_config, external_id)
|
||||
mime = 'application/xml'
|
||||
filename = f'cfdi_{uuid_fiscal or external_id}.xml'
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
from flask import Response
|
||||
return Response(
|
||||
content,
|
||||
mimetype=mime,
|
||||
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user