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.
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
- **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
«Mi cuenta» es un retoque cosmético opcional.
+44 -17
View File
@@ -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,
}
if return_url:
data["return_url"] = return_url
response = requests.post(url, headers=headers, json=data)
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")
+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 %}
<p style="color: red;">Error: {{ error }}</p>
<p><a href="battlepay">Volver a mis compras</a></p>
{% else %}
{% if product_name %}
<p>Producto: <strong>{{ product_name }}</strong></p>
<p>Importe: <strong>{{ price_eur }} €</strong></p>
{% endif %}
<div id="sumup-card"></div>
<script>
@@ -18,8 +23,8 @@
checkoutId: "{{ checkout_id }}",
onResponse: function (type, body) {
if (type === 'success') {
alert("Pago exitoso. ID de transacción: " + body.transaction_code);
console.log("Pago exitoso:", body);
alert("Pago exitoso. El producto se entregará en el juego en breve.");
window.location = "battlepay";
} else if (type === 'error') {
alert("Error en el pago: " + body.message);
console.error("Error en el pago:", body);
@@ -27,6 +32,7 @@
}
});
</script>
<p><a href="battlepay">Cancelar y volver</a></p>
{% endif %}
</body>
</html>
+2
View File
@@ -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"),
]
+120 -32
View File
@@ -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"""
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})
except Exception as e:
return render(request, "account/pago_sumup.html", {"error": str(e)})
# ---------------------------------------------------------------------------
# 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 = 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', {
'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)
# 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")
# 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)
return JsonResponse({"error": "Evento no reconocido"}, status=400)
data = json.loads(request.body or b'{}')
except json.JSONDecodeError:
return JsonResponse({"error": "JSON inválido"}, status=400)
return JsonResponse({"error": "Método no permitido"}, status=405)
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:
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)
pagado = status in ('PAID', 'SUCCESSFUL') or event_type == 'transaction.successful'
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({"message": "Evento ignorado"}, status=200)
+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`);