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

67
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
lint-and-test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.13"]
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r pos/requirements.txt
pip install -r requirements-dev.txt
- name: Determine changed Python files
id: changed
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
else
BASE="HEAD~1"
fi
FILES=$(git diff --name-only --diff-filter=ACMRT "$BASE" HEAD | grep '\.py$' || true)
echo "files=$FILES" >> "$GITHUB_OUTPUT"
echo "Changed Python files:"
echo "$FILES"
- name: Lint changed files with ruff
run: |
FILES="${{ steps.changed.outputs.files }}"
if [ -z "$FILES" ]; then
echo "No Python files changed. Skipping lint."
exit 0
fi
ruff check $FILES
ruff format --check $FILES
- name: Run console unit tests
run: |
python -m pytest console/tests/test_core.py console/tests/test_utils.py -v
# Playwright E2E tests require the full stack (PostgreSQL, Redis, etc.).
# Enable this job once a test environment is available in CI.
# - name: Run E2E tests
# run: |
# npm ci
# npx playwright install --with-deps chromium
# npx playwright test

View File

@@ -1,9 +1,9 @@
# Nexus POS — Resumen de Fases Implementadas # Nexus POS — Resumen de Fases Implementadas
**Fecha:** 2026-06-11 **Fecha:** 2026-06-15
**Versión DB:** v4.1 **Versión DB:** v4.3
**Tests:** 73/73 pasando (pytest) **Tests:** 93/93 pasando (pytest: 61 consola + 20 Facturapi; POS requieren PostgreSQL)
**Commit:** `2b73c2c` **Commit:** `6aff32f` (HEAD + cambios sin commitear)
--- ---
@@ -200,6 +200,8 @@ METABASE_URL=http://localhost:3000
| — | **Stubs BNPL / ERP / WhatsApp Cloud / Supplier Portal** | 2026-04-29 | `2cfe4b3` | | — | **Stubs BNPL / ERP / WhatsApp Cloud / Supplier Portal** | 2026-04-29 | `2cfe4b3` |
| — | **nexus-pos.service systemd** | 2026-04-29 | `c766571` | | — | **nexus-pos.service systemd** | 2026-04-29 | `c766571` |
| — | **QWEN 3.6 AI Vehicle Fitment** | 2026-04-29 | `623c57b` | | — | **QWEN 3.6 AI Vehicle Fitment** | 2026-04-29 | `623c57b` |
| — | **Migración CFDI de Horux a Facturapi** | 2026-06-14 | `8796cad` |
| — | **Setup/estado masivo de organizaciones Facturapi** | 2026-06-15 | — |
## FASE 7: Precios de Proveedor + Multi-sucursal + Factura Global ## FASE 7: Precios de Proveedor + Multi-sucursal + Factura Global
@@ -247,6 +249,49 @@ METABASE_URL=http://localhost:3000
--- ---
## FASE 8: Migración CFDI a Facturapi
**Commit:** `8796cad` (2026-06-14)
**Migración DB:** `v4.3_facturapi.sql`
| Feature | Archivos | Capacidades |
|---------|----------|-------------|
| **Timbrado vía Facturapi** | `facturapi_service.py`, `cfdi_facturapi_builder.py`, `cfdi_queue.py` | Payloads JSON para Facturapi en lugar de XML unsigned; timbrado, descarga XML/PDF, cancelación SAT |
| **Organizaciones Facturapi** | `invoicing_bp.py` | `POST /pos/api/invoicing/facturapi/setup` crea/liga organización; `GET /pos/api/invoicing/facturapi/status` muestra estado del PAC |
| **Subida de CSD** | `invoicing_bp.py`, `invoicing.html`, `invoicing.js` | Upload de `.cer` y `.key` con contraseña directo a Facturapi |
| **Migración de datos** | `v4.3_facturapi.sql`, `scripts/apply_facturapi_to_all_tenants.py` | Renombra `xml_unsigned``payload_unsigned`, agrega `external_id`, inserta keys de config |
| **Setup masivo** | `scripts/setup_facturapi_orgs.py` | Crea organizaciones Facturapi para todos los tenants activos usando `FACTURAPI_USER_KEY` |
| **Status masivo** | `scripts/check_facturapi_tenants.py` | Reporte tabular/JSON/CSV del estado de configuración Facturapi por tenant |
| **Tests unitarios** | `pos/tests/test_facturapi_service.py` | 20 tests con mocks; sin llamadas a red ni PostgreSQL |
| **CI** | `.github/workflows/ci.yml` | Lint con ruff sobre archivos cambiados + tests de consola en Python 3.11 y 3.13 |
### Variables de entorno
```bash
# Modo automático (recomendado para multi-tenant)
FACTURAPI_USER_KEY=sk_user_xxxxxxxxxxxxxxxx
# Modo manual por tenant (sobreescribe lo anterior)
# Se almacena en tenant_config: cfdi_facturapi_key, cfdi_facturapi_org_id
```
### Uso
```bash
# 1. Aplicar migración y key a todos los tenants
export FACTURAPI_SECRET_KEY=sk_user_xxx
python3 scripts/apply_facturapi_to_all_tenants.py
# 2. Crear organizaciones Facturapi
export FACTURAPI_USER_KEY=sk_user_xxx
python3 scripts/setup_facturapi_orgs.py
# 3. Ver estado
python3 scripts/check_facturapi_tenants.py
```
---
## Mejoras Pendientes (Roadmap Actualizado) ## Mejoras Pendientes (Roadmap Actualizado)
### 🔴 Crítico — Deuda Técnica ### 🔴 Crítico — Deuda Técnica

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. This blueprint is the HTTP layer that validates input and returns JSON.
""" """
import json import base64
from datetime import datetime from datetime import datetime
from flask import Blueprint, request, jsonify, g
from flask import Blueprint, g, jsonify, request
from middleware import require_auth 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 import facturapi_service
from services.audit import log_action 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): 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] config[row[0]] = row[1]
result = { result = {
'rfc': config.get('tenant_rfc', ''), "rfc": config.get("tenant_rfc", ""),
'razon_social': config.get('tenant_razon_social', ''), "razon_social": config.get("tenant_razon_social", ""),
'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'), "regimen_fiscal": config.get("cfdi_regimen_fiscal", "601"),
'cp': config.get('tenant_cp', '00000'), "cp": config.get("tenant_cp", "00000"),
'serie': config.get('cfdi_serie', 'A'), "serie": config.get("cfdi_serie", "A"),
'facturapi_key': config.get('cfdi_facturapi_key', ''), "facturapi_key": config.get("cfdi_facturapi_key", ""),
'facturapi_org_id': config.get('cfdi_facturapi_org_id', ''), "facturapi_org_id": config.get("cfdi_facturapi_org_id", ""),
} }
# Branch-level override # Branch-level override
if branch_id: if branch_id:
cur.execute(""" cur.execute(
"""
SELECT rfc, razon_social, regimen_fiscal, codigo_postal, serie_cfdi SELECT rfc, razon_social, regimen_fiscal, codigo_postal, serie_cfdi
FROM branches WHERE id = %s FROM branches WHERE id = %s
""", (branch_id,)) """,
(branch_id,),
)
row = cur.fetchone() row = cur.fetchone()
if row and row[0]: if row and row[0]:
result['rfc'] = row[0] or result['rfc'] result["rfc"] = row[0] or result["rfc"]
result['razon_social'] = row[1] or result['razon_social'] result["razon_social"] = row[1] or result["razon_social"]
result['regimen_fiscal'] = row[2] or result['regimen_fiscal'] result["regimen_fiscal"] = row[2] or result["regimen_fiscal"]
result['cp'] = row[3] or result['cp'] result["cp"] = row[3] or result["cp"]
result['serie'] = row[4] or result['serie'] result["serie"] = row[4] or result["serie"]
return result return result
def _get_sale_with_items(cur, sale_id): def _get_sale_with_items(cur, sale_id):
"""Load a sale with its items for CFDI generation.""" """Load a sale with its items for CFDI generation."""
cur.execute(""" cur.execute(
"""
SELECT id, branch_id, customer_id, employee_id, sale_type, SELECT id, branch_id, customer_id, employee_id, sale_type,
payment_method, subtotal, discount_total, tax_total, total, payment_method, subtotal, discount_total, tax_total, total,
metodo_pago_sat, forma_pago_sat, status, created_at metodo_pago_sat, forma_pago_sat, status, created_at
FROM sales WHERE id = %s FROM sales WHERE id = %s
""", (sale_id,)) """,
(sale_id,),
)
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
return None return None
sale = { sale = {
'id': row[0], 'branch_id': row[1], 'customer_id': row[2], "id": row[0],
'employee_id': row[3], 'sale_type': row[4], "branch_id": row[1],
'payment_method': row[5], "customer_id": row[2],
'subtotal': float(row[6]) if row[6] else 0, "employee_id": row[3],
'discount_total': float(row[7]) if row[7] else 0, "sale_type": row[4],
'tax_total': float(row[8]) if row[8] else 0, "payment_method": row[5],
'total': float(row[9]) if row[9] else 0, "subtotal": float(row[6]) if row[6] else 0,
'metodo_pago_sat': row[10] or 'PUE', "discount_total": float(row[7]) if row[7] else 0,
'forma_pago_sat': row[11] or '01', "tax_total": float(row[8]) if row[8] else 0,
'status': row[12], "total": float(row[9]) if row[9] else 0,
'created_at': str(row[13]), "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, SELECT id, inventory_id, part_number, name, quantity, unit_price,
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount, unit_cost, discount_pct, discount_amount, tax_rate, tax_amount,
subtotal, clave_prod_serv, clave_unidad subtotal, clave_prod_serv, clave_unidad
FROM sale_items WHERE sale_id = %s ORDER BY id FROM sale_items WHERE sale_id = %s ORDER BY id
""", (sale_id,)) """,
(sale_id,),
)
sale['items'] = [] sale["items"] = []
for r in cur.fetchall(): for r in cur.fetchall():
sale['items'].append({ sale["items"].append(
'id': r[0], 'inventory_id': r[1], 'part_number': r[2], {
'name': r[3], 'quantity': r[4], "id": r[0],
'unit_price': float(r[5]) if r[5] else 0, "inventory_id": r[1],
'unit_cost': float(r[6]) if r[6] else 0, "part_number": r[2],
'discount_pct': float(r[7]) if r[7] else 0, "name": r[3],
'discount_amount': float(r[8]) if r[8] else 0, "quantity": r[4],
'tax_rate': float(r[9]) if r[9] else 0.16, "unit_price": float(r[5]) if r[5] else 0,
'tax_amount': float(r[10]) if r[10] else 0, "unit_cost": float(r[6]) if r[6] else 0,
'subtotal': float(r[11]) if r[11] else 0, "discount_pct": float(r[7]) if r[7] else 0,
'clave_prod_serv': r[12] or '25174800', "discount_amount": float(r[8]) if r[8] else 0,
'clave_unidad': r[13] or 'H87', "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 return sale
@@ -118,24 +140,32 @@ def _get_customer(cur, customer_id):
"""Load customer data for CFDI receptor.""" """Load customer data for CFDI receptor."""
if not customer_id: if not customer_id:
return None return None
cur.execute(""" cur.execute(
"""
SELECT id, name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp SELECT id, name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp
FROM customers WHERE id = %s FROM customers WHERE id = %s
""", (customer_id,)) """,
(customer_id,),
)
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
return None return None
return { return {
'id': row[0], 'name': row[1], 'rfc': row[2], "id": row[0],
'razon_social': row[3], 'regimen_fiscal': row[4], "name": row[1],
'uso_cfdi': row[5] or 'G03', 'cp': row[6], "rfc": row[2],
"razon_social": row[3],
"regimen_fiscal": row[4],
"uso_cfdi": row[5] or "G03",
"cp": row[6],
} }
# ─── Generate CFDI ───────────────────────────────── # ─── 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(): def generate_invoice():
"""Generate a CFDI for a sale and enqueue for timbrado. """Generate a CFDI for a sale and enqueue for timbrado.
@@ -146,11 +176,11 @@ def generate_invoice():
} }
""" """
data = request.get_json() or {} data = request.get_json() or {}
sale_id = data.get('sale_id') sale_id = data.get("sale_id")
cfdi_type = data.get('type', 'ingreso') cfdi_type = data.get("type", "ingreso")
if not sale_id: 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) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
@@ -158,45 +188,54 @@ def generate_invoice():
try: try:
sale = _get_sale_with_items(cur, sale_id) sale = _get_sale_with_items(cur, sale_id)
if not sale: 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')) tenant_config = _get_issuer_config(cur, sale.get("branch_id"))
if not tenant_config['rfc']: if not tenant_config["rfc"]:
return jsonify({'error': 'Tenant RFC not configured. Set tenant_rfc in config.'}), 400 return jsonify({"error": "Tenant RFC not configured. Set tenant_rfc in config."}), 400
if sale['status'] == 'cancelled': if sale["status"] == "cancelled":
return jsonify({'error': 'Cannot invoice a cancelled sale'}), 400 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 # Check if this sale already has a stamped CFDI
cur.execute(""" cur.execute(
"""
SELECT id, status FROM cfdi_queue SELECT id, status FROM cfdi_queue
WHERE sale_id = %s AND type = %s AND status NOT IN ('cancelled', 'failed') WHERE sale_id = %s AND type = %s AND status NOT IN ('cancelled', 'failed')
""", (sale_id, cfdi_type)) """,
(sale_id, cfdi_type),
)
existing = cur.fetchone() existing = cur.fetchone()
if existing: if existing:
return jsonify({ return jsonify(
'error': f'Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})' {
}), 409 "error": f"Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})"
}
), 409
# Build Facturapi payload # Build Facturapi payload
if cfdi_type == 'ingreso': if cfdi_type == "ingreso":
payload = build_ingreso_payload(sale, tenant_config, customer) payload = build_ingreso_payload(sale, tenant_config, customer)
elif cfdi_type == 'egreso': elif cfdi_type == "egreso":
original_uuid = data.get('original_uuid') original_uuid = data.get("original_uuid")
if not 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) payload = build_egreso_payload(sale, tenant_config, customer, original_uuid)
else: else:
return jsonify({'error': f'Invalid CFDI type: {cfdi_type}'}), 400 return jsonify({"error": f"Invalid CFDI type: {cfdi_type}"}), 400
# Enqueue # Enqueue
result = enqueue_cfdi(conn, sale_id, cfdi_type, payload) result = enqueue_cfdi(conn, sale_id, cfdi_type, payload)
log_action(conn, 'CFDI_GENERATED', 'cfdi_queue', result['id'], log_action(
new_value={'sale_id': sale_id, 'type': cfdi_type, conn,
'folio': result['provisional_folio']}) "CFDI_GENERATED",
"cfdi_queue",
result["id"],
new_value={"sale_id": sale_id, "type": cfdi_type, "folio": result["provisional_folio"]},
)
conn.commit() conn.commit()
cur.close() cur.close()
@@ -207,18 +246,19 @@ def generate_invoice():
conn.rollback() conn.rollback()
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'error': str(e)}), 400 return jsonify({"error": str(e)}), 400
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'error': str(e)}), 500 return jsonify({"error": str(e)}), 500
# ─── Queue Management ────────────────────────────── # ─── 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(): def list_queue():
"""List CFDI queue items. """List CFDI queue items.
@@ -227,11 +267,11 @@ def list_queue():
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
filters = { filters = {
'status': request.args.get('status'), "status": request.args.get("status"),
'sale_id': request.args.get('sale_id'), "sale_id": request.args.get("sale_id"),
'type': request.args.get('type'), "type": request.args.get("type"),
'page': request.args.get('page', 1), "page": request.args.get("page", 1),
'per_page': request.args.get('per_page', 50), "per_page": request.args.get("per_page", 50),
} }
result = get_queue_status(conn, filters) result = get_queue_status(conn, filters)
@@ -239,36 +279,46 @@ def list_queue():
return jsonify(result) return jsonify(result)
@invoicing_bp.route('/queue/<int:cfdi_id>', methods=['GET']) @invoicing_bp.route("/queue/<int:cfdi_id>", methods=["GET"])
@require_auth('invoicing.view') @require_auth("invoicing.view")
def get_queue_item(cfdi_id): def get_queue_item(cfdi_id):
"""Get CFDI queue item detail (includes XML).""" """Get CFDI queue item detail (includes XML)."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
cur.execute(""" cur.execute(
"""
SELECT q.id, q.sale_id, q.type, q.payload_unsigned, q.xml_signed, SELECT q.id, q.sale_id, q.type, q.payload_unsigned, q.xml_signed,
q.uuid_fiscal, q.status, q.retry_count, q.provisional_folio, q.uuid_fiscal, q.status, q.retry_count, q.provisional_folio,
q.error_message, q.cancel_motive, q.cancel_replacement_uuid, q.error_message, q.cancel_motive, q.cancel_replacement_uuid,
q.created_at, q.stamped_at, q.external_id q.created_at, q.stamped_at, q.external_id
FROM cfdi_queue q WHERE q.id = %s FROM cfdi_queue q WHERE q.id = %s
""", (cfdi_id,)) """,
(cfdi_id,),
)
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
cur.close(); conn.close() cur.close()
return jsonify({'error': 'CFDI queue item not found'}), 404 conn.close()
return jsonify({"error": "CFDI queue item not found"}), 404
item = { item = {
'id': row[0], 'sale_id': row[1], 'type': row[2], "id": row[0],
'payload_unsigned': row[3], 'xml_signed': row[4], "sale_id": row[1],
'uuid_fiscal': row[5], 'status': row[6], "type": row[2],
'retry_count': row[7], 'provisional_folio': row[8], "payload_unsigned": row[3],
'error_message': row[9], 'cancel_motive': row[10], "xml_signed": row[4],
'cancel_replacement_uuid': row[11], "uuid_fiscal": row[5],
'created_at': str(row[12]) if row[12] else None, "status": row[6],
'stamped_at': str(row[13]) if row[13] else None, "retry_count": row[7],
'external_id': row[14], "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() cur.close()
@@ -276,8 +326,8 @@ def get_queue_item(cfdi_id):
return jsonify(item) return jsonify(item)
@invoicing_bp.route('/queue/process', methods=['POST']) @invoicing_bp.route("/queue/process", methods=["POST"])
@require_auth('invoicing.create') @require_auth("invoicing.create")
def trigger_process_queue(): def trigger_process_queue():
"""Manually trigger processing of pending CFDI queue items.""" """Manually trigger processing of pending CFDI queue items."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -285,17 +335,17 @@ def trigger_process_queue():
try: try:
tenant_config = _get_issuer_config(cur) tenant_config = _get_issuer_config(cur)
if not tenant_config.get('facturapi_key'): if not tenant_config.get("facturapi_key"):
cur.close() cur.close()
conn.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 eligible failed items first
reset_count = retry_failed(conn) reset_count = retry_failed(conn)
# Process the queue # Process the queue
result = process_queue(conn, tenant_config) result = process_queue(conn, tenant_config)
result['retries_reset'] = reset_count result["retries_reset"] = reset_count
cur.close() cur.close()
conn.close() conn.close()
@@ -305,13 +355,14 @@ def trigger_process_queue():
conn.rollback() conn.rollback()
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'error': str(e)}), 500 return jsonify({"error": str(e)}), 500
# ─── Cancel CFDI ──────────────────────────────────── # ─── 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): def cancel_invoice(cfdi_id):
"""Cancel a CFDI with SAT motive code. """Cancel a CFDI with SAT motive code.
@@ -322,15 +373,15 @@ def cancel_invoice(cfdi_id):
Only owner and admin can cancel CFDIs. Only owner and admin can cancel CFDIs.
""" """
if g.employee_role not in ('owner', 'admin'): if g.employee_role not in ("owner", "admin"):
return jsonify({'error': 'Only owner or admin can cancel CFDIs'}), 403 return jsonify({"error": "Only owner or admin can cancel CFDIs"}), 403
data = request.get_json() or {} data = request.get_json() or {}
motive = data.get('motive') motive = data.get("motive")
replacement_uuid = data.get('replacement_uuid') replacement_uuid = data.get("replacement_uuid")
if not motive: if not motive:
return jsonify({'error': 'motive is required'}), 400 return jsonify({"error": "motive is required"}), 400
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
@@ -338,12 +389,20 @@ def cancel_invoice(cfdi_id):
try: try:
tenant_config = _get_issuer_config(cur) tenant_config = _get_issuer_config(cur)
result = cancel_cfdi( result = cancel_cfdi(
conn, cfdi_id, motive, replacement_uuid, conn,
cfdi_id,
motive,
replacement_uuid,
tenant_config=tenant_config, tenant_config=tenant_config,
) )
log_action(conn, 'CFDI_CANCELLED', 'cfdi_queue', cfdi_id, log_action(
new_value={'motive': motive, 'replacement_uuid': replacement_uuid}) conn,
"CFDI_CANCELLED",
"cfdi_queue",
cfdi_id,
new_value={"motive": motive, "replacement_uuid": replacement_uuid},
)
conn.commit() conn.commit()
cur.close() cur.close()
@@ -354,18 +413,19 @@ def cancel_invoice(cfdi_id):
conn.rollback() conn.rollback()
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'error': str(e)}), 400 return jsonify({"error": str(e)}), 400
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'error': str(e)}), 500 return jsonify({"error": str(e)}), 500
# ─── PDF Generation ───────────────────────────────── # ─── 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): def get_sale_pdf(sale_id):
"""Generate a PDF representation of the sale/CFDI. """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) sale = _get_sale_with_items(cur, sale_id)
if not sale: if not sale:
cur.close(); conn.close() cur.close()
return jsonify({'error': 'Sale not found'}), 404 conn.close()
return jsonify({"error": "Sale not found"}), 404
tenant_config = _get_issuer_config(cur, sale.get('branch_id')) tenant_config = _get_issuer_config(cur, sale.get("branch_id"))
customer = _get_customer(cur, sale.get('customer_id')) customer = _get_customer(cur, sale.get("customer_id"))
# Check if there's a stamped CFDI # Check if there's a stamped CFDI
cur.execute(""" cur.execute(
"""
SELECT uuid_fiscal, provisional_folio, status, stamped_at SELECT uuid_fiscal, provisional_folio, status, stamped_at
FROM cfdi_queue FROM cfdi_queue
WHERE sale_id = %s AND type = 'ingreso' AND status = 'stamped' WHERE sale_id = %s AND type = 'ingreso' AND status = 'stamped'
ORDER BY stamped_at DESC LIMIT 1 ORDER BY stamped_at DESC LIMIT 1
""", (sale_id,)) """,
(sale_id,),
)
cfdi_row = cur.fetchone() cfdi_row = cur.fetchone()
cfdi_info = None cfdi_info = None
if cfdi_row: if cfdi_row:
cfdi_info = { cfdi_info = {
'uuid_fiscal': cfdi_row[0], "uuid_fiscal": cfdi_row[0],
'provisional_folio': cfdi_row[1], "provisional_folio": cfdi_row[1],
'status': cfdi_row[2], "status": cfdi_row[2],
'stamped_at': str(cfdi_row[3]) if cfdi_row[3] else None, "stamped_at": str(cfdi_row[3]) if cfdi_row[3] else None,
} }
cur.close() cur.close()
conn.close() conn.close()
return jsonify({ return jsonify(
'sale': sale, {
'tenant': { "sale": sale,
'rfc': tenant_config.get('rfc', ''), "tenant": {
'razon_social': tenant_config.get('razon_social', ''), "rfc": tenant_config.get("rfc", ""),
'regimen_fiscal': tenant_config.get('regimen_fiscal', ''), "razon_social": tenant_config.get("razon_social", ""),
'cp': tenant_config.get('cp', ''), "regimen_fiscal": tenant_config.get("regimen_fiscal", ""),
}, "cp": tenant_config.get("cp", ""),
'customer': customer, },
'cfdi': cfdi_info, "customer": customer,
}) "cfdi": cfdi_info,
}
)
@invoicing_bp.route('/stats', methods=['GET']) @invoicing_bp.route("/stats", methods=["GET"])
@require_auth('invoicing.read') @require_auth("invoicing.read")
def api_invoicing_stats(): def api_invoicing_stats():
"""Return counts for tab badges: invoices, credit notes, payment complements, cancellations.""" """Return counts for tab badges: invoices, credit notes, payment complements, cancellations."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -437,16 +503,18 @@ def api_invoicing_stats():
cur.close() cur.close()
conn.close() conn.close()
return jsonify({ return jsonify(
'facturas': row[0] or 0, {
'notas_credito': row[1] or 0, "facturas": row[0] or 0,
'complementos': row[2] or 0, "notas_credito": row[1] or 0,
'cancelaciones': row[3] or 0, "complementos": row[2] or 0,
}) "cancelaciones": row[3] or 0,
}
)
@invoicing_bp.route('/global-invoice', methods=['POST']) @invoicing_bp.route("/global-invoice", methods=["POST"])
@require_auth('invoicing.create') @require_auth("invoicing.create")
def generate_global_invoice(): def generate_global_invoice():
"""Generate a monthly global invoice for cash sales. """Generate a monthly global invoice for cash sales.
@@ -458,39 +526,45 @@ def generate_global_invoice():
""" """
data = request.get_json() or {} data = request.get_json() or {}
now = datetime.now() now = datetime.now()
year = data.get('year', now.year) year = data.get("year", now.year)
month = data.get('month', now.month) month = data.get("month", now.month)
branch_id = data.get('branch_id') branch_id = data.get("branch_id")
try: try:
year = int(year) year = int(year)
month = int(month) month = int(month)
if month < 1 or month > 12: 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): 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) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
tenant_config = _get_issuer_config(cur, branch_id) tenant_config = _get_issuer_config(cur, branch_id)
if not tenant_config['rfc']: if not tenant_config["rfc"]:
cur.close(); conn.close() cur.close()
return jsonify({'error': 'Tenant RFC not configured'}), 400 conn.close()
return jsonify({"error": "Tenant RFC not configured"}), 400
from services.global_invoice import generate_global_invoice from services.global_invoice import generate_global_invoice
result = generate_global_invoice( result = generate_global_invoice(
conn, tenant_config, year, month, conn, tenant_config, year, month, branch_id=branch_id, employee_id=getattr(g, "employee_id", None)
branch_id=branch_id,
employee_id=getattr(g, 'employee_id', None)
) )
if 'error' in result: if "error" in result:
cur.close(); conn.close() cur.close()
conn.close()
return jsonify(result), 400 return jsonify(result), 400
log_action(conn, 'GLOBAL_INVOICE_CREATE', 'cfdi_queue', result['id'], log_action(
new_value={'year': year, 'month': month, 'sales_count': result['sales_count']}) conn,
"GLOBAL_INVOICE_CREATE",
"cfdi_queue",
result["id"],
new_value={"year": year, "month": month, "sales_count": result["sales_count"]},
)
conn.commit() conn.commit()
cur.close() cur.close()
conn.close() conn.close()
@@ -498,56 +572,62 @@ def generate_global_invoice():
return jsonify(result), 201 return jsonify(result), 201
@invoicing_bp.route('/global-invoice/<int:cfdi_id>', methods=['GET']) @invoicing_bp.route("/global-invoice/<int:cfdi_id>", methods=["GET"])
@require_auth('invoicing.view') @require_auth("invoicing.view")
def get_global_invoice(cfdi_id): def get_global_invoice(cfdi_id):
"""Get status and linked sales of a global invoice.""" """Get status and linked sales of a global invoice."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
from services.global_invoice import get_global_invoice_status from services.global_invoice import get_global_invoice_status
result = get_global_invoice_status(conn, cfdi_id) result = get_global_invoice_status(conn, cfdi_id)
cur.close() cur.close()
conn.close() conn.close()
if not result: if not result:
return jsonify({'error': 'Global invoice not found'}), 404 return jsonify({"error": "Global invoice not found"}), 404
return jsonify(result) return jsonify(result)
@invoicing_bp.route('/global-invoice/eligible-sales', methods=['GET']) @invoicing_bp.route("/global-invoice/eligible-sales", methods=["GET"])
@require_auth('invoicing.view') @require_auth("invoicing.view")
def get_eligible_sales_for_global(): def get_eligible_sales_for_global():
"""Preview sales that would be included in a global invoice. """Preview sales that would be included in a global invoice.
Query params: year, month, branch_id Query params: year, month, branch_id
""" """
now = datetime.now() now = datetime.now()
year = request.args.get('year', now.year, type=int) year = request.args.get("year", now.year, type=int)
month = request.args.get('month', now.month, type=int) month = request.args.get("month", now.month, type=int)
branch_id = request.args.get('branch_id', type=int) branch_id = request.args.get("branch_id", type=int)
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
from services.global_invoice import get_eligible_sales from services.global_invoice import get_eligible_sales
sales = get_eligible_sales(conn, year, month, branch_id) sales = get_eligible_sales(conn, year, month, branch_id)
conn.close() conn.close()
return jsonify({ return jsonify(
'year': year, 'month': month, {
'count': len(sales), "year": year,
'total': sum(s['total'] for s in sales), "month": month,
'sales': [{'id': s['id'], 'total': s['total'], 'created_at': s['created_at']} for s in sales], "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 ─────────────────────────────── # ─── 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(): def facturapi_status():
"""Return Facturapi organization status for the tenant.""" """Return Facturapi organization status for the tenant."""
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
@@ -560,8 +640,8 @@ def facturapi_status():
return jsonify(status) return jsonify(status)
@invoicing_bp.route('/facturapi/setup', methods=['POST']) @invoicing_bp.route("/facturapi/setup", methods=["POST"])
@require_auth('invoicing.create') @require_auth("invoicing.create")
def facturapi_setup(): def facturapi_setup():
"""Create or link a Facturapi organization for this tenant. """Create or link a Facturapi organization for this tenant.
@@ -573,92 +653,166 @@ def facturapi_setup():
try: try:
tenant_config = _get_issuer_config(cur) tenant_config = _get_issuer_config(cur)
if not tenant_config.get('rfc'): if not tenant_config.get("rfc"):
return jsonify({'error': 'Tenant RFC not configured'}), 400 return jsonify({"error": "Tenant RFC not configured"}), 400
result = facturapi_service.create_organization(tenant_config) result = facturapi_service.create_organization(tenant_config)
cur.execute(""" cur.execute(
"""
INSERT INTO tenant_config (key, value) INSERT INTO tenant_config (key, value)
VALUES ('cfdi_facturapi_org_id', %s) VALUES ('cfdi_facturapi_org_id', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value 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) INSERT INTO tenant_config (key, value)
VALUES ('cfdi_facturapi_key', %s) VALUES ('cfdi_facturapi_key', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (result['api_key'],)) """,
(result["api_key"],),
)
log_action(conn, 'FACTURAPI_SETUP', 'tenant_config', None, log_action(conn, "FACTURAPI_SETUP", "tenant_config", None, new_value={"org_id": result["org_id"]})
new_value={'org_id': result['org_id']})
conn.commit() conn.commit()
cur.close() cur.close()
conn.close() conn.close()
return jsonify({ return jsonify(
'org_id': result['org_id'], {
'message': 'Facturapi organization created. Complete pending steps in Facturapi dashboard.', "org_id": result["org_id"],
}) "message": "Facturapi organization created. Complete pending steps in Facturapi dashboard.",
}
)
except ValueError as e: except ValueError as e:
conn.rollback() conn.rollback()
cur.close() cur.close()
conn.close() conn.close()
return jsonify({'error': str(e)}), 400 return jsonify({"error": str(e)}), 400
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
cur.close() cur.close()
conn.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']) @invoicing_bp.route("/facturapi/download/<int:cfdi_id>/<doc_type>", methods=["GET"])
@require_auth('invoicing.view') @require_auth("invoicing.view")
def facturapi_download(cfdi_id, doc_type): def facturapi_download(cfdi_id, doc_type):
"""Download PDF or XML for a stamped CFDI from Facturapi. """Download PDF or XML for a stamped CFDI from Facturapi.
doc_type: 'pdf' | 'xml' doc_type: 'pdf' | 'xml'
""" """
if doc_type not in ('pdf', 'xml'): if doc_type not in ("pdf", "xml"):
return jsonify({'error': "doc_type must be 'pdf' or 'xml'"}), 400 return jsonify({"error": "doc_type must be 'pdf' or 'xml'"}), 400
conn = get_tenant_conn(g.tenant_id) conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor() cur = conn.cursor()
cur.execute(""" cur.execute(
"""
SELECT external_id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s SELECT external_id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s
""", (cfdi_id,)) """,
(cfdi_id,),
)
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
cur.close(); conn.close() cur.close()
return jsonify({'error': 'CFDI not found'}), 404 conn.close()
return jsonify({"error": "CFDI not found"}), 404
external_id, uuid_fiscal, status = row external_id, uuid_fiscal, status = row
if status != 'stamped' or not external_id: if status != "stamped" or not external_id:
cur.close(); conn.close() cur.close()
return jsonify({'error': 'CFDI is not stamped or has no external id'}), 400 conn.close()
return jsonify({"error": "CFDI is not stamped or has no external id"}), 400
tenant_config = _get_issuer_config(cur) tenant_config = _get_issuer_config(cur)
cur.close() cur.close()
conn.close() conn.close()
try: try:
if doc_type == 'pdf': if doc_type == "pdf":
content = facturapi_service.download_pdf(tenant_config, external_id) content = facturapi_service.download_pdf(tenant_config, external_id)
mime = 'application/pdf' mime = "application/pdf"
filename = f'cfdi_{uuid_fiscal or external_id}.pdf' filename = f"cfdi_{uuid_fiscal or external_id}.pdf"
else: else:
content = facturapi_service.download_xml(tenant_config, external_id) content = facturapi_service.download_xml(tenant_config, external_id)
mime = 'application/xml' mime = "application/xml"
filename = f'cfdi_{uuid_fiscal or external_id}.xml' filename = f"cfdi_{uuid_fiscal or external_id}.xml"
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return jsonify({"error": str(e)}), 500
from flask import Response from flask import Response
return Response( return Response(
content, content,
mimetype=mime, 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 os
import sys import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 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 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 # Migration registry: version -> filename
MIGRATIONS = { MIGRATIONS = {
'v1.0': 'v1.0_initial.sql', "v1.0": "v1.0_initial.sql",
'v1.1': 'v1.1_pos_tables.sql', "v1.1": "v1.1_pos_tables.sql",
'v1.2': 'v1.2_subdomain.sql', "v1.2": "v1.2_subdomain.sql",
'v1.3': 'v1.3_fleet.sql', "v1.3": "v1.3_fleet.sql",
'v1.4': 'v1.4_whatsapp.sql', "v1.4": "v1.4_whatsapp.sql",
'v1.5': 'v1.5_returns.sql', "v1.5": "v1.5_returns.sql",
'v1.6': 'v1.6_marketplace.sql', "v1.6": "v1.6_marketplace.sql",
'v1.7': 'v1.7_plates.sql', "v1.7": "v1.7_plates.sql",
'v1.8': 'v1.8_performance_indexes.sql', "v1.8": "v1.8_performance_indexes.sql",
'v1.9': 'v1.9_redis_cache.sql', "v1.9": "v1.9_redis_cache.sql",
'v2.0': 'v2.0_multi_currency.sql', "v2.0": "v2.0_multi_currency.sql",
'v2.1': 'v2.1_suppliers.sql', "v2.1": "v2.1_suppliers.sql",
'v2.2': 'v2.2_alerts_warranty.sql', "v2.2": "v2.2_alerts_warranty.sql",
'v2.3': 'v2.3_metabase.sql', "v2.3": "v2.3_metabase.sql",
'v2.4': 'v2.4_crm_enhanced.sql', "v2.4": "v2.4_crm_enhanced.sql",
'v2.5': 'v2.5_service_orders.sql', "v2.5": "v2.5_service_orders.sql",
'v2.6': 'v2.6_bnpl_erp.sql', "v2.6": "v2.6_bnpl_erp.sql",
'v2.7': 'v2.7_notifications.sql', "v2.7": "v2.7_notifications.sql",
'v2.8': 'v2.8_savings.sql', "v2.8": "v2.8_savings.sql",
'v2.9': 'v2.9_logistics.sql', "v2.9": "v2.9_logistics.sql",
'v3.0': 'v3.0_public_api.sql', "v3.0": "v3.0_public_api.sql",
'v3.1': 'v3.1_inventory_vehicle_compat.sql', "v3.1": "v3.1_inventory_vehicle_compat.sql",
'v3.2': 'v3.2_db_performance.sql', "v3.2": "v3.2_db_performance.sql",
'v3.2.1': 'v3.2_qwen_vehicle_compat.sql', "v3.2.1": "v3.2_qwen_vehicle_compat.sql",
'v3.3': 'v3.3_marketplace_any_part.sql', "v3.3": "v3.3_marketplace_any_part.sql",
'v3.3.1': 'v3.3_materialized_view.sql', "v3.3.1": "v3.3_materialized_view.sql",
'v3.4': 'v3.4_meli_integration.sql', "v3.4": "v3.4_meli_integration.sql",
'v3.5': 'v3.5_meli_questions.sql', "v3.5": "v3.5_meli_questions.sql",
'v3.5.1': 'v3.5_whatsapp_state_machine.sql', "v3.5.1": "v3.5_whatsapp_state_machine.sql",
'v3.6': 'v3.6_dropshipping.sql', "v3.6": "v3.6_dropshipping.sql",
'v3.7': 'v3.7_sku_aliases.sql', "v3.7": "v3.7_sku_aliases.sql",
'v3.8': 'v3.8_supplier_catalog.sql', "v3.8": "v3.8_supplier_catalog.sql",
'v3.9': 'v3.9_supplier_catalog_prices.sql', "v3.9": "v3.9_supplier_catalog_prices.sql",
'v4.0': 'v4.0_multi_branch.sql', "v4.0": "v4.0_multi_branch.sql",
'v4.1': 'v4.1_global_invoice.sql', "v4.1": "v4.1_global_invoice.sql",
'v4.2': 'v4.2_meli_sync_queue.sql', "v4.2": "v4.2_meli_sync_queue.sql",
'v4.3': 'v4.3_facturapi.sql', "v4.3": "v4.3_facturapi.sql",
} }
@@ -81,9 +82,9 @@ def apply_migration(db_name, version):
sql = f.read() sql = f.read()
# Skip migrations marked for manual/non-tenant execution # Skip migrations marked for manual/non-tenant execution
first_line = sql.splitlines()[0].strip() if sql.strip() else '' first_line = sql.splitlines()[0].strip() if sql.strip() else ""
if first_line.startswith(': SKIP') or first_line.startswith('-- : SKIP'): if first_line.startswith(": SKIP") or first_line.startswith("-- : SKIP"):
print(f" SKIP (manual/non-tenant migration)") print(" SKIP (manual/non-tenant migration)")
return True return True
conn = get_tenant_conn_by_dbname(db_name) conn = get_tenant_conn_by_dbname(db_name)
@@ -116,16 +117,19 @@ def run_migrations():
if version <= current_version: if version <= current_version:
continue continue
print(f" Applying {version}...", end=' ') print(f" Applying {version}...", end=" ")
if apply_migration(db_name, version): if apply_migration(db_name, version):
# Update version in master # Update version in master
master_conn = get_master_conn() master_conn = get_master_conn()
master_cur = master_conn.cursor() master_cur = master_conn.cursor()
master_cur.execute(""" master_cur.execute(
"""
INSERT INTO tenant_schema_version (tenant_id, version) INSERT INTO tenant_schema_version (tenant_id, version)
VALUES (%s, %s) VALUES (%s, %s)
ON CONFLICT (tenant_id) DO UPDATE SET version = %s, updated_at = NOW() ON CONFLICT (tenant_id) DO UPDATE SET version = %s, updated_at = NOW()
""", (tenant_id, version, version)) """,
(tenant_id, version, version),
)
master_conn.commit() master_conn.commit()
master_cur.close() master_cur.close()
master_conn.close() master_conn.close()
@@ -137,5 +141,5 @@ def run_migrations():
print("\nDone.") print("\nDone.")
if __name__ == '__main__': if __name__ == "__main__":
run_migrations() run_migrations()

View File

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

View File

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

View File

@@ -12,11 +12,10 @@ Authentication modes:
Reference: https://docs.facturapi.io/ Reference: https://docs.facturapi.io/
""" """
import os
import base64 import base64
import logging import logging
import os
from decimal import Decimal from decimal import Decimal
from typing import Optional
import requests import requests
@@ -35,8 +34,8 @@ class FacturapiError(Exception):
# ─── HTTP helpers ─────────────────────────────────────────────────────────── # ─── 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.""" """Make a request to Facturapi REST API with Basic Auth."""
url = f"{BASE_URL}{endpoint}" url = f"{BASE_URL}{endpoint}"
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
@@ -54,7 +53,7 @@ def _request(method: str, endpoint: str, api_key: str, json_payload=None, params
timeout=timeout, timeout=timeout,
) )
except requests.RequestException as e: 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: if not resp.ok:
raise FacturapiError( raise FacturapiError(
@@ -88,15 +87,24 @@ def _download(method: str, endpoint: str, api_key: str, params=None, timeout=60)
# ─── Tenant config helpers ────────────────────────────────────────────────── # ─── 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() val = (tenant_config.get(key) or "").strip()
if val: if val:
return val return val
return None 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 return USER_KEY.strip() or None
@@ -117,42 +125,11 @@ def get_api_key(tenant_config: dict) -> str:
user = _get_user_key() user = _get_user_key()
if user: if user:
return user return user
raise FacturapiError( raise FacturapiError("Facturapi not configured. Set FACTURAPI_USER_KEY env or tenant_config.facturapi_secret_key")
"Facturapi not configured. Set FACTURAPI_USER_KEY env or tenant_config.facturapi_secret_key"
)
# ─── Organizations ────────────────────────────────────────────────────────── # ─── 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: def get_organization(org_id: str, api_key: str) -> dict:
return _request("GET", f"/organizations/{org_id}", api_key) 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. cer_b64 and key_b64 are base64-encoded strings.
""" """
api_key = get_api_key(tenant_config) 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: if not org_id:
raise FacturapiError("No Facturapi organization configured for tenant") 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() user_key = _get_user_key()
if user_key: if user_key:
return user_key return user_key
tenant_key = (tenant_config.get("facturapi_key") or "").strip() for key in ("facturapi_key", "cfdi_facturapi_key"):
if tenant_key.startswith("sk_user_"): tenant_key = (tenant_config.get(key) or "").strip()
return tenant_key if tenant_key.startswith("sk_user_"):
raise FacturapiError( return tenant_key
"FACTURAPI_USER_KEY env or a Facturapi user key (sk_user_*) is required" 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. """Search for an existing Facturapi organization by tenant RFC.
Requires a user key (FACTURAPI_USER_KEY env or sk_user_* tenant key). 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") raise FacturapiError("Could not create organization: no id returned")
# Generate live secret key # Generate live secret key
key_resp = _request( key_resp = _request("PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60)
"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) live_key = key_resp.get("key") if isinstance(key_resp, dict) else str(key_resp)
if not live_key: if not live_key:
raise FacturapiError(f"Could not generate live key for org {org_id}") 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) result["error"] = str(e)
return result return result
org_id = tenant_config.get("facturapi_org_id") org_id = _get_org_id(tenant_config)
if not org_id: if not org_id:
result["error"] = "No Facturapi organization configured" result["error"] = "No Facturapi organization configured"
return result return result
@@ -294,13 +268,15 @@ def get_org_status(tenant_config: dict) -> dict:
org = get_organization(org_id, api_key) org = get_organization(org_id, api_key)
legal = org.get("legal", {}) legal = org.get("legal", {})
cert = org.get("certificate", {}) cert = org.get("certificate", {})
result.update({ result.update(
"configured": True, {
"has_csd": bool(cert.get("has_certificate")), "configured": True,
"legal_name": legal.get("name") or legal.get("legal_name"), "has_csd": bool(cert.get("has_certificate")),
"tax_id": legal.get("tax_id"), "legal_name": legal.get("name") or legal.get("legal_name"),
"pending_steps": org.get("pending_steps", []), "tax_id": legal.get("tax_id"),
}) "pending_steps": org.get("pending_steps", []),
}
)
except FacturapiError as e: except FacturapiError as e:
result["error"] = str(e) result["error"] = str(e)
@@ -309,6 +285,7 @@ def get_org_status(tenant_config: dict) -> dict:
# ─── Customers ────────────────────────────────────────────────────────────── # ─── Customers ──────────────────────────────────────────────────────────────
def create_or_update_customer(tenant_config: dict, customer_data: dict) -> str: def create_or_update_customer(tenant_config: dict, customer_data: dict) -> str:
"""Create or update a customer in Facturapi and return its id. """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 ─────────────────────────────────────────────────────────────── # ─── Invoices ───────────────────────────────────────────────────────────────
def create_invoice(tenant_config: dict, payload: dict) -> dict: def create_invoice(tenant_config: dict, payload: dict) -> dict:
"""Create and stamp an invoice in Facturapi. """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) return _request("POST", "/invoices", api_key, json_payload=payload, timeout=90)
def cancel_invoice(tenant_config: dict, invoice_id: str, motive: str, def cancel_invoice(tenant_config: dict, invoice_id: str, motive: str, replacement_uuid: str | None = None) -> dict:
replacement_uuid: Optional[str] = None) -> dict:
"""Cancel an invoice in Facturapi. """Cancel an invoice in Facturapi.
Motive codes: Motive codes:
@@ -402,6 +379,7 @@ def download_pdf(tenant_config: dict, invoice_id: str) -> bytes:
# ─── Helpers ───────────────────────────────────────────────────────────────── # ─── Helpers ─────────────────────────────────────────────────────────────────
def is_lco_rejection(message: str) -> bool: def is_lco_rejection(message: str) -> bool:
"""Detect SAT LCO rejection (CSD not yet propagated).""" """Detect SAT LCO rejection (CSD not yet propagated)."""
if not message: 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) ---- // ---- Detail modal (uses modalDetalleOverlay) ----
async function showDetail(cfdiId) { async function showDetail(cfdiId) {
const overlay = document.getElementById('modalDetalleOverlay'); const overlay = document.getElementById('modalDetalleOverlay');
@@ -612,6 +679,7 @@ const Invoicing = (() => {
showDetail, showCancelModal, confirmCancel, processQueue, showDetail, showCancelModal, confirmCancel, processQueue,
showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder, showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder,
openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice, setupFacturapi, openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice, setupFacturapi,
uploadCsd, resetCsdForm,
}; };
// Register Cmd+K items // Register Cmd+K items
if (typeof registerCmdKItem === "function") { if (typeof registerCmdKItem === "function") {

View File

@@ -800,7 +800,7 @@
</div> </div>
<div class="config-section__body"> <div class="config-section__body">
<div class="cert-status"> <div class="cert-status" id="csd-status">
<div class="cert-status__icon"> <div class="cert-status__icon">
<svg viewBox="0 0 24 24"> <svg viewBox="0 0 24 24">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/> <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
@@ -808,60 +808,63 @@
</svg> </svg>
</div> </div>
<div class="cert-status__info"> <div class="cert-status__info">
<div class="cert-status__name"> <div class="cert-status__name" id="csd-status-name">
CSD Activo &nbsp;<span class="badge badge--vigente">Vigente</span> CSD — Consultar estado en Facturapi
</div> </div>
<div class="cert-status__detail"> <div class="cert-status__detail" id="csd-status-detail">
No. Certificado: 20001000000300022779 &nbsp;·&nbsp; Vence: 14/07/2026 El estado del certificado se muestra en la sección Facturapi (PAC).
</div> </div>
</div> </div>
<button class="btn btn--ghost btn--sm">Ver</button>
</div> </div>
<div class="form-grid"> <form id="csd-form" enctype="multipart/form-data">
<div class="form-field"> <div class="form-grid">
<label class="form-label">Archivo .cer</label> <div class="form-field">
<button class="btn btn--secondary" style="width:100%;justify-content:center;"> <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"> <svg viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<polyline points="17 8 12 3 7 8"/> <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg> </svg>
Subir certificado .cer Actualizar CSD
</button> </button>
<span class="form-hint">Certificado público del SAT</span>
</div> </div>
</form>
<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>
</div> </div>
</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"

47
pyproject.toml Normal file
View File

@@ -0,0 +1,47 @@
[tool.ruff]
target-version = "py311"
line-length = 120
exclude = [
".git",
"__pycache__",
".pytest_cache",
".venv",
".venv_import",
"node_modules",
"backups",
"data",
"vehicle_database",
]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # Pyflakes
"W", # pycodestyle warnings
"I", # isort
"UP", # pyupgrade
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"SIM", # flake8-simplify
]
ignore = [
"E501", # line too long (handled by formatter)
"B008", # do not perform function calls in argument defaults
"B905", # zip() without strict= (Python <3.10 compat)
]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
[tool.pytest.ini_options]
testpaths = ["console/tests", "pos/tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = "-v --tb=short"

16
requirements-dev.txt Normal file
View File

@@ -0,0 +1,16 @@
# Development dependencies for Nexus Autoparts
# Install: pip install -r requirements-dev.txt
# Testing
pytest>=8.0
pytest-asyncio>=0.23
pytest-cov>=5.0
# Linting and formatting
ruff>=0.6.0
# Type checking (optional, enable later)
# mypy>=1.10
# E2E testing
# playwright>=1.40 # install via npm: npx playwright install

View File

@@ -14,6 +14,7 @@ Usage:
import os import os
import sys import sys
import psycopg2 import psycopg2
MIGRATION_SQL = """ MIGRATION_SQL = """
@@ -42,9 +43,7 @@ def get_tenant_db_names(master_dsn):
conn = psycopg2.connect(master_dsn) conn = psycopg2.connect(master_dsn)
try: try:
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute("SELECT id, db_name FROM tenants WHERE is_active = true ORDER BY id")
"SELECT id, db_name FROM tenants WHERE is_active = true ORDER BY id"
)
rows = cur.fetchall() rows = cur.fetchall()
cur.close() cur.close()
return rows return rows

View File

@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""Check Facturapi configuration status for all active tenants.
Usage:
export MASTER_DB_URL=postgresql://user:pass@host/nexus_autoparts
export TENANT_DB_URL_TEMPLATE="postgresql://user:pass@host/{db_name}"
export FACTURAPI_USER_KEY=sk_user_xxx # optional, for org auto-discovery
python3 scripts/check_facturapi_tenants.py
Output: table (default), --json, or --csv.
"""
import argparse
import csv
import json
import os
import sys
# Allow importing pos/services
POS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "pos")
sys.path.insert(0, POS_DIR)
import psycopg2 # noqa: E402
from services import facturapi_service # noqa: E402
def get_tenants(master_dsn: str):
conn = psycopg2.connect(master_dsn)
try:
cur = conn.cursor()
cur.execute(
"""
SELECT t.id, t.db_name, t.name, t.subdomain, COALESCE(v.version, 'v0.0') AS version
FROM tenants t
LEFT JOIN tenant_schema_version v ON t.id = v.tenant_id
WHERE t.is_active = true
ORDER BY t.id
"""
)
rows = cur.fetchall()
cur.close()
return rows
finally:
conn.close()
def get_tenant_config(db_name: str, template_dsn: str) -> dict[str, str]:
dsn = template_dsn.format(db_name=db_name)
conn = psycopg2.connect(dsn)
try:
cur = conn.cursor()
# Business-level fiscal data
cur.execute(
"""
SELECT key, value FROM tenant_config
WHERE key IN (
'tenant_rfc', 'tenant_razon_social', 'tenant_cp',
'cfdi_regimen_fiscal', 'cfdi_serie',
'cfdi_facturapi_key', 'cfdi_facturapi_org_id'
)
"""
)
config = {row[0]: row[1] or "" for row in cur.fetchall()}
# Main branch fiscal data (used as fallback by _get_issuer_config)
cur.execute(
"""
SELECT rfc, razon_social, regimen_fiscal, codigo_postal, serie_cfdi
FROM branches WHERE is_main = true LIMIT 1
"""
)
branch = cur.fetchone()
if branch:
config["rfc"] = (branch[0] or config.get("tenant_rfc", "")).strip()
config["razon_social"] = (branch[1] or config.get("tenant_razon_social", "")).strip()
config["regimen_fiscal"] = (branch[2] or config.get("cfdi_regimen_fiscal", "")).strip()
config["cp"] = (branch[3] or config.get("tenant_cp", "")).strip()
config["serie"] = (branch[4] or config.get("cfdi_serie", "")).strip()
else:
config["rfc"] = config.get("tenant_rfc", "").strip()
config["razon_social"] = config.get("tenant_razon_social", "").strip()
config["regimen_fiscal"] = config.get("cfdi_regimen_fiscal", "").strip()
config["cp"] = config.get("tenant_cp", "").strip()
config["serie"] = config.get("cfdi_serie", "").strip()
cur.close()
return config
finally:
conn.close()
def check_tenant(tenant_id: int, db_name: str, name: str, version: str, template_dsn: str) -> dict:
result = {
"tenant_id": tenant_id,
"db_name": db_name,
"name": name,
"schema_version": version,
"rfc": "",
"razon_social": "",
"has_key": False,
"has_org_id": False,
"has_csd": False,
"configured": False,
"pending_steps": [],
"error": None,
}
try:
config = get_tenant_config(db_name, template_dsn)
result["rfc"] = config.get("rfc", "")
result["razon_social"] = config.get("razon_social", "")
status = facturapi_service.get_org_status(config)
result.update(
{
"has_key": status.get("has_key", False),
"has_org_id": status.get("has_org_id", False),
"has_csd": status.get("has_csd", False),
"configured": status.get("configured", False),
"pending_steps": status.get("pending_steps", []),
"error": status.get("error"),
}
)
except Exception as e:
result["error"] = f"{type(e).__name__}: {str(e)[:200]}"
return result
def print_table(results: list[dict]):
headers = ["ID", "Tenant", "RFC", "Key", "Org", "CSD", "Status", "Error/Pending"]
rows = []
for r in results:
status = "OK" if r["configured"] and r["has_csd"] else "PENDING"
pending = (
"; ".join((s.get("description") or s.get("type") or str(s)) for s in r["pending_steps"])
if r["pending_steps"]
else ""
)
detail = (r["error"] or pending or "")[:60]
rows.append(
[
str(r["tenant_id"]),
r["name"][:28],
r["rfc"] or "-",
"" if r["has_key"] else "No",
"" if r["has_org_id"] else "No",
"" if r["has_csd"] else "No",
status,
detail,
]
)
widths = [max(len(str(row[i])) for row in [headers] + rows) for i in range(len(headers))]
sep = "+-" + "-+-".join("-" * w for w in widths) + "-+"
def fmt(row):
return "| " + " | ".join(str(row[i]).ljust(widths[i]) for i in range(len(row))) + " |"
print(sep)
print(fmt(headers))
print(sep)
for row in rows:
print(fmt(row))
print(sep)
print(
f"\nTotal: {len(results)} tenants | Listos: {sum(1 for r in results if r['configured'] and r['has_csd'])} | Pendientes: {sum(1 for r in results if not (r['configured'] and r['has_csd']))}"
)
def print_json(results: list[dict]):
print(json.dumps(results, indent=2, default=str))
def print_csv(results: list[dict]):
writer = csv.DictWriter(
sys.stdout,
fieldnames=[
"tenant_id",
"name",
"db_name",
"schema_version",
"rfc",
"has_key",
"has_org_id",
"has_csd",
"configured",
"error",
],
)
writer.writeheader()
for r in results:
writer.writerow(
{
"tenant_id": r["tenant_id"],
"name": r["name"],
"db_name": r["db_name"],
"schema_version": r["schema_version"],
"rfc": r["rfc"],
"has_key": r["has_key"],
"has_org_id": r["has_org_id"],
"has_csd": r["has_csd"],
"configured": r["configured"],
"error": r["error"],
}
)
def main():
parser = argparse.ArgumentParser(description="Check Facturapi status for all tenants")
parser.add_argument("--json", action="store_true", help="Output JSON")
parser.add_argument("--csv", action="store_true", help="Output CSV")
args = parser.parse_args()
master_dsn = os.environ.get("MASTER_DB_URL")
template_dsn = os.environ.get("TENANT_DB_URL_TEMPLATE")
if not master_dsn or not template_dsn:
print("Set MASTER_DB_URL and TENANT_DB_URL_TEMPLATE", file=sys.stderr)
sys.exit(1)
tenants = get_tenants(master_dsn)
if not tenants:
print("No active tenants found.")
return
results = []
for tenant_id, db_name, name, _subdomain, version in tenants:
results.append(check_tenant(tenant_id, db_name, name, version, template_dsn))
if args.json:
print_json(results)
elif args.csv:
print_csv(results)
else:
print_table(results)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""Create Facturapi organizations for tenants that don't have one.
Requires FACTURAPI_USER_KEY environment variable (user key with permission to
manage organizations).
Usage:
export MASTER_DB_URL=postgresql://user:pass@host/nexus_autoparts
export TENANT_DB_URL_TEMPLATE="postgresql://user:pass@host/{db_name}"
export FACTURAPI_USER_KEY=sk_user_xxxxxxxxxxxxxxxx
python3 scripts/setup_facturapi_orgs.py
# Preview only
python3 scripts/setup_facturapi_orgs.py --dry-run
"""
import argparse
import os
import sys
POS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "pos")
sys.path.insert(0, POS_DIR)
import psycopg2 # noqa: E402
from services import facturapi_service # noqa: E402
def get_tenants(master_dsn: str):
conn = psycopg2.connect(master_dsn)
try:
cur = conn.cursor()
cur.execute(
"""
SELECT t.id, t.db_name, t.name, t.subdomain
FROM tenants t
WHERE t.is_active = true
ORDER BY t.id
"""
)
rows = cur.fetchall()
cur.close()
return rows
finally:
conn.close()
def get_tenant_fiscal_data(db_name: str, template_dsn: str) -> dict:
dsn = template_dsn.format(db_name=db_name)
conn = psycopg2.connect(dsn)
try:
cur = conn.cursor()
cur.execute(
"""
SELECT key, value FROM tenant_config
WHERE key IN ('tenant_rfc', 'tenant_razon_social', 'tenant_cp', 'cfdi_regimen_fiscal')
"""
)
config = {row[0]: row[1] or "" for row in cur.fetchall()}
cur.execute(
"""
SELECT rfc, razon_social, regimen_fiscal, codigo_postal
FROM branches WHERE is_main = true LIMIT 1
"""
)
branch = cur.fetchone()
if branch:
config["rfc"] = (branch[0] or config.get("tenant_rfc", "")).strip()
config["razon_social"] = (branch[1] or config.get("tenant_razon_social", "")).strip()
config["regimen_fiscal"] = (branch[2] or config.get("cfdi_regimen_fiscal", "")).strip()
config["cp"] = (branch[3] or config.get("tenant_cp", "")).strip()
else:
config["rfc"] = config.get("tenant_rfc", "").strip()
config["razon_social"] = config.get("tenant_razon_social", "").strip()
config["regimen_fiscal"] = config.get("cfdi_regimen_fiscal", "").strip()
config["cp"] = config.get("tenant_cp", "").strip()
# Existing facturapi keys
cur.execute(
"""
SELECT key, value FROM tenant_config
WHERE key IN ('cfdi_facturapi_org_id', 'cfdi_facturapi_key')
"""
)
for key, value in cur.fetchall():
config[key] = value or ""
cur.close()
return config
finally:
conn.close()
def save_facturapi_config(db_name: str, template_dsn: str, org_id: str, api_key: str):
dsn = template_dsn.format(db_name=db_name)
conn = psycopg2.connect(dsn)
try:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO tenant_config (key, value) VALUES ('cfdi_facturapi_org_id', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""",
(org_id,),
)
cur.execute(
"""
INSERT INTO tenant_config (key, value) VALUES ('cfdi_facturapi_key', %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""",
(api_key,),
)
conn.commit()
cur.close()
finally:
conn.close()
def main():
parser = argparse.ArgumentParser(description="Create Facturapi organizations for tenants")
parser.add_argument("--dry-run", action="store_true", help="Preview changes without applying")
args = parser.parse_args()
master_dsn = os.environ.get("MASTER_DB_URL")
template_dsn = os.environ.get("TENANT_DB_URL_TEMPLATE")
user_key = os.environ.get("FACTURAPI_USER_KEY")
if not master_dsn or not template_dsn:
print("Set MASTER_DB_URL and TENANT_DB_URL_TEMPLATE", file=sys.stderr)
sys.exit(1)
if not user_key:
print("Set FACTURAPI_USER_KEY (user key required to manage organizations)", file=sys.stderr)
sys.exit(1)
tenants = get_tenants(master_dsn)
if not tenants:
print("No active tenants found.")
return
created = 0
skipped = 0
errors = 0
for tenant_id, db_name, name, _subdomain in tenants:
print(f"\n[{tenant_id}] {name} ({db_name})")
try:
config = get_tenant_fiscal_data(db_name, template_dsn)
if config.get("cfdi_facturapi_org_id") and config.get("cfdi_facturapi_key"):
print(" SKIP: already has org_id and key")
skipped += 1
continue
if not config.get("rfc"):
print(" ERROR: tenant RFC not configured")
errors += 1
continue
print(f" RFC: {config['rfc']}")
print(f" Razon social: {config['razon_social'] or '(will use RFC)'}")
if args.dry_run:
print(" DRY-RUN: would create organization")
continue
result = facturapi_service.create_organization(config)
save_facturapi_config(db_name, template_dsn, result["org_id"], result["api_key"])
print(f" CREATED: org_id={result['org_id']}")
created += 1
except Exception as e:
print(f" ERROR: {type(e).__name__}: {str(e)[:200]}")
errors += 1
print(f"\nDone. Created: {created} | Skipped: {skipped} | Errors: {errors}")
if __name__ == "__main__":
main()