- 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
244 lines
7.5 KiB
Python
244 lines
7.5 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 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
|