feat(pos): migrate CFDI timbrado from Horux to Facturapi
- Add Facturapi REST service (invoices, customers, orgs, cancel, downloads) - Add JSON payload builder for ingreso/egreso/pago/global invoices - Replace XML queue with Facturapi JSON queue (payload_unsigned, external_id) - Update invoicing blueprint with Facturapi config and download endpoints - Update global invoice service to use Facturapi payloads - Add migration v4.3_facturapi.sql and tenant rollout script - Update invoicing UI: payload preview, PDF/XML downloads, PAC status panel - Add FACTURAPI_USER_KEY to .env.example
This commit is contained in:
@@ -57,6 +57,13 @@ METABASE_ADMIN_EMAIL=admin@nexus.local
|
|||||||
METABASE_ADMIN_PASS=change-me-to-a-strong-password
|
METABASE_ADMIN_PASS=change-me-to-a-strong-password
|
||||||
METABASE_DB_PASS=metabase_secret
|
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
|
# CURRENCY
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ from datetime import datetime
|
|||||||
from flask import Blueprint, request, jsonify, g
|
from flask import Blueprint, request, jsonify, g
|
||||||
from middleware import require_auth
|
from middleware import require_auth
|
||||||
from tenant_db import get_tenant_conn
|
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 (
|
from services.cfdi_queue import (
|
||||||
enqueue_cfdi, process_queue, retry_failed,
|
enqueue_cfdi, process_queue, retry_failed,
|
||||||
cancel_cfdi, get_queue_status,
|
cancel_cfdi, get_queue_status,
|
||||||
)
|
)
|
||||||
|
from services import facturapi_service
|
||||||
from services.audit import log_action
|
from services.audit import log_action
|
||||||
|
|
||||||
invoicing_bp = Blueprint('invoicing', __name__, url_prefix='/pos/api/invoicing')
|
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'),
|
'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'),
|
||||||
'cp': config.get('tenant_cp', '00000'),
|
'cp': config.get('tenant_cp', '00000'),
|
||||||
'serie': config.get('cfdi_serie', 'A'),
|
'serie': config.get('cfdi_serie', 'A'),
|
||||||
'horux_api_url': config.get('cfdi_horux_api_url', ''),
|
'facturapi_key': config.get('cfdi_facturapi_key', ''),
|
||||||
'horux_api_key': config.get('cfdi_horux_api_key', ''),
|
'facturapi_org_id': config.get('cfdi_facturapi_org_id', ''),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Branch-level override
|
# 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]})'
|
'error': f'Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})'
|
||||||
}), 409
|
}), 409
|
||||||
|
|
||||||
# Build XML
|
# Build Facturapi payload
|
||||||
if cfdi_type == 'ingreso':
|
if cfdi_type == 'ingreso':
|
||||||
xml = build_ingreso_xml(sale, tenant_config, customer)
|
payload = build_ingreso_payload(sale, tenant_config, customer)
|
||||||
elif cfdi_type == 'egreso':
|
elif cfdi_type == 'egreso':
|
||||||
original_uuid = data.get('original_uuid')
|
original_uuid = data.get('original_uuid')
|
||||||
if not original_uuid:
|
if not original_uuid:
|
||||||
return jsonify({'error': 'original_uuid required for egreso'}), 400
|
return jsonify({'error': 'original_uuid required for egreso'}), 400
|
||||||
xml = build_egreso_xml(sale, tenant_config, customer, original_uuid)
|
payload = build_egreso_payload(sale, tenant_config, customer, original_uuid)
|
||||||
else:
|
else:
|
||||||
return jsonify({'error': f'Invalid CFDI type: {cfdi_type}'}), 400
|
return jsonify({'error': f'Invalid CFDI type: {cfdi_type}'}), 400
|
||||||
|
|
||||||
# Enqueue
|
# Enqueue
|
||||||
result = enqueue_cfdi(conn, sale_id, cfdi_type, xml)
|
result = enqueue_cfdi(conn, sale_id, cfdi_type, payload)
|
||||||
|
|
||||||
log_action(conn, 'CFDI_GENERATED', 'cfdi_queue', result['id'],
|
log_action(conn, 'CFDI_GENERATED', 'cfdi_queue', result['id'],
|
||||||
new_value={'sale_id': sale_id, 'type': cfdi_type,
|
new_value={'sale_id': sale_id, 'type': cfdi_type,
|
||||||
@@ -244,10 +247,10 @@ def get_queue_item(cfdi_id):
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
cur.execute("""
|
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.uuid_fiscal, q.status, q.retry_count, q.provisional_folio,
|
||||||
q.error_message, q.cancel_motive, q.cancel_replacement_uuid,
|
q.error_message, q.cancel_motive, q.cancel_replacement_uuid,
|
||||||
q.created_at, q.stamped_at
|
q.created_at, q.stamped_at, q.external_id
|
||||||
FROM cfdi_queue q WHERE q.id = %s
|
FROM cfdi_queue q WHERE q.id = %s
|
||||||
""", (cfdi_id,))
|
""", (cfdi_id,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
@@ -258,13 +261,14 @@ def get_queue_item(cfdi_id):
|
|||||||
|
|
||||||
item = {
|
item = {
|
||||||
'id': row[0], 'sale_id': row[1], 'type': row[2],
|
'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],
|
'uuid_fiscal': row[5], 'status': row[6],
|
||||||
'retry_count': row[7], 'provisional_folio': row[8],
|
'retry_count': row[7], 'provisional_folio': row[8],
|
||||||
'error_message': row[9], 'cancel_motive': row[10],
|
'error_message': row[9], 'cancel_motive': row[10],
|
||||||
'cancel_replacement_uuid': row[11],
|
'cancel_replacement_uuid': row[11],
|
||||||
'created_at': str(row[12]) if row[12] else None,
|
'created_at': str(row[12]) if row[12] else None,
|
||||||
'stamped_at': str(row[13]) if row[13] else None,
|
'stamped_at': str(row[13]) if row[13] else None,
|
||||||
|
'external_id': row[14],
|
||||||
}
|
}
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -281,19 +285,16 @@ def trigger_process_queue():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
tenant_config = _get_issuer_config(cur)
|
tenant_config = _get_issuer_config(cur)
|
||||||
horux_url = tenant_config.get('horux_api_url')
|
if not tenant_config.get('facturapi_key'):
|
||||||
horux_key = tenant_config.get('horux_api_key')
|
|
||||||
|
|
||||||
if not horux_url or not horux_key:
|
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.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 eligible failed items first
|
||||||
reset_count = retry_failed(conn)
|
reset_count = retry_failed(conn)
|
||||||
|
|
||||||
# Process the queue
|
# Process the queue
|
||||||
result = process_queue(conn, horux_url, horux_key)
|
result = process_queue(conn, tenant_config)
|
||||||
result['retries_reset'] = reset_count
|
result['retries_reset'] = reset_count
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -338,8 +339,7 @@ def cancel_invoice(cfdi_id):
|
|||||||
tenant_config = _get_issuer_config(cur)
|
tenant_config = _get_issuer_config(cur)
|
||||||
result = cancel_cfdi(
|
result = cancel_cfdi(
|
||||||
conn, cfdi_id, motive, replacement_uuid,
|
conn, cfdi_id, motive, replacement_uuid,
|
||||||
tenant_config.get('horux_api_url'),
|
tenant_config=tenant_config,
|
||||||
tenant_config.get('horux_api_key'),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
log_action(conn, 'CFDI_CANCELLED', 'cfdi_queue', cfdi_id,
|
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),
|
'total': sum(s['total'] for s in sales),
|
||||||
'sales': [{'id': s['id'], 'total': s['total'], 'created_at': s['created_at']} 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/<int:cfdi_id>/<doc_type>', methods=['GET'])
|
||||||
|
@require_auth('invoicing.view')
|
||||||
|
def facturapi_download(cfdi_id, doc_type):
|
||||||
|
"""Download PDF or XML for a stamped CFDI from Facturapi.
|
||||||
|
|
||||||
|
doc_type: 'pdf' | 'xml'
|
||||||
|
"""
|
||||||
|
if doc_type not in ('pdf', 'xml'):
|
||||||
|
return jsonify({'error': "doc_type must be 'pdf' or 'xml'"}), 400
|
||||||
|
|
||||||
|
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}"'},
|
||||||
|
)
|
||||||
|
|||||||
34
pos/migrations/v4.3_facturapi.sql
Normal file
34
pos/migrations/v4.3_facturapi.sql
Normal file
@@ -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';
|
||||||
@@ -7,3 +7,4 @@ gunicorn>=22.0
|
|||||||
redis>=5.0
|
redis>=5.0
|
||||||
meilisearch>=0.40
|
meilisearch>=0.40
|
||||||
orjson
|
orjson
|
||||||
|
facturapi>=1.0
|
||||||
|
|||||||
243
pos/services/cfdi_facturapi_builder.py
Normal file
243
pos/services/cfdi_facturapi_builder.py
Normal file
@@ -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
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
# /home/Autopartes/pos/services/cfdi_queue.py
|
# /home/Autopartes/pos/services/cfdi_queue.py
|
||||||
"""CFDI queue service: manages the timbrado pipeline.
|
"""CFDI queue service: manages the Facturapi timbrado pipeline.
|
||||||
|
|
||||||
Flow:
|
Flow:
|
||||||
1. enqueue_cfdi() — inserts XML into cfdi_queue with status='pending'
|
1. enqueue_cfdi() — inserts Facturapi JSON payload into cfdi_queue with status='pending'
|
||||||
2. process_queue() — sends pending items to Horux API, updates status
|
2. process_queue() — sends pending items to Facturapi, updates status
|
||||||
3. retry_failed() — retries failed items with exponential backoff
|
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:
|
Facturapi endpoints used:
|
||||||
POST /api/nexus/cfdi/stamp — send unsigned XML, receive signed+timbrado
|
POST /v2/invoices — create and stamp an invoice
|
||||||
GET /api/nexus/cfdi/status/:uuid — check timbrado status
|
GET /v2/invoices/:id — fetch invoice metadata
|
||||||
POST /api/nexus/cfdi/cancel — cancel CFDI with SAT motive code
|
DELETE /v2/invoices/:id — cancel with SAT motive
|
||||||
|
|
||||||
Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries)
|
Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import requests
|
from services import facturapi_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -29,10 +29,7 @@ MAX_RETRIES = len(BACKOFF_INTERVALS)
|
|||||||
|
|
||||||
|
|
||||||
def _generate_provisional_folio(conn):
|
def _generate_provisional_folio(conn):
|
||||||
"""Generate a provisional folio like PRE-00001.
|
"""Generate a provisional folio like PRE-00001."""
|
||||||
|
|
||||||
Uses the cfdi_queue table's max id to avoid collisions.
|
|
||||||
"""
|
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
|
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
|
||||||
seq = cur.fetchone()[0]
|
seq = cur.fetchone()[0]
|
||||||
@@ -40,14 +37,14 @@ def _generate_provisional_folio(conn):
|
|||||||
return f'PRE-{seq:05d}'
|
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.
|
"""Add a CFDI to the timbrado queue.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
conn: psycopg2 connection
|
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'
|
cfdi_type: 'ingreso' | 'egreso' | 'pago'
|
||||||
xml: str (unsigned XML from cfdi_builder)
|
payload: dict (Facturapi JSON payload) or str (JSON string)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: {id, sale_id, type, status, provisional_folio}
|
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)
|
provisional_folio = _generate_provisional_folio(conn)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
payload_json = payload if isinstance(payload, str) else json.dumps(payload)
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO cfdi_queue
|
INSERT INTO cfdi_queue
|
||||||
(sale_id, type, xml_unsigned, status, provisional_folio)
|
(sale_id, type, payload_unsigned, status, provisional_folio)
|
||||||
VALUES (%s, %s, %s, 'pending', %s)
|
VALUES (%s, %s, %s, 'pending', %s)
|
||||||
RETURNING id, created_at
|
RETURNING id, created_at
|
||||||
""", (sale_id, cfdi_type, xml, provisional_folio))
|
""", (sale_id, cfdi_type, payload_json, provisional_folio))
|
||||||
cfdi_id, created_at = cur.fetchone()
|
cfdi_id, created_at = cur.fetchone()
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|
||||||
@@ -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.
|
"""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
|
the record with the signed XML and UUID fiscal. On failure, increments
|
||||||
retry_count and records the error.
|
retry_count and records the error.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
conn: psycopg2 connection
|
conn: psycopg2 connection
|
||||||
horux_api_url: str base URL for Horux API (e.g. 'https://horux.example.com')
|
tenant_config: dict with facturapi_key (and optional facturapi_org_id)
|
||||||
api_key: str Horux API key
|
dry_run: if True, validates payload without stamping
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: {processed: int, stamped: int, failed: int, details: [...]}
|
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 = conn.cursor()
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT id, sale_id, type, xml_unsigned, retry_count
|
SELECT id, sale_id, type, payload_unsigned, retry_count
|
||||||
FROM cfdi_queue
|
FROM cfdi_queue
|
||||||
WHERE status IN ('pending', 'failed')
|
WHERE status IN ('pending', 'failed')
|
||||||
AND retry_count < %s
|
AND retry_count < %s
|
||||||
@@ -103,7 +102,12 @@ def process_queue(conn, horux_api_url, api_key):
|
|||||||
|
|
||||||
results = {'processed': 0, 'stamped': 0, 'failed': 0, 'details': []}
|
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
|
results['processed'] += 1
|
||||||
|
|
||||||
# Update status to 'sending'
|
# Update status to 'sending'
|
||||||
@@ -113,54 +117,47 @@ def process_queue(conn, horux_api_url, api_key):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
payload = json.loads(payload_unsigned or '{}')
|
||||||
f'{horux_api_url}/api/nexus/cfdi/stamp',
|
if not payload:
|
||||||
headers={
|
raise ValueError("Empty payload in queue item")
|
||||||
'Authorization': f'Bearer {api_key}',
|
|
||||||
'Content-Type': 'application/xml',
|
|
||||||
},
|
|
||||||
data=xml_unsigned.encode('utf-8'),
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
if dry_run:
|
||||||
data = response.json()
|
# TODO: Facturapi dry-run validation (not officially supported)
|
||||||
uuid_fiscal = data.get('uuid')
|
# For now we just skip the API call and mark as stamped with a fake UUID
|
||||||
xml_signed = data.get('xml', '')
|
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')
|
||||||
|
|
||||||
|
# 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 = ''
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
UPDATE cfdi_queue
|
UPDATE cfdi_queue
|
||||||
SET status = 'stamped',
|
SET status = 'stamped',
|
||||||
xml_signed = %s,
|
xml_signed = %s,
|
||||||
uuid_fiscal = %s,
|
uuid_fiscal = %s,
|
||||||
|
external_id = %s,
|
||||||
stamped_at = NOW(),
|
stamped_at = NOW(),
|
||||||
error_message = NULL
|
error_message = NULL
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
""", (xml_signed, uuid_fiscal, cfdi_id))
|
""", (xml_signed_str, uuid_fiscal, invoice_id, cfdi_id))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
results['stamped'] += 1
|
results['stamped'] += 1
|
||||||
results['details'].append({
|
results['details'].append({
|
||||||
'id': cfdi_id, 'status': 'stamped', 'uuid': uuid_fiscal
|
'id': cfdi_id, 'status': 'stamped',
|
||||||
})
|
'uuid': uuid_fiscal, 'external_id': invoice_id,
|
||||||
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()
|
|
||||||
|
|
||||||
results['failed'] += 1
|
|
||||||
results['details'].append({
|
|
||||||
'id': cfdi_id, 'status': 'failed', 'error': error_msg
|
|
||||||
})
|
})
|
||||||
|
|
||||||
except requests.RequestException as e:
|
except Exception as e:
|
||||||
error_msg = f'Connection error: {str(e)[:500]}'
|
error_msg = f'{type(e).__name__}: {str(e)[:500]}'
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
UPDATE cfdi_queue
|
UPDATE cfdi_queue
|
||||||
SET status = 'failed',
|
SET status = 'failed',
|
||||||
@@ -180,20 +177,13 @@ def process_queue(conn, horux_api_url, api_key):
|
|||||||
|
|
||||||
|
|
||||||
def retry_failed(conn):
|
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
|
Uses exponential backoff: item is eligible for retry only if enough
|
||||||
time has passed since the last attempt based on retry_count.
|
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()
|
cur = conn.cursor()
|
||||||
|
|
||||||
# For each failed item, check if enough time has passed for its retry level
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT id, retry_count, created_at
|
SELECT id, retry_count, created_at
|
||||||
FROM cfdi_queue
|
FROM cfdi_queue
|
||||||
@@ -206,15 +196,15 @@ def retry_failed(conn):
|
|||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
|
|
||||||
for cfdi_id, retry_count, created_at in items:
|
for cfdi_id, retry_count, created_at in items:
|
||||||
# Calculate required wait time based on retry count
|
|
||||||
if retry_count < len(BACKOFF_INTERVALS):
|
if retry_count < len(BACKOFF_INTERVALS):
|
||||||
wait_seconds = BACKOFF_INTERVALS[retry_count]
|
wait_seconds = BACKOFF_INTERVALS[retry_count]
|
||||||
else:
|
else:
|
||||||
wait_seconds = BACKOFF_INTERVALS[-1] # max backoff
|
wait_seconds = BACKOFF_INTERVALS[-1]
|
||||||
|
|
||||||
# Check if enough time has passed (use created_at as approximation)
|
# Use created_at as approximation for last attempt.
|
||||||
# In production, you'd track last_attempt_at separately
|
# In production, track last_attempt_at separately.
|
||||||
if True: # Always eligible for manual retry trigger
|
elapsed = (now - created_at).total_seconds()
|
||||||
|
if elapsed >= wait_seconds:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
UPDATE cfdi_queue SET status = 'pending' WHERE id = %s
|
UPDATE cfdi_queue SET status = 'pending' WHERE id = %s
|
||||||
""", (cfdi_id,))
|
""", (cfdi_id,))
|
||||||
@@ -226,8 +216,8 @@ def retry_failed(conn):
|
|||||||
|
|
||||||
|
|
||||||
def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
||||||
horux_api_url=None, api_key=None):
|
tenant_config=None):
|
||||||
"""Cancel a stamped CFDI via Horux API.
|
"""Cancel a stamped CFDI via Facturapi.
|
||||||
|
|
||||||
SAT cancellation motives:
|
SAT cancellation motives:
|
||||||
01: Comprobante emitido con errores con relacion (requires replacement UUID)
|
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)
|
cfdi_id: int (cfdi_queue.id)
|
||||||
motive: str ('01', '02', '03', '04')
|
motive: str ('01', '02', '03', '04')
|
||||||
replacement_uuid: str (required if motive == '01')
|
replacement_uuid: str (required if motive == '01')
|
||||||
horux_api_url: str (optional, skips API call if None — for offline)
|
tenant_config: dict with facturapi_key
|
||||||
api_key: str (optional)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: {id, status, message}
|
dict: {id, status, message}
|
||||||
@@ -258,13 +247,13 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
cur.execute("""
|
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,))
|
""", (cfdi_id,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise ValueError(f"CFDI queue item {cfdi_id} not found")
|
raise ValueError(f"CFDI queue item {cfdi_id} not found")
|
||||||
|
|
||||||
_, uuid_fiscal, current_status = row
|
_, uuid_fiscal, external_id, current_status = row
|
||||||
|
|
||||||
if current_status == 'cancelled':
|
if current_status == 'cancelled':
|
||||||
raise ValueError("CFDI is already cancelled")
|
raise ValueError("CFDI is already cancelled")
|
||||||
@@ -280,27 +269,20 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
|||||||
cur.close()
|
cur.close()
|
||||||
return {'id': cfdi_id, 'status': 'cancelled', 'message': 'Cancelled locally (was not stamped)'}
|
return {'id': cfdi_id, 'status': 'cancelled', 'message': 'Cancelled locally (was not stamped)'}
|
||||||
|
|
||||||
# Send cancel request to Horux
|
if not tenant_config or not tenant_config.get('facturapi_key'):
|
||||||
if horux_api_url and api_key:
|
cur.close()
|
||||||
try:
|
raise ValueError("Facturapi key not configured for tenant")
|
||||||
payload = {
|
|
||||||
'uuid': uuid_fiscal,
|
|
||||||
'motive': motive,
|
|
||||||
}
|
|
||||||
if replacement_uuid:
|
|
||||||
payload['replacement_uuid'] = replacement_uuid
|
|
||||||
|
|
||||||
response = requests.post(
|
if not external_id:
|
||||||
f'{horux_api_url}/api/nexus/cfdi/cancel',
|
cur.close()
|
||||||
headers={
|
raise ValueError("Cannot cancel: no Facturapi invoice id stored")
|
||||||
'Authorization': f'Bearer {api_key}',
|
|
||||||
'Content-Type': 'application/json',
|
try:
|
||||||
},
|
facturapi_service.cancel_invoice(
|
||||||
json=payload,
|
tenant_config, external_id, motive,
|
||||||
timeout=30,
|
replacement_uuid=replacement_uuid,
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
UPDATE cfdi_queue
|
UPDATE cfdi_queue
|
||||||
SET status = 'cancelled',
|
SET status = 'cancelled',
|
||||||
@@ -316,8 +298,9 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
|||||||
'status': 'cancelled',
|
'status': 'cancelled',
|
||||||
'message': f'Cancelled with SAT (motive {motive})',
|
'message': f'Cancelled with SAT (motive {motive})',
|
||||||
}
|
}
|
||||||
else:
|
|
||||||
error_msg = f'Cancel failed: HTTP {response.status_code}: {response.text[:500]}'
|
except Exception as e:
|
||||||
|
error_msg = f'Cancel failed: {str(e)[:500]}'
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
UPDATE cfdi_queue
|
UPDATE cfdi_queue
|
||||||
SET error_message = %s
|
SET error_message = %s
|
||||||
@@ -327,42 +310,9 @@ def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
|||||||
cur.close()
|
cur.close()
|
||||||
raise ValueError(error_msg)
|
raise ValueError(error_msg)
|
||||||
|
|
||||||
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'
|
|
||||||
WHERE id = %s
|
|
||||||
""", (motive, replacement_uuid, cfdi_id))
|
|
||||||
conn.commit()
|
|
||||||
cur.close()
|
|
||||||
return {
|
|
||||||
'id': cfdi_id,
|
|
||||||
'status': 'cancelled',
|
|
||||||
'message': 'Cancelled offline, pending SAT sync',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_queue_status(conn, filters=None):
|
def get_queue_status(conn, filters=None):
|
||||||
"""Get CFDI queue items with optional filters.
|
"""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: {...}}
|
|
||||||
"""
|
|
||||||
filters = filters or {}
|
filters = filters or {}
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
@@ -392,7 +342,7 @@ def get_queue_status(conn, filters=None):
|
|||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
|
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
|
||||||
q.retry_count, q.provisional_folio, q.error_message,
|
q.retry_count, q.provisional_folio, q.error_message,
|
||||||
q.cancel_motive, q.created_at, q.stamped_at
|
q.cancel_motive, q.created_at, q.stamped_at, q.external_id
|
||||||
FROM cfdi_queue q
|
FROM cfdi_queue q
|
||||||
WHERE {where}
|
WHERE {where}
|
||||||
ORDER BY q.created_at DESC
|
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],
|
'error_message': r[7], 'cancel_motive': r[8],
|
||||||
'created_at': str(r[9]) if r[9] else None,
|
'created_at': str(r[9]) if r[9] else None,
|
||||||
'stamped_at': str(r[10]) if r[10] else None,
|
'stamped_at': str(r[10]) if r[10] else None,
|
||||||
|
'external_id': r[11],
|
||||||
})
|
})
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|||||||
329
pos/services/facturapi_service.py
Normal file
329
pos/services/facturapi_service.py
Normal file
@@ -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)
|
||||||
@@ -8,7 +8,7 @@ monthly CFDI with InformacionGlobal per SAT requirements.
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
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
|
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',
|
return {'error': 'NO_ELIGIBLE_SALES',
|
||||||
'message': f'No hay ventas elegibles para factura global de {month:02d}/{year}'}
|
'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)
|
# 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']
|
cfdi_id = result['id']
|
||||||
|
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
@@ -167,7 +167,7 @@ def generate_global_invoice(conn, tenant_config, year, month, branch_id=None,
|
|||||||
'sales_count': len(sales),
|
'sales_count': len(sales),
|
||||||
'total': sum(s['total'] for s in sales),
|
'total': sum(s['total'] for s in sales),
|
||||||
'provisional_folio': result['provisional_folio'],
|
'provisional_folio': result['provisional_folio'],
|
||||||
'xml': xml,
|
'payload': payload,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const Invoicing = (() => {
|
|||||||
if (name === 'notas') loadNotas();
|
if (name === 'notas') loadNotas();
|
||||||
if (name === 'complementos') loadComplementos();
|
if (name === 'complementos') loadComplementos();
|
||||||
if (name === 'cancelaciones') loadCancelaciones();
|
if (name === 'cancelaciones') loadCancelaciones();
|
||||||
|
if (name === 'config') loadFacturapiStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Badge helpers ----
|
// ---- 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 = `<p style="color:var(--color-error);">Facturapi no configurado. Configura la llave API en Configuración.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = `
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4);">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">Organización</div>
|
||||||
|
<div style="font-weight:var(--font-weight-semibold);">${status.legal_name || '-'}</div>
|
||||||
|
<div style="font-family:var(--font-mono);font-size:var(--text-body-sm);">${status.tax_id || ''}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">CSD</div>
|
||||||
|
<div style="font-weight:var(--font-weight-semibold);">${status.has_csd ? '<span style="color:var(--color-success);">Activo</span>' : '<span style="color:var(--color-error);">Pendiente</span>'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = `<p style="color:var(--color-error);">Error: ${e.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Detail modal (uses modalDetalleOverlay) ----
|
// ---- Detail modal (uses modalDetalleOverlay) ----
|
||||||
async function showDetail(cfdiId) {
|
async function showDetail(cfdiId) {
|
||||||
const overlay = document.getElementById('modalDetalleOverlay');
|
const overlay = document.getElementById('modalDetalleOverlay');
|
||||||
@@ -300,10 +330,16 @@ const Invoicing = (() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${item.error_message ? `<p style="color:var(--color-error);margin-bottom:var(--space-3);"><strong>Error:</strong> ${escapeHtml(item.error_message)}</p>` : ''}
|
${item.error_message ? `<p style="color:var(--color-error);margin-bottom:var(--space-3);"><strong>Error:</strong> ${escapeHtml(item.error_message)}</p>` : ''}
|
||||||
${(item.xml_signed || item.xml_unsigned) ? `
|
${(item.xml_signed || item.payload_unsigned) ? `
|
||||||
<div style="font-size:var(--text-caption); color:var(--color-text-muted); text-transform:uppercase; letter-spacing:var(--tracking-widest); margin-bottom:var(--space-2);">Vista previa XML</div>
|
<div style="font-size:var(--text-caption); color:var(--color-text-muted); text-transform:uppercase; letter-spacing:var(--tracking-widest); margin-bottom:var(--space-2);">${item.xml_signed ? 'Vista previa XML' : 'Payload Facturapi'}</div>
|
||||||
<pre style="background:var(--color-surface-3); border:1px solid var(--color-border); border-radius:var(--radius-md); padding:var(--space-4); font-family:var(--font-mono); font-size:11px; color:var(--color-text-secondary); overflow-x:auto; max-height:200px; line-height:1.6;">${escapeHtml(item.xml_signed || item.xml_unsigned)}</pre>
|
<pre style="background:var(--color-surface-3); border:1px solid var(--color-border); border-radius:var(--radius-md); padding:var(--space-4); font-family:var(--font-mono); font-size:11px; color:var(--color-text-secondary); overflow-x:auto; max-height:200px; line-height:1.6;">${escapeHtml(item.xml_signed || item.payload_unsigned)}</pre>
|
||||||
` : ''}`;
|
` : ''}
|
||||||
|
${item.status === 'stamped' && item.external_id ? `
|
||||||
|
<div style="display:flex;gap:var(--space-3);margin-top:var(--space-4);">
|
||||||
|
<a class="btn btn--ghost btn--sm" href="${API}/facturapi/download/${item.id}/xml" target="_blank">Descargar XML</a>
|
||||||
|
<a class="btn btn--ghost btn--sm" href="${API}/facturapi/download/${item.id}/pdf" target="_blank">Descargar PDF</a>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire the cancel button inside modal footer
|
// Wire the cancel button inside modal footer
|
||||||
@@ -531,7 +567,7 @@ const Invoicing = (() => {
|
|||||||
window.notaCreditoPlaceholder = notaCreditoPlaceholder;
|
window.notaCreditoPlaceholder = notaCreditoPlaceholder;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones,
|
switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones, loadFacturapiStatus,
|
||||||
showDetail, showCancelModal, confirmCancel, processQueue,
|
showDetail, showCancelModal, confirmCancel, processQueue,
|
||||||
showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder,
|
showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder,
|
||||||
openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice,
|
openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice,
|
||||||
|
|||||||
@@ -865,6 +865,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- FACTURAPI STATUS -->
|
||||||
|
<div class="config-section" style="grid-column: span 2;">
|
||||||
|
<div class="config-section__header">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
|
</svg>
|
||||||
|
<span class="config-section__title">Facturapi (PAC)</span>
|
||||||
|
</div>
|
||||||
|
<div class="config-section__body" id="facturapi-status">
|
||||||
|
<p style="color:var(--color-text-muted);">Cargando estado de Facturapi...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- CONFIGURACIÓN DE SERIES — full width -->
|
<!-- CONFIGURACIÓN DE SERIES — full width -->
|
||||||
<div class="config-section" style="grid-column: span 2;">
|
<div class="config-section" style="grid-column: span 2;">
|
||||||
<div class="config-section__header">
|
<div class="config-section__header">
|
||||||
|
|||||||
96
scripts/apply_facturapi_to_all_tenants.py
Executable file
96
scripts/apply_facturapi_to_all_tenants.py
Executable file
@@ -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()
|
||||||
Reference in New Issue
Block a user