From 0fef9ac35be3e981ab1c85aaad5f9a37780a2e7b Mon Sep 17 00:00:00 2001 From: adevopg Date: Sun, 12 Jul 2026 16:48:16 +0000 Subject: [PATCH] =?UTF-8?q?Integra=20Battlepay=20con=20SumUp=20(=C3=B3rden?= =?UTF-8?q?es=20battlepay=5Forders)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pagos_sumup.py: crear_checkout_battlepay(reference,...) y obtener_checkout() - battlepay_view: lista las órdenes PENDING de la cuenta de juego - battlepay_pay_view: crea checkout SumUp para una orden concreta (checkout_reference=reference) - sumup_webhook: marca la orden PAID (valida estado contra la API de SumUp si falta) - plantillas account/battlepay.html y partials/pago_sumup.html - sql/battlepay_sumup.sql (tablas battlepay_orders/battlepay_price) y docs actualizados Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/MIGRACION_3.4.3.md | 42 +++++-- home/pagos_sumup.py | 65 +++++++--- home/templates/account/battlepay.html | 60 ++++++++++ home/templates/partials/pago_sumup.html | 10 +- home/urls.py | 2 + home/views.py | 152 +++++++++++++++++++----- sql/battlepay_sumup.sql | 46 +++++++ 7 files changed, 317 insertions(+), 60 deletions(-) create mode 100644 home/templates/account/battlepay.html create mode 100644 sql/battlepay_sumup.sql diff --git a/docs/MIGRACION_3.4.3.md b/docs/MIGRACION_3.4.3.md index 35d9ad8..6ed672c 100644 --- a/docs/MIGRACION_3.4.3.md +++ b/docs/MIGRACION_3.4.3.md @@ -81,14 +81,42 @@ guardan `bnet_id` y `bnet_email` para cambios de contraseña/email. 3. Registrar una cuenta desde la web y comprobar el email de activación. 4. Activar y **probar login en el cliente 3.4.3** (ver nota de verificación). +## Battlepay / SumUp (tienda del cliente) + +Integración de la tienda nativa del cliente (Battlepay) con la pasarela SumUp, +usando las tablas `battlepay_orders` / `battlepay_price` del source +(`sql/battlepay_sumup.sql`, aplicar sobre la BD `auth`). + +**Flujo:** +1. En el juego, el jugador pulsa *Comprar*; si el producto tiene precio en + `battlepay_price`, el worldserver crea una orden `PENDING` en + `battlepay_orders` (con `account_id` = id de cuenta de juego y una + `reference` única). +2. En la web, `/es/battlepay/` lista las órdenes `PENDING` de la cuenta de juego + seleccionada y permite pagarlas. +3. `/es/battlepay/pay/` crea un checkout de SumUp usando la `reference` de la + orden como `checkout_reference`. +4. El webhook `/es/sumup-webhook/` marca la orden como `PAID` + (`paid_at = UNIX_TIMESTAMP()`), validando el estado contra la API de SumUp si + hace falta. +5. El worldserver entrega el producto y marca la orden `DELIVERED`. + +**Código:** `home/pagos_sumup.py` (`crear_checkout_battlepay`, `obtener_checkout`), +vistas `battlepay_view` / `battlepay_pay_view` / `sumup_webhook` en `home/views.py`, +plantillas `account/battlepay.html` y `partials/pago_sumup.html`. + +> Nota: `battlepay_orders.account_id` es el **id de cuenta de juego** +> (`account.id`), mientras que `battlePayCredits` (créditos del sistema clásico +> de puntos) vive en la **cuenta bnet** (`battlenet_accounts.id`); se unen por +> `account.battlenet_account`. Esta pasarela usa las órdenes en €, no los créditos. + +**Configuración necesaria:** +- Aplicar `sql/battlepay_sumup.sql` en la BD `auth` y rellenar `battlepay_price` + con tus `product_id` reales. +- Configurar el webhook de SumUp apuntando a `https:///es/sumup-webhook/`. +- Enlazar `/es/battlepay/` desde el menú de «Mi cuenta» (opcional). + ## Pendiente / opcional -- **Battlepay/SumUp**: el source trae `battlePayCredits` (en `battlenet_accounts`) - y las tablas `battlepay_orders` / `battlepay_price` (`sql/custom/battlepay_sumup.sql`). - Ojo: `battlepay_orders.account_id` es el **id de cuenta de juego** - (`account.id`), mientras que `battlePayCredits` está en la **cuenta bnet** - (`battlenet_accounts.id`); se unen por `account.battlenet_account`. La - integración de la pasarela con estas tablas se puede abordar como paso - siguiente. - Mostrar el email (en lugar de `#1`) como nombre visible en «Mi cuenta» es un retoque cosmético opcional. diff --git a/home/pagos_sumup.py b/home/pagos_sumup.py index 94cd753..733e61f 100644 --- a/home/pagos_sumup.py +++ b/home/pagos_sumup.py @@ -6,40 +6,67 @@ SUMUP_CLIENT_ID = settings.SUMUP_CLIENT_ID SUMUP_CLIENT_SECRET = settings.SUMUP_CLIENT_SECRET SUMUP_EMAIL = settings.SUMUP_MERCHANT_EMAIL +API_BASE = "https://api.sumup.com" + + def obtener_token(): - """Obtiene un access token de SumUp""" - url = "https://api.sumup.com/token" + """Obtiene un access token de SumUp (client_credentials).""" + url = f"{API_BASE}/token" data = { "grant_type": "client_credentials", "client_id": SUMUP_CLIENT_ID, - "client_secret": SUMUP_CLIENT_SECRET + "client_secret": SUMUP_CLIENT_SECRET, } headers = {"Content-Type": "application/x-www-form-urlencoded"} - - response = requests.post(url, headers=headers, data=data) + + response = requests.post(url, headers=headers, data=data, timeout=15) if response.status_code == 200: return response.json().get("access_token") - else: - raise Exception(f"Error al obtener token: {response.json()}") + raise Exception(f"Error al obtener token: {response.text}") -def crear_checkout(monto, moneda="EUR"): - """Crea un pago en SumUp y devuelve el checkout_id""" + +def crear_checkout_battlepay(reference, monto, descripcion, moneda="EUR", return_url=None): + """ + Crea un checkout de SumUp para una orden Battlepay concreta. + + ``reference`` es la referencia única de la orden (battlepay_orders.reference); + se usa como ``checkout_reference`` para poder mapear el webhook a la orden. + Devuelve el JSON del checkout (incluye ``id``). + """ access_token = obtener_token() - url = "https://api.sumup.com/v0.1/checkouts" + url = f"{API_BASE}/v0.1/checkouts" headers = { "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json" + "Content-Type": "application/json", } data = { - "checkout_reference": "pedido-12345", - "amount": monto, + "checkout_reference": reference, + "amount": float(monto), "currency": moneda, "pay_to_email": SUMUP_EMAIL, - "description": "Pago de prueba" + "description": descripcion, } - - response = requests.post(url, headers=headers, json=data) + if return_url: + data["return_url"] = return_url + + response = requests.post(url, headers=headers, json=data, timeout=15) if response.status_code == 201: - return response.json().get("id") # checkout_id - else: - raise Exception(f"Error al crear checkout: {response.json()}") + return response.json() + raise Exception(f"Error al crear checkout: {response.text}") + + +def obtener_checkout(checkout_id): + """Consulta un checkout por id. Devuelve su JSON (status, checkout_reference...).""" + access_token = obtener_token() + url = f"{API_BASE}/v0.1/checkouts/{checkout_id}" + headers = {"Authorization": f"Bearer {access_token}"} + response = requests.get(url, headers=headers, timeout=15) + if response.status_code == 200: + return response.json() + raise Exception(f"Error al consultar checkout: {response.text}") + + +def crear_checkout(monto, moneda="EUR"): + """Compatibilidad: checkout genérico de prueba. Devuelve el checkout_id.""" + checkout = crear_checkout_battlepay("pedido-prueba", monto, "Pago de prueba", moneda) + return checkout.get("id") diff --git a/home/templates/account/battlepay.html b/home/templates/account/battlepay.html new file mode 100644 index 0000000..2ab4588 --- /dev/null +++ b/home/templates/account/battlepay.html @@ -0,0 +1,60 @@ + + + {% include 'partials/head.html' %} + {% include 'partials/header.html' %} + {% include 'partials/video.html' %} + +
+
+
+
+

Tienda del cliente (Battlepay)

+
+
+
+

Compras pendientes de pago de la cuenta {{ username }}.

+
+ + {% if orders %} + + + + + + + + + + {% for order in orders %} + + + + + + {% endfor %} + +
ProductoPrecio
{{ order.product_name }}{{ order.price_eur }} € +
+ {% csrf_token %} + + +
+
+ {% else %} +

No tienes compras pendientes.

+
+

Para comprar, pulsa Comprar en la tienda del cliente dentro del juego; + la compra aparecerá aquí para completar el pago.

+ {% endif %} + +
+

Volver a mi cuenta

+
+
+
+
+
+ + {% include 'partials/social.html' %} + {% include 'partials/footer.html' %} + {% include 'partials/final.html' %} diff --git a/home/templates/partials/pago_sumup.html b/home/templates/partials/pago_sumup.html index d409e8f..ad66f8d 100644 --- a/home/templates/partials/pago_sumup.html +++ b/home/templates/partials/pago_sumup.html @@ -10,7 +10,12 @@ {% if error %}

Error: {{ error }}

+

Volver a mis compras

{% else %} + {% if product_name %} +

Producto: {{ product_name }}

+

Importe: {{ price_eur }} €

+ {% endif %}
+

Cancelar y volver

{% endif %} diff --git a/home/urls.py b/home/urls.py index 6c82b25..6338271 100644 --- a/home/urls.py +++ b/home/urls.py @@ -77,6 +77,8 @@ urlpatterns = [ path('guild-success/', views.guild_rename_success_view, name='guild-success'), path('guild-cancel/', views.guild_rename_cancel_view, name='guild-cancel'), path("pagar-sumup/", views.pagar_sumup, name="pagar_sumup"), + path("battlepay/", views.battlepay_view, name="battlepay"), + path("battlepay/pay/", views.battlepay_pay_view, name="battlepay_pay"), path("sumup-webhook/", views.sumup_webhook, name="sumup_webhook"), ] diff --git a/home/views.py b/home/views.py index 192950b..c69ea32 100644 --- a/home/views.py +++ b/home/views.py @@ -5,7 +5,7 @@ from django.http import HttpResponse, JsonResponse from django.db import connections from django.contrib import messages from django.contrib.auth import logout -from .pagos_sumup import crear_checkout +from .pagos_sumup import crear_checkout, crear_checkout_battlepay, obtener_checkout from django import forms import stripe from .pagos_stripe import create_checkout_session @@ -2979,44 +2979,132 @@ def novawow_players_view(request): }) -def pagar_sumup(request): - """Genera un pago de prueba y lo muestra en el widget""" +# --------------------------------------------------------------------------- +# Battlepay (tienda nativa del cliente) pagado vía SumUp +# +# Flujo: el worldserver crea una orden PENDING en `battlepay_orders` cuando el +# jugador pulsa "Comprar" en la tienda del cliente y el producto tiene precio en +# `battlepay_price`. El portal muestra esas órdenes, cobra por SumUp y las marca +# PAID; el worldserver las entrega y las marca DELIVERED. +# --------------------------------------------------------------------------- + +def get_pending_battlepay_orders(account_id): + """Órdenes Battlepay PENDING de una cuenta de juego.""" + with connections['acore_auth'].cursor() as cursor: + cursor.execute( + "SELECT id, reference, product_id, product_name, price_eur, status, created_at " + "FROM battlepay_orders WHERE account_id = %s AND status = 'PENDING' " + "ORDER BY created_at DESC", + [account_id], + ) + cols = [c[0] for c in cursor.description] + return [dict(zip(cols, row)) for row in cursor.fetchall()] + + +def _mark_order_paid_by_reference(reference): + """Marca una orden como PAID solo si estaba PENDING. Devuelve filas afectadas.""" + with connections['acore_auth'].cursor() as cursor: + cursor.execute( + "UPDATE battlepay_orders SET status = 'PAID', paid_at = UNIX_TIMESTAMP() " + "WHERE reference = %s AND status = 'PENDING'", + [reference], + ) + return cursor.rowcount + + +def battlepay_view(request): + """Lista las órdenes Battlepay pendientes de la cuenta de juego seleccionada.""" + account_id = request.session.get('account_id') + if not account_id: + if request.session.get('bnet_id'): + return redirect('select-account') + return redirect('login') + + orders = get_pending_battlepay_orders(account_id) + return render(request, 'account/battlepay.html', { + 'orders': orders, + 'username': request.session.get('username'), + }) + + +def battlepay_pay_view(request): + """Crea un checkout de SumUp para pagar una orden Battlepay pendiente.""" + account_id = request.session.get('account_id') + if not account_id: + return redirect('login') + if request.method != 'POST': + return redirect('battlepay') + + reference = request.POST.get('reference', '').strip() + + # Validar que la orden pertenece a la cuenta y sigue pendiente + with connections['acore_auth'].cursor() as cursor: + cursor.execute( + "SELECT product_name, price_eur FROM battlepay_orders " + "WHERE reference = %s AND account_id = %s AND status = 'PENDING'", + [reference, account_id], + ) + row = cursor.fetchone() + + if not row: + return render(request, 'account/pago_sumup.html', {'error': 'Orden no encontrada o ya procesada.'}) + + product_name, price_eur = row try: - checkout_id = crear_checkout(10.00) # Pago de 10 EUR - pedido = Pedido.objects.create(email_cliente="cliente@example.com", monto=10.00) - return render(request, "account/pago_sumup.html", {"checkout_id": checkout_id, "pedido": pedido}) + checkout = crear_checkout_battlepay(reference, price_eur, product_name) except Exception as e: - return render(request, "account/pago_sumup.html", {"error": str(e)}) - - + return render(request, 'account/pago_sumup.html', {'error': str(e)}) + + return render(request, 'account/pago_sumup.html', { + 'checkout_id': checkout.get('id'), + 'reference': reference, + 'product_name': product_name, + 'price_eur': price_eur, + }) + + +def pagar_sumup(request): + """Alias de compatibilidad: envía al listado de órdenes Battlepay.""" + return redirect('battlepay') + @csrf_exempt def sumup_webhook(request): - """Recibe el webhook de SumUp y actualiza la base de datos""" - if request.method == "POST": + """Webhook de SumUp: marca la orden Battlepay como PAID al completarse el pago.""" + if request.method != "POST": + return JsonResponse({"error": "Método no permitido"}, status=405) + + try: + data = json.loads(request.body or b'{}') + except json.JSONDecodeError: + return JsonResponse({"error": "JSON inválido"}, status=400) + + payload = data.get('payload') or data.get('data') or {} + reference = data.get('checkout_reference') or payload.get('checkout_reference') + status = (data.get('status') or payload.get('status') or '').upper() + checkout_id = data.get('id') or payload.get('checkout_id') or payload.get('id') + event_type = (data.get('event_type') or '').lower() + + # Si falta la referencia o el estado, consultarlo a la API de SumUp + if (not reference or status not in ('PAID', 'SUCCESSFUL')) and checkout_id: try: - data = json.loads(request.body) + checkout = obtener_checkout(checkout_id) + reference = reference or checkout.get('checkout_reference') + status = (checkout.get('status') or status).upper() + except Exception as e: + logger.error("SumUp webhook: no se pudo consultar el checkout %s: %s", checkout_id, e) - # Verificar si el evento es un pago exitoso - if data.get("event_type") == "transaction.successful": - transaction_id = data.get("data", {}).get("transaction_id") - amount = data.get("data", {}).get("amount") - email_cliente = data.get("data", {}).get("customer_email") + pagado = status in ('PAID', 'SUCCESSFUL') or event_type == 'transaction.successful' - # Buscar el pedido y actualizarlo como pagado - pedido = Pedido.objects.filter(email_cliente=email_cliente, monto=amount).first() - if pedido: - pedido.transaction_id = transaction_id - pedido.estado = "Pagado" - pedido.save() - return JsonResponse({"message": "Pago actualizado"}, status=200) - else: - return JsonResponse({"error": "Pedido no encontrado"}, status=404) + if reference and pagado: + try: + afectadas = _mark_order_paid_by_reference(reference) + except Exception as e: + logger.error("SumUp webhook: error actualizando la orden %s: %s", reference, e) + return JsonResponse({"error": "Error interno"}, status=500) + if afectadas: + return JsonResponse({"message": "Orden marcada como pagada"}, status=200) + return JsonResponse({"message": "Orden no pendiente o inexistente"}, status=200) - return JsonResponse({"error": "Evento no reconocido"}, status=400) - - except json.JSONDecodeError: - return JsonResponse({"error": "JSON inválido"}, status=400) - - return JsonResponse({"error": "Método no permitido"}, status=405) + return JsonResponse({"message": "Evento ignorado"}, status=200) \ No newline at end of file diff --git a/sql/battlepay_sumup.sql b/sql/battlepay_sumup.sql new file mode 100644 index 0000000..7feb875 --- /dev/null +++ b/sql/battlepay_sumup.sql @@ -0,0 +1,46 @@ +-- ─────────────────────────────────────────────────────────────────────────── +-- Battlepay nativo → pasarela SumUp (NovaWeb) +-- Se aplica sobre la base de datos `auth` (LoginDatabase). +-- +-- Flujo: +-- 1) El jugador pulsa "Comprar" en la tienda nativa del cliente. +-- 2) El worldserver (SendMakePurchase) busca el producto en `battlepay_price`. +-- Si tiene precio en €, crea una orden PENDING aquí y NO exige puntos. +-- 3) El cliente abre /login/sso → /es/checkout (NovaWeb) y paga con SumUp. +-- 4) NovaWeb marca la orden PAID (creditPaymentIfPaid). +-- 5) El worldserver (BattlepayManager::Update) ve la orden PAID del jugador +-- conectado y la entrega con ProcessDelivery → la marca DELIVERED. +-- ─────────────────────────────────────────────────────────────────────────── + +-- Órdenes de compra (las escribe el worldserver, las lee/actualiza NovaWeb). +CREATE TABLE IF NOT EXISTS `battlepay_orders` ( + `id` INT NOT NULL AUTO_INCREMENT, + `reference` VARCHAR(64) NOT NULL, + `account_id` INT NOT NULL, -- auth.account.id (cuenta de juego) + `product_id` INT NOT NULL, -- Battlepay ProductID nativo + `product_name` VARCHAR(120) NOT NULL DEFAULT '', + `price_eur` DECIMAL(10,2) NOT NULL DEFAULT 0, + `status` VARCHAR(16) NOT NULL DEFAULT 'PENDING', -- PENDING | PAID | DELIVERED + `created_at` INT NOT NULL, + `paid_at` INT NULL, + `delivered_at` INT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_reference` (`reference`), + KEY `idx_account_status` (`account_id`, `status`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Mapa producto nativo → precio real en €. Si un ProductID NO está aquí (o +-- enabled=0), el worldserver usa el flujo clásico de puntos para ese producto. +CREATE TABLE IF NOT EXISTS `battlepay_price` ( + `product_id` INT NOT NULL, + `product_name` VARCHAR(120) NOT NULL DEFAULT '', + `price_eur` DECIMAL(10,2) NOT NULL DEFAULT 0, + `enabled` TINYINT(1) NOT NULL DEFAULT 1, + PRIMARY KEY (`product_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Ejemplo (ajusta product_id al de tu tienda nativa): +INSERT INTO `battlepay_price` (`product_id`, `product_name`, `price_eur`, `enabled`) +VALUES (54811, 'Corcel Celestial', 5.00, 1) +ON DUPLICATE KEY UPDATE `product_name`=VALUES(`product_name`), `price_eur`=VALUES(`price_eur`);