Integración Shopify → SAP Business One (snobbycl y on-chile)
Pipeline de integración de órdenes de Shopify a SAP Service Layer:
Invoice de reserva, pago con tarjeta o transferencia por transacción,
solicitud de traslado, traslado de stock y entrega.
- Invoice igual a lo cobrado; fulfillments limitados a lo facturado
- Estados terminales integrated, timeout, ignored y cancelled
- Idempotencia contra SAP: adopta documentos existentes en vez de duplicar
- Reintentos de red en el cliente de Shopify
- Secretos fuera de los JSON de tienda (${VARIABLE} resuelta desde .env)
- Herramientas de consulta, integración puntual y carga de pruebas
- Documentación en docs/ (flujo, casos especiales, mapeo, configuración)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
901dd8763d
47 files changed
+4709
No files matched your search
@@ -0,0 +1,136 @@
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List
|
||||
|
||||
CARD_LAST_DIGITS = "1234"
|
||||
CARD_VALID_UNTIL = "2030-12-31" # 12/30 para todas las tarjetas
|
||||
VOUCHER_MAX_LEN = 20 # SAP rechaza VoucherNum más largo; VoucherNum = name de la orden de Shopify
|
||||
|
||||
|
||||
class PaymentConfigError(Exception):
|
||||
"""El pago no se puede armar (medio sin configurar, montos que no cuadran, etc.)."""
|
||||
|
||||
|
||||
# ============================================================================
|
||||
def transacciones_efectivas(transactions: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Transacciones que representan dinero realmente cobrado: status success y
|
||||
kind sale/capture. Se ignoran failure/pending y refund/void. Si una
|
||||
authorization tiene su capture (parent_id), solo cuenta la capture.
|
||||
"""
|
||||
exitosas = [t for t in transactions if t.get("status") == "success"]
|
||||
autorizaciones_capturadas = {t.get("parent_id") for t in exitosas if t.get("kind") == "capture" and t.get("parent_id")}
|
||||
|
||||
efectivas = []
|
||||
for t in exitosas:
|
||||
if t.get("kind") == "sale":
|
||||
efectivas.append(t)
|
||||
elif t.get("kind") == "capture":
|
||||
efectivas.append(t)
|
||||
# authorization sin capture y el resto de kinds no suman
|
||||
return [t for t in efectivas if t.get("id") not in autorizaciones_capturadas]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
def _metodo(store_config: Dict[str, Any], gateway: str) -> Dict[str, Any]:
|
||||
metodos = store_config["sap"].get("payment_methods") or {}
|
||||
metodo = metodos.get(gateway)
|
||||
if not metodo:
|
||||
raise PaymentConfigError(f"Medio de pago '{gateway}' no está configurado en sap.payment_methods")
|
||||
return metodo
|
||||
|
||||
|
||||
def construir_incoming_payment(
|
||||
order: Dict[str, Any],
|
||||
transactions: List[Dict[str, Any]],
|
||||
store_config: Dict[str, Any],
|
||||
invoice: Dict[str, Any],
|
||||
invoice_doc_entry: int,
|
||||
invoice_total: float,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Arma el JSON para POST IncomingPayments, aplicado a la Invoice de reserva
|
||||
ya creada (CardCode, CardName y fecha salen de esa Invoice).
|
||||
|
||||
Los medios de pago salen de las transacciones efectivas (no de
|
||||
payment_gateway_names): cada una según sap.payment_methods[gateway]:
|
||||
- credit_card -> una línea en PaymentCreditCards
|
||||
- transfer -> suma a TransferSum con su transfer_account
|
||||
La suma debe igualar el total de la Invoice.
|
||||
"""
|
||||
series = store_config["sap"]["numbering_series"]["INCOMING_PAYMENT"]
|
||||
doc_date = invoice["DocDate"]
|
||||
|
||||
efectivas = transacciones_efectivas(transactions)
|
||||
if not efectivas:
|
||||
raise PaymentConfigError("La orden no tiene transacciones de pago exitosas")
|
||||
|
||||
tarjetas: List[Dict[str, Any]] = []
|
||||
transferencia = Decimal("0")
|
||||
cuentas_transfer = set()
|
||||
gateways: List[str] = []
|
||||
|
||||
for t in efectivas:
|
||||
gateway = t.get("gateway")
|
||||
metodo = _metodo(store_config, gateway)
|
||||
monto = Decimal(str(t["amount"]))
|
||||
gateways.append(gateway)
|
||||
|
||||
if metodo["type"] == "credit_card":
|
||||
if not metodo.get("credit_card"):
|
||||
raise PaymentConfigError(f"Medio de pago '{gateway}' sin tarjeta de crédito (credit_card) asociada")
|
||||
linea = {
|
||||
"CreditCard": metodo["credit_card"],
|
||||
"CreditCardNumber": CARD_LAST_DIGITS,
|
||||
"CardValidUntil": CARD_VALID_UNTIL,
|
||||
"VoucherNum": str(order.get("name") or "")[:VOUCHER_MAX_LEN],
|
||||
"CreditSum": float(monto),
|
||||
"CreditCur": "CLP",
|
||||
"CreditType": "cr_Regular",
|
||||
"NumOfPayments": metodo.get("num_of_payments", 1),
|
||||
"FirstPaymentDue": doc_date,
|
||||
"SplitPayments": "tYES" if metodo.get("split_payment") else "tNO",
|
||||
}
|
||||
if metodo.get("payment_method_code"):
|
||||
linea["PaymentMethodCode"] = metodo["payment_method_code"]
|
||||
tarjetas.append(linea)
|
||||
elif metodo["type"] == "transfer":
|
||||
if not metodo.get("transfer_account"):
|
||||
raise PaymentConfigError(f"Medio de pago '{gateway}' sin transfer_account")
|
||||
cuentas_transfer.add(metodo["transfer_account"])
|
||||
transferencia += monto
|
||||
else:
|
||||
raise PaymentConfigError(f"Medio de pago '{gateway}' con type inválido: {metodo['type']!r}")
|
||||
|
||||
if len(cuentas_transfer) > 1:
|
||||
raise PaymentConfigError(f"Transferencias con cuentas distintas no soportado: {sorted(cuentas_transfer)}")
|
||||
|
||||
total_pagado = transferencia + sum((Decimal(str(l["CreditSum"])) for l in tarjetas), Decimal("0"))
|
||||
if total_pagado != Decimal(str(invoice_total)):
|
||||
raise PaymentConfigError(f"Suma de pagos ({total_pagado}) no coincide con el total de la Invoice ({invoice_total})")
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"CardCode": invoice["CardCode"],
|
||||
"CardName": invoice["CardName"],
|
||||
"DocType": "rCustomer",
|
||||
"DocDate": doc_date,
|
||||
"Series": series,
|
||||
"DocCurrency": "CLP",
|
||||
"Remarks": " + ".join(dict.fromkeys(gateways)),
|
||||
"PaymentInvoices": [
|
||||
{
|
||||
"DocEntry": invoice_doc_entry,
|
||||
"InvoiceType": "it_Invoice",
|
||||
"SumApplied": invoice_total,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
if tarjetas:
|
||||
payload["PaymentCreditCards"] = tarjetas
|
||||
if transferencia:
|
||||
payload["TransferAccount"] = next(iter(cuentas_transfer))
|
||||
payload["TransferSum"] = float(transferencia)
|
||||
payload["TransferDate"] = doc_date
|
||||
payload["TransferReference"] = order.get("name")
|
||||
|
||||
return payload
|
||||
Reference in new issue
Block a user