Integra Battlepay con SumUp (órdenes battlepay_orders)

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 16:48:16 +00:00
parent 215061776b
commit 0fef9ac35b
7 changed files with 317 additions and 60 deletions
+35 -7
View File
@@ -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. 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). 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://<tu-dominio>/es/sumup-webhook/`.
- Enlazar `/es/battlepay/` desde el menú de «Mi cuenta» (opcional).
## Pendiente / 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 `<bnetId>#1`) como nombre visible en - Mostrar el email (en lugar de `<bnetId>#1`) como nombre visible en
«Mi cuenta» es un retoque cosmético opcional. «Mi cuenta» es un retoque cosmético opcional.
+46 -19
View File
@@ -6,40 +6,67 @@ SUMUP_CLIENT_ID = settings.SUMUP_CLIENT_ID
SUMUP_CLIENT_SECRET = settings.SUMUP_CLIENT_SECRET SUMUP_CLIENT_SECRET = settings.SUMUP_CLIENT_SECRET
SUMUP_EMAIL = settings.SUMUP_MERCHANT_EMAIL SUMUP_EMAIL = settings.SUMUP_MERCHANT_EMAIL
API_BASE = "https://api.sumup.com"
def obtener_token(): def obtener_token():
"""Obtiene un access token de SumUp""" """Obtiene un access token de SumUp (client_credentials)."""
url = "https://api.sumup.com/token" url = f"{API_BASE}/token"
data = { data = {
"grant_type": "client_credentials", "grant_type": "client_credentials",
"client_id": SUMUP_CLIENT_ID, "client_id": SUMUP_CLIENT_ID,
"client_secret": SUMUP_CLIENT_SECRET "client_secret": SUMUP_CLIENT_SECRET,
} }
headers = {"Content-Type": "application/x-www-form-urlencoded"} 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: if response.status_code == 200:
return response.json().get("access_token") return response.json().get("access_token")
else: raise Exception(f"Error al obtener token: {response.text}")
raise Exception(f"Error al obtener token: {response.json()}")
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() access_token = obtener_token()
url = "https://api.sumup.com/v0.1/checkouts" url = f"{API_BASE}/v0.1/checkouts"
headers = { headers = {
"Authorization": f"Bearer {access_token}", "Authorization": f"Bearer {access_token}",
"Content-Type": "application/json" "Content-Type": "application/json",
} }
data = { data = {
"checkout_reference": "pedido-12345", "checkout_reference": reference,
"amount": monto, "amount": float(monto),
"currency": moneda, "currency": moneda,
"pay_to_email": SUMUP_EMAIL, "pay_to_email": SUMUP_EMAIL,
"description": "Pago de prueba" "description": descripcion,
} }
if return_url:
response = requests.post(url, headers=headers, json=data) data["return_url"] = return_url
response = requests.post(url, headers=headers, json=data, timeout=15)
if response.status_code == 201: if response.status_code == 201:
return response.json().get("id") # checkout_id return response.json()
else: raise Exception(f"Error al crear checkout: {response.text}")
raise Exception(f"Error al crear checkout: {response.json()}")
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")
+60
View File
@@ -0,0 +1,60 @@
<!DOCTYPE html>
{% include 'partials/head.html' %}
{% include 'partials/header.html' %}
{% include 'partials/video.html' %}
<div class="main-page">
<div class="middle-content">
<div class="body-content">
<div class="title-content">
<h1>Tienda del cliente (Battlepay)</h1>
</div>
<div class="box-content">
<div class="body-box-content centered">
<p>Compras pendientes de pago de la cuenta <strong>{{ username }}</strong>.</p>
<br>
{% if orders %}
<table class="middle-center-table">
<thead>
<tr>
<th>Producto</th>
<th>Precio</th>
<th></th>
</tr>
</thead>
<tbody>
{% for order in orders %}
<tr>
<td>{{ order.product_name }}</td>
<td>{{ order.price_eur }} €</td>
<td>
<form action="{% url 'battlepay_pay' %}" method="POST" accept-charset="utf-8">
{% csrf_token %}
<input type="hidden" name="reference" value="{{ order.reference }}">
<button type="submit" class="login-button">Pagar con SumUp</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No tienes compras pendientes.</p>
<br>
<p>Para comprar, pulsa <strong>Comprar</strong> en la tienda del cliente dentro del juego;
la compra aparecerá aquí para completar el pago.</p>
{% endif %}
<hr>
<p><a href="my-account">Volver a mi cuenta</a></p>
</div>
</div>
</div>
</div>
</div>
{% include 'partials/social.html' %}
{% include 'partials/footer.html' %}
{% include 'partials/final.html' %}
+8 -2
View File
@@ -10,7 +10,12 @@
{% if error %} {% if error %}
<p style="color: red;">Error: {{ error }}</p> <p style="color: red;">Error: {{ error }}</p>
<p><a href="battlepay">Volver a mis compras</a></p>
{% else %} {% else %}
{% if product_name %}
<p>Producto: <strong>{{ product_name }}</strong></p>
<p>Importe: <strong>{{ price_eur }} €</strong></p>
{% endif %}
<div id="sumup-card"></div> <div id="sumup-card"></div>
<script> <script>
@@ -18,8 +23,8 @@
checkoutId: "{{ checkout_id }}", checkoutId: "{{ checkout_id }}",
onResponse: function (type, body) { onResponse: function (type, body) {
if (type === 'success') { if (type === 'success') {
alert("Pago exitoso. ID de transacción: " + body.transaction_code); alert("Pago exitoso. El producto se entregará en el juego en breve.");
console.log("Pago exitoso:", body); window.location = "battlepay";
} else if (type === 'error') { } else if (type === 'error') {
alert("Error en el pago: " + body.message); alert("Error en el pago: " + body.message);
console.error("Error en el pago:", body); console.error("Error en el pago:", body);
@@ -27,6 +32,7 @@
} }
}); });
</script> </script>
<p><a href="battlepay">Cancelar y volver</a></p>
{% endif %} {% endif %}
</body> </body>
</html> </html>
+2
View File
@@ -77,6 +77,8 @@ urlpatterns = [
path('guild-success/', views.guild_rename_success_view, name='guild-success'), path('guild-success/', views.guild_rename_success_view, name='guild-success'),
path('guild-cancel/', views.guild_rename_cancel_view, name='guild-cancel'), path('guild-cancel/', views.guild_rename_cancel_view, name='guild-cancel'),
path("pagar-sumup/", views.pagar_sumup, name="pagar_sumup"), 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"), path("sumup-webhook/", views.sumup_webhook, name="sumup_webhook"),
] ]
+120 -32
View File
@@ -5,7 +5,7 @@ from django.http import HttpResponse, JsonResponse
from django.db import connections from django.db import connections
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import logout 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 from django import forms
import stripe import stripe
from .pagos_stripe import create_checkout_session 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: try:
checkout_id = crear_checkout(10.00) # Pago de 10 EUR checkout = crear_checkout_battlepay(reference, price_eur, product_name)
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})
except Exception as e: 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 @csrf_exempt
def sumup_webhook(request): def sumup_webhook(request):
"""Recibe el webhook de SumUp y actualiza la base de datos""" """Webhook de SumUp: marca la orden Battlepay como PAID al completarse el pago."""
if request.method == "POST": 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: 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 pagado = status in ('PAID', 'SUCCESSFUL') or event_type == 'transaction.successful'
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")
# Buscar el pedido y actualizarlo como pagado if reference and pagado:
pedido = Pedido.objects.filter(email_cliente=email_cliente, monto=amount).first() try:
if pedido: afectadas = _mark_order_paid_by_reference(reference)
pedido.transaction_id = transaction_id except Exception as e:
pedido.estado = "Pagado" logger.error("SumUp webhook: error actualizando la orden %s: %s", reference, e)
pedido.save() return JsonResponse({"error": "Error interno"}, status=500)
return JsonResponse({"message": "Pago actualizado"}, status=200) if afectadas:
else: return JsonResponse({"message": "Orden marcada como pagada"}, status=200)
return JsonResponse({"error": "Pedido no encontrado"}, status=404) return JsonResponse({"message": "Orden no pendiente o inexistente"}, status=200)
return JsonResponse({"error": "Evento no reconocido"}, status=400) return JsonResponse({"message": "Evento ignorado"}, status=200)
except json.JSONDecodeError:
return JsonResponse({"error": "JSON inválido"}, status=400)
return JsonResponse({"error": "Método no permitido"}, status=405)
+46
View File
@@ -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`);