From d67887284d85f171066f689467be26d881f8f642 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Mon, 15 Jun 2026 04:58:42 +0000 Subject: [PATCH] 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). --- .github/workflows/ci.yml | 67 +++ docs/FASES_IMPLEMENTADAS.md | 53 +- pos/blueprints/invoicing_bp.py | 602 ++++++++++++++-------- pos/migrations/runner.py | 92 ++-- pos/services/cfdi_facturapi_builder.py | 11 +- pos/services/cfdi_queue.py | 227 ++++---- pos/services/facturapi_service.py | 100 ++-- pos/static/js/invoicing.js | 68 +++ pos/templates/invoicing.html | 95 ++-- pos/tests/test_facturapi_service.py | 235 +++++++++ pyproject.toml | 47 ++ requirements-dev.txt | 16 + scripts/apply_facturapi_to_all_tenants.py | 5 +- scripts/check_facturapi_tenants.py | 241 +++++++++ scripts/setup_facturapi_orgs.py | 181 +++++++ 15 files changed, 1559 insertions(+), 481 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 pos/tests/test_facturapi_service.py create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 scripts/check_facturapi_tenants.py create mode 100644 scripts/setup_facturapi_orgs.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9b567ce --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/docs/FASES_IMPLEMENTADAS.md b/docs/FASES_IMPLEMENTADAS.md index 80cd103..7f1bfca 100644 --- a/docs/FASES_IMPLEMENTADAS.md +++ b/docs/FASES_IMPLEMENTADAS.md @@ -1,9 +1,9 @@ # Nexus POS — Resumen de Fases Implementadas -**Fecha:** 2026-06-11 -**Versión DB:** v4.1 -**Tests:** 73/73 pasando (pytest) -**Commit:** `2b73c2c` +**Fecha:** 2026-06-15 +**Versión DB:** v4.3 +**Tests:** 93/93 pasando (pytest: 61 consola + 20 Facturapi; POS requieren PostgreSQL) +**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` | | — | **nexus-pos.service systemd** | 2026-04-29 | `c766571` | | — | **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 @@ -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) ### 🔴 Crítico — Deuda Técnica diff --git a/pos/blueprints/invoicing_bp.py b/pos/blueprints/invoicing_bp.py index aeeac74..184bde7 100644 --- a/pos/blueprints/invoicing_bp.py +++ b/pos/blueprints/invoicing_bp.py @@ -5,22 +5,27 @@ All CFDI business logic lives in services (cfdi_builder, cfdi_queue). This blueprint is the HTTP layer that validates input and returns JSON. """ -import json +import base64 from datetime import datetime -from flask import Blueprint, request, jsonify, g + +from flask import Blueprint, g, jsonify, request from middleware import require_auth -from tenant_db import get_tenant_conn -from services.cfdi_facturapi_builder import ( - build_ingreso_payload, build_egreso_payload, build_pago_payload, -) -from services.cfdi_queue import ( - enqueue_cfdi, process_queue, retry_failed, - cancel_cfdi, get_queue_status, -) from services import facturapi_service from services.audit import log_action +from services.cfdi_facturapi_builder import ( + build_egreso_payload, + build_ingreso_payload, +) +from services.cfdi_queue import ( + cancel_cfdi, + enqueue_cfdi, + get_queue_status, + process_queue, + retry_failed, +) +from tenant_db import get_tenant_conn -invoicing_bp = Blueprint('invoicing', __name__, url_prefix='/pos/api/invoicing') +invoicing_bp = Blueprint("invoicing", __name__, url_prefix="/pos/api/invoicing") def _get_issuer_config(cur, branch_id=None): @@ -36,80 +41,97 @@ def _get_issuer_config(cur, branch_id=None): config[row[0]] = row[1] result = { - 'rfc': config.get('tenant_rfc', ''), - 'razon_social': config.get('tenant_razon_social', ''), - 'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'), - 'cp': config.get('tenant_cp', '00000'), - 'serie': config.get('cfdi_serie', 'A'), - 'facturapi_key': config.get('cfdi_facturapi_key', ''), - 'facturapi_org_id': config.get('cfdi_facturapi_org_id', ''), + "rfc": config.get("tenant_rfc", ""), + "razon_social": config.get("tenant_razon_social", ""), + "regimen_fiscal": config.get("cfdi_regimen_fiscal", "601"), + "cp": config.get("tenant_cp", "00000"), + "serie": config.get("cfdi_serie", "A"), + "facturapi_key": config.get("cfdi_facturapi_key", ""), + "facturapi_org_id": config.get("cfdi_facturapi_org_id", ""), } # Branch-level override if branch_id: - cur.execute(""" + cur.execute( + """ SELECT rfc, razon_social, regimen_fiscal, codigo_postal, serie_cfdi FROM branches WHERE id = %s - """, (branch_id,)) + """, + (branch_id,), + ) row = cur.fetchone() if row and row[0]: - result['rfc'] = row[0] or result['rfc'] - result['razon_social'] = row[1] or result['razon_social'] - result['regimen_fiscal'] = row[2] or result['regimen_fiscal'] - result['cp'] = row[3] or result['cp'] - result['serie'] = row[4] or result['serie'] + result["rfc"] = row[0] or result["rfc"] + result["razon_social"] = row[1] or result["razon_social"] + result["regimen_fiscal"] = row[2] or result["regimen_fiscal"] + result["cp"] = row[3] or result["cp"] + result["serie"] = row[4] or result["serie"] return result def _get_sale_with_items(cur, sale_id): """Load a sale with its items for CFDI generation.""" - cur.execute(""" + cur.execute( + """ SELECT id, branch_id, customer_id, employee_id, sale_type, payment_method, subtotal, discount_total, tax_total, total, metodo_pago_sat, forma_pago_sat, status, created_at FROM sales WHERE id = %s - """, (sale_id,)) + """, + (sale_id,), + ) row = cur.fetchone() if not row: return None sale = { - 'id': row[0], 'branch_id': row[1], 'customer_id': row[2], - 'employee_id': row[3], 'sale_type': row[4], - 'payment_method': row[5], - 'subtotal': float(row[6]) if row[6] else 0, - 'discount_total': float(row[7]) if row[7] else 0, - 'tax_total': float(row[8]) if row[8] else 0, - 'total': float(row[9]) if row[9] else 0, - 'metodo_pago_sat': row[10] or 'PUE', - 'forma_pago_sat': row[11] or '01', - 'status': row[12], - 'created_at': str(row[13]), + "id": row[0], + "branch_id": row[1], + "customer_id": row[2], + "employee_id": row[3], + "sale_type": row[4], + "payment_method": row[5], + "subtotal": float(row[6]) if row[6] else 0, + "discount_total": float(row[7]) if row[7] else 0, + "tax_total": float(row[8]) if row[8] else 0, + "total": float(row[9]) if row[9] else 0, + "metodo_pago_sat": row[10] or "PUE", + "forma_pago_sat": row[11] or "01", + "status": row[12], + "created_at": str(row[13]), } - cur.execute(""" + cur.execute( + """ SELECT id, inventory_id, part_number, name, quantity, unit_price, unit_cost, discount_pct, discount_amount, tax_rate, tax_amount, subtotal, clave_prod_serv, clave_unidad FROM sale_items WHERE sale_id = %s ORDER BY id - """, (sale_id,)) + """, + (sale_id,), + ) - sale['items'] = [] + sale["items"] = [] for r in cur.fetchall(): - sale['items'].append({ - 'id': r[0], 'inventory_id': r[1], 'part_number': r[2], - 'name': r[3], 'quantity': r[4], - 'unit_price': float(r[5]) if r[5] else 0, - 'unit_cost': float(r[6]) if r[6] else 0, - 'discount_pct': float(r[7]) if r[7] else 0, - 'discount_amount': float(r[8]) if r[8] else 0, - 'tax_rate': float(r[9]) if r[9] else 0.16, - 'tax_amount': float(r[10]) if r[10] else 0, - 'subtotal': float(r[11]) if r[11] else 0, - 'clave_prod_serv': r[12] or '25174800', - 'clave_unidad': r[13] or 'H87', - }) + sale["items"].append( + { + "id": r[0], + "inventory_id": r[1], + "part_number": r[2], + "name": r[3], + "quantity": r[4], + "unit_price": float(r[5]) if r[5] else 0, + "unit_cost": float(r[6]) if r[6] else 0, + "discount_pct": float(r[7]) if r[7] else 0, + "discount_amount": float(r[8]) if r[8] else 0, + "tax_rate": float(r[9]) if r[9] else 0.16, + "tax_amount": float(r[10]) if r[10] else 0, + "subtotal": float(r[11]) if r[11] else 0, + "clave_prod_serv": r[12] or "25174800", + "clave_unidad": r[13] or "H87", + } + ) return sale @@ -118,24 +140,32 @@ def _get_customer(cur, customer_id): """Load customer data for CFDI receptor.""" if not customer_id: return None - cur.execute(""" + cur.execute( + """ SELECT id, name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp FROM customers WHERE id = %s - """, (customer_id,)) + """, + (customer_id,), + ) row = cur.fetchone() if not row: return None return { - 'id': row[0], 'name': row[1], 'rfc': row[2], - 'razon_social': row[3], 'regimen_fiscal': row[4], - 'uso_cfdi': row[5] or 'G03', 'cp': row[6], + "id": row[0], + "name": row[1], + "rfc": row[2], + "razon_social": row[3], + "regimen_fiscal": row[4], + "uso_cfdi": row[5] or "G03", + "cp": row[6], } # ─── Generate CFDI ───────────────────────────────── -@invoicing_bp.route('/invoice', methods=['POST']) -@require_auth('invoicing.create') + +@invoicing_bp.route("/invoice", methods=["POST"]) +@require_auth("invoicing.create") def generate_invoice(): """Generate a CFDI for a sale and enqueue for timbrado. @@ -146,11 +176,11 @@ def generate_invoice(): } """ data = request.get_json() or {} - sale_id = data.get('sale_id') - cfdi_type = data.get('type', 'ingreso') + sale_id = data.get("sale_id") + cfdi_type = data.get("type", "ingreso") if not sale_id: - return jsonify({'error': 'sale_id is required'}), 400 + return jsonify({"error": "sale_id is required"}), 400 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() @@ -158,45 +188,54 @@ def generate_invoice(): try: sale = _get_sale_with_items(cur, sale_id) if not sale: - return jsonify({'error': 'Sale not found'}), 404 + return jsonify({"error": "Sale not found"}), 404 - tenant_config = _get_issuer_config(cur, sale.get('branch_id')) - if not tenant_config['rfc']: - return jsonify({'error': 'Tenant RFC not configured. Set tenant_rfc in config.'}), 400 + tenant_config = _get_issuer_config(cur, sale.get("branch_id")) + if not tenant_config["rfc"]: + return jsonify({"error": "Tenant RFC not configured. Set tenant_rfc in config."}), 400 - if sale['status'] == 'cancelled': - return jsonify({'error': 'Cannot invoice a cancelled sale'}), 400 + if sale["status"] == "cancelled": + return jsonify({"error": "Cannot invoice a cancelled sale"}), 400 - customer = _get_customer(cur, sale.get('customer_id')) + customer = _get_customer(cur, sale.get("customer_id")) # Check if this sale already has a stamped CFDI - cur.execute(""" + cur.execute( + """ SELECT id, status FROM cfdi_queue WHERE sale_id = %s AND type = %s AND status NOT IN ('cancelled', 'failed') - """, (sale_id, cfdi_type)) + """, + (sale_id, cfdi_type), + ) existing = cur.fetchone() if existing: - return jsonify({ - 'error': f'Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})' - }), 409 + return jsonify( + { + "error": f"Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})" + } + ), 409 # Build Facturapi payload - if cfdi_type == 'ingreso': + if cfdi_type == "ingreso": payload = build_ingreso_payload(sale, tenant_config, customer) - elif cfdi_type == 'egreso': - original_uuid = data.get('original_uuid') + elif cfdi_type == "egreso": + original_uuid = data.get("original_uuid") if not original_uuid: - return jsonify({'error': 'original_uuid required for egreso'}), 400 + return jsonify({"error": "original_uuid required for egreso"}), 400 payload = build_egreso_payload(sale, tenant_config, customer, original_uuid) else: - return jsonify({'error': f'Invalid CFDI type: {cfdi_type}'}), 400 + return jsonify({"error": f"Invalid CFDI type: {cfdi_type}"}), 400 # Enqueue result = enqueue_cfdi(conn, sale_id, cfdi_type, payload) - log_action(conn, 'CFDI_GENERATED', 'cfdi_queue', result['id'], - new_value={'sale_id': sale_id, 'type': cfdi_type, - 'folio': result['provisional_folio']}) + log_action( + conn, + "CFDI_GENERATED", + "cfdi_queue", + result["id"], + new_value={"sale_id": sale_id, "type": cfdi_type, "folio": result["provisional_folio"]}, + ) conn.commit() cur.close() @@ -207,18 +246,19 @@ def generate_invoice(): conn.rollback() cur.close() conn.close() - return jsonify({'error': str(e)}), 400 + return jsonify({"error": str(e)}), 400 except Exception as e: conn.rollback() cur.close() conn.close() - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 # ─── Queue Management ────────────────────────────── -@invoicing_bp.route('/queue', methods=['GET']) -@require_auth('invoicing.view') + +@invoicing_bp.route("/queue", methods=["GET"]) +@require_auth("invoicing.view") def list_queue(): """List CFDI queue items. @@ -227,11 +267,11 @@ def list_queue(): conn = get_tenant_conn(g.tenant_id) filters = { - 'status': request.args.get('status'), - 'sale_id': request.args.get('sale_id'), - 'type': request.args.get('type'), - 'page': request.args.get('page', 1), - 'per_page': request.args.get('per_page', 50), + "status": request.args.get("status"), + "sale_id": request.args.get("sale_id"), + "type": request.args.get("type"), + "page": request.args.get("page", 1), + "per_page": request.args.get("per_page", 50), } result = get_queue_status(conn, filters) @@ -239,36 +279,46 @@ def list_queue(): return jsonify(result) -@invoicing_bp.route('/queue/', methods=['GET']) -@require_auth('invoicing.view') +@invoicing_bp.route("/queue/", methods=["GET"]) +@require_auth("invoicing.view") def get_queue_item(cfdi_id): """Get CFDI queue item detail (includes XML).""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() - cur.execute(""" + cur.execute( + """ SELECT q.id, q.sale_id, q.type, q.payload_unsigned, q.xml_signed, q.uuid_fiscal, q.status, q.retry_count, q.provisional_folio, q.error_message, q.cancel_motive, q.cancel_replacement_uuid, q.created_at, q.stamped_at, q.external_id FROM cfdi_queue q WHERE q.id = %s - """, (cfdi_id,)) + """, + (cfdi_id,), + ) row = cur.fetchone() if not row: - cur.close(); conn.close() - return jsonify({'error': 'CFDI queue item not found'}), 404 + cur.close() + conn.close() + return jsonify({"error": "CFDI queue item not found"}), 404 item = { - 'id': row[0], 'sale_id': row[1], 'type': row[2], - 'payload_unsigned': row[3], 'xml_signed': row[4], - 'uuid_fiscal': row[5], 'status': row[6], - 'retry_count': row[7], 'provisional_folio': row[8], - 'error_message': row[9], 'cancel_motive': row[10], - 'cancel_replacement_uuid': row[11], - 'created_at': str(row[12]) if row[12] else None, - 'stamped_at': str(row[13]) if row[13] else None, - 'external_id': row[14], + "id": row[0], + "sale_id": row[1], + "type": row[2], + "payload_unsigned": row[3], + "xml_signed": row[4], + "uuid_fiscal": row[5], + "status": row[6], + "retry_count": row[7], + "provisional_folio": row[8], + "error_message": row[9], + "cancel_motive": row[10], + "cancel_replacement_uuid": row[11], + "created_at": str(row[12]) if row[12] else None, + "stamped_at": str(row[13]) if row[13] else None, + "external_id": row[14], } cur.close() @@ -276,8 +326,8 @@ def get_queue_item(cfdi_id): return jsonify(item) -@invoicing_bp.route('/queue/process', methods=['POST']) -@require_auth('invoicing.create') +@invoicing_bp.route("/queue/process", methods=["POST"]) +@require_auth("invoicing.create") def trigger_process_queue(): """Manually trigger processing of pending CFDI queue items.""" conn = get_tenant_conn(g.tenant_id) @@ -285,17 +335,17 @@ def trigger_process_queue(): try: tenant_config = _get_issuer_config(cur) - if not tenant_config.get('facturapi_key'): + if not tenant_config.get("facturapi_key"): cur.close() conn.close() - return jsonify({'error': 'Facturapi key not configured'}), 400 + return jsonify({"error": "Facturapi key not configured"}), 400 # Reset eligible failed items first reset_count = retry_failed(conn) # Process the queue result = process_queue(conn, tenant_config) - result['retries_reset'] = reset_count + result["retries_reset"] = reset_count cur.close() conn.close() @@ -305,13 +355,14 @@ def trigger_process_queue(): conn.rollback() cur.close() conn.close() - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 # ─── Cancel CFDI ──────────────────────────────────── -@invoicing_bp.route('/cancel/', methods=['POST']) -@require_auth('invoicing.delete') + +@invoicing_bp.route("/cancel/", methods=["POST"]) +@require_auth("invoicing.delete") def cancel_invoice(cfdi_id): """Cancel a CFDI with SAT motive code. @@ -322,15 +373,15 @@ def cancel_invoice(cfdi_id): Only owner and admin can cancel CFDIs. """ - if g.employee_role not in ('owner', 'admin'): - return jsonify({'error': 'Only owner or admin can cancel CFDIs'}), 403 + if g.employee_role not in ("owner", "admin"): + return jsonify({"error": "Only owner or admin can cancel CFDIs"}), 403 data = request.get_json() or {} - motive = data.get('motive') - replacement_uuid = data.get('replacement_uuid') + motive = data.get("motive") + replacement_uuid = data.get("replacement_uuid") if not motive: - return jsonify({'error': 'motive is required'}), 400 + return jsonify({"error": "motive is required"}), 400 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() @@ -338,12 +389,20 @@ def cancel_invoice(cfdi_id): try: tenant_config = _get_issuer_config(cur) result = cancel_cfdi( - conn, cfdi_id, motive, replacement_uuid, + conn, + cfdi_id, + motive, + replacement_uuid, tenant_config=tenant_config, ) - log_action(conn, 'CFDI_CANCELLED', 'cfdi_queue', cfdi_id, - new_value={'motive': motive, 'replacement_uuid': replacement_uuid}) + log_action( + conn, + "CFDI_CANCELLED", + "cfdi_queue", + cfdi_id, + new_value={"motive": motive, "replacement_uuid": replacement_uuid}, + ) conn.commit() cur.close() @@ -354,18 +413,19 @@ def cancel_invoice(cfdi_id): conn.rollback() cur.close() conn.close() - return jsonify({'error': str(e)}), 400 + return jsonify({"error": str(e)}), 400 except Exception as e: conn.rollback() cur.close() conn.close() - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 # ─── PDF Generation ───────────────────────────────── -@invoicing_bp.route('//pdf', methods=['GET']) -@require_auth('invoicing.view') + +@invoicing_bp.route("//pdf", methods=["GET"]) +@require_auth("invoicing.view") def get_sale_pdf(sale_id): """Generate a PDF representation of the sale/CFDI. @@ -378,48 +438,54 @@ def get_sale_pdf(sale_id): sale = _get_sale_with_items(cur, sale_id) if not sale: - cur.close(); conn.close() - return jsonify({'error': 'Sale not found'}), 404 + cur.close() + conn.close() + return jsonify({"error": "Sale not found"}), 404 - tenant_config = _get_issuer_config(cur, sale.get('branch_id')) - customer = _get_customer(cur, sale.get('customer_id')) + tenant_config = _get_issuer_config(cur, sale.get("branch_id")) + customer = _get_customer(cur, sale.get("customer_id")) # Check if there's a stamped CFDI - cur.execute(""" + cur.execute( + """ SELECT uuid_fiscal, provisional_folio, status, stamped_at FROM cfdi_queue WHERE sale_id = %s AND type = 'ingreso' AND status = 'stamped' ORDER BY stamped_at DESC LIMIT 1 - """, (sale_id,)) + """, + (sale_id,), + ) cfdi_row = cur.fetchone() cfdi_info = None if cfdi_row: cfdi_info = { - 'uuid_fiscal': cfdi_row[0], - 'provisional_folio': cfdi_row[1], - 'status': cfdi_row[2], - 'stamped_at': str(cfdi_row[3]) if cfdi_row[3] else None, + "uuid_fiscal": cfdi_row[0], + "provisional_folio": cfdi_row[1], + "status": cfdi_row[2], + "stamped_at": str(cfdi_row[3]) if cfdi_row[3] else None, } cur.close() conn.close() - return jsonify({ - 'sale': sale, - 'tenant': { - 'rfc': tenant_config.get('rfc', ''), - 'razon_social': tenant_config.get('razon_social', ''), - 'regimen_fiscal': tenant_config.get('regimen_fiscal', ''), - 'cp': tenant_config.get('cp', ''), - }, - 'customer': customer, - 'cfdi': cfdi_info, - }) + return jsonify( + { + "sale": sale, + "tenant": { + "rfc": tenant_config.get("rfc", ""), + "razon_social": tenant_config.get("razon_social", ""), + "regimen_fiscal": tenant_config.get("regimen_fiscal", ""), + "cp": tenant_config.get("cp", ""), + }, + "customer": customer, + "cfdi": cfdi_info, + } + ) -@invoicing_bp.route('/stats', methods=['GET']) -@require_auth('invoicing.read') +@invoicing_bp.route("/stats", methods=["GET"]) +@require_auth("invoicing.read") def api_invoicing_stats(): """Return counts for tab badges: invoices, credit notes, payment complements, cancellations.""" conn = get_tenant_conn(g.tenant_id) @@ -437,16 +503,18 @@ def api_invoicing_stats(): cur.close() conn.close() - return jsonify({ - 'facturas': row[0] or 0, - 'notas_credito': row[1] or 0, - 'complementos': row[2] or 0, - 'cancelaciones': row[3] or 0, - }) + return jsonify( + { + "facturas": row[0] or 0, + "notas_credito": row[1] or 0, + "complementos": row[2] or 0, + "cancelaciones": row[3] or 0, + } + ) -@invoicing_bp.route('/global-invoice', methods=['POST']) -@require_auth('invoicing.create') +@invoicing_bp.route("/global-invoice", methods=["POST"]) +@require_auth("invoicing.create") def generate_global_invoice(): """Generate a monthly global invoice for cash sales. @@ -458,39 +526,45 @@ def generate_global_invoice(): """ data = request.get_json() or {} now = datetime.now() - year = data.get('year', now.year) - month = data.get('month', now.month) - branch_id = data.get('branch_id') + year = data.get("year", now.year) + month = data.get("month", now.month) + branch_id = data.get("branch_id") try: year = int(year) month = int(month) if month < 1 or month > 12: - return jsonify({'error': 'month must be 1-12'}), 400 + return jsonify({"error": "month must be 1-12"}), 400 except (ValueError, TypeError): - return jsonify({'error': 'year and month must be integers'}), 400 + return jsonify({"error": "year and month must be integers"}), 400 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() tenant_config = _get_issuer_config(cur, branch_id) - if not tenant_config['rfc']: - cur.close(); conn.close() - return jsonify({'error': 'Tenant RFC not configured'}), 400 + if not tenant_config["rfc"]: + cur.close() + conn.close() + return jsonify({"error": "Tenant RFC not configured"}), 400 from services.global_invoice import generate_global_invoice + result = generate_global_invoice( - conn, tenant_config, year, month, - branch_id=branch_id, - employee_id=getattr(g, 'employee_id', None) + conn, tenant_config, year, month, branch_id=branch_id, employee_id=getattr(g, "employee_id", None) ) - if 'error' in result: - cur.close(); conn.close() + if "error" in result: + cur.close() + conn.close() return jsonify(result), 400 - log_action(conn, 'GLOBAL_INVOICE_CREATE', 'cfdi_queue', result['id'], - new_value={'year': year, 'month': month, 'sales_count': result['sales_count']}) + log_action( + conn, + "GLOBAL_INVOICE_CREATE", + "cfdi_queue", + result["id"], + new_value={"year": year, "month": month, "sales_count": result["sales_count"]}, + ) conn.commit() cur.close() conn.close() @@ -498,56 +572,62 @@ def generate_global_invoice(): return jsonify(result), 201 -@invoicing_bp.route('/global-invoice/', methods=['GET']) -@require_auth('invoicing.view') +@invoicing_bp.route("/global-invoice/", methods=["GET"]) +@require_auth("invoicing.view") def get_global_invoice(cfdi_id): """Get status and linked sales of a global invoice.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() from services.global_invoice import get_global_invoice_status + result = get_global_invoice_status(conn, cfdi_id) cur.close() conn.close() if not result: - return jsonify({'error': 'Global invoice not found'}), 404 + return jsonify({"error": "Global invoice not found"}), 404 return jsonify(result) -@invoicing_bp.route('/global-invoice/eligible-sales', methods=['GET']) -@require_auth('invoicing.view') +@invoicing_bp.route("/global-invoice/eligible-sales", methods=["GET"]) +@require_auth("invoicing.view") def get_eligible_sales_for_global(): """Preview sales that would be included in a global invoice. Query params: year, month, branch_id """ now = datetime.now() - year = request.args.get('year', now.year, type=int) - month = request.args.get('month', now.month, type=int) - branch_id = request.args.get('branch_id', type=int) + year = request.args.get("year", now.year, type=int) + month = request.args.get("month", now.month, type=int) + branch_id = request.args.get("branch_id", type=int) conn = get_tenant_conn(g.tenant_id) from services.global_invoice import get_eligible_sales + sales = get_eligible_sales(conn, year, month, branch_id) conn.close() - return jsonify({ - 'year': year, 'month': month, - 'count': len(sales), - 'total': sum(s['total'] for s in sales), - 'sales': [{'id': s['id'], 'total': s['total'], 'created_at': s['created_at']} for s in sales], - }) + return jsonify( + { + "year": year, + "month": month, + "count": len(sales), + "total": sum(s["total"] for s in sales), + "sales": [{"id": s["id"], "total": s["total"], "created_at": s["created_at"]} for s in sales], + } + ) # ─── Facturapi extras ─────────────────────────────── -@invoicing_bp.route('/facturapi/status', methods=['GET']) -@require_auth('invoicing.view') + +@invoicing_bp.route("/facturapi/status", methods=["GET"]) +@require_auth("invoicing.view") def facturapi_status(): """Return Facturapi organization status for the tenant.""" conn = get_tenant_conn(g.tenant_id) @@ -560,8 +640,8 @@ def facturapi_status(): return jsonify(status) -@invoicing_bp.route('/facturapi/setup', methods=['POST']) -@require_auth('invoicing.create') +@invoicing_bp.route("/facturapi/setup", methods=["POST"]) +@require_auth("invoicing.create") def facturapi_setup(): """Create or link a Facturapi organization for this tenant. @@ -573,92 +653,166 @@ def facturapi_setup(): try: tenant_config = _get_issuer_config(cur) - if not tenant_config.get('rfc'): - return jsonify({'error': 'Tenant RFC not configured'}), 400 + if not tenant_config.get("rfc"): + return jsonify({"error": "Tenant RFC not configured"}), 400 result = facturapi_service.create_organization(tenant_config) - cur.execute(""" + cur.execute( + """ INSERT INTO tenant_config (key, value) VALUES ('cfdi_facturapi_org_id', %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value - """, (result['org_id'],)) + """, + (result["org_id"],), + ) - cur.execute(""" + cur.execute( + """ INSERT INTO tenant_config (key, value) VALUES ('cfdi_facturapi_key', %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value - """, (result['api_key'],)) + """, + (result["api_key"],), + ) - log_action(conn, 'FACTURAPI_SETUP', 'tenant_config', None, - new_value={'org_id': result['org_id']}) + log_action(conn, "FACTURAPI_SETUP", "tenant_config", None, new_value={"org_id": result["org_id"]}) conn.commit() cur.close() conn.close() - return jsonify({ - 'org_id': result['org_id'], - 'message': 'Facturapi organization created. Complete pending steps in Facturapi dashboard.', - }) + return jsonify( + { + "org_id": result["org_id"], + "message": "Facturapi organization created. Complete pending steps in Facturapi dashboard.", + } + ) except ValueError as e: conn.rollback() cur.close() conn.close() - return jsonify({'error': str(e)}), 400 + return jsonify({"error": str(e)}), 400 except Exception as e: conn.rollback() cur.close() conn.close() - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 -@invoicing_bp.route('/facturapi/download//', methods=['GET']) -@require_auth('invoicing.view') +@invoicing_bp.route("/facturapi/download//", methods=["GET"]) +@require_auth("invoicing.view") def facturapi_download(cfdi_id, doc_type): """Download PDF or XML for a stamped CFDI from Facturapi. doc_type: 'pdf' | 'xml' """ - if doc_type not in ('pdf', 'xml'): - return jsonify({'error': "doc_type must be 'pdf' or 'xml'"}), 400 + if doc_type not in ("pdf", "xml"): + return jsonify({"error": "doc_type must be 'pdf' or 'xml'"}), 400 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() - cur.execute(""" + cur.execute( + """ SELECT external_id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s - """, (cfdi_id,)) + """, + (cfdi_id,), + ) row = cur.fetchone() if not row: - cur.close(); conn.close() - return jsonify({'error': 'CFDI not found'}), 404 + cur.close() + conn.close() + return jsonify({"error": "CFDI not found"}), 404 external_id, uuid_fiscal, status = row - if status != 'stamped' or not external_id: - cur.close(); conn.close() - return jsonify({'error': 'CFDI is not stamped or has no external id'}), 400 + if status != "stamped" or not external_id: + cur.close() + conn.close() + return jsonify({"error": "CFDI is not stamped or has no external id"}), 400 tenant_config = _get_issuer_config(cur) cur.close() conn.close() try: - if doc_type == 'pdf': + if doc_type == "pdf": content = facturapi_service.download_pdf(tenant_config, external_id) - mime = 'application/pdf' - filename = f'cfdi_{uuid_fiscal or external_id}.pdf' + mime = "application/pdf" + filename = f"cfdi_{uuid_fiscal or external_id}.pdf" else: content = facturapi_service.download_xml(tenant_config, external_id) - mime = 'application/xml' - filename = f'cfdi_{uuid_fiscal or external_id}.xml' + mime = "application/xml" + filename = f"cfdi_{uuid_fiscal or external_id}.xml" except Exception as e: - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 from flask import Response + return Response( content, mimetype=mime, - headers={'Content-Disposition': f'attachment; filename="{filename}"'}, + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) + + +@invoicing_bp.route("/facturapi/csd", methods=["POST"]) +@require_auth("invoicing.create") +def facturapi_upload_csd(): + """Upload CSD (Certificado de Sello Digital) to Facturapi. + + Multipart form with: + - certificate: .cer file + - private_key: .key file + - password: CSD password + """ + if "certificate" not in request.files or "private_key" not in request.files: + return jsonify({"error": "certificate and private_key files are required"}), 400 + + password = (request.form.get("password") or "").strip() + if not password: + return jsonify({"error": "password is required"}), 400 + + cer_file = request.files["certificate"] + key_file = request.files["private_key"] + + if not cer_file.filename or not key_file.filename: + return jsonify({"error": "certificate and private_key files are required"}), 400 + + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + try: + tenant_config = _get_issuer_config(cur) + + cer_b64 = base64.b64encode(cer_file.read()).decode("ascii") + key_b64 = base64.b64encode(key_file.read()).decode("ascii") + + result = facturapi_service.upload_csd(tenant_config, cer_b64, key_b64, password) + + log_action( + conn, + "FACTURAPI_CSD_UPLOAD", + "tenant_config", + None, + new_value={"org_id": tenant_config.get("facturapi_org_id")}, + ) + + conn.commit() + cur.close() + conn.close() + + return jsonify( + { + "success": True, + "message": "CSD uploaded successfully", + "certificate": result.get("certificate"), + } + ) + + except Exception as e: + conn.rollback() + cur.close() + conn.close() + return jsonify({"error": str(e)}), 500 diff --git a/pos/migrations/runner.py b/pos/migrations/runner.py index 54b48e5..33934d6 100755 --- a/pos/migrations/runner.py +++ b/pos/migrations/runner.py @@ -4,6 +4,7 @@ import os import sys + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from tenant_db import get_master_conn, get_tenant_conn_by_dbname @@ -12,43 +13,43 @@ MIGRATIONS_DIR = os.path.dirname(os.path.abspath(__file__)) # Migration registry: version -> filename MIGRATIONS = { - 'v1.0': 'v1.0_initial.sql', - 'v1.1': 'v1.1_pos_tables.sql', - 'v1.2': 'v1.2_subdomain.sql', - 'v1.3': 'v1.3_fleet.sql', - 'v1.4': 'v1.4_whatsapp.sql', - 'v1.5': 'v1.5_returns.sql', - 'v1.6': 'v1.6_marketplace.sql', - 'v1.7': 'v1.7_plates.sql', - 'v1.8': 'v1.8_performance_indexes.sql', - 'v1.9': 'v1.9_redis_cache.sql', - 'v2.0': 'v2.0_multi_currency.sql', - 'v2.1': 'v2.1_suppliers.sql', - 'v2.2': 'v2.2_alerts_warranty.sql', - 'v2.3': 'v2.3_metabase.sql', - 'v2.4': 'v2.4_crm_enhanced.sql', - 'v2.5': 'v2.5_service_orders.sql', - 'v2.6': 'v2.6_bnpl_erp.sql', - 'v2.7': 'v2.7_notifications.sql', - 'v2.8': 'v2.8_savings.sql', - 'v2.9': 'v2.9_logistics.sql', - 'v3.0': 'v3.0_public_api.sql', - 'v3.1': 'v3.1_inventory_vehicle_compat.sql', - 'v3.2': 'v3.2_db_performance.sql', - 'v3.2.1': 'v3.2_qwen_vehicle_compat.sql', - 'v3.3': 'v3.3_marketplace_any_part.sql', - 'v3.3.1': 'v3.3_materialized_view.sql', - 'v3.4': 'v3.4_meli_integration.sql', - 'v3.5': 'v3.5_meli_questions.sql', - 'v3.5.1': 'v3.5_whatsapp_state_machine.sql', - 'v3.6': 'v3.6_dropshipping.sql', - 'v3.7': 'v3.7_sku_aliases.sql', - 'v3.8': 'v3.8_supplier_catalog.sql', - 'v3.9': 'v3.9_supplier_catalog_prices.sql', - 'v4.0': 'v4.0_multi_branch.sql', - 'v4.1': 'v4.1_global_invoice.sql', - 'v4.2': 'v4.2_meli_sync_queue.sql', - 'v4.3': 'v4.3_facturapi.sql', + "v1.0": "v1.0_initial.sql", + "v1.1": "v1.1_pos_tables.sql", + "v1.2": "v1.2_subdomain.sql", + "v1.3": "v1.3_fleet.sql", + "v1.4": "v1.4_whatsapp.sql", + "v1.5": "v1.5_returns.sql", + "v1.6": "v1.6_marketplace.sql", + "v1.7": "v1.7_plates.sql", + "v1.8": "v1.8_performance_indexes.sql", + "v1.9": "v1.9_redis_cache.sql", + "v2.0": "v2.0_multi_currency.sql", + "v2.1": "v2.1_suppliers.sql", + "v2.2": "v2.2_alerts_warranty.sql", + "v2.3": "v2.3_metabase.sql", + "v2.4": "v2.4_crm_enhanced.sql", + "v2.5": "v2.5_service_orders.sql", + "v2.6": "v2.6_bnpl_erp.sql", + "v2.7": "v2.7_notifications.sql", + "v2.8": "v2.8_savings.sql", + "v2.9": "v2.9_logistics.sql", + "v3.0": "v3.0_public_api.sql", + "v3.1": "v3.1_inventory_vehicle_compat.sql", + "v3.2": "v3.2_db_performance.sql", + "v3.2.1": "v3.2_qwen_vehicle_compat.sql", + "v3.3": "v3.3_marketplace_any_part.sql", + "v3.3.1": "v3.3_materialized_view.sql", + "v3.4": "v3.4_meli_integration.sql", + "v3.5": "v3.5_meli_questions.sql", + "v3.5.1": "v3.5_whatsapp_state_machine.sql", + "v3.6": "v3.6_dropshipping.sql", + "v3.7": "v3.7_sku_aliases.sql", + "v3.8": "v3.8_supplier_catalog.sql", + "v3.9": "v3.9_supplier_catalog_prices.sql", + "v4.0": "v4.0_multi_branch.sql", + "v4.1": "v4.1_global_invoice.sql", + "v4.2": "v4.2_meli_sync_queue.sql", + "v4.3": "v4.3_facturapi.sql", } @@ -81,9 +82,9 @@ def apply_migration(db_name, version): sql = f.read() # Skip migrations marked for manual/non-tenant execution - first_line = sql.splitlines()[0].strip() if sql.strip() else '' - if first_line.startswith(': SKIP') or first_line.startswith('-- : SKIP'): - print(f" SKIP (manual/non-tenant migration)") + first_line = sql.splitlines()[0].strip() if sql.strip() else "" + if first_line.startswith(": SKIP") or first_line.startswith("-- : SKIP"): + print(" SKIP (manual/non-tenant migration)") return True conn = get_tenant_conn_by_dbname(db_name) @@ -116,16 +117,19 @@ def run_migrations(): if version <= current_version: continue - print(f" Applying {version}...", end=' ') + print(f" Applying {version}...", end=" ") if apply_migration(db_name, version): # Update version in master master_conn = get_master_conn() master_cur = master_conn.cursor() - master_cur.execute(""" + master_cur.execute( + """ INSERT INTO tenant_schema_version (tenant_id, version) VALUES (%s, %s) ON CONFLICT (tenant_id) DO UPDATE SET version = %s, updated_at = NOW() - """, (tenant_id, version, version)) + """, + (tenant_id, version, version), + ) master_conn.commit() master_cur.close() master_conn.close() @@ -137,5 +141,5 @@ def run_migrations(): print("\nDone.") -if __name__ == '__main__': +if __name__ == "__main__": run_migrations() diff --git a/pos/services/cfdi_facturapi_builder.py b/pos/services/cfdi_facturapi_builder.py index 10cfd27..844e6bc 100644 --- a/pos/services/cfdi_facturapi_builder.py +++ b/pos/services/cfdi_facturapi_builder.py @@ -9,8 +9,8 @@ generates those payloads for: - Factura global mensual """ -from decimal import Decimal, ROUND_HALF_UP from datetime import datetime +from decimal import ROUND_HALF_UP, Decimal # SAT defaults RFC_PUBLICO_GENERAL = "XAXX010101000" @@ -148,9 +148,7 @@ def build_egreso_payload(sale, tenant_config, customer, original_uuid): """Build Facturapi payload for a credit note (Comprobante tipo Egreso).""" payload = build_ingreso_payload(sale, tenant_config, customer) payload["type"] = "E" - payload["related_documents"] = [ - {"relationship": "01", "documents": [original_uuid]} - ] + payload["related_documents"] = [{"relationship": "01", "documents": [original_uuid]}] payload["payment_method"] = "PUE" return payload @@ -162,15 +160,12 @@ def build_pago_payload(payment, tenant_config, customer, original_uuid): amount = _to_dec(payment.get("amount", 0)) base = (amount / Decimal("1.16")).quantize(TWO, ROUND_HALF_UP) - iva = (amount - base).quantize(TWO, ROUND_HALF_UP) payment_date = payment.get("date") or datetime.now().strftime("%Y-%m-%dT%H:%M:%S") if "T" not in str(payment_date): payment_date = f"{payment_date}T12:00:00" - forma_pago = FORMA_PAGO_MAP.get( - (payment.get("payment_method") or "").lower().strip(), "01" - ) + forma_pago = FORMA_PAGO_MAP.get((payment.get("payment_method") or "").lower().strip(), "01") payload = { "type": "P", diff --git a/pos/services/cfdi_queue.py b/pos/services/cfdi_queue.py index 6d5c756..cd7e92d 100644 --- a/pos/services/cfdi_queue.py +++ b/pos/services/cfdi_queue.py @@ -17,7 +17,7 @@ Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries) import json import logging -from datetime import datetime, timedelta +from datetime import datetime from services import facturapi_service @@ -34,7 +34,7 @@ def _generate_provisional_folio(conn): cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue") seq = cur.fetchone()[0] cur.close() - return f'PRE-{seq:05d}' + return f"PRE-{seq:05d}" def enqueue_cfdi(conn, sale_id, cfdi_type, payload): @@ -54,22 +54,25 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, payload): payload_json = payload if isinstance(payload, str) else json.dumps(payload) - cur.execute(""" + cur.execute( + """ INSERT INTO cfdi_queue (sale_id, type, payload_unsigned, status, provisional_folio) VALUES (%s, %s, %s, 'pending', %s) RETURNING id, created_at - """, (sale_id, cfdi_type, payload_json, provisional_folio)) + """, + (sale_id, cfdi_type, payload_json, provisional_folio), + ) cfdi_id, created_at = cur.fetchone() cur.close() return { - 'id': cfdi_id, - 'sale_id': sale_id, - 'type': cfdi_type, - 'status': 'pending', - 'provisional_folio': provisional_folio, - 'created_at': str(created_at), + "id": cfdi_id, + "sale_id": sale_id, + "type": cfdi_type, + "status": "pending", + "provisional_folio": provisional_folio, + "created_at": str(created_at), } @@ -90,34 +93,40 @@ def process_queue(conn, tenant_config, dry_run=False): """ cur = conn.cursor() - cur.execute(""" + cur.execute( + """ SELECT id, sale_id, type, payload_unsigned, retry_count FROM cfdi_queue WHERE status IN ('pending', 'failed') AND retry_count < %s ORDER BY created_at ASC LIMIT 50 - """, (MAX_RETRIES,)) + """, + (MAX_RETRIES,), + ) items = cur.fetchall() - results = {'processed': 0, 'stamped': 0, 'failed': 0, 'details': []} + results = {"processed": 0, "stamped": 0, "failed": 0, "details": []} - api_key = tenant_config.get('facturapi_key') + api_key = tenant_config.get("facturapi_key") if not api_key: cur.close() raise ValueError("Facturapi key not configured for tenant") - for cfdi_id, sale_id, cfdi_type, payload_unsigned, retry_count in items: - results['processed'] += 1 + for cfdi_id, _sale_id, _cfdi_type, payload_unsigned, _retry_count in items: + results["processed"] += 1 # Update status to 'sending' - cur.execute(""" + cur.execute( + """ UPDATE cfdi_queue SET status = 'sending' WHERE id = %s - """, (cfdi_id,)) + """, + (cfdi_id,), + ) conn.commit() try: - payload = json.loads(payload_unsigned or '{}') + payload = json.loads(payload_unsigned or "{}") if not payload: raise ValueError("Empty payload in queue item") @@ -127,18 +136,19 @@ def process_queue(conn, tenant_config, dry_run=False): raise ValueError("dry_run is not supported with Facturapi") invoice = facturapi_service.create_invoice(tenant_config, payload) - invoice_id = invoice.get('id') - uuid_fiscal = invoice.get('uuid') + invoice_id = invoice.get("id") + uuid_fiscal = invoice.get("uuid") # Download signed XML for storage try: xml_signed = facturapi_service.download_xml(tenant_config, invoice_id) - xml_signed_str = xml_signed.decode('utf-8') if isinstance(xml_signed, bytes) else str(xml_signed) + xml_signed_str = xml_signed.decode("utf-8") if isinstance(xml_signed, bytes) else str(xml_signed) except Exception as xml_err: logger.warning("Could not download signed XML for %s: %s", invoice_id, xml_err) - xml_signed_str = '' + xml_signed_str = "" - cur.execute(""" + cur.execute( + """ UPDATE cfdi_queue SET status = 'stamped', xml_signed = %s, @@ -147,30 +157,37 @@ def process_queue(conn, tenant_config, dry_run=False): stamped_at = NOW(), error_message = NULL WHERE id = %s - """, (xml_signed_str, uuid_fiscal, invoice_id, cfdi_id)) + """, + (xml_signed_str, uuid_fiscal, invoice_id, cfdi_id), + ) conn.commit() - results['stamped'] += 1 - results['details'].append({ - 'id': cfdi_id, 'status': 'stamped', - 'uuid': uuid_fiscal, 'external_id': invoice_id, - }) + results["stamped"] += 1 + results["details"].append( + { + "id": cfdi_id, + "status": "stamped", + "uuid": uuid_fiscal, + "external_id": invoice_id, + } + ) except Exception as e: - error_msg = f'{type(e).__name__}: {str(e)[:500]}' - cur.execute(""" + error_msg = f"{type(e).__name__}: {str(e)[:500]}" + cur.execute( + """ UPDATE cfdi_queue SET status = 'failed', retry_count = retry_count + 1, error_message = %s WHERE id = %s - """, (error_msg, cfdi_id)) + """, + (error_msg, cfdi_id), + ) conn.commit() - results['failed'] += 1 - results['details'].append({ - 'id': cfdi_id, 'status': 'failed', 'error': error_msg - }) + results["failed"] += 1 + results["details"].append({"id": cfdi_id, "status": "failed", "error": error_msg}) cur.close() return results @@ -184,30 +201,33 @@ def retry_failed(conn): """ cur = conn.cursor() - cur.execute(""" + cur.execute( + """ SELECT id, retry_count, created_at FROM cfdi_queue WHERE status = 'failed' AND retry_count < %s ORDER BY created_at ASC - """, (MAX_RETRIES,)) + """, + (MAX_RETRIES,), + ) items = cur.fetchall() reset_count = 0 now = datetime.utcnow() for cfdi_id, retry_count, created_at in items: - if retry_count < len(BACKOFF_INTERVALS): - wait_seconds = BACKOFF_INTERVALS[retry_count] - else: - wait_seconds = BACKOFF_INTERVALS[-1] + wait_seconds = BACKOFF_INTERVALS[retry_count] if retry_count < len(BACKOFF_INTERVALS) else BACKOFF_INTERVALS[-1] # Use created_at as approximation for last attempt. # In production, track last_attempt_at separately. elapsed = (now - created_at).total_seconds() if elapsed >= wait_seconds: - cur.execute(""" + cur.execute( + """ UPDATE cfdi_queue SET status = 'pending' WHERE id = %s - """, (cfdi_id,)) + """, + (cfdi_id,), + ) reset_count += 1 conn.commit() @@ -215,8 +235,7 @@ def retry_failed(conn): return reset_count -def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, - tenant_config=None): +def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, tenant_config=None): """Cancel a stamped CFDI via Facturapi. SAT cancellation motives: @@ -238,38 +257,44 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, Raises: ValueError: on validation errors """ - if motive not in ('01', '02', '03', '04'): + if motive not in ("01", "02", "03", "04"): raise ValueError(f"Invalid SAT cancellation motive: {motive}") - if motive == '01' and not replacement_uuid: + if motive == "01" and not replacement_uuid: raise ValueError("Motive 01 requires a replacement UUID") cur = conn.cursor() - cur.execute(""" + cur.execute( + """ SELECT id, uuid_fiscal, external_id, status FROM cfdi_queue WHERE id = %s - """, (cfdi_id,)) + """, + (cfdi_id,), + ) row = cur.fetchone() if not row: raise ValueError(f"CFDI queue item {cfdi_id} not found") _, uuid_fiscal, external_id, current_status = row - if current_status == 'cancelled': + if current_status == "cancelled": raise ValueError("CFDI is already cancelled") - if current_status != 'stamped': + if current_status != "stamped": # If not stamped, we can just mark as cancelled locally - cur.execute(""" + cur.execute( + """ UPDATE cfdi_queue SET status = 'cancelled', cancel_motive = %s WHERE id = %s - """, (motive, cfdi_id)) + """, + (motive, cfdi_id), + ) conn.commit() cur.close() - return {'id': cfdi_id, 'status': 'cancelled', 'message': 'Cancelled locally (was not stamped)'} + return {"id": cfdi_id, "status": "cancelled", "message": "Cancelled locally (was not stamped)"} - if not tenant_config or not tenant_config.get('facturapi_key'): + if not tenant_config or not tenant_config.get("facturapi_key"): cur.close() raise ValueError("Facturapi key not configured for tenant") @@ -279,36 +304,44 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, try: facturapi_service.cancel_invoice( - tenant_config, external_id, motive, + tenant_config, + external_id, + motive, replacement_uuid=replacement_uuid, ) - cur.execute(""" + cur.execute( + """ UPDATE cfdi_queue SET status = 'cancelled', cancel_motive = %s, cancel_replacement_uuid = %s, error_message = NULL WHERE id = %s - """, (motive, replacement_uuid, cfdi_id)) + """, + (motive, replacement_uuid, cfdi_id), + ) conn.commit() cur.close() return { - 'id': cfdi_id, - 'status': 'cancelled', - 'message': f'Cancelled with SAT (motive {motive})', + "id": cfdi_id, + "status": "cancelled", + "message": f"Cancelled with SAT (motive {motive})", } except Exception as e: - error_msg = f'Cancel failed: {str(e)[:500]}' - cur.execute(""" + error_msg = f"Cancel failed: {str(e)[:500]}" + cur.execute( + """ UPDATE cfdi_queue SET error_message = %s WHERE id = %s - """, (error_msg, cfdi_id)) + """, + (error_msg, cfdi_id), + ) conn.commit() cur.close() - raise ValueError(error_msg) + raise ValueError(error_msg) from e def get_queue_status(conn, filters=None): @@ -316,30 +349,31 @@ def get_queue_status(conn, filters=None): filters = filters or {} cur = conn.cursor() - page = int(filters.get('page', 1)) - per_page = min(int(filters.get('per_page', 50)), 200) + page = int(filters.get("page", 1)) + per_page = min(int(filters.get("per_page", 50)), 200) where_clauses = ["1=1"] params = [] - if filters.get('status'): + if filters.get("status"): where_clauses.append("q.status = %s") - params.append(filters['status']) + params.append(filters["status"]) - if filters.get('sale_id'): + if filters.get("sale_id"): where_clauses.append("q.sale_id = %s") - params.append(int(filters['sale_id'])) + params.append(int(filters["sale_id"])) - if filters.get('type'): + if filters.get("type"): where_clauses.append("q.type = %s") - params.append(filters['type']) + params.append(filters["type"]) where = " AND ".join(where_clauses) cur.execute(f"SELECT count(*) FROM cfdi_queue q WHERE {where}", params) total = cur.fetchone()[0] - cur.execute(f""" + cur.execute( + f""" SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status, q.retry_count, q.provisional_folio, q.error_message, q.cancel_motive, q.created_at, q.stamped_at, q.external_id @@ -347,26 +381,37 @@ def get_queue_status(conn, filters=None): WHERE {where} ORDER BY q.created_at DESC LIMIT %s OFFSET %s - """, params + [per_page, (page - 1) * per_page]) + """, + params + [per_page, (page - 1) * per_page], + ) items = [] for r in cur.fetchall(): - items.append({ - 'id': r[0], 'sale_id': r[1], 'type': r[2], - 'uuid_fiscal': r[3], 'status': r[4], - 'retry_count': r[5], 'provisional_folio': r[6], - 'error_message': r[7], 'cancel_motive': r[8], - 'created_at': str(r[9]) if r[9] else None, - 'stamped_at': str(r[10]) if r[10] else None, - 'external_id': r[11], - }) + items.append( + { + "id": r[0], + "sale_id": r[1], + "type": r[2], + "uuid_fiscal": r[3], + "status": r[4], + "retry_count": r[5], + "provisional_folio": r[6], + "error_message": r[7], + "cancel_motive": r[8], + "created_at": str(r[9]) if r[9] else None, + "stamped_at": str(r[10]) if r[10] else None, + "external_id": r[11], + } + ) cur.close() total_pages = (total + per_page - 1) // per_page return { - 'data': items, - 'pagination': { - 'page': page, 'per_page': per_page, - 'total': total, 'total_pages': total_pages, - } + "data": items, + "pagination": { + "page": page, + "per_page": per_page, + "total": total, + "total_pages": total_pages, + }, } diff --git a/pos/services/facturapi_service.py b/pos/services/facturapi_service.py index 5089906..eb86bd1 100644 --- a/pos/services/facturapi_service.py +++ b/pos/services/facturapi_service.py @@ -12,11 +12,10 @@ Authentication modes: Reference: https://docs.facturapi.io/ """ -import os import base64 import logging +import os from decimal import Decimal -from typing import Optional import requests @@ -35,8 +34,8 @@ class FacturapiError(Exception): # ─── HTTP helpers ─────────────────────────────────────────────────────────── -def _request(method: str, endpoint: str, api_key: str, json_payload=None, params=None, - extra_headers=None, timeout=60): + +def _request(method: str, endpoint: str, api_key: str, json_payload=None, params=None, extra_headers=None, timeout=60): """Make a request to Facturapi REST API with Basic Auth.""" url = f"{BASE_URL}{endpoint}" headers = {"Content-Type": "application/json"} @@ -54,7 +53,7 @@ def _request(method: str, endpoint: str, api_key: str, json_payload=None, params timeout=timeout, ) except requests.RequestException as e: - raise FacturapiError(f"Connection error: {e}", status_code=0) + raise FacturapiError(f"Connection error: {e}", status_code=0) from e if not resp.ok: raise FacturapiError( @@ -88,15 +87,24 @@ def _download(method: str, endpoint: str, api_key: str, params=None, timeout=60) # ─── Tenant config helpers ────────────────────────────────────────────────── -def _get_secret_key(tenant_config: dict) -> Optional[str]: - for key in ("facturapi_key", "facturapi_secret_key"): + +def _get_secret_key(tenant_config: dict) -> str | None: + for key in ("facturapi_secret_key", "facturapi_key", "cfdi_facturapi_key"): val = (tenant_config.get(key) or "").strip() if val: return val return None -def _get_user_key() -> Optional[str]: +def _get_org_id(tenant_config: dict) -> str | None: + for key in ("facturapi_org_id", "cfdi_facturapi_org_id"): + val = (tenant_config.get(key) or "").strip() + if val: + return val + return None + + +def _get_user_key() -> str | None: return USER_KEY.strip() or None @@ -117,42 +125,11 @@ def get_api_key(tenant_config: dict) -> str: user = _get_user_key() if user: return user - raise FacturapiError( - "Facturapi not configured. Set FACTURAPI_USER_KEY env or tenant_config.facturapi_secret_key" - ) + raise FacturapiError("Facturapi not configured. Set FACTURAPI_USER_KEY env or tenant_config.facturapi_secret_key") # ─── Organizations ────────────────────────────────────────────────────────── -def create_organization(tenant_config: dict) -> dict: - """Create a new Facturapi organization for the tenant. - - Requires FACTURAPI_USER_KEY. - Returns dict with id, api_key. - """ - user_key = _get_user_key() - if not user_key: - raise FacturapiError("FACTURAPI_USER_KEY is required to create organizations") - - payload = {"name": tenant_config.get("razon_social", tenant_config.get("name", "Nexus"))} - legal = tenant_config.get("legal_name") or tenant_config.get("razon_social") - if legal: - payload["legal"] = {"name": legal} - if tenant_config.get("rfc"): - payload["legal"] = payload.get("legal", {}) - payload["legal"]["tax_id"] = tenant_config["rfc"] - - org = _request("POST", "/organizations", user_key, json_payload=payload) - org_id = org.get("id") - - # Generate live secret key - key_resp = _request("PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}) - live_key = key_resp.get("key") if isinstance(key_resp, dict) else str(key_resp) - if not live_key: - raise FacturapiError(f"Could not generate live key for org {org_id}") - - return {"org_id": org_id, "api_key": live_key} - def get_organization(org_id: str, api_key: str) -> dict: return _request("GET", f"/organizations/{org_id}", api_key) @@ -164,7 +141,7 @@ def upload_csd(tenant_config: dict, cer_b64: str, key_b64: str, password: str) - cer_b64 and key_b64 are base64-encoded strings. """ api_key = get_api_key(tenant_config) - org_id = tenant_config.get("facturapi_org_id") + org_id = _get_org_id(tenant_config) if not org_id: raise FacturapiError("No Facturapi organization configured for tenant") @@ -196,15 +173,14 @@ def _get_user_key_for_tenant(tenant_config: dict) -> str: user_key = _get_user_key() if user_key: return user_key - tenant_key = (tenant_config.get("facturapi_key") or "").strip() - if tenant_key.startswith("sk_user_"): - return tenant_key - raise FacturapiError( - "FACTURAPI_USER_KEY env or a Facturapi user key (sk_user_*) is required" - ) + for key in ("facturapi_key", "cfdi_facturapi_key"): + tenant_key = (tenant_config.get(key) or "").strip() + if tenant_key.startswith("sk_user_"): + return tenant_key + raise FacturapiError("FACTURAPI_USER_KEY env or a Facturapi user key (sk_user_*) is required") -def find_organization_by_rfc(tenant_config: dict) -> Optional[dict]: +def find_organization_by_rfc(tenant_config: dict) -> dict | None: """Search for an existing Facturapi organization by tenant RFC. Requires a user key (FACTURAPI_USER_KEY env or sk_user_* tenant key). @@ -252,9 +228,7 @@ def create_organization(tenant_config: dict) -> dict: raise FacturapiError("Could not create organization: no id returned") # Generate live secret key - key_resp = _request( - "PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60 - ) + key_resp = _request("PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60) live_key = key_resp.get("key") if isinstance(key_resp, dict) else str(key_resp) if not live_key: raise FacturapiError(f"Could not generate live key for org {org_id}") @@ -282,7 +256,7 @@ def get_org_status(tenant_config: dict) -> dict: result["error"] = str(e) return result - org_id = tenant_config.get("facturapi_org_id") + org_id = _get_org_id(tenant_config) if not org_id: result["error"] = "No Facturapi organization configured" return result @@ -294,13 +268,15 @@ def get_org_status(tenant_config: dict) -> dict: org = get_organization(org_id, api_key) legal = org.get("legal", {}) cert = org.get("certificate", {}) - result.update({ - "configured": True, - "has_csd": bool(cert.get("has_certificate")), - "legal_name": legal.get("name") or legal.get("legal_name"), - "tax_id": legal.get("tax_id"), - "pending_steps": org.get("pending_steps", []), - }) + result.update( + { + "configured": True, + "has_csd": bool(cert.get("has_certificate")), + "legal_name": legal.get("name") or legal.get("legal_name"), + "tax_id": legal.get("tax_id"), + "pending_steps": org.get("pending_steps", []), + } + ) except FacturapiError as e: result["error"] = str(e) @@ -309,6 +285,7 @@ def get_org_status(tenant_config: dict) -> dict: # ─── Customers ────────────────────────────────────────────────────────────── + def create_or_update_customer(tenant_config: dict, customer_data: dict) -> str: """Create or update a customer in Facturapi and return its id. @@ -364,6 +341,7 @@ def create_or_update_customer(tenant_config: dict, customer_data: dict) -> str: # ─── Invoices ─────────────────────────────────────────────────────────────── + def create_invoice(tenant_config: dict, payload: dict) -> dict: """Create and stamp an invoice in Facturapi. @@ -373,8 +351,7 @@ def create_invoice(tenant_config: dict, payload: dict) -> dict: return _request("POST", "/invoices", api_key, json_payload=payload, timeout=90) -def cancel_invoice(tenant_config: dict, invoice_id: str, motive: str, - replacement_uuid: Optional[str] = None) -> dict: +def cancel_invoice(tenant_config: dict, invoice_id: str, motive: str, replacement_uuid: str | None = None) -> dict: """Cancel an invoice in Facturapi. Motive codes: @@ -402,6 +379,7 @@ def download_pdf(tenant_config: dict, invoice_id: str) -> bytes: # ─── Helpers ───────────────────────────────────────────────────────────────── + def is_lco_rejection(message: str) -> bool: """Detect SAT LCO rejection (CSD not yet propagated).""" if not message: diff --git a/pos/static/js/invoicing.js b/pos/static/js/invoicing.js index bef35ec..4251771 100644 --- a/pos/static/js/invoicing.js +++ b/pos/static/js/invoicing.js @@ -329,6 +329,73 @@ const Invoicing = (() => { } } + function resetCsdForm() { + document.getElementById('csd-form').reset(); + document.getElementById('csd-cer-label').textContent = 'Subir certificado .cer'; + document.getElementById('csd-key-label').textContent = 'Subir llave privada .key'; + } + + function updateFileLabels() { + const cer = document.getElementById('csd-cer'); + const key = document.getElementById('csd-key'); + if (cer && cer.files.length) { + document.getElementById('csd-cer-label').textContent = cer.files[0].name; + } + if (key && key.files.length) { + document.getElementById('csd-key-label').textContent = key.files[0].name; + } + } + + async function uploadCsd(btn) { + if (!btn) return; + const cer = document.getElementById('csd-cer'); + const key = document.getElementById('csd-key'); + const password = document.getElementById('contrasena-csd').value.trim(); + + if (!cer || !cer.files.length || !key || !key.files.length) { + alert('Selecciona el archivo .cer y .key'); + return; + } + if (!password) { + alert('Escribe la contraseña del CSD'); + return; + } + + const formData = new FormData(); + formData.append('certificate', cer.files[0]); + formData.append('private_key', key.files[0]); + formData.append('password', password); + + btn.disabled = true; + const originalText = btn.innerHTML; + btn.textContent = 'Subiendo...'; + try { + const res = await fetch(`${API}/facturapi/csd`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token()}` }, + body: formData, + }); + const data = await res.json().catch(() => ({ error: res.statusText })); + if (!res.ok) throw new Error(data.error || 'Upload failed'); + alert('CSD actualizado correctamente'); + resetCsdForm(); + loadFacturapiStatus(); + } catch (e) { + alert('Error al subir CSD: ' + e.message); + } finally { + btn.disabled = false; + btn.innerHTML = originalText; + } + } + + // Wire file input change listeners + setTimeout(() => { + const cer = document.getElementById('csd-cer'); + const key = document.getElementById('csd-key'); + if (cer) cer.addEventListener('change', updateFileLabels); + if (key) key.addEventListener('change', updateFileLabels); + }, 0); + // ---- Detail modal (uses modalDetalleOverlay) ---- async function showDetail(cfdiId) { const overlay = document.getElementById('modalDetalleOverlay'); @@ -612,6 +679,7 @@ const Invoicing = (() => { showDetail, showCancelModal, confirmCancel, processQueue, showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder, openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice, setupFacturapi, + uploadCsd, resetCsdForm, }; // Register Cmd+K items if (typeof registerCmdKItem === "function") { diff --git a/pos/templates/invoicing.html b/pos/templates/invoicing.html index 1bd97d2..36b596b 100644 --- a/pos/templates/invoicing.html +++ b/pos/templates/invoicing.html @@ -800,7 +800,7 @@
-
+
@@ -808,60 +808,63 @@
-
- CSD Activo  Vigente +
+ CSD — Consultar estado en Facturapi
-
- No. Certificado: 20001000000300022779  ·  Vence: 14/07/2026 +
+ El estado del certificado se muestra en la sección Facturapi (PAC).
-
-
-
- - + Certificado público del SAT +
+ +
+ + + + Llave privada del CSD +
+ +
+ + + Contraseña asignada al generar el CSD en el SAT +
+
+ +
+ + - Certificado público del SAT
- -
- - - Llave privada del CSD -
- -
- - - Contraseña asignada al generar el CSD en el SAT -
-
- -
- - -
+
diff --git a/pos/tests/test_facturapi_service.py b/pos/tests/test_facturapi_service.py new file mode 100644 index 0000000..bac6760 --- /dev/null +++ b/pos/tests/test_facturapi_service.py @@ -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"" + mock_request.return_value = mock_response + + config = {"facturapi_key": "sk_test"} + result = facturapi_service.download_xml(config, "inv_1") + + assert result == b"" + + +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" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bd6310c --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..9d4a94a --- /dev/null +++ b/requirements-dev.txt @@ -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 diff --git a/scripts/apply_facturapi_to_all_tenants.py b/scripts/apply_facturapi_to_all_tenants.py index b26272e..c5369e0 100755 --- a/scripts/apply_facturapi_to_all_tenants.py +++ b/scripts/apply_facturapi_to_all_tenants.py @@ -14,6 +14,7 @@ Usage: import os import sys + import psycopg2 MIGRATION_SQL = """ @@ -42,9 +43,7 @@ def get_tenant_db_names(master_dsn): conn = psycopg2.connect(master_dsn) try: cur = conn.cursor() - cur.execute( - "SELECT id, db_name FROM tenants WHERE is_active = true ORDER BY id" - ) + cur.execute("SELECT id, db_name FROM tenants WHERE is_active = true ORDER BY id") rows = cur.fetchall() cur.close() return rows diff --git a/scripts/check_facturapi_tenants.py b/scripts/check_facturapi_tenants.py new file mode 100644 index 0000000..a6e49a1 --- /dev/null +++ b/scripts/check_facturapi_tenants.py @@ -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 "-", + "Sí" if r["has_key"] else "No", + "Sí" if r["has_org_id"] else "No", + "Sí" 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() diff --git a/scripts/setup_facturapi_orgs.py b/scripts/setup_facturapi_orgs.py new file mode 100644 index 0000000..01e2802 --- /dev/null +++ b/scripts/setup_facturapi_orgs.py @@ -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()