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:
@@ -5,22 +5,27 @@ All CFDI business logic lives in services (cfdi_builder, cfdi_queue).
|
||||
This blueprint is the HTTP layer that validates input and returns JSON.
|
||||
"""
|
||||
|
||||
import json
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
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
|
||||
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')
|
||||
invoicing_bp = Blueprint("invoicing", __name__, url_prefix="/pos/api/invoicing")
|
||||
|
||||
|
||||
def _get_issuer_config(cur, branch_id=None):
|
||||
@@ -36,80 +41,97 @@ def _get_issuer_config(cur, branch_id=None):
|
||||
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', ''),
|
||||
"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("""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT rfc, razon_social, regimen_fiscal, codigo_postal, serie_cfdi
|
||||
FROM branches WHERE id = %s
|
||||
""", (branch_id,))
|
||||
""",
|
||||
(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']
|
||||
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("""
|
||||
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,))
|
||||
""",
|
||||
(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]),
|
||||
"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("""
|
||||
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_id,),
|
||||
)
|
||||
|
||||
sale['items'] = []
|
||||
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',
|
||||
})
|
||||
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
|
||||
|
||||
@@ -118,24 +140,32 @@ def _get_customer(cur, customer_id):
|
||||
"""Load customer data for CFDI receptor."""
|
||||
if not customer_id:
|
||||
return None
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp
|
||||
FROM customers WHERE id = %s
|
||||
""", (customer_id,))
|
||||
""",
|
||||
(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],
|
||||
"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')
|
||||
|
||||
@invoicing_bp.route("/invoice", methods=["POST"])
|
||||
@require_auth("invoicing.create")
|
||||
def generate_invoice():
|
||||
"""Generate a CFDI for a sale and enqueue for timbrado.
|
||||
|
||||
@@ -146,11 +176,11 @@ def generate_invoice():
|
||||
}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
sale_id = data.get('sale_id')
|
||||
cfdi_type = data.get('type', 'ingreso')
|
||||
sale_id = data.get("sale_id")
|
||||
cfdi_type = data.get("type", "ingreso")
|
||||
|
||||
if not sale_id:
|
||||
return jsonify({'error': 'sale_id is required'}), 400
|
||||
return jsonify({"error": "sale_id is required"}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
@@ -158,45 +188,54 @@ def generate_invoice():
|
||||
try:
|
||||
sale = _get_sale_with_items(cur, sale_id)
|
||||
if not sale:
|
||||
return jsonify({'error': 'Sale not found'}), 404
|
||||
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
|
||||
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
|
||||
if sale["status"] == "cancelled":
|
||||
return jsonify({"error": "Cannot invoice a cancelled sale"}), 400
|
||||
|
||||
customer = _get_customer(cur, sale.get('customer_id'))
|
||||
customer = _get_customer(cur, sale.get("customer_id"))
|
||||
|
||||
# Check if this sale already has a stamped CFDI
|
||||
cur.execute("""
|
||||
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))
|
||||
""",
|
||||
(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
|
||||
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':
|
||||
if cfdi_type == "ingreso":
|
||||
payload = build_ingreso_payload(sale, tenant_config, customer)
|
||||
elif cfdi_type == 'egreso':
|
||||
original_uuid = data.get('original_uuid')
|
||||
elif cfdi_type == "egreso":
|
||||
original_uuid = data.get("original_uuid")
|
||||
if not original_uuid:
|
||||
return jsonify({'error': 'original_uuid required for egreso'}), 400
|
||||
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
|
||||
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']})
|
||||
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()
|
||||
@@ -207,18 +246,19 @@ def generate_invoice():
|
||||
conn.rollback()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
# ─── Queue Management ──────────────────────────────
|
||||
|
||||
@invoicing_bp.route('/queue', methods=['GET'])
|
||||
@require_auth('invoicing.view')
|
||||
|
||||
@invoicing_bp.route("/queue", methods=["GET"])
|
||||
@require_auth("invoicing.view")
|
||||
def list_queue():
|
||||
"""List CFDI queue items.
|
||||
|
||||
@@ -227,11 +267,11 @@ def list_queue():
|
||||
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),
|
||||
"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)
|
||||
@@ -239,36 +279,46 @@ def list_queue():
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@invoicing_bp.route('/queue/<int:cfdi_id>', methods=['GET'])
|
||||
@require_auth('invoicing.view')
|
||||
@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("""
|
||||
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,))
|
||||
""",
|
||||
(cfdi_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'CFDI queue item not found'}), 404
|
||||
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],
|
||||
"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()
|
||||
@@ -276,8 +326,8 @@ def get_queue_item(cfdi_id):
|
||||
return jsonify(item)
|
||||
|
||||
|
||||
@invoicing_bp.route('/queue/process', methods=['POST'])
|
||||
@require_auth('invoicing.create')
|
||||
@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)
|
||||
@@ -285,17 +335,17 @@ def trigger_process_queue():
|
||||
|
||||
try:
|
||||
tenant_config = _get_issuer_config(cur)
|
||||
if not tenant_config.get('facturapi_key'):
|
||||
if not tenant_config.get("facturapi_key"):
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'error': 'Facturapi key 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, tenant_config)
|
||||
result['retries_reset'] = reset_count
|
||||
result["retries_reset"] = reset_count
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
@@ -305,13 +355,14 @@ def trigger_process_queue():
|
||||
conn.rollback()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
# ─── Cancel CFDI ────────────────────────────────────
|
||||
|
||||
@invoicing_bp.route('/cancel/<int:cfdi_id>', methods=['POST'])
|
||||
@require_auth('invoicing.delete')
|
||||
|
||||
@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.
|
||||
|
||||
@@ -322,15 +373,15 @@ def cancel_invoice(cfdi_id):
|
||||
|
||||
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
|
||||
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')
|
||||
motive = data.get("motive")
|
||||
replacement_uuid = data.get("replacement_uuid")
|
||||
|
||||
if not motive:
|
||||
return jsonify({'error': 'motive is required'}), 400
|
||||
return jsonify({"error": "motive is required"}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
@@ -338,12 +389,20 @@ def cancel_invoice(cfdi_id):
|
||||
try:
|
||||
tenant_config = _get_issuer_config(cur)
|
||||
result = cancel_cfdi(
|
||||
conn, cfdi_id, motive, replacement_uuid,
|
||||
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})
|
||||
log_action(
|
||||
conn,
|
||||
"CFDI_CANCELLED",
|
||||
"cfdi_queue",
|
||||
cfdi_id,
|
||||
new_value={"motive": motive, "replacement_uuid": replacement_uuid},
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
@@ -354,18 +413,19 @@ def cancel_invoice(cfdi_id):
|
||||
conn.rollback()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
# ─── PDF Generation ─────────────────────────────────
|
||||
|
||||
@invoicing_bp.route('/<int:sale_id>/pdf', methods=['GET'])
|
||||
@require_auth('invoicing.view')
|
||||
|
||||
@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.
|
||||
|
||||
@@ -378,48 +438,54 @@ def get_sale_pdf(sale_id):
|
||||
|
||||
sale = _get_sale_with_items(cur, sale_id)
|
||||
if not sale:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Sale not found'}), 404
|
||||
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'))
|
||||
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("""
|
||||
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,))
|
||||
""",
|
||||
(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,
|
||||
"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,
|
||||
})
|
||||
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')
|
||||
@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)
|
||||
@@ -437,16 +503,18 @@ def api_invoicing_stats():
|
||||
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,
|
||||
})
|
||||
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')
|
||||
@invoicing_bp.route("/global-invoice", methods=["POST"])
|
||||
@require_auth("invoicing.create")
|
||||
def generate_global_invoice():
|
||||
"""Generate a monthly global invoice for cash sales.
|
||||
|
||||
@@ -458,39 +526,45 @@ def generate_global_invoice():
|
||||
"""
|
||||
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')
|
||||
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
|
||||
return jsonify({"error": "month must be 1-12"}), 400
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'error': 'year and month must be integers'}), 400
|
||||
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
|
||||
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)
|
||||
conn, tenant_config, year, month, branch_id=branch_id, employee_id=getattr(g, "employee_id", None)
|
||||
)
|
||||
|
||||
if 'error' in result:
|
||||
cur.close(); conn.close()
|
||||
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']})
|
||||
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()
|
||||
@@ -498,56 +572,62 @@ def generate_global_invoice():
|
||||
return jsonify(result), 201
|
||||
|
||||
|
||||
@invoicing_bp.route('/global-invoice/<int:cfdi_id>', methods=['GET'])
|
||||
@require_auth('invoicing.view')
|
||||
@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({"error": "Global invoice not found"}), 404
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@invoicing_bp.route('/global-invoice/eligible-sales', methods=['GET'])
|
||||
@require_auth('invoicing.view')
|
||||
@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)
|
||||
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],
|
||||
})
|
||||
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')
|
||||
|
||||
@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)
|
||||
@@ -560,8 +640,8 @@ def facturapi_status():
|
||||
return jsonify(status)
|
||||
|
||||
|
||||
@invoicing_bp.route('/facturapi/setup', methods=['POST'])
|
||||
@require_auth('invoicing.create')
|
||||
@invoicing_bp.route("/facturapi/setup", methods=["POST"])
|
||||
@require_auth("invoicing.create")
|
||||
def facturapi_setup():
|
||||
"""Create or link a Facturapi organization for this tenant.
|
||||
|
||||
@@ -573,92 +653,166 @@ def facturapi_setup():
|
||||
|
||||
try:
|
||||
tenant_config = _get_issuer_config(cur)
|
||||
if not tenant_config.get('rfc'):
|
||||
return jsonify({'error': 'Tenant RFC not configured'}), 400
|
||||
if not tenant_config.get("rfc"):
|
||||
return jsonify({"error": "Tenant RFC not configured"}), 400
|
||||
|
||||
result = facturapi_service.create_organization(tenant_config)
|
||||
|
||||
cur.execute("""
|
||||
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'],))
|
||||
""",
|
||||
(result["org_id"],),
|
||||
)
|
||||
|
||||
cur.execute("""
|
||||
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'],))
|
||||
""",
|
||||
(result["api_key"],),
|
||||
)
|
||||
|
||||
log_action(conn, 'FACTURAPI_SETUP', 'tenant_config', None,
|
||||
new_value={'org_id': result['org_id']})
|
||||
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.',
|
||||
})
|
||||
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
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@invoicing_bp.route('/facturapi/download/<int:cfdi_id>/<doc_type>', methods=['GET'])
|
||||
@require_auth('invoicing.view')
|
||||
@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
|
||||
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("""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT external_id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s
|
||||
""", (cfdi_id,))
|
||||
""",
|
||||
(cfdi_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'CFDI not found'}), 404
|
||||
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
|
||||
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':
|
||||
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'
|
||||
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'
|
||||
mime = "application/xml"
|
||||
filename = f"cfdi_{uuid_fiscal or external_id}.xml"
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
from flask import Response
|
||||
|
||||
return Response(
|
||||
content,
|
||||
mimetype=mime,
|
||||
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user