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:
2026-06-15 04:58:42 +00:00
parent 6aff32f93b
commit d67887284d
15 changed files with 1559 additions and 481 deletions

View File

@@ -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

View File

@@ -4,6 +4,7 @@
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from tenant_db import get_master_conn, get_tenant_conn_by_dbname
@@ -12,43 +13,43 @@ MIGRATIONS_DIR = os.path.dirname(os.path.abspath(__file__))
# Migration registry: version -> filename
MIGRATIONS = {
'v1.0': 'v1.0_initial.sql',
'v1.1': 'v1.1_pos_tables.sql',
'v1.2': 'v1.2_subdomain.sql',
'v1.3': 'v1.3_fleet.sql',
'v1.4': 'v1.4_whatsapp.sql',
'v1.5': 'v1.5_returns.sql',
'v1.6': 'v1.6_marketplace.sql',
'v1.7': 'v1.7_plates.sql',
'v1.8': 'v1.8_performance_indexes.sql',
'v1.9': 'v1.9_redis_cache.sql',
'v2.0': 'v2.0_multi_currency.sql',
'v2.1': 'v2.1_suppliers.sql',
'v2.2': 'v2.2_alerts_warranty.sql',
'v2.3': 'v2.3_metabase.sql',
'v2.4': 'v2.4_crm_enhanced.sql',
'v2.5': 'v2.5_service_orders.sql',
'v2.6': 'v2.6_bnpl_erp.sql',
'v2.7': 'v2.7_notifications.sql',
'v2.8': 'v2.8_savings.sql',
'v2.9': 'v2.9_logistics.sql',
'v3.0': 'v3.0_public_api.sql',
'v3.1': 'v3.1_inventory_vehicle_compat.sql',
'v3.2': 'v3.2_db_performance.sql',
'v3.2.1': 'v3.2_qwen_vehicle_compat.sql',
'v3.3': 'v3.3_marketplace_any_part.sql',
'v3.3.1': 'v3.3_materialized_view.sql',
'v3.4': 'v3.4_meli_integration.sql',
'v3.5': 'v3.5_meli_questions.sql',
'v3.5.1': 'v3.5_whatsapp_state_machine.sql',
'v3.6': 'v3.6_dropshipping.sql',
'v3.7': 'v3.7_sku_aliases.sql',
'v3.8': 'v3.8_supplier_catalog.sql',
'v3.9': 'v3.9_supplier_catalog_prices.sql',
'v4.0': 'v4.0_multi_branch.sql',
'v4.1': 'v4.1_global_invoice.sql',
'v4.2': 'v4.2_meli_sync_queue.sql',
'v4.3': 'v4.3_facturapi.sql',
"v1.0": "v1.0_initial.sql",
"v1.1": "v1.1_pos_tables.sql",
"v1.2": "v1.2_subdomain.sql",
"v1.3": "v1.3_fleet.sql",
"v1.4": "v1.4_whatsapp.sql",
"v1.5": "v1.5_returns.sql",
"v1.6": "v1.6_marketplace.sql",
"v1.7": "v1.7_plates.sql",
"v1.8": "v1.8_performance_indexes.sql",
"v1.9": "v1.9_redis_cache.sql",
"v2.0": "v2.0_multi_currency.sql",
"v2.1": "v2.1_suppliers.sql",
"v2.2": "v2.2_alerts_warranty.sql",
"v2.3": "v2.3_metabase.sql",
"v2.4": "v2.4_crm_enhanced.sql",
"v2.5": "v2.5_service_orders.sql",
"v2.6": "v2.6_bnpl_erp.sql",
"v2.7": "v2.7_notifications.sql",
"v2.8": "v2.8_savings.sql",
"v2.9": "v2.9_logistics.sql",
"v3.0": "v3.0_public_api.sql",
"v3.1": "v3.1_inventory_vehicle_compat.sql",
"v3.2": "v3.2_db_performance.sql",
"v3.2.1": "v3.2_qwen_vehicle_compat.sql",
"v3.3": "v3.3_marketplace_any_part.sql",
"v3.3.1": "v3.3_materialized_view.sql",
"v3.4": "v3.4_meli_integration.sql",
"v3.5": "v3.5_meli_questions.sql",
"v3.5.1": "v3.5_whatsapp_state_machine.sql",
"v3.6": "v3.6_dropshipping.sql",
"v3.7": "v3.7_sku_aliases.sql",
"v3.8": "v3.8_supplier_catalog.sql",
"v3.9": "v3.9_supplier_catalog_prices.sql",
"v4.0": "v4.0_multi_branch.sql",
"v4.1": "v4.1_global_invoice.sql",
"v4.2": "v4.2_meli_sync_queue.sql",
"v4.3": "v4.3_facturapi.sql",
}
@@ -81,9 +82,9 @@ def apply_migration(db_name, version):
sql = f.read()
# Skip migrations marked for manual/non-tenant execution
first_line = sql.splitlines()[0].strip() if sql.strip() else ''
if first_line.startswith(': SKIP') or first_line.startswith('-- : SKIP'):
print(f" SKIP (manual/non-tenant migration)")
first_line = sql.splitlines()[0].strip() if sql.strip() else ""
if first_line.startswith(": SKIP") or first_line.startswith("-- : SKIP"):
print(" SKIP (manual/non-tenant migration)")
return True
conn = get_tenant_conn_by_dbname(db_name)
@@ -116,16 +117,19 @@ def run_migrations():
if version <= current_version:
continue
print(f" Applying {version}...", end=' ')
print(f" Applying {version}...", end=" ")
if apply_migration(db_name, version):
# Update version in master
master_conn = get_master_conn()
master_cur = master_conn.cursor()
master_cur.execute("""
master_cur.execute(
"""
INSERT INTO tenant_schema_version (tenant_id, version)
VALUES (%s, %s)
ON CONFLICT (tenant_id) DO UPDATE SET version = %s, updated_at = NOW()
""", (tenant_id, version, version))
""",
(tenant_id, version, version),
)
master_conn.commit()
master_cur.close()
master_conn.close()
@@ -137,5 +141,5 @@ def run_migrations():
print("\nDone.")
if __name__ == '__main__':
if __name__ == "__main__":
run_migrations()

View File

@@ -9,8 +9,8 @@ generates those payloads for:
- Factura global mensual
"""
from decimal import Decimal, ROUND_HALF_UP
from datetime import datetime
from decimal import ROUND_HALF_UP, Decimal
# SAT defaults
RFC_PUBLICO_GENERAL = "XAXX010101000"
@@ -148,9 +148,7 @@ def build_egreso_payload(sale, tenant_config, customer, original_uuid):
"""Build Facturapi payload for a credit note (Comprobante tipo Egreso)."""
payload = build_ingreso_payload(sale, tenant_config, customer)
payload["type"] = "E"
payload["related_documents"] = [
{"relationship": "01", "documents": [original_uuid]}
]
payload["related_documents"] = [{"relationship": "01", "documents": [original_uuid]}]
payload["payment_method"] = "PUE"
return payload
@@ -162,15 +160,12 @@ def build_pago_payload(payment, tenant_config, customer, original_uuid):
amount = _to_dec(payment.get("amount", 0))
base = (amount / Decimal("1.16")).quantize(TWO, ROUND_HALF_UP)
iva = (amount - base).quantize(TWO, ROUND_HALF_UP)
payment_date = payment.get("date") or datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
if "T" not in str(payment_date):
payment_date = f"{payment_date}T12:00:00"
forma_pago = FORMA_PAGO_MAP.get(
(payment.get("payment_method") or "").lower().strip(), "01"
)
forma_pago = FORMA_PAGO_MAP.get((payment.get("payment_method") or "").lower().strip(), "01")
payload = {
"type": "P",

View File

@@ -17,7 +17,7 @@ Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries)
import json
import logging
from datetime import datetime, timedelta
from datetime import datetime
from services import facturapi_service
@@ -34,7 +34,7 @@ def _generate_provisional_folio(conn):
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
seq = cur.fetchone()[0]
cur.close()
return f'PRE-{seq:05d}'
return f"PRE-{seq:05d}"
def enqueue_cfdi(conn, sale_id, cfdi_type, payload):
@@ -54,22 +54,25 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, payload):
payload_json = payload if isinstance(payload, str) else json.dumps(payload)
cur.execute("""
cur.execute(
"""
INSERT INTO cfdi_queue
(sale_id, type, payload_unsigned, status, provisional_folio)
VALUES (%s, %s, %s, 'pending', %s)
RETURNING id, created_at
""", (sale_id, cfdi_type, payload_json, provisional_folio))
""",
(sale_id, cfdi_type, payload_json, provisional_folio),
)
cfdi_id, created_at = cur.fetchone()
cur.close()
return {
'id': cfdi_id,
'sale_id': sale_id,
'type': cfdi_type,
'status': 'pending',
'provisional_folio': provisional_folio,
'created_at': str(created_at),
"id": cfdi_id,
"sale_id": sale_id,
"type": cfdi_type,
"status": "pending",
"provisional_folio": provisional_folio,
"created_at": str(created_at),
}
@@ -90,34 +93,40 @@ def process_queue(conn, tenant_config, dry_run=False):
"""
cur = conn.cursor()
cur.execute("""
cur.execute(
"""
SELECT id, sale_id, type, payload_unsigned, retry_count
FROM cfdi_queue
WHERE status IN ('pending', 'failed')
AND retry_count < %s
ORDER BY created_at ASC
LIMIT 50
""", (MAX_RETRIES,))
""",
(MAX_RETRIES,),
)
items = cur.fetchall()
results = {'processed': 0, 'stamped': 0, 'failed': 0, 'details': []}
results = {"processed": 0, "stamped": 0, "failed": 0, "details": []}
api_key = tenant_config.get('facturapi_key')
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
for cfdi_id, _sale_id, _cfdi_type, payload_unsigned, _retry_count in items:
results["processed"] += 1
# Update status to 'sending'
cur.execute("""
cur.execute(
"""
UPDATE cfdi_queue SET status = 'sending' WHERE id = %s
""", (cfdi_id,))
""",
(cfdi_id,),
)
conn.commit()
try:
payload = json.loads(payload_unsigned or '{}')
payload = json.loads(payload_unsigned or "{}")
if not payload:
raise ValueError("Empty payload in queue item")
@@ -127,18 +136,19 @@ def process_queue(conn, tenant_config, dry_run=False):
raise ValueError("dry_run is not supported with Facturapi")
invoice = facturapi_service.create_invoice(tenant_config, payload)
invoice_id = invoice.get('id')
uuid_fiscal = invoice.get('uuid')
invoice_id = invoice.get("id")
uuid_fiscal = invoice.get("uuid")
# 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)
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 = ''
xml_signed_str = ""
cur.execute("""
cur.execute(
"""
UPDATE cfdi_queue
SET status = 'stamped',
xml_signed = %s,
@@ -147,30 +157,37 @@ def process_queue(conn, tenant_config, dry_run=False):
stamped_at = NOW(),
error_message = NULL
WHERE id = %s
""", (xml_signed_str, uuid_fiscal, invoice_id, cfdi_id))
""",
(xml_signed_str, uuid_fiscal, invoice_id, cfdi_id),
)
conn.commit()
results['stamped'] += 1
results['details'].append({
'id': cfdi_id, 'status': 'stamped',
'uuid': uuid_fiscal, 'external_id': invoice_id,
})
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("""
error_msg = f"{type(e).__name__}: {str(e)[:500]}"
cur.execute(
"""
UPDATE cfdi_queue
SET status = 'failed',
retry_count = retry_count + 1,
error_message = %s
WHERE id = %s
""", (error_msg, cfdi_id))
""",
(error_msg, cfdi_id),
)
conn.commit()
results['failed'] += 1
results['details'].append({
'id': cfdi_id, 'status': 'failed', 'error': error_msg
})
results["failed"] += 1
results["details"].append({"id": cfdi_id, "status": "failed", "error": error_msg})
cur.close()
return results
@@ -184,30 +201,33 @@ def retry_failed(conn):
"""
cur = conn.cursor()
cur.execute("""
cur.execute(
"""
SELECT id, retry_count, created_at
FROM cfdi_queue
WHERE status = 'failed' AND retry_count < %s
ORDER BY created_at ASC
""", (MAX_RETRIES,))
""",
(MAX_RETRIES,),
)
items = cur.fetchall()
reset_count = 0
now = datetime.utcnow()
for cfdi_id, retry_count, created_at in items:
if retry_count < len(BACKOFF_INTERVALS):
wait_seconds = BACKOFF_INTERVALS[retry_count]
else:
wait_seconds = BACKOFF_INTERVALS[-1]
wait_seconds = BACKOFF_INTERVALS[retry_count] if retry_count < len(BACKOFF_INTERVALS) else BACKOFF_INTERVALS[-1]
# 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("""
cur.execute(
"""
UPDATE cfdi_queue SET status = 'pending' WHERE id = %s
""", (cfdi_id,))
""",
(cfdi_id,),
)
reset_count += 1
conn.commit()
@@ -215,8 +235,7 @@ def retry_failed(conn):
return reset_count
def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
tenant_config=None):
def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, tenant_config=None):
"""Cancel a stamped CFDI via Facturapi.
SAT cancellation motives:
@@ -238,38 +257,44 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
Raises:
ValueError: on validation errors
"""
if motive not in ('01', '02', '03', '04'):
if motive not in ("01", "02", "03", "04"):
raise ValueError(f"Invalid SAT cancellation motive: {motive}")
if motive == '01' and not replacement_uuid:
if motive == "01" and not replacement_uuid:
raise ValueError("Motive 01 requires a replacement UUID")
cur = conn.cursor()
cur.execute("""
cur.execute(
"""
SELECT id, uuid_fiscal, external_id, status FROM cfdi_queue WHERE id = %s
""", (cfdi_id,))
""",
(cfdi_id,),
)
row = cur.fetchone()
if not row:
raise ValueError(f"CFDI queue item {cfdi_id} not found")
_, uuid_fiscal, external_id, current_status = row
if current_status == 'cancelled':
if current_status == "cancelled":
raise ValueError("CFDI is already cancelled")
if current_status != 'stamped':
if current_status != "stamped":
# If not stamped, we can just mark as cancelled locally
cur.execute("""
cur.execute(
"""
UPDATE cfdi_queue
SET status = 'cancelled', cancel_motive = %s
WHERE id = %s
""", (motive, cfdi_id))
""",
(motive, cfdi_id),
)
conn.commit()
cur.close()
return {'id': cfdi_id, 'status': 'cancelled', 'message': 'Cancelled locally (was not stamped)'}
return {"id": cfdi_id, "status": "cancelled", "message": "Cancelled locally (was not stamped)"}
if not tenant_config or not tenant_config.get('facturapi_key'):
if not tenant_config or not tenant_config.get("facturapi_key"):
cur.close()
raise ValueError("Facturapi key not configured for tenant")
@@ -279,36 +304,44 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
try:
facturapi_service.cancel_invoice(
tenant_config, external_id, motive,
tenant_config,
external_id,
motive,
replacement_uuid=replacement_uuid,
)
cur.execute("""
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))
""",
(motive, replacement_uuid, cfdi_id),
)
conn.commit()
cur.close()
return {
'id': cfdi_id,
'status': 'cancelled',
'message': f'Cancelled with SAT (motive {motive})',
"id": cfdi_id,
"status": "cancelled",
"message": f"Cancelled with SAT (motive {motive})",
}
except Exception as e:
error_msg = f'Cancel failed: {str(e)[:500]}'
cur.execute("""
error_msg = f"Cancel failed: {str(e)[:500]}"
cur.execute(
"""
UPDATE cfdi_queue
SET error_message = %s
WHERE id = %s
""", (error_msg, cfdi_id))
""",
(error_msg, cfdi_id),
)
conn.commit()
cur.close()
raise ValueError(error_msg)
raise ValueError(error_msg) from e
def get_queue_status(conn, filters=None):
@@ -316,30 +349,31 @@ def get_queue_status(conn, filters=None):
filters = filters or {}
cur = conn.cursor()
page = int(filters.get('page', 1))
per_page = min(int(filters.get('per_page', 50)), 200)
page = int(filters.get("page", 1))
per_page = min(int(filters.get("per_page", 50)), 200)
where_clauses = ["1=1"]
params = []
if filters.get('status'):
if filters.get("status"):
where_clauses.append("q.status = %s")
params.append(filters['status'])
params.append(filters["status"])
if filters.get('sale_id'):
if filters.get("sale_id"):
where_clauses.append("q.sale_id = %s")
params.append(int(filters['sale_id']))
params.append(int(filters["sale_id"]))
if filters.get('type'):
if filters.get("type"):
where_clauses.append("q.type = %s")
params.append(filters['type'])
params.append(filters["type"])
where = " AND ".join(where_clauses)
cur.execute(f"SELECT count(*) FROM cfdi_queue q WHERE {where}", params)
total = cur.fetchone()[0]
cur.execute(f"""
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.external_id
@@ -347,26 +381,37 @@ def get_queue_status(conn, filters=None):
WHERE {where}
ORDER BY q.created_at DESC
LIMIT %s OFFSET %s
""", params + [per_page, (page - 1) * per_page])
""",
params + [per_page, (page - 1) * per_page],
)
items = []
for r in cur.fetchall():
items.append({
'id': r[0], 'sale_id': r[1], 'type': r[2],
'uuid_fiscal': r[3], 'status': r[4],
'retry_count': r[5], 'provisional_folio': r[6],
'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],
})
items.append(
{
"id": r[0],
"sale_id": r[1],
"type": r[2],
"uuid_fiscal": r[3],
"status": r[4],
"retry_count": r[5],
"provisional_folio": r[6],
"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()
total_pages = (total + per_page - 1) // per_page
return {
'data': items,
'pagination': {
'page': page, 'per_page': per_page,
'total': total, 'total_pages': total_pages,
}
"data": items,
"pagination": {
"page": page,
"per_page": per_page,
"total": total,
"total_pages": total_pages,
},
}

View File

@@ -12,11 +12,10 @@ Authentication modes:
Reference: https://docs.facturapi.io/
"""
import os
import base64
import logging
import os
from decimal import Decimal
from typing import Optional
import requests
@@ -35,8 +34,8 @@ class FacturapiError(Exception):
# ─── HTTP helpers ───────────────────────────────────────────────────────────
def _request(method: str, endpoint: str, api_key: str, json_payload=None, params=None,
extra_headers=None, timeout=60):
def _request(method: str, endpoint: str, api_key: str, json_payload=None, params=None, extra_headers=None, timeout=60):
"""Make a request to Facturapi REST API with Basic Auth."""
url = f"{BASE_URL}{endpoint}"
headers = {"Content-Type": "application/json"}
@@ -54,7 +53,7 @@ def _request(method: str, endpoint: str, api_key: str, json_payload=None, params
timeout=timeout,
)
except requests.RequestException as e:
raise FacturapiError(f"Connection error: {e}", status_code=0)
raise FacturapiError(f"Connection error: {e}", status_code=0) from e
if not resp.ok:
raise FacturapiError(
@@ -88,15 +87,24 @@ def _download(method: str, endpoint: str, api_key: str, params=None, timeout=60)
# ─── Tenant config helpers ──────────────────────────────────────────────────
def _get_secret_key(tenant_config: dict) -> Optional[str]:
for key in ("facturapi_key", "facturapi_secret_key"):
def _get_secret_key(tenant_config: dict) -> str | None:
for key in ("facturapi_secret_key", "facturapi_key", "cfdi_facturapi_key"):
val = (tenant_config.get(key) or "").strip()
if val:
return val
return None
def _get_user_key() -> Optional[str]:
def _get_org_id(tenant_config: dict) -> str | None:
for key in ("facturapi_org_id", "cfdi_facturapi_org_id"):
val = (tenant_config.get(key) or "").strip()
if val:
return val
return None
def _get_user_key() -> str | None:
return USER_KEY.strip() or None
@@ -117,42 +125,11 @@ def get_api_key(tenant_config: dict) -> str:
user = _get_user_key()
if user:
return user
raise FacturapiError(
"Facturapi not configured. Set FACTURAPI_USER_KEY env or tenant_config.facturapi_secret_key"
)
raise FacturapiError("Facturapi not configured. Set FACTURAPI_USER_KEY env or tenant_config.facturapi_secret_key")
# ─── Organizations ──────────────────────────────────────────────────────────
def create_organization(tenant_config: dict) -> dict:
"""Create a new Facturapi organization for the tenant.
Requires FACTURAPI_USER_KEY.
Returns dict with id, api_key.
"""
user_key = _get_user_key()
if not user_key:
raise FacturapiError("FACTURAPI_USER_KEY is required to create organizations")
payload = {"name": tenant_config.get("razon_social", tenant_config.get("name", "Nexus"))}
legal = tenant_config.get("legal_name") or tenant_config.get("razon_social")
if legal:
payload["legal"] = {"name": legal}
if tenant_config.get("rfc"):
payload["legal"] = payload.get("legal", {})
payload["legal"]["tax_id"] = tenant_config["rfc"]
org = _request("POST", "/organizations", user_key, json_payload=payload)
org_id = org.get("id")
# Generate live secret key
key_resp = _request("PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={})
live_key = key_resp.get("key") if isinstance(key_resp, dict) else str(key_resp)
if not live_key:
raise FacturapiError(f"Could not generate live key for org {org_id}")
return {"org_id": org_id, "api_key": live_key}
def get_organization(org_id: str, api_key: str) -> dict:
return _request("GET", f"/organizations/{org_id}", api_key)
@@ -164,7 +141,7 @@ def upload_csd(tenant_config: dict, cer_b64: str, key_b64: str, password: str) -
cer_b64 and key_b64 are base64-encoded strings.
"""
api_key = get_api_key(tenant_config)
org_id = tenant_config.get("facturapi_org_id")
org_id = _get_org_id(tenant_config)
if not org_id:
raise FacturapiError("No Facturapi organization configured for tenant")
@@ -196,15 +173,14 @@ def _get_user_key_for_tenant(tenant_config: dict) -> str:
user_key = _get_user_key()
if user_key:
return user_key
tenant_key = (tenant_config.get("facturapi_key") or "").strip()
if tenant_key.startswith("sk_user_"):
return tenant_key
raise FacturapiError(
"FACTURAPI_USER_KEY env or a Facturapi user key (sk_user_*) is required"
)
for key in ("facturapi_key", "cfdi_facturapi_key"):
tenant_key = (tenant_config.get(key) or "").strip()
if tenant_key.startswith("sk_user_"):
return tenant_key
raise FacturapiError("FACTURAPI_USER_KEY env or a Facturapi user key (sk_user_*) is required")
def find_organization_by_rfc(tenant_config: dict) -> Optional[dict]:
def find_organization_by_rfc(tenant_config: dict) -> dict | None:
"""Search for an existing Facturapi organization by tenant RFC.
Requires a user key (FACTURAPI_USER_KEY env or sk_user_* tenant key).
@@ -252,9 +228,7 @@ def create_organization(tenant_config: dict) -> dict:
raise FacturapiError("Could not create organization: no id returned")
# Generate live secret key
key_resp = _request(
"PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60
)
key_resp = _request("PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60)
live_key = key_resp.get("key") if isinstance(key_resp, dict) else str(key_resp)
if not live_key:
raise FacturapiError(f"Could not generate live key for org {org_id}")
@@ -282,7 +256,7 @@ def get_org_status(tenant_config: dict) -> dict:
result["error"] = str(e)
return result
org_id = tenant_config.get("facturapi_org_id")
org_id = _get_org_id(tenant_config)
if not org_id:
result["error"] = "No Facturapi organization configured"
return result
@@ -294,13 +268,15 @@ def get_org_status(tenant_config: dict) -> dict:
org = get_organization(org_id, api_key)
legal = org.get("legal", {})
cert = org.get("certificate", {})
result.update({
"configured": True,
"has_csd": bool(cert.get("has_certificate")),
"legal_name": legal.get("name") or legal.get("legal_name"),
"tax_id": legal.get("tax_id"),
"pending_steps": org.get("pending_steps", []),
})
result.update(
{
"configured": True,
"has_csd": bool(cert.get("has_certificate")),
"legal_name": legal.get("name") or legal.get("legal_name"),
"tax_id": legal.get("tax_id"),
"pending_steps": org.get("pending_steps", []),
}
)
except FacturapiError as e:
result["error"] = str(e)
@@ -309,6 +285,7 @@ def get_org_status(tenant_config: dict) -> dict:
# ─── Customers ──────────────────────────────────────────────────────────────
def create_or_update_customer(tenant_config: dict, customer_data: dict) -> str:
"""Create or update a customer in Facturapi and return its id.
@@ -364,6 +341,7 @@ def create_or_update_customer(tenant_config: dict, customer_data: dict) -> str:
# ─── Invoices ───────────────────────────────────────────────────────────────
def create_invoice(tenant_config: dict, payload: dict) -> dict:
"""Create and stamp an invoice in Facturapi.
@@ -373,8 +351,7 @@ def create_invoice(tenant_config: dict, payload: dict) -> dict:
return _request("POST", "/invoices", api_key, json_payload=payload, timeout=90)
def cancel_invoice(tenant_config: dict, invoice_id: str, motive: str,
replacement_uuid: Optional[str] = None) -> dict:
def cancel_invoice(tenant_config: dict, invoice_id: str, motive: str, replacement_uuid: str | None = None) -> dict:
"""Cancel an invoice in Facturapi.
Motive codes:
@@ -402,6 +379,7 @@ def download_pdf(tenant_config: dict, invoice_id: str) -> bytes:
# ─── Helpers ─────────────────────────────────────────────────────────────────
def is_lco_rejection(message: str) -> bool:
"""Detect SAT LCO rejection (CSD not yet propagated)."""
if not message:

View File

@@ -329,6 +329,73 @@ const Invoicing = (() => {
}
}
function resetCsdForm() {
document.getElementById('csd-form').reset();
document.getElementById('csd-cer-label').textContent = 'Subir certificado .cer';
document.getElementById('csd-key-label').textContent = 'Subir llave privada .key';
}
function updateFileLabels() {
const cer = document.getElementById('csd-cer');
const key = document.getElementById('csd-key');
if (cer && cer.files.length) {
document.getElementById('csd-cer-label').textContent = cer.files[0].name;
}
if (key && key.files.length) {
document.getElementById('csd-key-label').textContent = key.files[0].name;
}
}
async function uploadCsd(btn) {
if (!btn) return;
const cer = document.getElementById('csd-cer');
const key = document.getElementById('csd-key');
const password = document.getElementById('contrasena-csd').value.trim();
if (!cer || !cer.files.length || !key || !key.files.length) {
alert('Selecciona el archivo .cer y .key');
return;
}
if (!password) {
alert('Escribe la contraseña del CSD');
return;
}
const formData = new FormData();
formData.append('certificate', cer.files[0]);
formData.append('private_key', key.files[0]);
formData.append('password', password);
btn.disabled = true;
const originalText = btn.innerHTML;
btn.textContent = 'Subiendo...';
try {
const res = await fetch(`${API}/facturapi/csd`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token()}` },
body: formData,
});
const data = await res.json().catch(() => ({ error: res.statusText }));
if (!res.ok) throw new Error(data.error || 'Upload failed');
alert('CSD actualizado correctamente');
resetCsdForm();
loadFacturapiStatus();
} catch (e) {
alert('Error al subir CSD: ' + e.message);
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
}
}
// Wire file input change listeners
setTimeout(() => {
const cer = document.getElementById('csd-cer');
const key = document.getElementById('csd-key');
if (cer) cer.addEventListener('change', updateFileLabels);
if (key) key.addEventListener('change', updateFileLabels);
}, 0);
// ---- Detail modal (uses modalDetalleOverlay) ----
async function showDetail(cfdiId) {
const overlay = document.getElementById('modalDetalleOverlay');
@@ -612,6 +679,7 @@ const Invoicing = (() => {
showDetail, showCancelModal, confirmCancel, processQueue,
showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder,
openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice, setupFacturapi,
uploadCsd, resetCsdForm,
};
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {

View File

@@ -800,7 +800,7 @@
</div>
<div class="config-section__body">
<div class="cert-status">
<div class="cert-status" id="csd-status">
<div class="cert-status__icon">
<svg viewBox="0 0 24 24">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
@@ -808,60 +808,63 @@
</svg>
</div>
<div class="cert-status__info">
<div class="cert-status__name">
CSD Activo &nbsp;<span class="badge badge--vigente">Vigente</span>
<div class="cert-status__name" id="csd-status-name">
CSD — Consultar estado en Facturapi
</div>
<div class="cert-status__detail">
No. Certificado: 20001000000300022779 &nbsp;·&nbsp; Vence: 14/07/2026
<div class="cert-status__detail" id="csd-status-detail">
El estado del certificado se muestra en la sección Facturapi (PAC).
</div>
</div>
<button class="btn btn--ghost btn--sm">Ver</button>
</div>
<div class="form-grid">
<div class="form-field">
<label class="form-label">Archivo .cer</label>
<button class="btn btn--secondary" style="width:100%;justify-content:center;">
<form id="csd-form" enctype="multipart/form-data">
<div class="form-grid">
<div class="form-field">
<label class="form-label">Archivo .cer</label>
<input type="file" id="csd-cer" name="certificate" accept=".cer" style="display:none;" />
<button type="button" class="btn btn--secondary" id="csd-cer-btn" style="width:100%;justify-content:center;" onclick="document.getElementById('csd-cer').click()">
<svg viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<span id="csd-cer-label">Subir certificado .cer</span>
</button>
<span class="form-hint">Certificado público del SAT</span>
</div>
<div class="form-field">
<label class="form-label">Archivo .key</label>
<input type="file" id="csd-key" name="private_key" accept=".key" style="display:none;" />
<button type="button" class="btn btn--secondary" id="csd-key-btn" style="width:100%;justify-content:center;" onclick="document.getElementById('csd-key').click()">
<svg viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<span id="csd-key-label">Subir llave privada .key</span>
</button>
<span class="form-hint">Llave privada del CSD</span>
</div>
<div class="form-field form-field--span2">
<label class="form-label" for="contrasena-csd">Contraseña del CSD</label>
<input class="form-input" id="contrasena-csd" name="password" type="password" placeholder="Contraseña de la llave privada" />
<span class="form-hint">Contraseña asignada al generar el CSD en el SAT</span>
</div>
</div>
<div style="margin-top:var(--space-4);display:flex;justify-content:flex-end;gap:var(--space-3);">
<button type="button" class="btn btn--ghost" onclick="Invoicing.resetCsdForm()">Cancelar</button>
<button type="button" class="btn btn--primary" id="csd-submit-btn" onclick="Invoicing.uploadCsd(this)">
<svg viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
Subir certificado .cer
Actualizar CSD
</button>
<span class="form-hint">Certificado público del SAT</span>
</div>
<div class="form-field">
<label class="form-label">Archivo .key</label>
<button class="btn btn--secondary" style="width:100%;justify-content:center;">
<svg viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Subir llave privada .key
</button>
<span class="form-hint">Llave privada del CSD</span>
</div>
<div class="form-field form-field--span2">
<label class="form-label" for="contrasena-csd">Contraseña del CSD</label>
<input class="form-input" id="contrasena-csd" type="password" placeholder="Contraseña de la llave privada" />
<span class="form-hint">Contraseña asignada al generar el CSD en el SAT</span>
</div>
</div>
<div style="margin-top:var(--space-4);display:flex;justify-content:flex-end;gap:var(--space-3);">
<button class="btn btn--ghost">Cancelar</button>
<button class="btn btn--primary">
<svg viewBox="0 0 24 24">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
Actualizar CSD
</button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,235 @@
"""Unit tests for Facturapi service with mocked HTTP calls.
These tests do not require PostgreSQL or network access.
"""
import base64
from unittest import mock
import pytest
# Import must not trigger DB connections.
from pos.services import facturapi_service
@pytest.fixture
def user_key():
"""Patch USER_KEY for the duration of the test."""
with mock.patch.object(facturapi_service, "USER_KEY", "sk_user_abc"):
yield
class TestGetApiKey:
def test_prefers_secret_key(self):
config = {
"facturapi_secret_key": "sk_secret_123",
"facturapi_key": "sk_test_456",
}
assert facturapi_service.get_api_key(config) == "sk_secret_123"
def test_falls_back_to_facturapi_key(self):
config = {"facturapi_key": "sk_test_456"}
assert facturapi_service.get_api_key(config) == "sk_test_456"
def test_falls_back_to_cfdi_prefixed_key(self):
config = {"cfdi_facturapi_key": "sk_test_789"}
assert facturapi_service.get_api_key(config) == "sk_test_789"
def test_falls_back_to_user_key_env(self, user_key):
assert facturapi_service.get_api_key({}) == "sk_user_abc"
def test_raises_when_nothing_configured(self):
with (
mock.patch.object(facturapi_service, "USER_KEY", ""),
pytest.raises(facturapi_service.FacturapiError),
):
facturapi_service.get_api_key({})
class TestGetOrgId:
def test_prefers_short_key(self):
config = {"facturapi_org_id": "org_123", "cfdi_facturapi_org_id": "org_456"}
assert facturapi_service._get_org_id(config) == "org_123"
def test_falls_back_to_cfdi_prefixed_key(self):
config = {"cfdi_facturapi_org_id": "org_789"}
assert facturapi_service._get_org_id(config) == "org_789"
def test_returns_none_when_missing(self):
assert facturapi_service._get_org_id({}) is None
class TestRequest:
@mock.patch("pos.services.facturapi_service.requests.request")
def test_successful_request_returns_json(self, mock_request):
mock_response = mock.Mock()
mock_response.ok = True
mock_response.status_code = 200
mock_response.content = b'{"id": "inv_1"}'
mock_response.json.return_value = {"id": "inv_1"}
mock_request.return_value = mock_response
result = facturapi_service._request("GET", "/invoices", "sk_test")
assert result == {"id": "inv_1"}
mock_request.assert_called_once()
_, kwargs = mock_request.call_args
assert kwargs["auth"] == ("sk_test", "")
@mock.patch("pos.services.facturapi_service.requests.request")
def test_failed_request_raises_facturapi_error(self, mock_request):
mock_response = mock.Mock()
mock_response.ok = False
mock_response.status_code = 400
mock_response.text = "Bad request"
mock_request.return_value = mock_response
with pytest.raises(facturapi_service.FacturapiError) as exc_info:
facturapi_service._request("POST", "/invoices", "sk_test", json_payload={})
assert exc_info.value.status_code == 400
class TestCreateOrganization:
@mock.patch("pos.services.facturapi_service.find_organization_by_rfc")
@mock.patch("pos.services.facturapi_service._request")
def test_creates_organization_and_generates_live_key(self, mock_request, mock_find, user_key):
mock_find.return_value = None
mock_request.side_effect = [
{"id": "org_123"}, # POST /organizations
{"key": "sk_live_abc"}, # PUT /organizations/org_123/apikeys/live
]
result = facturapi_service.create_organization({"rfc": "ABC010101AAA", "razon_social": "Test SA"})
assert result == {"org_id": "org_123", "api_key": "sk_live_abc"}
assert mock_request.call_count == 2
@mock.patch("pos.services.facturapi_service.find_organization_by_rfc")
@mock.patch("pos.services.facturapi_service._request")
def test_reuses_existing_organization_by_rfc(self, mock_request, mock_find, user_key):
mock_find.return_value = {"id": "org_existing"}
mock_request.return_value = {"key": "sk_live_existing"}
result = facturapi_service.create_organization({"rfc": "ABC010101AAA", "razon_social": "Test SA"})
assert result["org_id"] == "org_existing"
mock_request.assert_called_once()
class TestUploadCsd:
@mock.patch("pos.services.facturapi_service.requests.post")
def test_uploads_csd(self, mock_post):
mock_response = mock.Mock()
mock_response.ok = True
mock_response.json.return_value = {"certificate": {"has_certificate": True}}
mock_post.return_value = mock_response
config = {"facturapi_key": "sk_test", "facturapi_org_id": "org_1"}
cer_b64 = base64.b64encode(b"fake-cer").decode("ascii")
key_b64 = base64.b64encode(b"fake-key").decode("ascii")
result = facturapi_service.upload_csd(config, cer_b64, key_b64, "password")
assert result["certificate"]["has_certificate"] is True
mock_post.assert_called_once()
_, kwargs = mock_post.call_args
assert kwargs["auth"] == ("sk_test", "")
class TestCreateInvoice:
@mock.patch("pos.services.facturapi_service._request")
def test_creates_invoice(self, mock_request):
mock_request.return_value = {"id": "inv_1", "uuid": "uuid-1"}
config = {"facturapi_key": "sk_test"}
payload = {"customer": {"tax_id": "XAXX010101000"}}
result = facturapi_service.create_invoice(config, payload)
assert result["uuid"] == "uuid-1"
mock_request.assert_called_once_with("POST", "/invoices", "sk_test", json_payload=payload, timeout=90)
class TestCancelInvoice:
@mock.patch("pos.services.facturapi_service._request")
def test_cancel_invoice_with_replacement(self, mock_request):
mock_request.return_value = {"status": "canceled"}
config = {"facturapi_key": "sk_test"}
result = facturapi_service.cancel_invoice(config, "inv_1", "01", replacement_uuid="uuid-2")
assert result["status"] == "canceled"
mock_request.assert_called_once_with(
"DELETE",
"/invoices/inv_1",
"sk_test",
params={"motive": "01", "replacement": "uuid-2"},
timeout=60,
)
class TestDownloadXml:
@mock.patch("pos.services.facturapi_service.requests.request")
def test_downloads_xml(self, mock_request):
mock_response = mock.Mock()
mock_response.ok = True
mock_response.content = b"<xml/>"
mock_request.return_value = mock_response
config = {"facturapi_key": "sk_test"}
result = facturapi_service.download_xml(config, "inv_1")
assert result == b"<xml/>"
class TestGetOrgStatus:
@mock.patch("pos.services.facturapi_service.get_organization")
def test_returns_configured_with_csd(self, mock_get_org):
mock_get_org.return_value = {
"legal": {"name": "Test SA", "tax_id": "ABC010101AAA"},
"certificate": {"has_certificate": True},
"pending_steps": [],
}
config = {"facturapi_key": "sk_test", "cfdi_facturapi_org_id": "org_1"}
result = facturapi_service.get_org_status(config)
assert result["configured"] is True
assert result["has_csd"] is True
assert result["has_org_id"] is True
def test_returns_error_without_key(self):
with mock.patch.object(facturapi_service, "USER_KEY", ""):
result = facturapi_service.get_org_status({})
assert result["has_key"] is False
assert "not configured" in result["error"].lower()
def test_returns_error_without_org_id(self):
with mock.patch.object(facturapi_service, "USER_KEY", ""):
result = facturapi_service.get_org_status({"facturapi_key": "sk_test"})
assert result["has_org_id"] is False
assert "organization" in result["error"].lower()
class TestCreateOrUpdateCustomer:
@mock.patch("pos.services.facturapi_service._request")
def test_creates_new_customer_when_not_found(self, mock_request):
mock_request.side_effect = [
{"data": []}, # search
{"id": "cus_1"}, # create
]
config = {"facturapi_key": "sk_test"}
customer_id = facturapi_service.create_or_update_customer(
config,
{
"legal_name": "Test",
"tax_id": "ABC010101AAA",
"tax_system": "601",
"email": "test@example.com",
"zip": "01000",
},
)
assert customer_id == "cus_1"