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