# /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/", 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/", 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("//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/", 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//", 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