diff --git a/home/migrations/0013_stripelog_fulfilled.py b/home/migrations/0013_stripelog_fulfilled.py
new file mode 100644
index 0000000..3cef38e
--- /dev/null
+++ b/home/migrations/0013_stripelog_fulfilled.py
@@ -0,0 +1,18 @@
+# Generated by Django 6.0.7 on 2026-07-12 17:55
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('home', '0012_alter_accountactivation_salt_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='stripelog',
+ name='fulfilled',
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/home/models.py b/home/models.py
index 9b1218c..3ccbdba 100644
--- a/home/models.py
+++ b/home/models.py
@@ -296,6 +296,7 @@ class StripeLog(models.Model):
mode = models.CharField(max_length=10, choices=[("test", "Test"), ("live", "Live")]) # Modo de Stripe
character_name = models.CharField(max_length=100) # Nombre del personaje que compró
timestamp = models.DateTimeField(auto_now_add=True) # Fecha y hora
+ fulfilled = models.BooleanField(default=False) # Si la entrega ya se procesó (evita re-entregas)
def __str__(self):
return f"{self.username} compró {self.product_name} por {self.amount}€ ({self.mode})"
diff --git a/home/pagos_stripe.py b/home/pagos_stripe.py
index debec79..3da337b 100644
--- a/home/pagos_stripe.py
+++ b/home/pagos_stripe.py
@@ -4,8 +4,25 @@ from .models import StripeLog
from django.utils.timezone import now
stripe.api_key = settings.STRIPE_SECRET_KEY
+def is_session_paid(session_id):
+ """True si la sesión de Stripe existe y está realmente pagada."""
+ if not session_id:
+ return False
+ try:
+ s = stripe.checkout.Session.retrieve(session_id)
+ return bool(s and getattr(s, "payment_status", None) == "paid")
+ except Exception:
+ return False
+
+
def create_checkout_session(account_id, username, email, acore_ip, amount, character_name, currency="eur", success_url=None, cancel_url=None, product_name=""):
try:
+ # Añadir el id de sesión a la success_url para poder verificar el pago
+ # al volver (Stripe sustituye {CHECKOUT_SESSION_ID} por el id real).
+ if success_url:
+ sep = "&" if "?" in success_url else "?"
+ success_url = f"{success_url}{sep}session_id={{CHECKOUT_SESSION_ID}}"
+
# 🔹 Crear sesión de pago en Stripe
session = stripe.checkout.Session.create(
payment_method_types=["card"],
diff --git a/home/views.py b/home/views.py
index ced99b4..b18f3e9 100644
--- a/home/views.py
+++ b/home/views.py
@@ -8,7 +8,8 @@ from django.contrib.auth import logout
from .pagos_sumup import crear_checkout, crear_checkout_battlepay, obtener_checkout
from django import forms
import stripe
-from .pagos_stripe import create_checkout_session
+from .pagos_stripe import create_checkout_session, is_session_paid
+from functools import wraps
from .models import Noticia, ClienteCategoria, ServerSelection, RecruitAFriend, DownloadClientPage, ContentCreator, RecruitReward, ClaimedReward, AccountActivation, SecurityToken, GuildRenameSettings, VoteSite, VoteLog, HomeApiPoints, UnstuckHistory, ReviveHistory, RenamePrice, CustomizePrice, ChangeRacePrice, ChangeFactionPrice, LevelUpPrice, GoldPrice, TransferPrice, Category, Item, StripeLog, LoginAttempt, Pedido
from django.views.decorators.csrf import csrf_exempt
@@ -26,6 +27,33 @@ from decimal import Decimal, ROUND_HALF_UP
# Configuración del logger
logger = logging.getLogger(__name__)
+
+def require_paid_stripe(redirect_to='index'):
+ """
+ Protege las vistas de "éxito" de pago: exige un `session_id` de Stripe
+ realmente PAGADO en la URL (Stripe lo añade tras el pago) y evita re-entregas
+ marcando el StripeLog como `fulfilled`. Cierra el bypass de ir directo a la
+ success-url sin pagar.
+ """
+ def deco(view):
+ @wraps(view)
+ def wrapper(request, *args, **kwargs):
+ session_id = request.GET.get('session_id')
+ if not is_session_paid(session_id):
+ messages.error(request, 'No se ha podido verificar el pago.')
+ return redirect(redirect_to)
+ log = StripeLog.objects.filter(session_id=session_id).first()
+ if log is not None and log.fulfilled:
+ messages.info(request, 'Este pago ya había sido procesado.')
+ return redirect(redirect_to)
+ response = view(request, *args, **kwargs)
+ if log is not None:
+ log.fulfilled = True
+ log.save(update_fields=['fulfilled'])
+ return response
+ return wrapper
+ return deco
+
@csrf_exempt
def stripe_webhook(request):
payload = request.body
@@ -541,7 +569,10 @@ def recruit_a_friend_view(request):
if request.method == 'POST':
if not username:
return JsonResponse({'success': False, 'message': 'Debes estar logueado para reclamar una recompensa.'})
-
+
+ if not account_id or account_status is None:
+ return JsonResponse({'success': False, 'message': 'No se ha encontrado tu cuenta.'})
+
reward_id = request.POST.get('reward_id')
character_name = request.POST.get('character')
@@ -903,10 +934,10 @@ def change_password_view(request):
email = user_data['email']
if request.method == 'POST':
- current_password = request.POST.get('cur-password').strip()
- new_password = request.POST.get('new-password').strip()
- conf_new_password = request.POST.get('conf-new-password').strip()
- security_token = request.POST.get('security-token').strip()
+ current_password = request.POST.get('cur-password', '').strip()
+ new_password = request.POST.get('new-password', '').strip()
+ conf_new_password = request.POST.get('conf-new-password', '').strip()
+ security_token = request.POST.get('security-token', '').strip()
# Validar campos vacíos
if not current_password or not new_password or not conf_new_password or not security_token:
@@ -1321,6 +1352,7 @@ def rename_character_view(request):
return render(request, 'account/rename_character.html', {'characters': characters, 'price': price})
+@require_paid_stripe()
def rename_success_view(request):
# Procesar el comando SOAP tras el pago exitoso
character_name = request.session.get('rename_character')
@@ -1592,6 +1624,7 @@ def customize_character_view(request):
return render(request, 'account/customize_character.html', {'characters': characters, 'price': price})
+@require_paid_stripe()
def customize_success_view(request):
# Procesar el comando SOAP tras el pago exitoso
character_name = request.session.get('customize_character')
@@ -1682,6 +1715,7 @@ def change_race_character_view(request):
return render(request, 'account/change_race.html', {'characters': characters, 'price': price})
+@require_paid_stripe()
def change_race_success_view(request):
# Procesar el comando SOAP tras el pago exitoso
character_name = request.session.get('change_race_character')
@@ -1772,6 +1806,7 @@ def change_faction_character_view(request):
return render(request, 'account/change_faction.html', {'characters': characters, 'price': price})
+@require_paid_stripe()
def change_faction_success_view(request):
# Procesar el comando SOAP tras el pago exitoso
character_name = request.session.get('change_faction_character')
@@ -1863,6 +1898,7 @@ def level_up_character_view(request):
return render(request, 'account/level_up.html', {'characters': characters, 'price': price})
+@require_paid_stripe()
def level_up_success_view(request):
character_name = request.session.get('levelup_character')
if not character_name:
@@ -1944,8 +1980,8 @@ def gold_character_view(request):
if not gold_price:
return JsonResponse({'success': False, 'message': 'Cantidad de oro no válida.'})
- # Convertir el precio a formato que Stripe acepta (en centavos)
- stripe_price = int(float(gold_price.price) * 1)
+ # El precio va en euros (con decimales); el helper lo pasa a céntimos (×100)
+ stripe_price = gold_price.price
# Crear sesión de pago con Stripe
success_url = request.build_absolute_uri(reverse('gold-success'))
@@ -1981,6 +2017,7 @@ def gold_character_view(request):
return render(request, 'account/gold_character.html', {'characters': characters, 'gold_prices': gold_prices})
+@require_paid_stripe()
def gold_success_view(request):
# Procesar el pago exitoso
transaction = request.session.pop('gold_transaction', None)
@@ -2129,6 +2166,7 @@ def transfer_character_view(request):
+@require_paid_stripe()
def transfer_success_view(request):
transfer_data = request.session.get('transfer_data')
if not transfer_data:
@@ -2185,8 +2223,11 @@ def store_novawow_view(request):
# Si se está solicitando una categoría específica, devolver solo los ítems de esa categoría
if category_name:
- items_in_category = Category.objects.get(name=category_name).items.values()
- return JsonResponse({'success': True, 'items': list(items_in_category)})
+ try:
+ category = Category.objects.get(name=category_name)
+ except Category.DoesNotExist:
+ return JsonResponse({'success': False, 'message': 'Categoría no encontrada'}, status=404)
+ return JsonResponse({'success': True, 'items': list(category.items.values())})
# Obtener carrito desde la sesión
carrito = request.session.get('carrito', [])
@@ -2199,25 +2240,49 @@ def store_novawow_view(request):
# Obtener datos del body
data = json.loads(request.body)
character_name = data.get('character')
- carrito = data.get('carrito')
+ carrito_cliente = data.get('carrito') or []
if not character_name:
return JsonResponse({'success': False, 'message': 'Por favor, selecciona un personaje.'})
- # ACTUALIZAR EL CARRITO Y FORZAR LA SESIÓN
- request.session['carrito'] = carrito
- request.session.modified = True # Forzar que Django guarde la sesión inmediatamente
-
- # RECALCULAR EL PRECIO CON EL CARRITO ACTUALIZADO
- total_price = sum(Decimal(item['precio']) for item in carrito)
- total_price_with_iva = (total_price * Decimal('1.21')).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
-
# Verificar si el personaje pertenece al usuario
if character_name not in characters:
- return JsonResponse({'success': False, 'message': 'No tienes permiso para renombrar este personaje.'})
+ return JsonResponse({'success': False, 'message': 'No tienes permiso para este personaje.'})
- # Definir siempre `product_description` antes de usarlo
- product_description = ", ".join([f"{item.get('nombre', 'Ítem desconocido')} x{item.get('cantidad', 1)}" for item in carrito])
+ if not carrito_cliente:
+ return JsonResponse({'success': False, 'message': 'El carrito está vacío.'})
+
+ # VALIDAR ÍTEMS Y PRECIOS CONTRA LA BASE DE DATOS (nunca confiar en el
+ # precio ni en los ítems que manda el cliente).
+ try:
+ ids_solicitados = [int(item['id']) for item in carrito_cliente]
+ except (KeyError, TypeError, ValueError):
+ return JsonResponse({'success': False, 'message': 'Carrito no válido.'})
+
+ items_db = {i.item_id: i for i in Item.objects.filter(item_id__in=ids_solicitados)}
+
+ carrito = [] # carrito saneado que se guardará en sesión
+ total_price = Decimal('0.00')
+ for item in carrito_cliente:
+ item_id = int(item['id'])
+ db_item = items_db.get(item_id)
+ if not db_item:
+ return JsonResponse({'success': False, 'message': f'Ítem no disponible en la tienda (ID {item_id}).'})
+ try:
+ cantidad = max(1, int(item.get('cantidad', 1)))
+ except (TypeError, ValueError):
+ cantidad = 1
+ total_price += db_item.price * cantidad
+ carrito.append({'id': item_id, 'nombre': db_item.name, 'cantidad': cantidad})
+
+ total_price_with_iva = (total_price * Decimal('1.21')).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
+
+ # Guardar en sesión el carrito YA VALIDADO (para la entrega tras el pago)
+ request.session['carrito'] = carrito
+ request.session.modified = True
+
+ # Descripción del producto a partir de los datos validados
+ product_description = ", ".join([f"{it['nombre']} x{it['cantidad']}" for it in carrito])
# Obtener detalles del usuario
with connections['acore_auth'].cursor() as cursor:
@@ -2266,6 +2331,7 @@ def store_novawow_view(request):
from collections import Counter
+@require_paid_stripe(redirect_to='store-novawow')
def store_novawow_success_view(request):
# Procesar el comando SOAP tras el pago exitoso
character_name = request.session.get('store_novawow')
@@ -2350,13 +2416,17 @@ def revive_character_view(request):
command = f".revive {character_name}"
response = execute_soap_command(command)
- # Si la respuesta es vacía o None, asumir éxito
- if response:
+ # None = fallo de comunicación con el servidor
+ if response is None:
+ return JsonResponse({'success': False, 'message': 'Error de comunicación con el servidor. Intente nuevamente más tarde.'})
+
+ # Respuesta vacía = éxito (el comando .revive no devuelve texto si va bien)
+ if response.strip() == "":
ReviveHistory.objects.create(character_name=character_name, used_at=now())
return JsonResponse({'success': True, 'message': f'El personaje {character_name} ha sido revivido con éxito.'})
- if response is None:
- return JsonResponse({'success': False, 'message': 'Error de comunicación con el servidor. Intente nuevamente más tarde.'})
+ # Respuesta no vacía = el servidor devolvió un mensaje/error
+ return JsonResponse({'success': False, 'message': f'{response}'})
return render(request, 'account/revive_character.html', {'characters': characters.keys()})
@@ -2516,6 +2586,7 @@ def rename_guild_view(request):
})
+@require_paid_stripe()
def guild_rename_success_view(request):
# Obtener los datos de la sesión
guild_rename_data = request.session.get('guild_rename')