# /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, }, }