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