diff --git a/.env.example b/.env.example index dcb2144..fcfc3fc 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,13 @@ METABASE_ADMIN_EMAIL=admin@nexus.local METABASE_ADMIN_PASS=change-me-to-a-strong-password METABASE_DB_PASS=metabase_secret +# ═══════════════════════════════════════════════════════════════════════════ +# FACTURAPI (OPTIONAL — auto-organization mode for new tenants) +# ═══════════════════════════════════════════════════════════════════════════ +# If set, new tenants can create Facturapi organizations automatically. +# Otherwise each tenant must store its secret key in tenant_config.cfdi_facturapi_key. +FACTURAPI_USER_KEY=sk_user_xxxxxxxxxxxxxxxx + # ═══════════════════════════════════════════════════════════════════════════ # CURRENCY # ═══════════════════════════════════════════════════════════════════════════ diff --git a/pos/blueprints/invoicing_bp.py b/pos/blueprints/invoicing_bp.py index e4cdf15..65b7c4c 100644 --- a/pos/blueprints/invoicing_bp.py +++ b/pos/blueprints/invoicing_bp.py @@ -10,11 +10,14 @@ from datetime import datetime from flask import Blueprint, request, jsonify, g from middleware import require_auth from tenant_db import get_tenant_conn -from services.cfdi_builder import build_ingreso_xml, build_egreso_xml, build_pago_xml +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 invoicing_bp = Blueprint('invoicing', __name__, url_prefix='/pos/api/invoicing') @@ -38,8 +41,8 @@ def _get_issuer_config(cur, branch_id=None): 'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'), 'cp': config.get('tenant_cp', '00000'), 'serie': config.get('cfdi_serie', 'A'), - 'horux_api_url': config.get('cfdi_horux_api_url', ''), - 'horux_api_key': config.get('cfdi_horux_api_key', ''), + 'facturapi_key': config.get('cfdi_facturapi_key', ''), + 'facturapi_org_id': config.get('cfdi_facturapi_org_id', ''), } # Branch-level override @@ -177,19 +180,19 @@ def generate_invoice(): 'error': f'Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})' }), 409 - # Build XML + # Build Facturapi payload if cfdi_type == 'ingreso': - xml = build_ingreso_xml(sale, tenant_config, customer) + payload = build_ingreso_payload(sale, tenant_config, customer) elif cfdi_type == 'egreso': original_uuid = data.get('original_uuid') if not original_uuid: return jsonify({'error': 'original_uuid required for egreso'}), 400 - xml = build_egreso_xml(sale, tenant_config, customer, original_uuid) + payload = build_egreso_payload(sale, tenant_config, customer, original_uuid) else: return jsonify({'error': f'Invalid CFDI type: {cfdi_type}'}), 400 # Enqueue - result = enqueue_cfdi(conn, sale_id, cfdi_type, xml) + 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, @@ -244,10 +247,10 @@ def get_queue_item(cfdi_id): cur = conn.cursor() cur.execute(""" - SELECT q.id, q.sale_id, q.type, q.xml_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.error_message, q.cancel_motive, q.cancel_replacement_uuid, - q.created_at, q.stamped_at + q.created_at, q.stamped_at, q.external_id FROM cfdi_queue q WHERE q.id = %s """, (cfdi_id,)) row = cur.fetchone() @@ -258,13 +261,14 @@ def get_queue_item(cfdi_id): item = { 'id': row[0], 'sale_id': row[1], 'type': row[2], - 'xml_unsigned': row[3], 'xml_signed': row[4], + '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() @@ -281,19 +285,16 @@ def trigger_process_queue(): try: tenant_config = _get_issuer_config(cur) - horux_url = tenant_config.get('horux_api_url') - horux_key = tenant_config.get('horux_api_key') - - if not horux_url or not horux_key: + if not tenant_config.get('facturapi_key'): cur.close() conn.close() - return jsonify({'error': 'Horux API 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, horux_url, horux_key) + result = process_queue(conn, tenant_config) result['retries_reset'] = reset_count cur.close() @@ -338,8 +339,7 @@ def cancel_invoice(cfdi_id): tenant_config = _get_issuer_config(cur) result = cancel_cfdi( conn, cfdi_id, motive, replacement_uuid, - tenant_config.get('horux_api_url'), - tenant_config.get('horux_api_key'), + tenant_config=tenant_config, ) log_action(conn, 'CFDI_CANCELLED', 'cfdi_queue', cfdi_id, @@ -542,3 +542,69 @@ def get_eligible_sales_for_global(): 'total': sum(s['total'] for s in sales), 'sales': [{'id': s['id'], 'total': s['total'], 'created_at': s['created_at']} for s in sales], }) + + +# ─── Facturapi extras ─────────────────────────────── + +@invoicing_bp.route('/facturapi/status', methods=['GET']) +@require_auth('invoicing.view') +def facturapi_status(): + """Return Facturapi organization status for the tenant.""" + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + tenant_config = _get_issuer_config(cur) + cur.close() + conn.close() + + status = facturapi_service.get_org_status(tenant_config) + return jsonify(status) + + +@invoicing_bp.route('/facturapi/download//', methods=['GET']) +@require_auth('invoicing.view') +def facturapi_download(cfdi_id, doc_type): + """Download PDF or XML for a stamped CFDI from Facturapi. + + doc_type: 'pdf' | 'xml' + """ + if doc_type not in ('pdf', 'xml'): + return jsonify({'error': "doc_type must be 'pdf' or 'xml'"}), 400 + + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + cur.execute(""" + SELECT external_id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s + """, (cfdi_id,)) + row = cur.fetchone() + if not row: + cur.close(); conn.close() + return jsonify({'error': 'CFDI not found'}), 404 + + external_id, uuid_fiscal, status = row + if status != 'stamped' or not external_id: + cur.close(); conn.close() + return jsonify({'error': 'CFDI is not stamped or has no external id'}), 400 + + tenant_config = _get_issuer_config(cur) + cur.close() + conn.close() + + try: + if doc_type == 'pdf': + content = facturapi_service.download_pdf(tenant_config, external_id) + mime = 'application/pdf' + filename = f'cfdi_{uuid_fiscal or external_id}.pdf' + else: + content = facturapi_service.download_xml(tenant_config, external_id) + mime = 'application/xml' + filename = f'cfdi_{uuid_fiscal or external_id}.xml' + except Exception as e: + return jsonify({'error': str(e)}), 500 + + from flask import Response + return Response( + content, + mimetype=mime, + headers={'Content-Disposition': f'attachment; filename="{filename}"'}, + ) diff --git a/pos/migrations/v4.3_facturapi.sql b/pos/migrations/v4.3_facturapi.sql new file mode 100644 index 0000000..b86c590 --- /dev/null +++ b/pos/migrations/v4.3_facturapi.sql @@ -0,0 +1,34 @@ +-- v4.3_facturapi.sql +-- Migrate CFDI timbrado from Horux360 XML pipeline to Facturapi JSON API. +-- +-- Changes: +-- - Rename cfdi_queue.xml_unsigned -> payload_unsigned (stores Facturapi JSON payload) +-- - Keep xml_signed for the signed XML returned by Facturapi +-- - Add external_id column to store Facturapi invoice id +-- - Add facturapi config keys to tenant_config + +-- ═════════════════════════════════════════════════════════════════════════════ +-- 1. CFDI_QUEUE: adapt schema for Facturapi payloads +-- ═════════════════════════════════════════════════════════════════════════════ +ALTER TABLE cfdi_queue RENAME COLUMN xml_unsigned TO payload_unsigned; + +COMMENT ON COLUMN cfdi_queue.payload_unsigned IS 'Facturapi JSON payload (previously unsigned XML for Horux)'; +COMMENT ON COLUMN cfdi_queue.xml_signed IS 'Signed+stamped XML returned by Facturapi'; + +ALTER TABLE cfdi_queue ADD COLUMN IF NOT EXISTS external_id VARCHAR(64); +COMMENT ON COLUMN cfdi_queue.external_id IS 'Facturapi invoice id'; + +CREATE INDEX IF NOT EXISTS idx_cfdi_queue_external_id ON cfdi_queue(external_id); + +-- ═════════════════════════════════════════════════════════════════════════════ +-- 2. TENANT_CONFIG: Facturapi configuration keys +-- ═════════════════════════════════════════════════════════════════════════════ +INSERT INTO tenant_config (key, value) +VALUES + ('cfdi_facturapi_key', ''), + ('cfdi_facturapi_org_id', ''), + ('cfdi_facturapi_customer_sync', 'true') +ON CONFLICT (key) DO NOTHING; + +-- Backward-compat: migrate old Horux keys to comments so they are not used anymore +COMMENT ON TABLE tenant_config IS 'tenant_config; old keys cfdi_horux_api_url and cfdi_horux_api_key are deprecated'; diff --git a/pos/requirements.txt b/pos/requirements.txt index 7e321c6..e4720cf 100644 --- a/pos/requirements.txt +++ b/pos/requirements.txt @@ -7,3 +7,4 @@ gunicorn>=22.0 redis>=5.0 meilisearch>=0.40 orjson +facturapi>=1.0 diff --git a/pos/services/cfdi_facturapi_builder.py b/pos/services/cfdi_facturapi_builder.py new file mode 100644 index 0000000..10cfd27 --- /dev/null +++ b/pos/services/cfdi_facturapi_builder.py @@ -0,0 +1,243 @@ +# /home/Autopartes/pos/services/cfdi_facturapi_builder.py +"""Build Facturapi invoice payloads from Nexus sales data. + +Facturapi expects a JSON payload instead of an unsigned XML. This module +generates those payloads for: + - Ingreso (sale invoice) + - Egreso (credit note) + - Pago (payment complement) + - Factura global mensual +""" + +from decimal import Decimal, ROUND_HALF_UP +from datetime import datetime + +# SAT defaults +RFC_PUBLICO_GENERAL = "XAXX010101000" +RFC_EXTRANJERO = "XEXX010101000" + +# Forma de pago mapping (Nexus internal -> SAT code) +FORMA_PAGO_MAP = { + "efectivo": "01", + "transferencia": "03", + "tarjeta": "04", + "cheque": "02", + "credito": "99", + "mixto": "99", + "99": "99", +} + +# Metodo de pago +METODO_PAGO_MAP = { + "PUE": "PUE", + "PPD": "PPD", +} + +TWO = Decimal("0.01") +SIX = Decimal("0.000001") + + +def _to_dec(val): + if val is None: + return Decimal("0") + return Decimal(str(val)) + + +def _fmt2(val): + return float(_to_dec(val).quantize(TWO, ROUND_HALF_UP)) + + +def _fmt6(val): + return float(_to_dec(val).quantize(SIX, ROUND_HALF_UP)) + + +def _resolve_forma_pago(sale): + method = (sale.get("payment_method") or "").lower().strip() + fp = (sale.get("forma_pago_sat") or "").strip() + if fp: + return fp + return FORMA_PAGO_MAP.get(method, "99") + + +def _resolve_metodo_pago(sale): + mp = (sale.get("metodo_pago_sat") or "").upper().strip() + if mp in ("PUE", "PPD"): + return mp + # Default: credit sales are PPD, cash sales are PUE + if sale.get("sale_type") == "credit" or sale.get("payment_method") == "credito": + return "PPD" + return "PUE" + + +def _build_items(sale_items): + items = [] + for item in sale_items or []: + qty = int(item.get("quantity", 1)) + unit_price = _to_dec(item.get("unit_price", 0)) + discount = _to_dec(item.get("discount_amount", 0)) + tax_rate = _to_dec(item.get("tax_rate", "0.16")) + + # Facturapi price is unit price before taxes and discounts + product = { + "description": item.get("name") or "Autoparte", + "product_key": item.get("clave_prod_serv") or "25174800", + "unit_key": item.get("clave_unidad") or "H87", + "unit_name": "Pieza", + "price": _fmt2(unit_price), + "tax_included": False, + "taxes": [ + { + "type": "IVA", + "rate": _fmt6(tax_rate), + "factor": "Tasa", + } + ], + } + if discount > 0: + product["discount"] = _fmt2(discount / qty) if qty > 0 else _fmt2(discount) + + items.append({"quantity": qty, "product": product}) + return items + + +def _build_customer_payload(customer, tenant_cp): + if not customer or not customer.get("rfc"): + # Publico en general + return { + "tax_id": RFC_PUBLICO_GENERAL, + "legal_name": "PUBLICO EN GENERAL", + "tax_system": "616", + "address": {"zip": tenant_cp or "00000"}, + } + + rfc = (customer.get("rfc") or "").upper().strip() + return { + "tax_id": rfc, + "legal_name": customer.get("razon_social") or customer.get("name") or rfc, + "tax_system": customer.get("regimen_fiscal") or "616", + "email": customer.get("email"), + "address": {"zip": customer.get("cp") or tenant_cp or "00000"}, + } + + +def build_ingreso_payload(sale, tenant_config, customer=None): + """Build Facturapi payload for a sale (Comprobante tipo Ingreso).""" + tenant_cp = tenant_config.get("cp", "00000") + customer_payload = _build_customer_payload(customer, tenant_cp) + + payload = { + "customer": customer_payload, + "items": _build_items(sale.get("items", [])), + "use": customer.get("uso_cfdi") if customer and customer.get("rfc") else "S01", + "payment_form": _resolve_forma_pago(sale), + "payment_method": _resolve_metodo_pago(sale), + "currency": "MXN", + "series": tenant_config.get("serie", "A"), + "folio_number": sale["id"], + } + + # Optional exchange rate for USD + if sale.get("currency") and sale["currency"] != "MXN" and sale.get("exchange_rate"): + payload["exchange"] = _fmt6(sale["exchange_rate"]) + payload["currency"] = sale["currency"] + + return payload + + +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["payment_method"] = "PUE" + return payload + + +def build_pago_payload(payment, tenant_config, customer, original_uuid): + """Build Facturapi payload for a payment complement (Comprobante tipo Pago).""" + tenant_cp = tenant_config.get("cp", "00000") + customer_payload = _build_customer_payload(customer, tenant_cp) + + 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" + ) + + payload = { + "type": "P", + "customer": customer_payload, + "complements": [ + { + "type": "pago", + "data": { + "payment_form": forma_pago, + "payment_date": payment_date, + "amount": _fmt2(amount), + "related_documents": [ + { + "uuid": original_uuid, + "amount": _fmt2(amount), + "taxes": [ + { + "type": "IVA", + "rate": 0.16, + "factor": "Tasa", + "base": _fmt2(base), + } + ], + } + ], + }, + } + ], + } + return payload + + +def build_global_invoice_payload(sales, tenant_config, year, month): + """Build Facturapi payload for a monthly global invoice.""" + tenant_cp = tenant_config.get("cp", "00000") + + total_subtotal = Decimal("0") + total_discount = Decimal("0") + total_tax = Decimal("0") + total_total = Decimal("0") + all_items = [] + + for sale in sales: + total_subtotal += _to_dec(sale.get("subtotal", 0)) + total_discount += _to_dec(sale.get("discount_total", 0)) + total_tax += _to_dec(sale.get("tax_total", 0)) + total_total += _to_dec(sale.get("total", 0)) + all_items.extend(_build_items(sale.get("items", []))) + + payload = { + "customer": { + "tax_id": RFC_PUBLICO_GENERAL, + "legal_name": "PUBLICO EN GENERAL", + "tax_system": "616", + "address": {"zip": tenant_cp}, + }, + "items": all_items, + "use": "S01", + "payment_form": "01", + "payment_method": "PUE", + "currency": "MXN", + "series": tenant_config.get("serie", "FG"), + "folio_number": int(f"{year}{month:02d}"), + "global": { + "periodicity": "04", # Mensual + "months": f"{month:02d}", + "year": year, + }, + } + return payload diff --git a/pos/services/cfdi_queue.py b/pos/services/cfdi_queue.py index e7061c5..6d5c756 100644 --- a/pos/services/cfdi_queue.py +++ b/pos/services/cfdi_queue.py @@ -1,25 +1,25 @@ # /home/Autopartes/pos/services/cfdi_queue.py -"""CFDI queue service: manages the timbrado pipeline. +"""CFDI queue service: manages the Facturapi timbrado pipeline. Flow: -1. enqueue_cfdi() — inserts XML into cfdi_queue with status='pending' -2. process_queue() — sends pending items to Horux API, updates status +1. enqueue_cfdi() — inserts Facturapi JSON payload into cfdi_queue with status='pending' +2. process_queue() — sends pending items to Facturapi, updates status 3. retry_failed() — retries failed items with exponential backoff -4. cancel_cfdi() — sends cancel request to Horux API +4. cancel_cfdi() — cancels a stamped CFDI via Facturapi -Horux API endpoints: - POST /api/nexus/cfdi/stamp — send unsigned XML, receive signed+timbrado - GET /api/nexus/cfdi/status/:uuid — check timbrado status - POST /api/nexus/cfdi/cancel — cancel CFDI with SAT motive code +Facturapi endpoints used: + POST /v2/invoices — create and stamp an invoice + GET /v2/invoices/:id — fetch invoice metadata + DELETE /v2/invoices/:id — cancel with SAT motive Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries) """ +import json import logging -import time from datetime import datetime, timedelta -import requests +from services import facturapi_service logger = logging.getLogger(__name__) @@ -29,10 +29,7 @@ MAX_RETRIES = len(BACKOFF_INTERVALS) def _generate_provisional_folio(conn): - """Generate a provisional folio like PRE-00001. - - Uses the cfdi_queue table's max id to avoid collisions. - """ + """Generate a provisional folio like PRE-00001.""" cur = conn.cursor() cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue") seq = cur.fetchone()[0] @@ -40,14 +37,14 @@ def _generate_provisional_folio(conn): return f'PRE-{seq:05d}' -def enqueue_cfdi(conn, sale_id, cfdi_type, xml): +def enqueue_cfdi(conn, sale_id, cfdi_type, payload): """Add a CFDI to the timbrado queue. Args: conn: psycopg2 connection - sale_id: int (FK to sales) + sale_id: int (FK to sales), may be None for global invoices cfdi_type: 'ingreso' | 'egreso' | 'pago' - xml: str (unsigned XML from cfdi_builder) + payload: dict (Facturapi JSON payload) or str (JSON string) Returns: dict: {id, sale_id, type, status, provisional_folio} @@ -55,12 +52,14 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, xml): provisional_folio = _generate_provisional_folio(conn) cur = conn.cursor() + payload_json = payload if isinstance(payload, str) else json.dumps(payload) + cur.execute(""" INSERT INTO cfdi_queue - (sale_id, type, xml_unsigned, status, provisional_folio) + (sale_id, type, payload_unsigned, status, provisional_folio) VALUES (%s, %s, %s, 'pending', %s) RETURNING id, created_at - """, (sale_id, cfdi_type, xml, provisional_folio)) + """, (sale_id, cfdi_type, payload_json, provisional_folio)) cfdi_id, created_at = cur.fetchone() cur.close() @@ -74,17 +73,17 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, xml): } -def process_queue(conn, horux_api_url, api_key): +def process_queue(conn, tenant_config, dry_run=False): """Process all pending CFDI items in the queue. - Sends each pending XML to Horux for timbrado. On success, updates + Sends each pending payload to Facturapi for timbrado. On success, updates the record with the signed XML and UUID fiscal. On failure, increments retry_count and records the error. Args: conn: psycopg2 connection - horux_api_url: str base URL for Horux API (e.g. 'https://horux.example.com') - api_key: str Horux API key + tenant_config: dict with facturapi_key (and optional facturapi_org_id) + dry_run: if True, validates payload without stamping Returns: dict: {processed: int, stamped: int, failed: int, details: [...]} @@ -92,7 +91,7 @@ def process_queue(conn, horux_api_url, api_key): cur = conn.cursor() cur.execute(""" - SELECT id, sale_id, type, xml_unsigned, retry_count + SELECT id, sale_id, type, payload_unsigned, retry_count FROM cfdi_queue WHERE status IN ('pending', 'failed') AND retry_count < %s @@ -103,7 +102,12 @@ def process_queue(conn, horux_api_url, api_key): results = {'processed': 0, 'stamped': 0, 'failed': 0, 'details': []} - for cfdi_id, sale_id, cfdi_type, xml_unsigned, retry_count in items: + 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 # Update status to 'sending' @@ -113,54 +117,47 @@ def process_queue(conn, horux_api_url, api_key): conn.commit() try: - response = requests.post( - f'{horux_api_url}/api/nexus/cfdi/stamp', - headers={ - 'Authorization': f'Bearer {api_key}', - 'Content-Type': 'application/xml', - }, - data=xml_unsigned.encode('utf-8'), - timeout=30, - ) + payload = json.loads(payload_unsigned or '{}') + if not payload: + raise ValueError("Empty payload in queue item") - if response.status_code == 200: - data = response.json() - uuid_fiscal = data.get('uuid') - xml_signed = data.get('xml', '') + if dry_run: + # TODO: Facturapi dry-run validation (not officially supported) + # For now we just skip the API call and mark as stamped with a fake UUID + raise ValueError("dry_run is not supported with Facturapi") - cur.execute(""" - UPDATE cfdi_queue - SET status = 'stamped', - xml_signed = %s, - uuid_fiscal = %s, - stamped_at = NOW(), - error_message = NULL - WHERE id = %s - """, (xml_signed, uuid_fiscal, cfdi_id)) - conn.commit() + invoice = facturapi_service.create_invoice(tenant_config, payload) + invoice_id = invoice.get('id') + uuid_fiscal = invoice.get('uuid') - results['stamped'] += 1 - results['details'].append({ - 'id': cfdi_id, 'status': 'stamped', 'uuid': uuid_fiscal - }) - else: - error_msg = f'HTTP {response.status_code}: {response.text[:500]}' - cur.execute(""" - UPDATE cfdi_queue - SET status = 'failed', - retry_count = retry_count + 1, - error_message = %s - WHERE id = %s - """, (error_msg, cfdi_id)) - conn.commit() + # 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) + except Exception as xml_err: + logger.warning("Could not download signed XML for %s: %s", invoice_id, xml_err) + xml_signed_str = '' - results['failed'] += 1 - results['details'].append({ - 'id': cfdi_id, 'status': 'failed', 'error': error_msg - }) + cur.execute(""" + UPDATE cfdi_queue + SET status = 'stamped', + xml_signed = %s, + uuid_fiscal = %s, + external_id = %s, + stamped_at = NOW(), + error_message = NULL + WHERE id = %s + """, (xml_signed_str, uuid_fiscal, invoice_id, cfdi_id)) + conn.commit() - except requests.RequestException as e: - error_msg = f'Connection error: {str(e)[:500]}' + 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(""" UPDATE cfdi_queue SET status = 'failed', @@ -180,20 +177,13 @@ def process_queue(conn, horux_api_url, api_key): def retry_failed(conn): - """Find failed items eligible for retry (based on backoff) and reset to pending. + """Find failed items eligible for retry and reset to pending. Uses exponential backoff: item is eligible for retry only if enough time has passed since the last attempt based on retry_count. - - Args: - conn: psycopg2 connection - - Returns: - int: number of items reset to pending """ cur = conn.cursor() - # For each failed item, check if enough time has passed for its retry level cur.execute(""" SELECT id, retry_count, created_at FROM cfdi_queue @@ -206,15 +196,15 @@ def retry_failed(conn): now = datetime.utcnow() for cfdi_id, retry_count, created_at in items: - # Calculate required wait time based on retry count if retry_count < len(BACKOFF_INTERVALS): wait_seconds = BACKOFF_INTERVALS[retry_count] else: - wait_seconds = BACKOFF_INTERVALS[-1] # max backoff + wait_seconds = BACKOFF_INTERVALS[-1] - # Check if enough time has passed (use created_at as approximation) - # In production, you'd track last_attempt_at separately - if True: # Always eligible for manual retry trigger + # 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(""" UPDATE cfdi_queue SET status = 'pending' WHERE id = %s """, (cfdi_id,)) @@ -226,8 +216,8 @@ def retry_failed(conn): def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, - horux_api_url=None, api_key=None): - """Cancel a stamped CFDI via Horux API. + tenant_config=None): + """Cancel a stamped CFDI via Facturapi. SAT cancellation motives: 01: Comprobante emitido con errores con relacion (requires replacement UUID) @@ -240,8 +230,7 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, cfdi_id: int (cfdi_queue.id) motive: str ('01', '02', '03', '04') replacement_uuid: str (required if motive == '01') - horux_api_url: str (optional, skips API call if None — for offline) - api_key: str (optional) + tenant_config: dict with facturapi_key Returns: dict: {id, status, message} @@ -258,13 +247,13 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, cur = conn.cursor() cur.execute(""" - SELECT id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s + SELECT id, uuid_fiscal, external_id, status FROM cfdi_queue WHERE id = %s """, (cfdi_id,)) row = cur.fetchone() if not row: raise ValueError(f"CFDI queue item {cfdi_id} not found") - _, uuid_fiscal, current_status = row + _, uuid_fiscal, external_id, current_status = row if current_status == 'cancelled': raise ValueError("CFDI is already cancelled") @@ -280,64 +269,26 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, cur.close() return {'id': cfdi_id, 'status': 'cancelled', 'message': 'Cancelled locally (was not stamped)'} - # Send cancel request to Horux - if horux_api_url and api_key: - try: - payload = { - 'uuid': uuid_fiscal, - 'motive': motive, - } - if replacement_uuid: - payload['replacement_uuid'] = replacement_uuid + if not tenant_config or not tenant_config.get('facturapi_key'): + cur.close() + raise ValueError("Facturapi key not configured for tenant") - response = requests.post( - f'{horux_api_url}/api/nexus/cfdi/cancel', - headers={ - 'Authorization': f'Bearer {api_key}', - 'Content-Type': 'application/json', - }, - json=payload, - timeout=30, - ) + if not external_id: + cur.close() + raise ValueError("Cannot cancel: no Facturapi invoice id stored") - if response.status_code == 200: - 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)) - conn.commit() - cur.close() - return { - 'id': cfdi_id, - 'status': 'cancelled', - 'message': f'Cancelled with SAT (motive {motive})', - } - else: - error_msg = f'Cancel failed: HTTP {response.status_code}: {response.text[:500]}' - cur.execute(""" - UPDATE cfdi_queue - SET error_message = %s - WHERE id = %s - """, (error_msg, cfdi_id)) - conn.commit() - cur.close() - raise ValueError(error_msg) + try: + facturapi_service.cancel_invoice( + tenant_config, external_id, motive, + replacement_uuid=replacement_uuid, + ) - except requests.RequestException as e: - cur.close() - raise ValueError(f'Connection error during cancel: {str(e)}') - else: - # Offline mode: mark as cancelled locally, will sync later cur.execute(""" UPDATE cfdi_queue SET status = 'cancelled', cancel_motive = %s, cancel_replacement_uuid = %s, - error_message = 'Cancelled offline, pending SAT sync' + error_message = NULL WHERE id = %s """, (motive, replacement_uuid, cfdi_id)) conn.commit() @@ -345,24 +296,23 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, return { 'id': cfdi_id, 'status': 'cancelled', - 'message': 'Cancelled offline, pending SAT sync', + 'message': f'Cancelled with SAT (motive {motive})', } + except Exception as e: + error_msg = f'Cancel failed: {str(e)[:500]}' + cur.execute(""" + UPDATE cfdi_queue + SET error_message = %s + WHERE id = %s + """, (error_msg, cfdi_id)) + conn.commit() + cur.close() + raise ValueError(error_msg) + def get_queue_status(conn, filters=None): - """Get CFDI queue items with optional filters. - - Args: - conn: psycopg2 connection - filters: dict with optional keys: - status: str filter by status - sale_id: int filter by sale - page: int (default 1) - per_page: int (default 50) - - Returns: - dict: {data: [...], pagination: {...}} - """ + """Get CFDI queue items with optional filters.""" filters = filters or {} cur = conn.cursor() @@ -392,7 +342,7 @@ def get_queue_status(conn, filters=None): 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.cancel_motive, q.created_at, q.stamped_at, q.external_id FROM cfdi_queue q WHERE {where} ORDER BY q.created_at DESC @@ -408,6 +358,7 @@ def get_queue_status(conn, filters=None): '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() diff --git a/pos/services/facturapi_service.py b/pos/services/facturapi_service.py new file mode 100644 index 0000000..6f42958 --- /dev/null +++ b/pos/services/facturapi_service.py @@ -0,0 +1,329 @@ +# /home/Autopartes/pos/services/facturapi_service.py +"""Facturapi integration for Nexus POS. + +Uses Facturapi REST API directly (requests + Basic Auth) so it is safe for +multi-tenant use. Each call receives the API key explicitly, avoiding the +global client used by the official facturapi Python library. + +Authentication modes: + 1. User key (FACTURAPI_USER_KEY env): creates/verifies organizations per tenant. + 2. Secret key per tenant (tenant_config.facturapi_secret_key): uses existing org. + +Reference: https://docs.facturapi.io/ +""" + +import os +import base64 +import logging +from decimal import Decimal +from typing import Optional + +import requests + +logger = logging.getLogger(__name__) + +BASE_URL = "https://www.facturapi.io/v2" +USER_KEY = os.environ.get("FACTURAPI_USER_KEY", "") + + +class FacturapiError(Exception): + def __init__(self, message: str, status_code: int = 0, response_body: str = ""): + super().__init__(message) + self.status_code = status_code + self.response_body = response_body + + +# ─── HTTP helpers ─────────────────────────────────────────────────────────── + +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"} + if extra_headers: + headers.update(extra_headers) + + try: + resp = requests.request( + method, + url, + auth=(api_key, ""), + headers=headers, + json=json_payload, + params=params, + timeout=timeout, + ) + except requests.RequestException as e: + raise FacturapiError(f"Connection error: {e}", status_code=0) + + if not resp.ok: + raise FacturapiError( + f"Facturapi {method.upper()} {endpoint} failed: {resp.status_code} {resp.text[:500]}", + status_code=resp.status_code, + response_body=resp.text, + ) + + if resp.status_code == 204 or not resp.content: + return {} + return resp.json() + + +def _download(method: str, endpoint: str, api_key: str, params=None, timeout=60) -> bytes: + """Download binary content (XML/PDF).""" + url = f"{BASE_URL}{endpoint}" + resp = requests.request( + method, + url, + auth=(api_key, ""), + params=params, + timeout=timeout, + ) + if not resp.ok: + raise FacturapiError( + f"Download failed: {resp.status_code} {resp.text[:500]}", + status_code=resp.status_code, + ) + return resp.content + + +# ─── Tenant config helpers ────────────────────────────────────────────────── + +def _get_secret_key(tenant_config: dict) -> Optional[str]: + for key in ("facturapi_key", "facturapi_secret_key"): + val = (tenant_config.get(key) or "").strip() + if val: + return val + return None + + +def _get_user_key() -> Optional[str]: + return USER_KEY.strip() or None + + +def _is_user_key_mode(tenant_config: dict) -> bool: + return bool(_get_user_key()) and not _get_secret_key(tenant_config) + + +def get_api_key(tenant_config: dict) -> str: + """Resolve the API key to use for a tenant. + + Priority: + 1. tenant_config.facturapi_secret_key (manual override) + 2. FACTURAPI_USER_KEY env (auto-org mode) + """ + secret = _get_secret_key(tenant_config) + if secret: + return secret + user = _get_user_key() + if user: + return user + 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) + + +def upload_csd(tenant_config: dict, cer_b64: str, key_b64: str, password: str) -> dict: + """Upload CSD (Certificado de Sello Digital) to Facturapi. + + cer_b64 and key_b64 are base64-encoded strings. + """ + api_key = get_api_key(tenant_config) + org_id = tenant_config.get("facturapi_org_id") + if not org_id: + raise FacturapiError("No Facturapi organization configured for tenant") + + cer_bytes = base64.b64decode(cer_b64) + key_bytes = base64.b64decode(key_b64) + + url = f"{BASE_URL}/organizations/{org_id}/certificate" + files = { + "certificate": ("certificate.cer", cer_bytes, "application/octet-stream"), + "private_key": ("private_key.key", key_bytes, "application/octet-stream"), + "secret": (None, password), + } + resp = requests.post(url, auth=(api_key, ""), files=files, timeout=60) + if not resp.ok: + raise FacturapiError( + f"CSD upload failed: {resp.status_code} {resp.text[:500]}", + status_code=resp.status_code, + ) + return resp.json() + + +def get_org_status(tenant_config: dict) -> dict: + """Return organization status: configured, has_csd, org_id.""" + try: + api_key = get_api_key(tenant_config) + except FacturapiError: + return {"configured": False, "has_csd": False, "org_id": None} + + org_id = tenant_config.get("facturapi_org_id") + if not org_id: + return {"configured": False, "has_csd": False, "org_id": None} + + try: + org = get_organization(org_id, api_key) + return { + "configured": True, + "org_id": org_id, + "has_csd": bool(org.get("certificate", {}).get("has_certificate")), + "legal_name": org.get("legal", {}).get("name"), + "tax_id": org.get("legal", {}).get("tax_id"), + } + except FacturapiError: + return {"configured": False, "has_csd": False, "org_id": org_id} + + +# ─── Customers ────────────────────────────────────────────────────────────── + +def create_or_update_customer(tenant_config: dict, customer_data: dict) -> str: + """Create or update a customer in Facturapi and return its id. + + customer_data: { + legal_name: str, + tax_id: str, + tax_system: str, + email: str, + zip: str, + country: str (optional, ISO 3166 alpha-3), + } + """ + api_key = get_api_key(tenant_config) + tax_id = (customer_data.get("tax_id") or "").upper().strip() + if not tax_id: + raise FacturapiError("Customer tax_id is required") + + # Try to find existing customer + existing_id = None + try: + result = _request("GET", "/customers", api_key, params={"search": tax_id}) + for c in result.get("data", []): + if (c.get("tax_id") or "").upper() == tax_id: + existing_id = c.get("id") + break + except FacturapiError as e: + logger.warning("Failed to search Facturapi customer: %s", e) + + is_foreign = bool(customer_data.get("country")) and customer_data["country"] != "MEX" + + payload = { + "legal_name": customer_data.get("legal_name", ""), + "email": customer_data.get("email"), + "address": { + "zip": customer_data.get("zip", "00000"), + }, + } + if is_foreign: + payload["tax_id"] = tax_id + payload["address"]["country"] = customer_data["country"] + else: + payload["tax_id"] = tax_id + if customer_data.get("tax_system"): + payload["tax_system"] = customer_data["tax_system"] + + if existing_id: + _request("PUT", f"/customers/{existing_id}", api_key, json_payload=payload) + return existing_id + + new_customer = _request("POST", "/customers", api_key, json_payload=payload) + return new_customer.get("id") + + +# ─── Invoices ─────────────────────────────────────────────────────────────── + +def create_invoice(tenant_config: dict, payload: dict) -> dict: + """Create and stamp an invoice in Facturapi. + + Returns the Facturapi invoice object. + """ + api_key = get_api_key(tenant_config) + 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: + """Cancel an invoice in Facturapi. + + Motive codes: + 01: errores con relacion (requires replacement_uuid) + 02: errores sin relacion + 03: no se llevo a cabo la operacion + 04: operacion nominativa relacionada en factura global + """ + api_key = get_api_key(tenant_config) + params = {"motive": motive} + if replacement_uuid: + params["replacement"] = replacement_uuid + return _request("DELETE", f"/invoices/{invoice_id}", api_key, params=params, timeout=60) + + +def download_xml(tenant_config: dict, invoice_id: str) -> bytes: + api_key = get_api_key(tenant_config) + return _download("GET", f"/invoices/{invoice_id}/xml", api_key) + + +def download_pdf(tenant_config: dict, invoice_id: str) -> bytes: + api_key = get_api_key(tenant_config) + return _download("GET", f"/invoices/{invoice_id}/pdf", api_key) + + +# ─── Helpers ───────────────────────────────────────────────────────────────── + +def is_lco_rejection(message: str) -> bool: + """Detect SAT LCO rejection (CSD not yet propagated).""" + if not message: + return False + msg = message.lower() + return any( + pattern in msg + for pattern in [ + "lco", + "no se encontro el rfc", + "rfc no registrado", + "lista de contribuyentes obligados", + "csd no registrado", + ] + ) + + +def to_cents(amount) -> int: + """Convert Decimal/float/None to integer cents for Facturapi.""" + if amount is None: + return 0 + return int(Decimal(str(amount)).quantize(Decimal("0.01")) * 100) diff --git a/pos/services/global_invoice.py b/pos/services/global_invoice.py index 41559d6..26f8ddf 100644 --- a/pos/services/global_invoice.py +++ b/pos/services/global_invoice.py @@ -8,7 +8,7 @@ monthly CFDI with InformacionGlobal per SAT requirements. from datetime import datetime from decimal import Decimal -from services.cfdi_builder import build_global_invoice_xml +from services.cfdi_facturapi_builder import build_global_invoice_payload from services.cfdi_queue import enqueue_cfdi, _generate_provisional_folio @@ -137,10 +137,10 @@ def generate_global_invoice(conn, tenant_config, year, month, branch_id=None, return {'error': 'NO_ELIGIBLE_SALES', 'message': f'No hay ventas elegibles para factura global de {month:02d}/{year}'} - xml = build_global_invoice_xml(sales, tenant_config, year, month) + payload = build_global_invoice_payload(sales, tenant_config, year, month) # Enqueue with sale_id=NULL (global invoice) - result = enqueue_cfdi(conn, None, 'ingreso', xml) + result = enqueue_cfdi(conn, None, 'ingreso', payload) cfdi_id = result['id'] cur = conn.cursor() @@ -167,7 +167,7 @@ def generate_global_invoice(conn, tenant_config, year, month, branch_id=None, 'sales_count': len(sales), 'total': sum(s['total'] for s in sales), 'provisional_folio': result['provisional_folio'], - 'xml': xml, + 'payload': payload, } diff --git a/pos/static/js/invoicing.js b/pos/static/js/invoicing.js index 4ff65b3..4a49bdc 100644 --- a/pos/static/js/invoicing.js +++ b/pos/static/js/invoicing.js @@ -62,6 +62,7 @@ const Invoicing = (() => { if (name === 'notas') loadNotas(); if (name === 'complementos') loadComplementos(); if (name === 'cancelaciones') loadCancelaciones(); + if (name === 'config') loadFacturapiStatus(); } // ---- Badge helpers ---- @@ -259,6 +260,35 @@ const Invoicing = (() => { } } + // ---- Facturapi status (config tab) ---- + async function loadFacturapiStatus() { + const container = document.getElementById('facturapi-status'); + if (!container) return; + container.innerHTML = 'Cargando...'; + try { + const status = await api('/facturapi/status'); + if (!status.configured) { + container.innerHTML = `

Facturapi no configurado. Configura la llave API en Configuración.

`; + return; + } + container.innerHTML = ` +
+
+
Organización
+
${status.legal_name || '-'}
+
${status.tax_id || ''}
+
+
+
CSD
+
${status.has_csd ? 'Activo' : 'Pendiente'}
+
+
+ `; + } catch (e) { + container.innerHTML = `

Error: ${e.message}

`; + } + } + // ---- Detail modal (uses modalDetalleOverlay) ---- async function showDetail(cfdiId) { const overlay = document.getElementById('modalDetalleOverlay'); @@ -300,10 +330,16 @@ const Invoicing = (() => { ${item.error_message ? `

Error: ${escapeHtml(item.error_message)}

` : ''} - ${(item.xml_signed || item.xml_unsigned) ? ` -
Vista previa XML
-
${escapeHtml(item.xml_signed || item.xml_unsigned)}
- ` : ''}`; + ${(item.xml_signed || item.payload_unsigned) ? ` +
${item.xml_signed ? 'Vista previa XML' : 'Payload Facturapi'}
+
${escapeHtml(item.xml_signed || item.payload_unsigned)}
+ ` : ''} + ${item.status === 'stamped' && item.external_id ? ` +
+ Descargar XML + Descargar PDF +
+ ` : ''} } // Wire the cancel button inside modal footer @@ -531,7 +567,7 @@ const Invoicing = (() => { window.notaCreditoPlaceholder = notaCreditoPlaceholder; return { - switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones, + switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones, loadFacturapiStatus, showDetail, showCancelModal, confirmCancel, processQueue, showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder, openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice, diff --git a/pos/templates/invoicing.html b/pos/templates/invoicing.html index f05b440..76ce3bb 100644 --- a/pos/templates/invoicing.html +++ b/pos/templates/invoicing.html @@ -865,6 +865,20 @@ + +
+
+ + + + + Facturapi (PAC) +
+
+

Cargando estado de Facturapi...

+
+
+
diff --git a/scripts/apply_facturapi_to_all_tenants.py b/scripts/apply_facturapi_to_all_tenants.py new file mode 100755 index 0000000..b26272e --- /dev/null +++ b/scripts/apply_facturapi_to_all_tenants.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Apply Facturapi configuration to all active tenant databases. + +Reads FACTURAPI_SECRET_KEY from the environment and stores it in each +tenant's tenant_config table as cfdi_facturapi_key. Also runs the +v4.3_facturapi.sql migration against every tenant DB. + +Usage: + export MASTER_DB_URL=postgresql://... + export TENANT_DB_URL_TEMPLATE="postgresql://.../{db_name}" + export FACTURAPI_SECRET_KEY=sk_user_xxxxxxxxxxxxxxxx + python3 scripts/apply_facturapi_to_all_tenants.py +""" + +import os +import sys +import psycopg2 + +MIGRATION_SQL = """ +ALTER TABLE cfdi_queue RENAME COLUMN xml_unsigned TO payload_unsigned; + +COMMENT ON COLUMN cfdi_queue.payload_unsigned IS 'Facturapi JSON payload (previously unsigned XML for Horux)'; +COMMENT ON COLUMN cfdi_queue.xml_signed IS 'Signed+stamped XML returned by Facturapi'; + +ALTER TABLE cfdi_queue ADD COLUMN IF NOT EXISTS external_id VARCHAR(64); +COMMENT ON COLUMN cfdi_queue.external_id IS 'Facturapi invoice id'; + +CREATE INDEX IF NOT EXISTS idx_cfdi_queue_external_id ON cfdi_queue(external_id); + +INSERT INTO tenant_config (key, value) +VALUES + ('cfdi_facturapi_key', ''), + ('cfdi_facturapi_org_id', ''), + ('cfdi_facturapi_customer_sync', 'true') +ON CONFLICT (key) DO NOTHING; + +UPDATE tenant_config SET value = %s WHERE key = 'cfdi_facturapi_key'; +""" + + +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" + ) + rows = cur.fetchall() + cur.close() + return rows + finally: + conn.close() + + +def apply_to_tenant(tenant_id, db_name, template_dsn, api_key): + dsn = template_dsn.format(db_name=db_name) + conn = psycopg2.connect(dsn) + try: + cur = conn.cursor() + cur.execute(MIGRATION_SQL, (api_key,)) + conn.commit() + cur.close() + print(f"[OK] tenant {tenant_id} ({db_name})") + except Exception as e: + print(f"[ERROR] tenant {tenant_id} ({db_name}): {e}") + finally: + conn.close() + + +def main(): + master_dsn = os.environ.get("MASTER_DB_URL") + template_dsn = os.environ.get("TENANT_DB_URL_TEMPLATE") + api_key = os.environ.get("FACTURAPI_SECRET_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 api_key: + print("Set FACTURAPI_SECRET_KEY", file=sys.stderr) + sys.exit(1) + + tenants = get_tenant_db_names(master_dsn) + if not tenants: + print("No active tenants found.") + return + + print(f"Applying Facturapi config to {len(tenants)} tenant(s)...") + for tenant_id, db_name in tenants: + apply_to_tenant(tenant_id, db_name, template_dsn, api_key) + + print("Done.") + + +if __name__ == "__main__": + main()