- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos - F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado - F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment - F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings) - F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant - Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas - Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant) - Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant - Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
846 lines
28 KiB
Python
846 lines
28 KiB
Python
# /home/Autopartes/pos/services/cfdi_queue.py
|
|
"""CFDI queue service: manages the Facturapi timbrado pipeline.
|
|
|
|
Flow:
|
|
1. enqueue_cfdi() — inserts Facturapi JSON payload into cfdi_queue with status='pending'
|
|
2. enqueue_pending_complements() — enqueues the missing payment complements
|
|
(CFDI type "P") of PPD invoices and the credit notes (type "E") of returns
|
|
3. process_queue() — sends pending items to Facturapi, updates status
|
|
4. retry_failed() — retries failed items with exponential backoff
|
|
5. cancel_cfdi() — cancels a stamped CFDI via Facturapi
|
|
|
|
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
|
|
from datetime import datetime, timedelta, timezone
|
|
from decimal import ROUND_HALF_UP, Decimal
|
|
|
|
from services import facturapi_service
|
|
from services.cfdi_facturapi_builder import build_egreso_payload, build_pago_payload
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Backoff intervals in seconds: 5s, 30s, 2m, 10m, 1h
|
|
BACKOFF_INTERVALS = [5, 30, 120, 600, 3600]
|
|
MAX_RETRIES = len(BACKOFF_INTERVALS)
|
|
|
|
# Movements older than this window are only reconciled on explicit backfill.
|
|
COMPLEMENT_LOOKBACK_DAYS = 7
|
|
|
|
TWO = Decimal("0.01")
|
|
|
|
|
|
|
|
|
|
def _send_cfdi_pdf_wa(conn, invoice_id_external, sale_id, tenant_config):
|
|
"""Descarga el PDF timbrado y lo envía por WhatsApp al cliente (si tiene teléfono)."""
|
|
try:
|
|
from services import facturapi_service
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT c.phone FROM sales s
|
|
JOIN customers c ON c.id = s.customer_id
|
|
WHERE s.id = %s
|
|
""", (sale_id,))
|
|
row = cur.fetchone()
|
|
cur.close()
|
|
if not row or not row[0]:
|
|
return
|
|
phone = row[0]
|
|
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'whatsapp_%' OR key LIKE 'wa_%'")
|
|
raw = {k: v for k, v in cur.fetchall()}
|
|
cur.close()
|
|
cfg = {
|
|
'provider': raw.get('whatsapp_provider', 'baileys'),
|
|
'phone_number_id': raw.get('wa_phone_number_id', ''),
|
|
'access_token': raw.get('wa_access_token', ''),
|
|
'bridge_url': raw.get('whatsapp_bridge_url', ''),
|
|
}
|
|
# Solo enviar si hay proveedor configurado (Meta o Baileys con bridge)
|
|
if cfg['provider'] != 'meta' and not cfg['bridge_url']:
|
|
return
|
|
|
|
pdf_bytes = facturapi_service.download_pdf(tenant_config, invoice_id_external)
|
|
import base64 as _b64
|
|
b64 = _b64.b64encode(pdf_bytes).decode()
|
|
filename = f"CFDI_{sale_id}.pdf"
|
|
|
|
from services.whatsapp_service import wa_send_document
|
|
result = wa_send_document(cfg, phone, b64, filename,
|
|
caption="Adjuntamos tu factura CFDI. Gracias por tu compra.")
|
|
try:
|
|
from services.audit import log_action
|
|
log_action(conn, 'CFDI_PDF_SENT', 'sale', sale_id,
|
|
new_value={'result': result, 'phone': phone})
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
try:
|
|
logger.warning("PDF WA send failed for sale %s: %s", sale_id, e)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _generate_provisional_folio(conn):
|
|
"""Generate a provisional folio like PRE-00001.
|
|
|
|
Uses a transaction-level advisory lock to avoid duplicate provisional
|
|
folios when multiple CFDIs are enqueued concurrently.
|
|
"""
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT pg_advisory_xact_lock(hashtext('cfdi_provisional_folio'))")
|
|
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
|
|
seq = cur.fetchone()[0]
|
|
cur.close()
|
|
return f"PRE-{seq:05d}"
|
|
|
|
|
|
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), may be None for global invoices
|
|
cfdi_type: 'ingreso' | 'egreso' | 'pago'
|
|
payload: dict (Facturapi JSON payload) or str (JSON string)
|
|
|
|
Returns:
|
|
dict: {id, sale_id, type, status, provisional_folio}
|
|
"""
|
|
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, payload_unsigned, status, provisional_folio)
|
|
VALUES (%s, %s, %s, 'pending', %s)
|
|
RETURNING id, created_at
|
|
""",
|
|
(sale_id, cfdi_type, payload_json, provisional_folio),
|
|
)
|
|
cfdi_id, created_at = cur.fetchone()
|
|
cur.close()
|
|
|
|
return {
|
|
"id": cfdi_id,
|
|
"sale_id": sale_id,
|
|
"type": cfdi_type,
|
|
"status": "pending",
|
|
"provisional_folio": provisional_folio,
|
|
"created_at": str(created_at),
|
|
}
|
|
|
|
|
|
# ─── Payment complements (REP) and credit notes reconciliation ──────────────
|
|
|
|
|
|
def _to_dec(val):
|
|
if val is None:
|
|
return Decimal("0")
|
|
return Decimal(str(val))
|
|
|
|
|
|
def _payload_customer(payload):
|
|
"""Adapt the receptor block of a stored CFDI payload to builder input."""
|
|
customer = payload.get("customer") or {}
|
|
tax_id = customer.get("tax_id")
|
|
if not tax_id:
|
|
return None
|
|
address = customer.get("address") or {}
|
|
return {
|
|
"rfc": tax_id,
|
|
"razon_social": customer.get("legal_name"),
|
|
"regimen_fiscal": customer.get("tax_system"),
|
|
"cp": address.get("zip"),
|
|
}
|
|
|
|
|
|
def _pago_related_amounts(payload):
|
|
"""Amounts of the related documents of a stored pago payload."""
|
|
amounts = []
|
|
for complement in payload.get("complements") or []:
|
|
data = complement.get("data")
|
|
blocks = data if isinstance(data, list) else [data]
|
|
for block in blocks:
|
|
if not isinstance(block, dict):
|
|
continue
|
|
for doc in block.get("related_documents") or []:
|
|
try:
|
|
amounts.append(round(float(doc.get("amount") or 0), 2))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
return amounts
|
|
|
|
|
|
def _items_signature(payload):
|
|
"""Comparable signature of a payload's concepts (product key, qty, price)."""
|
|
signature = []
|
|
for item in payload.get("items") or []:
|
|
product = item.get("product") or {}
|
|
try:
|
|
signature.append(
|
|
(
|
|
str(product.get("product_key") or ""),
|
|
int(item.get("quantity") or 0),
|
|
round(float(product.get("price") or 0), 2),
|
|
)
|
|
)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
return tuple(sorted(signature))
|
|
|
|
|
|
def _stored_signatures(cur, sale_id, cfdi_type):
|
|
"""Signatures/amounts of the non-cancelled CFDIs of a sale, for dedupe."""
|
|
cur.execute(
|
|
"""
|
|
SELECT payload_unsigned FROM cfdi_queue
|
|
WHERE sale_id = %s AND type = %s AND status <> 'cancelled'
|
|
""",
|
|
(sale_id, cfdi_type),
|
|
)
|
|
values = []
|
|
for (payload_unsigned,) in cur.fetchall():
|
|
try:
|
|
payload = json.loads(payload_unsigned or "{}")
|
|
except ValueError:
|
|
continue
|
|
if cfdi_type == "pago":
|
|
values.extend(_pago_related_amounts(payload))
|
|
else:
|
|
signature = _items_signature(payload)
|
|
if signature:
|
|
values.append(signature)
|
|
return values
|
|
|
|
|
|
def _enqueue_pending_pagos(conn, cur, tenant_config, since_dt):
|
|
"""Enqueue payment complements for every payment of a stamped PPD invoice."""
|
|
cur.execute(
|
|
"""
|
|
SELECT q.sale_id, q.uuid_fiscal, q.payload_unsigned,
|
|
s.total, s.subtotal, s.discount_total, s.tax_total
|
|
FROM cfdi_queue q
|
|
JOIN sales s ON s.id = q.sale_id
|
|
WHERE q.type = 'ingreso' AND q.status = 'stamped'
|
|
AND q.uuid_fiscal IS NOT NULL AND q.sale_id IS NOT NULL
|
|
AND (%s::timestamptz IS NULL OR EXISTS (
|
|
SELECT 1 FROM sale_payments sp
|
|
WHERE sp.sale_id = q.sale_id
|
|
AND sp.amount > 0
|
|
AND sp.created_at >= %s))
|
|
ORDER BY q.sale_id
|
|
""",
|
|
(since_dt, since_dt),
|
|
)
|
|
|
|
created = []
|
|
for sale_id, original_uuid, payload_unsigned, total, subtotal, discount_total, tax_total in cur.fetchall():
|
|
try:
|
|
invoice_payload = json.loads(payload_unsigned or "{}")
|
|
except ValueError:
|
|
continue
|
|
if str(invoice_payload.get("payment_method") or "").upper() != "PPD":
|
|
continue
|
|
|
|
customer = _payload_customer(invoice_payload)
|
|
if not customer:
|
|
continue
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id, amount, method, created_at
|
|
FROM sale_payments
|
|
WHERE sale_id = %s AND amount > 0
|
|
ORDER BY id
|
|
""",
|
|
(sale_id,),
|
|
)
|
|
payments = cur.fetchall()
|
|
if not payments:
|
|
continue
|
|
|
|
already = _stored_signatures(cur, sale_id, "pago")
|
|
|
|
base_total = _to_dec(subtotal) - _to_dec(discount_total)
|
|
tax_rate = (_to_dec(tax_total) / base_total) if base_total > 0 else Decimal("0.16")
|
|
|
|
balance = _to_dec(total)
|
|
for installment, (payment_id, amount, method, created_at) in enumerate(payments, start=1):
|
|
paid = _to_dec(amount)
|
|
previous_balance = balance if balance > Decimal("0") else Decimal("0")
|
|
balance -= paid
|
|
if paid <= 0:
|
|
continue
|
|
if round(float(paid), 2) in already:
|
|
already.remove(round(float(paid), 2))
|
|
continue
|
|
if since_dt and created_at and created_at < since_dt:
|
|
continue
|
|
|
|
try:
|
|
payload = build_pago_payload(
|
|
{
|
|
"id": payment_id,
|
|
"amount": paid,
|
|
"payment_method": method,
|
|
"date": created_at,
|
|
"installment": installment,
|
|
"last_balance": previous_balance,
|
|
"tax_rate": tax_rate,
|
|
},
|
|
tenant_config,
|
|
customer,
|
|
original_uuid,
|
|
idempotency_key=f"nexus-rep-{payment_id}",
|
|
)
|
|
queued = enqueue_cfdi(conn, sale_id, "pago", payload)
|
|
except Exception as e:
|
|
logger.warning("Could not enqueue payment complement for sale_payment %s: %s", payment_id, e)
|
|
continue
|
|
|
|
created.append(
|
|
{
|
|
"cfdi_id": queued["id"],
|
|
"sale_id": sale_id,
|
|
"sale_payment_id": payment_id,
|
|
"amount": float(paid),
|
|
"installment": installment,
|
|
"original_uuid": original_uuid,
|
|
}
|
|
)
|
|
return created
|
|
|
|
|
|
def _enqueue_pending_egresos(conn, cur, tenant_config, since_dt):
|
|
"""Enqueue credit notes (egreso) for returns of already stamped sales."""
|
|
cur.execute(
|
|
"""
|
|
SELECT r.id, r.sale_id, r.created_at, s.currency, s.exchange_rate,
|
|
ing.uuid_fiscal, ing.payload_unsigned
|
|
FROM returns r
|
|
JOIN sales s ON s.id = r.sale_id
|
|
JOIN LATERAL (
|
|
SELECT q.uuid_fiscal, q.payload_unsigned
|
|
FROM cfdi_queue q
|
|
WHERE q.sale_id = r.sale_id AND q.type = 'ingreso'
|
|
AND q.status = 'stamped' AND q.uuid_fiscal IS NOT NULL
|
|
ORDER BY q.stamped_at DESC NULLS LAST, q.id DESC
|
|
LIMIT 1
|
|
) ing ON TRUE
|
|
WHERE r.status = 'completed'
|
|
AND (%s::timestamptz IS NULL OR r.created_at >= %s)
|
|
ORDER BY r.id
|
|
""",
|
|
(since_dt, since_dt),
|
|
)
|
|
|
|
created = []
|
|
for return_id, sale_id, created_at, currency, exchange_rate, original_uuid, payload_unsigned in cur.fetchall():
|
|
if since_dt and created_at and created_at < since_dt:
|
|
continue
|
|
|
|
try:
|
|
invoice_payload = json.loads(payload_unsigned or "{}")
|
|
except ValueError:
|
|
invoice_payload = {}
|
|
customer = _payload_customer(invoice_payload)
|
|
if not customer:
|
|
continue
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT si.part_number, si.name, ri.quantity, si.unit_price, si.discount_pct,
|
|
si.tax_rate, si.clave_prod_serv, si.clave_unidad
|
|
FROM return_items ri
|
|
JOIN sale_items si ON si.id = ri.sale_item_id
|
|
WHERE ri.return_id = %s
|
|
ORDER BY ri.id
|
|
""",
|
|
(return_id,),
|
|
)
|
|
items = []
|
|
for part_number, name, quantity, unit_price, discount_pct, tax_rate, clave_prod, clave_unidad in cur.fetchall():
|
|
price = float(unit_price or 0)
|
|
items.append(
|
|
{
|
|
"part_number": part_number,
|
|
"name": name or part_number,
|
|
"quantity": int(quantity or 0),
|
|
"unit_price": price,
|
|
"discount_amount": round(price * float(discount_pct or 0) / 100 * int(quantity or 0), 2),
|
|
"tax_rate": float(tax_rate if tax_rate is not None else 0.16),
|
|
"clave_prod_serv": clave_prod,
|
|
"clave_unidad": clave_unidad,
|
|
}
|
|
)
|
|
if not items:
|
|
continue
|
|
|
|
try:
|
|
payload = build_egreso_payload(
|
|
{
|
|
"id": sale_id,
|
|
"items": items,
|
|
"currency": currency,
|
|
"exchange_rate": exchange_rate,
|
|
"forma_pago_sat": invoice_payload.get("payment_form"),
|
|
},
|
|
tenant_config,
|
|
customer,
|
|
original_uuid,
|
|
folio_number=return_id,
|
|
idempotency_key=f"nexus-egreso-{return_id}",
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Could not build credit note for return %s: %s", return_id, e)
|
|
continue
|
|
|
|
signature = _items_signature(payload)
|
|
already = _stored_signatures(cur, sale_id, "egreso")
|
|
if signature and signature in already:
|
|
already.remove(signature)
|
|
continue
|
|
|
|
try:
|
|
queued = enqueue_cfdi(conn, sale_id, "egreso", payload)
|
|
except Exception as e:
|
|
logger.warning("Could not enqueue credit note for return %s: %s", return_id, e)
|
|
continue
|
|
|
|
created.append(
|
|
{
|
|
"cfdi_id": queued["id"],
|
|
"sale_id": sale_id,
|
|
"return_id": return_id,
|
|
"original_uuid": original_uuid,
|
|
}
|
|
)
|
|
return created
|
|
|
|
|
|
def enqueue_pending_complements(conn, tenant_config=None, since=None, backfill=False):
|
|
"""Enqueue the missing payment complements and credit notes.
|
|
|
|
Every payment of a stamped PPD invoice gets a CFDI type "P" linked to the
|
|
original UUID, and every completed return of a stamped sale gets a CFDI
|
|
type "E". Idempotent: a movement that already has a non-cancelled CFDI of
|
|
the matching type/amount is skipped.
|
|
|
|
Args:
|
|
conn: psycopg2 connection
|
|
tenant_config: issuer config (only the default zip is used)
|
|
since: datetime or ISO string; reconcile movements created after it.
|
|
Defaults to COMPLEMENT_LOOKBACK_DAYS ago.
|
|
backfill: reconcile the whole history, ignoring `since`
|
|
|
|
Returns:
|
|
dict: {pagos: [...], egresos: [...], since: iso string}
|
|
"""
|
|
if backfill:
|
|
since_dt = None
|
|
elif since:
|
|
if isinstance(since, datetime):
|
|
since_dt = since
|
|
else:
|
|
since_dt = datetime.fromisoformat(str(since))
|
|
if since_dt.tzinfo is None:
|
|
since_dt = since_dt.replace(tzinfo=timezone.utc)
|
|
else:
|
|
since_dt = datetime.now(timezone.utc) - timedelta(days=COMPLEMENT_LOOKBACK_DAYS)
|
|
|
|
tenant_config = tenant_config or {}
|
|
cur = conn.cursor()
|
|
result = {"pagos": [], "egresos": [], "since": str(since_dt) if since_dt else None}
|
|
|
|
for key, sweep in (("pagos", _enqueue_pending_pagos), ("egresos", _enqueue_pending_egresos)):
|
|
try:
|
|
result[key] = sweep(conn, cur, tenant_config, since_dt)
|
|
except Exception as e:
|
|
logger.warning("Could not reconcile %s complements: %s", key, e)
|
|
conn.rollback()
|
|
|
|
conn.commit()
|
|
cur.close()
|
|
return result
|
|
|
|
|
|
def process_queue(conn, tenant_config, dry_run=False):
|
|
"""Process all pending CFDI items in the queue.
|
|
|
|
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
|
|
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: [...]}
|
|
"""
|
|
# First enqueue the complements (REP / credit notes) that are still missing,
|
|
# so they are stamped in this same run.
|
|
if not dry_run:
|
|
try:
|
|
enqueue_pending_complements(conn, tenant_config)
|
|
except Exception as e:
|
|
logger.warning("Complement reconciliation failed: %s", e)
|
|
conn.rollback()
|
|
|
|
cur = conn.cursor()
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id, sale_id, type, payload_unsigned, retry_count
|
|
FROM cfdi_queue
|
|
WHERE status IN ('pending', 'failed')
|
|
AND retry_count < %s
|
|
ORDER BY created_at ASC
|
|
LIMIT 50
|
|
FOR UPDATE SKIP LOCKED
|
|
""",
|
|
(MAX_RETRIES,),
|
|
)
|
|
items = cur.fetchall()
|
|
|
|
results = {"processed": 0, "stamped": 0, "failed": 0, "details": []}
|
|
|
|
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'
|
|
cur.execute(
|
|
"""
|
|
UPDATE cfdi_queue SET status = 'sending' WHERE id = %s
|
|
""",
|
|
(cfdi_id,),
|
|
)
|
|
conn.commit()
|
|
|
|
try:
|
|
payload = json.loads(payload_unsigned or "{}")
|
|
if not payload:
|
|
raise ValueError("Empty payload in queue item")
|
|
|
|
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")
|
|
|
|
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(
|
|
"""
|
|
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()
|
|
|
|
# Enviar PDF por WhatsApp al cliente (best effort)
|
|
try:
|
|
if _sale_id:
|
|
_send_cfdi_pdf_wa(conn, invoice_id, _sale_id, tenant_config)
|
|
except Exception:
|
|
pass
|
|
|
|
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',
|
|
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})
|
|
|
|
cur.close()
|
|
return results
|
|
|
|
|
|
def retry_failed(conn):
|
|
"""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.
|
|
"""
|
|
cur = conn.cursor()
|
|
|
|
cur.execute(
|
|
"""
|
|
SELECT id, retry_count, created_at
|
|
FROM cfdi_queue
|
|
WHERE status = 'failed' AND retry_count < %s
|
|
ORDER BY created_at ASC
|
|
""",
|
|
(MAX_RETRIES,),
|
|
)
|
|
items = cur.fetchall()
|
|
|
|
reset_count = 0
|
|
now = datetime.utcnow()
|
|
|
|
for cfdi_id, retry_count, created_at in items:
|
|
wait_seconds = BACKOFF_INTERVALS[retry_count] if retry_count < len(BACKOFF_INTERVALS) else BACKOFF_INTERVALS[-1]
|
|
|
|
# Use created_at as approximation for last attempt.
|
|
# In production, track last_attempt_at separately.
|
|
elapsed = (now - created_at).total_seconds()
|
|
if elapsed >= wait_seconds:
|
|
cur.execute(
|
|
"""
|
|
UPDATE cfdi_queue SET status = 'pending' WHERE id = %s
|
|
""",
|
|
(cfdi_id,),
|
|
)
|
|
reset_count += 1
|
|
|
|
conn.commit()
|
|
cur.close()
|
|
return reset_count
|
|
|
|
|
|
def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, tenant_config=None):
|
|
"""Cancel a stamped CFDI via Facturapi.
|
|
|
|
SAT cancellation motives:
|
|
01: Comprobante emitido con errores con relacion (requires replacement UUID)
|
|
02: Comprobante emitido con errores sin relacion
|
|
03: No se llevo a cabo la operacion
|
|
04: Operacion nominativa relacionada en factura global
|
|
|
|
Args:
|
|
conn: psycopg2 connection
|
|
cfdi_id: int (cfdi_queue.id)
|
|
motive: str ('01', '02', '03', '04')
|
|
replacement_uuid: str (required if motive == '01')
|
|
tenant_config: dict with facturapi_key
|
|
|
|
Returns:
|
|
dict: {id, status, message}
|
|
|
|
Raises:
|
|
ValueError: on validation errors
|
|
"""
|
|
if motive not in ("01", "02", "03", "04"):
|
|
raise ValueError(f"Invalid SAT cancellation motive: {motive}")
|
|
|
|
if motive == "01" and not replacement_uuid:
|
|
raise ValueError("Motive 01 requires a replacement UUID")
|
|
|
|
cur = conn.cursor()
|
|
|
|
cur.execute(
|
|
"""
|
|
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, external_id, current_status = row
|
|
|
|
if current_status == "cancelled":
|
|
raise ValueError("CFDI is already cancelled")
|
|
|
|
if current_status != "stamped":
|
|
# If not stamped, we can just mark as cancelled locally
|
|
cur.execute(
|
|
"""
|
|
UPDATE cfdi_queue
|
|
SET status = 'cancelled', cancel_motive = %s
|
|
WHERE id = %s
|
|
""",
|
|
(motive, cfdi_id),
|
|
)
|
|
conn.commit()
|
|
cur.close()
|
|
return {"id": cfdi_id, "status": "cancelled", "message": "Cancelled locally (was not stamped)"}
|
|
|
|
if not tenant_config or not tenant_config.get("facturapi_key"):
|
|
cur.close()
|
|
raise ValueError("Facturapi key not configured for tenant")
|
|
|
|
if not external_id:
|
|
cur.close()
|
|
raise ValueError("Cannot cancel: no Facturapi invoice id stored")
|
|
|
|
try:
|
|
facturapi_service.cancel_invoice(
|
|
tenant_config,
|
|
external_id,
|
|
motive,
|
|
replacement_uuid=replacement_uuid,
|
|
)
|
|
|
|
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})",
|
|
}
|
|
|
|
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) from e
|
|
|
|
|
|
def get_queue_status(conn, filters=None):
|
|
"""Get CFDI queue items with optional filters."""
|
|
filters = filters or {}
|
|
cur = conn.cursor()
|
|
|
|
page = int(filters.get("page", 1))
|
|
per_page = min(int(filters.get("per_page", 50)), 200)
|
|
|
|
where_clauses = ["1=1"]
|
|
params = []
|
|
|
|
if filters.get("status"):
|
|
where_clauses.append("q.status = %s")
|
|
params.append(filters["status"])
|
|
|
|
if filters.get("sale_id"):
|
|
where_clauses.append("q.sale_id = %s")
|
|
params.append(int(filters["sale_id"]))
|
|
|
|
if filters.get("type"):
|
|
where_clauses.append("LOWER(q.type) = LOWER(%s)")
|
|
params.append(filters["type"])
|
|
|
|
where = " AND ".join(where_clauses)
|
|
|
|
cur.execute(f"SELECT count(*) FROM cfdi_queue q WHERE {where}", params)
|
|
total = cur.fetchone()[0]
|
|
|
|
cur.execute(
|
|
f"""
|
|
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
|
|
q.retry_count, q.provisional_folio, q.error_message,
|
|
q.cancel_motive, q.created_at, q.stamped_at, q.external_id,
|
|
c.name as customer_name, c.rfc,
|
|
s.subtotal, s.tax_total, s.total, s.payment_method
|
|
FROM cfdi_queue q
|
|
LEFT JOIN sales s ON q.sale_id = s.id
|
|
LEFT JOIN customers c ON s.customer_id = c.id
|
|
WHERE {where}
|
|
ORDER BY q.created_at DESC
|
|
LIMIT %s OFFSET %s
|
|
""",
|
|
params + [per_page, (page - 1) * per_page],
|
|
)
|
|
|
|
items = []
|
|
for r in cur.fetchall():
|
|
items.append(
|
|
{
|
|
"id": r[0],
|
|
"sale_id": r[1],
|
|
"type": r[2],
|
|
"uuid_fiscal": r[3],
|
|
"status": r[4],
|
|
"retry_count": r[5],
|
|
"provisional_folio": r[6],
|
|
"error_message": r[7],
|
|
"cancel_motive": r[8],
|
|
"created_at": str(r[9]) if r[9] else None,
|
|
"stamped_at": str(r[10]) if r[10] else None,
|
|
"external_id": r[11],
|
|
"customer_name": r[12],
|
|
"rfc": r[13],
|
|
"subtotal": float(r[14]) if r[14] else 0,
|
|
"tax_total": float(r[15]) if r[15] else 0,
|
|
"total": float(r[16]) if r[16] else 0,
|
|
"payment_method": r[17],
|
|
}
|
|
)
|
|
|
|
cur.close()
|
|
total_pages = (total + per_page - 1) // per_page
|
|
return {
|
|
"data": items,
|
|
"pagination": {
|
|
"page": page,
|
|
"per_page": per_page,
|
|
"total": total,
|
|
"total_pages": total_pages,
|
|
},
|
|
}
|