Files
Autoparts-DB/pos/services/cfdi_facturapi_builder.py
consultoria-as a386ca5b4a
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- 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
2026-10-12 00:57:31 +00:00

287 lines
9.3 KiB
Python

# /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 datetime import datetime
from decimal import ROUND_HALF_UP, Decimal
# 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 _item_sat_keys(item):
"""Return (clave_prod_serv, clave_unidad) with SAT-rule fallback.
Precedence: sale-item value, then ``services.sat_keys`` rules over
name/brand/category, then the historic hard-coded default. Never raises.
"""
cps = item.get("clave_prod_serv")
cu = item.get("clave_unidad")
if cps and cu:
return cps, cu
try:
from services import sat_keys
k, u, _src = sat_keys.resolve_key(
name=item.get("name"), brand=item.get("brand"),
category=item.get("category"), part_number=item.get("part_number"),
)
except Exception:
k, u = None, None
return (cps or k or "25174800"), (cu or u or "H87")
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"))
product_key, unit_key = _item_sat_keys(item)
# Facturapi price is unit price before taxes and discounts
product = {
"description": item.get("name") or "Autoparte",
"product_key": product_key,
"unit_key": unit_key,
"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 currency/exchange rate for non-MXN sales
currency = (sale.get("currency") or "MXN").upper()
if currency != "MXN":
payload["currency"] = currency
if sale.get("exchange_rate"):
payload["exchange"] = _fmt6(sale["exchange_rate"])
return payload
def build_egreso_payload(sale, tenant_config, customer, original_uuid, folio_number=None, idempotency_key=None):
"""Build Facturapi payload for a credit note (Comprobante tipo Egreso)."""
payload = build_ingreso_payload(sale, tenant_config, customer)
payload["type"] = "E"
payload["use"] = "G02" # Devoluciones, descuentos o bonificaciones
payload["payment_method"] = "PUE"
payload["related_documents"] = [{"relationship": "01", "documents": original_uuid}]
if folio_number is not None:
payload["folio_number"] = folio_number
if idempotency_key:
payload["idempotency_key"] = idempotency_key
return payload
def _normalize_payment_date(value):
"""Return the ISO8601 datetime string Facturapi expects for a payment."""
if not value:
return datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
text = str(value).replace(" ", "T")
if len(text) == 10:
return f"{text}T12:00:00"
return text[:19]
def build_pago_payload(payment, tenant_config, customer, original_uuid, idempotency_key=None):
"""Build Facturapi payload for a payment complement (Comprobante tipo Pago).
`complements[0].data` is a list of payments (Facturapi JS SDK v5 types),
each payment carrying its own related_documents with the installment number
and the previous balance of the linked PPD invoice.
"""
tenant_cp = tenant_config.get("cp", "00000")
customer_payload = _build_customer_payload(customer, tenant_cp)
amount = _to_dec(payment.get("amount", 0))
rate = _to_dec(payment.get("tax_rate") or "0.16") or Decimal("0.16")
base = (amount / (Decimal("1") + rate)).quantize(TWO, ROUND_HALF_UP)
forma_pago = FORMA_PAGO_MAP.get((payment.get("payment_method") or "").lower().strip(), "01")
payload = {
"type": "P",
"customer": customer_payload,
"payment_form": forma_pago,
"complements": [
{
"type": "pago",
"data": [
{
"payment_form": forma_pago,
"date": _normalize_payment_date(payment.get("date")),
"related_documents": [
{
"uuid": original_uuid,
"amount": _fmt2(amount),
"installment": int(payment.get("installment") or 1),
"last_balance": _fmt2(payment.get("last_balance") or 0),
"taxes": [
{
"base": _fmt2(base),
"type": "IVA",
"rate": _fmt6(rate),
"factor": "Tasa",
"withholding": False,
}
],
}
],
}
],
}
],
}
if idempotency_key:
payload["idempotency_key"] = idempotency_key
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