Files
Autoparts-DB/pos/blueprints/invoicing_bp.py
consultoria-as d67887284d 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).
2026-06-15 04:58:42 +00:00

819 lines
24 KiB
Python

# /home/Autopartes/pos/blueprints/invoicing_bp.py
"""Invoicing blueprint: CFDI generation, queue management, cancellation.
All CFDI business logic lives in services (cfdi_builder, cfdi_queue).
This blueprint is the HTTP layer that validates input and returns JSON.
"""
import base64
from datetime import datetime
from flask import Blueprint, g, jsonify, request
from middleware import require_auth
from services import facturapi_service
from services.audit import log_action
from services.cfdi_facturapi_builder import (
build_egreso_payload,
build_ingreso_payload,
)
from services.cfdi_queue import (
cancel_cfdi,
enqueue_cfdi,
get_queue_status,
process_queue,
retry_failed,
)
from tenant_db import get_tenant_conn
invoicing_bp = Blueprint("invoicing", __name__, url_prefix="/pos/api/invoicing")
def _get_issuer_config(cur, branch_id=None):
"""Load CFDI issuer configuration.
If branch_id is provided and the branch has fiscal data, use it.
Otherwise fall back to tenant-level config.
"""
# Tenant-level defaults
config = {}
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'cfdi_%' OR key LIKE 'tenant_%'")
for row in cur.fetchall():
config[row[0]] = row[1]
result = {
"rfc": config.get("tenant_rfc", ""),
"razon_social": config.get("tenant_razon_social", ""),
"regimen_fiscal": config.get("cfdi_regimen_fiscal", "601"),
"cp": config.get("tenant_cp", "00000"),
"serie": config.get("cfdi_serie", "A"),
"facturapi_key": config.get("cfdi_facturapi_key", ""),
"facturapi_org_id": config.get("cfdi_facturapi_org_id", ""),
}
# Branch-level override
if branch_id:
cur.execute(
"""
SELECT rfc, razon_social, regimen_fiscal, codigo_postal, serie_cfdi
FROM branches WHERE id = %s
""",
(branch_id,),
)
row = cur.fetchone()
if row and row[0]:
result["rfc"] = row[0] or result["rfc"]
result["razon_social"] = row[1] or result["razon_social"]
result["regimen_fiscal"] = row[2] or result["regimen_fiscal"]
result["cp"] = row[3] or result["cp"]
result["serie"] = row[4] or result["serie"]
return result
def _get_sale_with_items(cur, sale_id):
"""Load a sale with its items for CFDI generation."""
cur.execute(
"""
SELECT id, branch_id, customer_id, employee_id, sale_type,
payment_method, subtotal, discount_total, tax_total, total,
metodo_pago_sat, forma_pago_sat, status, created_at
FROM sales WHERE id = %s
""",
(sale_id,),
)
row = cur.fetchone()
if not row:
return None
sale = {
"id": row[0],
"branch_id": row[1],
"customer_id": row[2],
"employee_id": row[3],
"sale_type": row[4],
"payment_method": row[5],
"subtotal": float(row[6]) if row[6] else 0,
"discount_total": float(row[7]) if row[7] else 0,
"tax_total": float(row[8]) if row[8] else 0,
"total": float(row[9]) if row[9] else 0,
"metodo_pago_sat": row[10] or "PUE",
"forma_pago_sat": row[11] or "01",
"status": row[12],
"created_at": str(row[13]),
}
cur.execute(
"""
SELECT id, inventory_id, part_number, name, quantity, unit_price,
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount,
subtotal, clave_prod_serv, clave_unidad
FROM sale_items WHERE sale_id = %s ORDER BY id
""",
(sale_id,),
)
sale["items"] = []
for r in cur.fetchall():
sale["items"].append(
{
"id": r[0],
"inventory_id": r[1],
"part_number": r[2],
"name": r[3],
"quantity": r[4],
"unit_price": float(r[5]) if r[5] else 0,
"unit_cost": float(r[6]) if r[6] else 0,
"discount_pct": float(r[7]) if r[7] else 0,
"discount_amount": float(r[8]) if r[8] else 0,
"tax_rate": float(r[9]) if r[9] else 0.16,
"tax_amount": float(r[10]) if r[10] else 0,
"subtotal": float(r[11]) if r[11] else 0,
"clave_prod_serv": r[12] or "25174800",
"clave_unidad": r[13] or "H87",
}
)
return sale
def _get_customer(cur, customer_id):
"""Load customer data for CFDI receptor."""
if not customer_id:
return None
cur.execute(
"""
SELECT id, name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp
FROM customers WHERE id = %s
""",
(customer_id,),
)
row = cur.fetchone()
if not row:
return None
return {
"id": row[0],
"name": row[1],
"rfc": row[2],
"razon_social": row[3],
"regimen_fiscal": row[4],
"uso_cfdi": row[5] or "G03",
"cp": row[6],
}
# ─── Generate CFDI ─────────────────────────────────
@invoicing_bp.route("/invoice", methods=["POST"])
@require_auth("invoicing.create")
def generate_invoice():
"""Generate a CFDI for a sale and enqueue for timbrado.
Body: {
sale_id: int,
type: 'ingreso' (default) | 'egreso',
original_uuid: str (required for egreso)
}
"""
data = request.get_json() or {}
sale_id = data.get("sale_id")
cfdi_type = data.get("type", "ingreso")
if not sale_id:
return jsonify({"error": "sale_id is required"}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
sale = _get_sale_with_items(cur, sale_id)
if not sale:
return jsonify({"error": "Sale not found"}), 404
tenant_config = _get_issuer_config(cur, sale.get("branch_id"))
if not tenant_config["rfc"]:
return jsonify({"error": "Tenant RFC not configured. Set tenant_rfc in config."}), 400
if sale["status"] == "cancelled":
return jsonify({"error": "Cannot invoice a cancelled sale"}), 400
customer = _get_customer(cur, sale.get("customer_id"))
# Check if this sale already has a stamped CFDI
cur.execute(
"""
SELECT id, status FROM cfdi_queue
WHERE sale_id = %s AND type = %s AND status NOT IN ('cancelled', 'failed')
""",
(sale_id, cfdi_type),
)
existing = cur.fetchone()
if existing:
return jsonify(
{
"error": f"Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})"
}
), 409
# Build Facturapi payload
if cfdi_type == "ingreso":
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
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, payload)
log_action(
conn,
"CFDI_GENERATED",
"cfdi_queue",
result["id"],
new_value={"sale_id": sale_id, "type": cfdi_type, "folio": result["provisional_folio"]},
)
conn.commit()
cur.close()
conn.close()
return jsonify(result), 201
except ValueError as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({"error": str(e)}), 400
except Exception as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({"error": str(e)}), 500
# ─── Queue Management ──────────────────────────────
@invoicing_bp.route("/queue", methods=["GET"])
@require_auth("invoicing.view")
def list_queue():
"""List CFDI queue items.
Query params: status, sale_id, type, page, per_page
"""
conn = get_tenant_conn(g.tenant_id)
filters = {
"status": request.args.get("status"),
"sale_id": request.args.get("sale_id"),
"type": request.args.get("type"),
"page": request.args.get("page", 1),
"per_page": request.args.get("per_page", 50),
}
result = get_queue_status(conn, filters)
conn.close()
return jsonify(result)
@invoicing_bp.route("/queue/<int:cfdi_id>", methods=["GET"])
@require_auth("invoicing.view")
def get_queue_item(cfdi_id):
"""Get CFDI queue item detail (includes XML)."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute(
"""
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.external_id
FROM cfdi_queue q WHERE q.id = %s
""",
(cfdi_id,),
)
row = cur.fetchone()
if not row:
cur.close()
conn.close()
return jsonify({"error": "CFDI queue item not found"}), 404
item = {
"id": row[0],
"sale_id": row[1],
"type": row[2],
"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()
conn.close()
return jsonify(item)
@invoicing_bp.route("/queue/process", methods=["POST"])
@require_auth("invoicing.create")
def trigger_process_queue():
"""Manually trigger processing of pending CFDI queue items."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
tenant_config = _get_issuer_config(cur)
if not tenant_config.get("facturapi_key"):
cur.close()
conn.close()
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, tenant_config)
result["retries_reset"] = reset_count
cur.close()
conn.close()
return jsonify(result)
except Exception as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({"error": str(e)}), 500
# ─── Cancel CFDI ────────────────────────────────────
@invoicing_bp.route("/cancel/<int:cfdi_id>", methods=["POST"])
@require_auth("invoicing.delete")
def cancel_invoice(cfdi_id):
"""Cancel a CFDI with SAT motive code.
Body: {
motive: '01' | '02' | '03' | '04',
replacement_uuid: str (required if motive == '01')
}
Only owner and admin can cancel CFDIs.
"""
if g.employee_role not in ("owner", "admin"):
return jsonify({"error": "Only owner or admin can cancel CFDIs"}), 403
data = request.get_json() or {}
motive = data.get("motive")
replacement_uuid = data.get("replacement_uuid")
if not motive:
return jsonify({"error": "motive is required"}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
tenant_config = _get_issuer_config(cur)
result = cancel_cfdi(
conn,
cfdi_id,
motive,
replacement_uuid,
tenant_config=tenant_config,
)
log_action(
conn,
"CFDI_CANCELLED",
"cfdi_queue",
cfdi_id,
new_value={"motive": motive, "replacement_uuid": replacement_uuid},
)
conn.commit()
cur.close()
conn.close()
return jsonify(result)
except ValueError as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({"error": str(e)}), 400
except Exception as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({"error": str(e)}), 500
# ─── PDF Generation ─────────────────────────────────
@invoicing_bp.route("/<int:sale_id>/pdf", methods=["GET"])
@require_auth("invoicing.view")
def get_sale_pdf(sale_id):
"""Generate a PDF representation of the sale/CFDI.
Returns an HTML page styled for print/PDF generation.
For actual PDF file generation, the frontend uses window.print() or
a headless browser. This endpoint returns the formatted HTML.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
sale = _get_sale_with_items(cur, sale_id)
if not sale:
cur.close()
conn.close()
return jsonify({"error": "Sale not found"}), 404
tenant_config = _get_issuer_config(cur, sale.get("branch_id"))
customer = _get_customer(cur, sale.get("customer_id"))
# Check if there's a stamped CFDI
cur.execute(
"""
SELECT uuid_fiscal, provisional_folio, status, stamped_at
FROM cfdi_queue
WHERE sale_id = %s AND type = 'ingreso' AND status = 'stamped'
ORDER BY stamped_at DESC LIMIT 1
""",
(sale_id,),
)
cfdi_row = cur.fetchone()
cfdi_info = None
if cfdi_row:
cfdi_info = {
"uuid_fiscal": cfdi_row[0],
"provisional_folio": cfdi_row[1],
"status": cfdi_row[2],
"stamped_at": str(cfdi_row[3]) if cfdi_row[3] else None,
}
cur.close()
conn.close()
return jsonify(
{
"sale": sale,
"tenant": {
"rfc": tenant_config.get("rfc", ""),
"razon_social": tenant_config.get("razon_social", ""),
"regimen_fiscal": tenant_config.get("regimen_fiscal", ""),
"cp": tenant_config.get("cp", ""),
},
"customer": customer,
"cfdi": cfdi_info,
}
)
@invoicing_bp.route("/stats", methods=["GET"])
@require_auth("invoicing.read")
def api_invoicing_stats():
"""Return counts for tab badges: invoices, credit notes, payment complements, cancellations."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT
COUNT(*) FILTER (WHERE type = 'ingreso' AND status IN ('pending', 'stamped', 'retry')) as facturas,
COUNT(*) FILTER (WHERE type = 'egreso' AND status IN ('pending', 'stamped', 'retry')) as notas_credito,
COUNT(*) FILTER (WHERE type = 'pago' AND status IN ('pending', 'stamped', 'retry')) as complementos,
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelaciones
FROM cfdi_queue
""")
row = cur.fetchone()
cur.close()
conn.close()
return jsonify(
{
"facturas": row[0] or 0,
"notas_credito": row[1] or 0,
"complementos": row[2] or 0,
"cancelaciones": row[3] or 0,
}
)
@invoicing_bp.route("/global-invoice", methods=["POST"])
@require_auth("invoicing.create")
def generate_global_invoice():
"""Generate a monthly global invoice for cash sales.
Body: {
year: int (default current year),
month: int (default current month),
branch_id: int (optional)
}
"""
data = request.get_json() or {}
now = datetime.now()
year = data.get("year", now.year)
month = data.get("month", now.month)
branch_id = data.get("branch_id")
try:
year = int(year)
month = int(month)
if month < 1 or month > 12:
return jsonify({"error": "month must be 1-12"}), 400
except (ValueError, TypeError):
return jsonify({"error": "year and month must be integers"}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
tenant_config = _get_issuer_config(cur, branch_id)
if not tenant_config["rfc"]:
cur.close()
conn.close()
return jsonify({"error": "Tenant RFC not configured"}), 400
from services.global_invoice import generate_global_invoice
result = generate_global_invoice(
conn, tenant_config, year, month, branch_id=branch_id, employee_id=getattr(g, "employee_id", None)
)
if "error" in result:
cur.close()
conn.close()
return jsonify(result), 400
log_action(
conn,
"GLOBAL_INVOICE_CREATE",
"cfdi_queue",
result["id"],
new_value={"year": year, "month": month, "sales_count": result["sales_count"]},
)
conn.commit()
cur.close()
conn.close()
return jsonify(result), 201
@invoicing_bp.route("/global-invoice/<int:cfdi_id>", methods=["GET"])
@require_auth("invoicing.view")
def get_global_invoice(cfdi_id):
"""Get status and linked sales of a global invoice."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
from services.global_invoice import get_global_invoice_status
result = get_global_invoice_status(conn, cfdi_id)
cur.close()
conn.close()
if not result:
return jsonify({"error": "Global invoice not found"}), 404
return jsonify(result)
@invoicing_bp.route("/global-invoice/eligible-sales", methods=["GET"])
@require_auth("invoicing.view")
def get_eligible_sales_for_global():
"""Preview sales that would be included in a global invoice.
Query params: year, month, branch_id
"""
now = datetime.now()
year = request.args.get("year", now.year, type=int)
month = request.args.get("month", now.month, type=int)
branch_id = request.args.get("branch_id", type=int)
conn = get_tenant_conn(g.tenant_id)
from services.global_invoice import get_eligible_sales
sales = get_eligible_sales(conn, year, month, branch_id)
conn.close()
return jsonify(
{
"year": year,
"month": month,
"count": len(sales),
"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/setup", methods=["POST"])
@require_auth("invoicing.create")
def facturapi_setup():
"""Create or link a Facturapi organization for this tenant.
Requires FACTURAPI_USER_KEY environment variable.
Stores cfdi_facturapi_org_id and cfdi_facturapi_key in tenant_config.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
tenant_config = _get_issuer_config(cur)
if not tenant_config.get("rfc"):
return jsonify({"error": "Tenant RFC not configured"}), 400
result = facturapi_service.create_organization(tenant_config)
cur.execute(
"""
INSERT INTO tenant_config (key, value)
VALUES ('cfdi_facturapi_org_id', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""",
(result["org_id"],),
)
cur.execute(
"""
INSERT INTO tenant_config (key, value)
VALUES ('cfdi_facturapi_key', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""",
(result["api_key"],),
)
log_action(conn, "FACTURAPI_SETUP", "tenant_config", None, new_value={"org_id": result["org_id"]})
conn.commit()
cur.close()
conn.close()
return jsonify(
{
"org_id": result["org_id"],
"message": "Facturapi organization created. Complete pending steps in Facturapi dashboard.",
}
)
except ValueError as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({"error": str(e)}), 400
except Exception as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({"error": str(e)}), 500
@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}"'},
)
@invoicing_bp.route("/facturapi/csd", methods=["POST"])
@require_auth("invoicing.create")
def facturapi_upload_csd():
"""Upload CSD (Certificado de Sello Digital) to Facturapi.
Multipart form with:
- certificate: .cer file
- private_key: .key file
- password: CSD password
"""
if "certificate" not in request.files or "private_key" not in request.files:
return jsonify({"error": "certificate and private_key files are required"}), 400
password = (request.form.get("password") or "").strip()
if not password:
return jsonify({"error": "password is required"}), 400
cer_file = request.files["certificate"]
key_file = request.files["private_key"]
if not cer_file.filename or not key_file.filename:
return jsonify({"error": "certificate and private_key files are required"}), 400
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
tenant_config = _get_issuer_config(cur)
cer_b64 = base64.b64encode(cer_file.read()).decode("ascii")
key_b64 = base64.b64encode(key_file.read()).decode("ascii")
result = facturapi_service.upload_csd(tenant_config, cer_b64, key_b64, password)
log_action(
conn,
"FACTURAPI_CSD_UPLOAD",
"tenant_config",
None,
new_value={"org_id": tenant_config.get("facturapi_org_id")},
)
conn.commit()
cur.close()
conn.close()
return jsonify(
{
"success": True,
"message": "CSD uploaded successfully",
"certificate": result.get("certificate"),
}
)
except Exception as e:
conn.rollback()
cur.close()
conn.close()
return jsonify({"error": str(e)}), 500