diff --git a/tienda/admin.py b/tienda/admin.py
index c31c77f..ab03a94 100644
--- a/tienda/admin.py
+++ b/tienda/admin.py
@@ -1,5 +1,5 @@
from django.contrib import admin
-from .models import Category, Image, Product, Cart, CartItem, Order, OrderItem, OrderMessage, StockReservation, StockReservationItem, User, VerificationCode
+from .models import Category, Image, Product, Cart, CartItem, Order, OrderItem, OrderMessage, StockReservation, StockReservationItem, User, VerificationCode, SavedPaymentMethod
# Register your models here.
admin.site.register(Category)
@@ -86,4 +86,11 @@ class StockReservationAdmin(admin.ModelAdmin):
list_display = ('id', 'user', 'session_key', 'status', 'payment_method', 'expires_at', 'created_at')
list_filter = ('status', 'payment_method', 'created_at')
search_fields = ('user__username', 'user__email', 'session_key')
- inlines = [StockReservationItemInline]
\ No newline at end of file
+ inlines = [StockReservationItemInline]
+
+
+@admin.register(SavedPaymentMethod)
+class SavedPaymentMethodAdmin(admin.ModelAdmin):
+ list_display = ('id', 'user', 'method_type', 'label', 'is_default', 'created_at')
+ list_filter = ('method_type', 'is_default', 'created_at')
+ search_fields = ('user__username', 'user__email', 'label', 'paypal_email')
\ No newline at end of file
diff --git a/tienda/migrations/0005_savedpaymentmethod.py b/tienda/migrations/0005_savedpaymentmethod.py
new file mode 100644
index 0000000..45617ca
--- /dev/null
+++ b/tienda/migrations/0005_savedpaymentmethod.py
@@ -0,0 +1,35 @@
+# Generated by Django 6.0.1 on 2026-04-10 06:09
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tienda', '0004_product_stock_stockreservation_stockreservationitem'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='SavedPaymentMethod',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('method_type', models.CharField(choices=[('card', 'Tarjeta'), ('paypal', 'PayPal')], max_length=10)),
+ ('label', models.CharField(max_length=200, verbose_name='Etiqueta')),
+ ('stripe_customer_id', models.CharField(blank=True, default='', max_length=100)),
+ ('stripe_payment_method_id', models.CharField(blank=True, default='', max_length=100)),
+ ('paypal_email', models.CharField(blank=True, default='', max_length=254)),
+ ('paypal_payer_id', models.CharField(blank=True, default='', max_length=100)),
+ ('is_default', models.BooleanField(default=False, verbose_name='Predeterminado')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payment_methods', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'verbose_name': 'Método de pago guardado',
+ 'verbose_name_plural': 'Métodos de pago guardados',
+ 'ordering': ['-is_default', '-created_at'],
+ },
+ ),
+ ]
diff --git a/tienda/migrations/__pycache__/0001_initial.cpython-312.pyc b/tienda/migrations/__pycache__/0001_initial.cpython-312.pyc
new file mode 100644
index 0000000..b63c144
Binary files /dev/null and b/tienda/migrations/__pycache__/0001_initial.cpython-312.pyc differ
diff --git a/tienda/migrations/__pycache__/0002_verificationcode_code_mode_and_more.cpython-312.pyc b/tienda/migrations/__pycache__/0002_verificationcode_code_mode_and_more.cpython-312.pyc
new file mode 100644
index 0000000..c83e2a8
Binary files /dev/null and b/tienda/migrations/__pycache__/0002_verificationcode_code_mode_and_more.cpython-312.pyc differ
diff --git a/tienda/migrations/__pycache__/0003_order_transaction_code.cpython-312.pyc b/tienda/migrations/__pycache__/0003_order_transaction_code.cpython-312.pyc
new file mode 100644
index 0000000..a1ecd9e
Binary files /dev/null and b/tienda/migrations/__pycache__/0003_order_transaction_code.cpython-312.pyc differ
diff --git a/tienda/migrations/__pycache__/0004_product_stock_stockreservation_stockreservationitem.cpython-312.pyc b/tienda/migrations/__pycache__/0004_product_stock_stockreservation_stockreservationitem.cpython-312.pyc
new file mode 100644
index 0000000..93a7921
Binary files /dev/null and b/tienda/migrations/__pycache__/0004_product_stock_stockreservation_stockreservationitem.cpython-312.pyc differ
diff --git a/tienda/migrations/__pycache__/0005_savedpaymentmethod.cpython-312.pyc b/tienda/migrations/__pycache__/0005_savedpaymentmethod.cpython-312.pyc
new file mode 100644
index 0000000..3fc5dc1
Binary files /dev/null and b/tienda/migrations/__pycache__/0005_savedpaymentmethod.cpython-312.pyc differ
diff --git a/tienda/migrations/__pycache__/__init__.cpython-312.pyc b/tienda/migrations/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..7168695
Binary files /dev/null and b/tienda/migrations/__pycache__/__init__.cpython-312.pyc differ
diff --git a/tienda/models.py b/tienda/models.py
index ab57703..be29a29 100644
--- a/tienda/models.py
+++ b/tienda/models.py
@@ -243,6 +243,41 @@ class OrderMessage(models.Model):
return f"Mensaje de {self.sender} - {self.created_at}"
+class SavedPaymentMethod(models.Model):
+ """Métodos de pago guardados por el usuario (tarjetas Stripe o cuentas PayPal)."""
+ TYPE_CARD = "card"
+ TYPE_PAYPAL = "paypal"
+ TYPE_CHOICES = [
+ (TYPE_CARD, "Tarjeta"),
+ (TYPE_PAYPAL, "PayPal"),
+ ]
+
+ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="payment_methods")
+ method_type = models.CharField(max_length=10, choices=TYPE_CHOICES)
+ label = models.CharField(max_length=200, verbose_name="Etiqueta")
+ # Stripe fields
+ stripe_customer_id = models.CharField(max_length=100, blank=True, default="")
+ stripe_payment_method_id = models.CharField(max_length=100, blank=True, default="")
+ # PayPal fields
+ paypal_email = models.CharField(max_length=254, blank=True, default="")
+ paypal_payer_id = models.CharField(max_length=100, blank=True, default="")
+ is_default = models.BooleanField(default=False, verbose_name="Predeterminado")
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ verbose_name = "Método de pago guardado"
+ verbose_name_plural = "Métodos de pago guardados"
+ ordering = ["-is_default", "-created_at"]
+
+ def __str__(self):
+ return f"{self.user.username} – {self.label}"
+
+ def save(self, *args, **kwargs):
+ if self.is_default:
+ SavedPaymentMethod.objects.filter(user=self.user, is_default=True).update(is_default=False)
+ super().save(*args, **kwargs)
+
+
class ShippingAddress(models.Model):
"""Direcciones de entrega de los usuarios"""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='shipping_addresses')
diff --git a/tienda/templates/tienda/agregar_paypal.html b/tienda/templates/tienda/agregar_paypal.html
new file mode 100644
index 0000000..56b57e2
--- /dev/null
+++ b/tienda/templates/tienda/agregar_paypal.html
@@ -0,0 +1,88 @@
+{% extends "tienda/base.html" %}
+{% load static %}
+
+{% block head %}
+
+{% endblock %}
+
+{% block content %}
+{% csrf_token %}
+
+
+
+
+
+
+
+ Se realizará un pequeño pago de verificación de 0,01 € para confirmar
+ tu cuenta de PayPal. Tu cuenta quedará guardada para futuras compras.
+
+
+
+
+
+
+
+
Cancelar
+
+
+
+
+
+
+{% endblock %}
diff --git a/tienda/templates/tienda/agregar_tarjeta.html b/tienda/templates/tienda/agregar_tarjeta.html
new file mode 100644
index 0000000..1b1b0a5
--- /dev/null
+++ b/tienda/templates/tienda/agregar_tarjeta.html
@@ -0,0 +1,130 @@
+{% extends "tienda/base.html" %}
+{% load static %}
+
+{% block head %}
+
+
+{% endblock %}
+
+{% block content %}
+{% csrf_token %}
+
+
+
+
+
+
+
+ Introduce los datos de tu tarjeta. No se realizará ningún cobro ahora; la tarjeta
+ se guardará de forma segura en Stripe para usar en tus próximas compras.
+
+
+
+
Datos de la tarjeta
+
+
+
+
+
+ 💳 Guardar tarjeta
+
+
+
+
+
Cancelar
+
+
+
+
+
+
+{% endblock %}
diff --git a/tienda/templates/tienda/checkout.html b/tienda/templates/tienda/checkout.html
index 8999c4b..1b88ee9 100644
--- a/tienda/templates/tienda/checkout.html
+++ b/tienda/templates/tienda/checkout.html
@@ -4,45 +4,33 @@
{% block head %}
-
-
+
{% endblock %}
-
{% block content %}
-
{% csrf_token %}
-
+
Checkout
← Volver al carrito
@@ -66,6 +54,7 @@
Si el pago no se completa en ese tiempo, la reserva se cancelará automáticamente.
+
1) Selecciona la dirección de envío
@@ -90,6 +79,7 @@
+
@@ -114,42 +104,110 @@
- Subtotal:
- {{ cart.get_total|format_price }}€
+ Subtotal:
+ {{ cart.get_total|format_price }}€
- IVA (21%):
- +{{ cart.get_vat_amount|format_price }}€
+ IVA (21%):
+ +{{ cart.get_vat_amount|format_price }}€
- Total:
- {{ cart.get_total_with_vat|format_price }}€
+ Total:
+ {{ cart.get_total_with_vat|format_price }}€
-
-
2) Selecciona tu método de pago
-
-
- 💳 Pagar con Stripe
-
-
-
- 🅿️ Pagar con PayPal
-
+
+
+
+
2) Selecciona tu método de pago
+
+
+
+
+
+ 💳 Tarjeta
+
+
+
+
+ 🅿️ PayPal
+
+
+
+
+
+
+ {% if saved_cards %}
+
+
Tarjetas guardadas:
+ {% for card in saved_cards %}
+
+
+
+ {{ card.label }}
+ {% if card.is_default %}Predeterminada {% endif %}
+
+
+ {% endfor %}
+
+
+ Usar nueva tarjeta
+
+
+ {% endif %}
+
+
+
+
Número de tarjeta
+
+
+
+
+
+
+ Guardar esta tarjeta para futuras compras
+
+
+
+
+
+ 💳 Pagar con tarjeta
+
+
+
+
+
+
+ {% if saved_paypal %}
+
+ Cuenta PayPal guardada:
+ {{ saved_paypal.paypal_email }}
+
+ {% endif %}
+
+
+
+ Guardar esta cuenta de PayPal para futuras compras
+
+
+
+ {% if not addresses or stock_issues %}
+
Selecciona una dirección de envío válida para activar el pago.
+ {% endif %}
+
+
+
+
+
+
{% else %}
Tu carrito está vacío.
{% endif %}
@@ -157,72 +215,169 @@
-{% endblock %}
\ No newline at end of file
+{% endblock %}
diff --git a/tienda/templates/tienda/direcciones.html b/tienda/templates/tienda/direcciones.html
index 6f98cd6..2584d2a 100644
--- a/tienda/templates/tienda/direcciones.html
+++ b/tienda/templates/tienda/direcciones.html
@@ -21,6 +21,7 @@
Inicio
Mi Perfil
Direcciones
+
Métodos de Pago
Mensajes
diff --git a/tienda/templates/tienda/editar_perfil.html b/tienda/templates/tienda/editar_perfil.html
index d812059..61c2319 100644
--- a/tienda/templates/tienda/editar_perfil.html
+++ b/tienda/templates/tienda/editar_perfil.html
@@ -21,6 +21,7 @@
Inicio
Mi Perfil
Direcciones
+
Métodos de Pago
Mensajes
diff --git a/tienda/templates/tienda/mensajes_comprador.html b/tienda/templates/tienda/mensajes_comprador.html
index 98cafe8..4694f5a 100644
--- a/tienda/templates/tienda/mensajes_comprador.html
+++ b/tienda/templates/tienda/mensajes_comprador.html
@@ -21,6 +21,7 @@
Inicio
Mi Perfil
Direcciones
+ Métodos de Pago
Mensajes
diff --git a/tienda/templates/tienda/metodos_pago.html b/tienda/templates/tienda/metodos_pago.html
new file mode 100644
index 0000000..4c8568d
--- /dev/null
+++ b/tienda/templates/tienda/metodos_pago.html
@@ -0,0 +1,90 @@
+{% extends "tienda/base.html" %}
+{% load static %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+ {% with has_card=False %}
+ {% for metodo in metodos %}{% if metodo.method_type == 'card' %}
+
+
+ {{ metodo.label }}
+ {% if metodo.is_default %}Predeterminada {% endif %}
+
+
+
+ {% endif %}{% endfor %}
+ {% endwith %}
+ {% if not cards_exist %}
+
No tienes tarjetas guardadas.
+ {% endif %}
+
+
+
+
+
+
+
+
+
+ {% for metodo in metodos %}{% if metodo.method_type == 'paypal' %}
+
+
+ {{ metodo.paypal_email }}
+ {% if metodo.is_default %}Predeterminada {% endif %}
+
+
+
+ {% endif %}{% endfor %}
+ {% if not paypal_exist %}
+
No tienes cuentas de PayPal guardadas.
+ {% endif %}
+
+
+
+
+
+{% endblock %}
diff --git a/tienda/templates/tienda/mis_compras.html b/tienda/templates/tienda/mis_compras.html
index 28dfdfd..16b4d8e 100644
--- a/tienda/templates/tienda/mis_compras.html
+++ b/tienda/templates/tienda/mis_compras.html
@@ -20,6 +20,7 @@
Inicio
Compras
Recibos
+ Métodos de Pago
Mensajes
diff --git a/tienda/templates/tienda/mis_recibos.html b/tienda/templates/tienda/mis_recibos.html
index 3c87fcb..ae193db 100644
--- a/tienda/templates/tienda/mis_recibos.html
+++ b/tienda/templates/tienda/mis_recibos.html
@@ -20,6 +20,7 @@
Inicio
Compras
Recibos
+ Métodos de Pago
Mensajes
diff --git a/tienda/templates/tienda/portal_usuario.html b/tienda/templates/tienda/portal_usuario.html
index 557497e..67546e3 100644
--- a/tienda/templates/tienda/portal_usuario.html
+++ b/tienda/templates/tienda/portal_usuario.html
@@ -18,6 +18,7 @@
Recibos
Mi Perfil
Direcciones
+ Métodos de Pago
Mensajes
@@ -67,6 +68,15 @@
+
+
+
+
💳 Métodos de Pago
+
gestiona tarjetas y cuentas PayPal
+
Gestionar
+
+
+
diff --git a/tienda/templatetags/__pycache__/__init__.cpython-312.pyc b/tienda/templatetags/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..2dd2093
Binary files /dev/null and b/tienda/templatetags/__pycache__/__init__.cpython-312.pyc differ
diff --git a/tienda/templatetags/__pycache__/vat_filters.cpython-312.pyc b/tienda/templatetags/__pycache__/vat_filters.cpython-312.pyc
new file mode 100644
index 0000000..f30aca6
Binary files /dev/null and b/tienda/templatetags/__pycache__/vat_filters.cpython-312.pyc differ
diff --git a/tienda/urls.py b/tienda/urls.py
index 3244b70..a473dcb 100644
--- a/tienda/urls.py
+++ b/tienda/urls.py
@@ -25,12 +25,15 @@ urlpatterns = [
path("cart/remove//", views.remove_from_cart, name="remove_from_cart"),
path("cart/clear/", views.clear_cart, name="clear_cart"),
path("checkout/", views.checkout, name="checkout"),
- # Stripe
- path("config/", views.stripe_config, name="stripe_config"),
- path("create-checkout-session/", views.create_checkout_session, name="create_checkout_session"),
+ # Stripe Payment Intents (nuevo sistema)
+ path("checkout/crear-payment-intent/", views.crear_payment_intent, name="crear_payment_intent"),
+ path("checkout/confirmar-pago-tarjeta/", views.confirmar_pago_tarjeta, name="confirmar_pago_tarjeta"),
path("checkout/success/", views.checkout_success, name="checkout_success"),
path("checkout/cancel/", views.checkout_cancel, name="checkout_cancel"),
- # PayPal
+ # PayPal Orders API (nuevo sistema)
+ path("paypal/crear-orden/", views.crear_orden_paypal, name="crear_orden_paypal"),
+ path("paypal/capturar-orden/", views.capturar_orden_paypal, name="capturar_orden_paypal"),
+ # PayPal (legacy - mantenido por compatibilidad)
path("paypal/create-payment/", views.create_paypal_payment, name="create_paypal_payment"),
path("paypal/execute/", views.paypal_execute, name="paypal_execute"),
# Portal de usuario
@@ -44,6 +47,15 @@ urlpatterns = [
path("usuario/direcciones//editar/", views.editar_direccion, name="editar_direccion"),
path("usuario/direcciones//eliminar/", views.eliminar_direccion, name="eliminar_direccion"),
path("usuario/mensajes/", views.mensajes_comprador, name="mensajes_comprador"),
+ # Métodos de pago del usuario
+ path("usuario/metodos-pago/", views.metodos_pago, name="metodos_pago"),
+ path("usuario/metodos-pago/agregar-tarjeta/", views.agregar_tarjeta, name="agregar_tarjeta"),
+ path("usuario/metodos-pago/agregar-tarjeta/crear-setup-intent/", views.crear_setup_intent, name="crear_setup_intent"),
+ path("usuario/metodos-pago/agregar-tarjeta/confirmar/", views.confirmar_setup_intent, name="confirmar_setup_intent"),
+ path("usuario/metodos-pago//eliminar/", views.eliminar_metodo_pago, name="eliminar_metodo_pago"),
+ path("usuario/metodos-pago/agregar-paypal/", views.agregar_paypal, name="agregar_paypal"),
+ path("usuario/metodos-pago/agregar-paypal/crear-orden/", views.crear_orden_paypal_setup, name="crear_orden_paypal_setup"),
+ path("usuario/metodos-pago/agregar-paypal/capturar/", views.capturar_orden_paypal_setup, name="capturar_orden_paypal_setup"),
path("verify/", views.verify, name="verify"),
path("rgpd", views.rgpd, name="rgpd"),
path("privacidad", views.rgpd, name="privacidad"),
diff --git a/tienda/views.py b/tienda/views.py
index fbb7fd4..c6a0a22 100644
--- a/tienda/views.py
+++ b/tienda/views.py
@@ -4,7 +4,7 @@ from django.contrib.auth import authenticate, login as auth_login, logout as aut
from django.contrib.auth.decorators import login_required
from django.contrib import messages
-from .models import User, Product, Category, Cart, CartItem, Image, Order, OrderItem, OrderMessage, ShippingAddress, StockReservation, StockReservationItem, VerificationCode
+from .models import User, Product, Category, Cart, CartItem, Image, Order, OrderItem, OrderMessage, ShippingAddress, StockReservation, StockReservationItem, VerificationCode, SavedPaymentMethod
from . import tasks
from .vars import (
PAGE_SIZE,
@@ -28,6 +28,7 @@ import unicodedata
import json
import random, string
import logging
+import requests
# Create your views here.
@@ -97,6 +98,88 @@ def _get_client_ip(request: HttpRequest) -> str:
return request.META.get("REMOTE_ADDR", "")
+# ==================== PAYPAL ORDERS API v2 HELPERS ====================
+
+def _get_paypal_base_url() -> str:
+ mode = getattr(settings, "PAYPAL_MODE", "sandbox")
+ if mode == "live":
+ return "https://api-m.paypal.com"
+ return "https://api-m.sandbox.paypal.com"
+
+
+def _get_paypal_access_token() -> str:
+ """Obtiene un access token de la API de PayPal."""
+ url = f"{_get_paypal_base_url()}/v1/oauth2/token"
+ response = requests.post(
+ url,
+ auth=(settings.PAYPAL_CLIENT_ID, settings.PAYPAL_CLIENT_SECRET),
+ data={"grant_type": "client_credentials"},
+ timeout=15,
+ )
+ response.raise_for_status()
+ return response.json()["access_token"]
+
+
+def _paypal_create_order(amount_eur: Decimal) -> dict:
+ """Crea una orden PayPal y retorna el diccionario de respuesta con id y approve_link."""
+ token = _get_paypal_access_token()
+ url = f"{_get_paypal_base_url()}/v2/checkout/orders"
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {token}",
+ }
+ payload = {
+ "intent": "CAPTURE",
+ "purchase_units": [
+ {
+ "amount": {
+ "currency_code": "EUR",
+ "value": format(amount_eur, ".2f"),
+ }
+ }
+ ],
+ "application_context": {
+ "brand_name": "Comercialmeria",
+ "shipping_preference": "NO_SHIPPING",
+ "user_action": "PAY_NOW",
+ },
+ }
+ response = requests.post(url, headers=headers, json=payload, timeout=15)
+ response.raise_for_status()
+ return response.json()
+
+
+def _paypal_capture_order(order_id: str) -> dict:
+ """Captura una orden PayPal aprobada por el comprador."""
+ token = _get_paypal_access_token()
+ url = f"{_get_paypal_base_url()}/v2/checkout/orders/{order_id}/capture"
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {token}",
+ }
+ response = requests.post(url, headers=headers, json={}, timeout=15)
+ response.raise_for_status()
+ return response.json()
+
+
+# ==================== STRIPE CUSTOMER HELPER ====================
+
+def _get_or_create_stripe_customer(user) -> str:
+ """Devuelve el stripe_customer_id del usuario, creando uno nuevo si es necesario."""
+ stripe.api_key = settings.STRIPE_SECRET_KEY
+ existing = SavedPaymentMethod.objects.filter(
+ user=user,
+ method_type=SavedPaymentMethod.TYPE_CARD,
+ stripe_customer_id__gt="",
+ ).first()
+ if existing:
+ return existing.stripe_customer_id
+ customer = stripe.Customer.create(
+ email=user.email,
+ name=(f"{user.first_name} {user.last_name}".strip()) or user.username,
+ )
+ return customer.id
+
def get_price_with_vat_decimal(price) -> Decimal:
"""Retorna un precio con IVA aplicado y redondeado a 2 decimales."""
return (Decimal(str(price)) * (Decimal("1") + Decimal(str(VAT_RATE)))).quantize(
@@ -1021,12 +1104,18 @@ def checkout(request: HttpRequest):
active_reservation_ids = _get_active_reservation_ids_for_request(request)
stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids)
addresses = ShippingAddress.objects.filter(user=request.user)
+ saved_cards = SavedPaymentMethod.objects.filter(user=request.user, method_type=SavedPaymentMethod.TYPE_CARD)
+ saved_paypal = SavedPaymentMethod.objects.filter(user=request.user, method_type=SavedPaymentMethod.TYPE_PAYPAL).first()
return render(request, "tienda/checkout.html", {
"cart": cart,
"cart_items": cart_items,
"addresses": addresses,
"stock_issues": stock_issues,
"reservation_minutes": STOCK_RESERVATION_MINUTES,
+ "saved_cards": saved_cards,
+ "saved_paypal": saved_paypal,
+ "stripe_publishable_key": settings.STRIPE_PUBLISHABLE_KEY,
+ "paypal_client_id": settings.PAYPAL_CLIENT_ID,
})
@csrf_exempt
@@ -1110,7 +1199,7 @@ def create_checkout_session(request: HttpRequest):
return JsonResponse({"sessionId": session.id})
except Exception as e:
logger.exception("STRIPE_CHECKOUT_SESSION_ERROR user_id=%s error=%s", request.user.id, str(e))
- return JsonResponse({"error": f"Error al crear sesión de pago: {str(e)}"}, status=500)
+ return JsonResponse({"error": "Error al crear la sesión de pago. Por favor inténtalo de nuevo."}, status=500)
@login_required
@@ -1169,7 +1258,28 @@ def search(request: HttpRequest):
})
-# ==================== PAYPAL PAYMENT ====================
+def search_suggestions(request: HttpRequest):
+ """API AJAX que retorna sugerencias de búsqueda en JSON"""
+ query = request.GET.get('q', '').strip()
+ suggestions = []
+
+ if query and len(query) >= 2:
+ products = Product.objects.filter(
+ models.Q(name__icontains=query) |
+ models.Q(briefdesc__icontains=query)
+ ).values_list('name', 'id', 'price', 'primary_image_id').distinct()[:8]
+
+ for name, product_id, price, image_id in products:
+ suggestions.append({
+ 'name': name,
+ 'id': product_id,
+ 'price': float(price),
+ })
+
+ return JsonResponse({'suggestions': suggestions})
+
+
+
@login_required
def create_paypal_payment(request: HttpRequest):
@@ -1354,26 +1464,496 @@ def paypal_execute(request: HttpRequest):
return redirect("checkout")
-def search_suggestions(request: HttpRequest):
- """API AJAX que retorna sugerencias de búsqueda en JSON"""
- query = request.GET.get('q', '').strip()
- suggestions = []
-
- if query and len(query) >= 2: # Mínimo 2 caracteres para sugerir
- # Buscar en nombre (primario) y descripción
- products = Product.objects.filter(
- models.Q(name__icontains=query) |
- models.Q(briefdesc__icontains=query)
- ).values_list('name', 'id', 'price', 'primary_image_id').distinct()[:8] # Máximo 8 sugerencias
-
- for name, product_id, price, image_id in products:
- suggestions.append({
- 'name': name,
- 'id': product_id,
- 'price': float(price)
- })
-
- return JsonResponse({'suggestions': suggestions})
+# ==================== STRIPE PAYMENT INTENTS ====================
+
+@login_required
+def crear_payment_intent(request: HttpRequest):
+ """
+ Crea un Stripe PaymentIntent para el carrito actual.
+ Acepta JSON: { shipping_address_id, saved_payment_method_id (opcional), save_card (bool) }
+ """
+ if request.method != "POST":
+ return JsonResponse({"error": "Método no permitido"}, status=405)
+
+ try:
+ payload = json.loads(request.body.decode("utf-8") or "{}")
+ except (json.JSONDecodeError, UnicodeDecodeError):
+ return JsonResponse({"error": "Cuerpo de la petición inválido"}, status=400)
+
+ shipping_address = _get_selected_shipping_address(request)
+ if shipping_address is None:
+ return JsonResponse({"error": "Debes seleccionar una dirección de envío válida."}, status=400)
+
+ cart = get_or_create_cart(request)
+ cart_items = list(cart.items.select_related("product"))
+
+ if not cart_items:
+ return JsonResponse({"error": "El carrito está vacío"}, status=400)
+
+ active_reservation_ids = _get_active_reservation_ids_for_request(request)
+ stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids)
+ if stock_issues:
+ return JsonResponse({"error": _build_stock_issue_message(stock_issues[0])}, status=400)
+
+ reservation, reservation_issues = _create_stock_reservation_for_cart(
+ request, cart_items, StockReservation.PAYMENT_STRIPE,
+ )
+ if reservation is None:
+ return JsonResponse({"error": reservation_issues[0]}, status=400)
+
+ try:
+ stripe.api_key = settings.STRIPE_SECRET_KEY
+
+ order_total = sum(
+ get_price_with_vat_decimal(item.product.price) * item.quantity
+ for item in cart_items
+ )
+ amount_cents = int(
+ (order_total).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) * 100
+ )
+
+ pi_params = {
+ "amount": amount_cents,
+ "currency": "eur",
+ "automatic_payment_methods": {"enabled": False},
+ "payment_method_types": ["card"],
+ }
+
+ # If using a saved card, attach customer + payment_method
+ saved_pm_id = payload.get("saved_payment_method_id")
+ if saved_pm_id:
+ saved_pm = SavedPaymentMethod.objects.filter(
+ id=saved_pm_id,
+ user=request.user,
+ method_type=SavedPaymentMethod.TYPE_CARD,
+ ).first()
+ if saved_pm is None:
+ return JsonResponse({"error": "Método de pago no encontrado."}, status=400)
+ pi_params["customer"] = saved_pm.stripe_customer_id
+ pi_params["payment_method"] = saved_pm.stripe_payment_method_id
+
+ payment_intent = stripe.PaymentIntent.create(**pi_params)
+
+ request.session[STOCK_RESERVATION_SESSION_KEY] = reservation.id
+ request.session[STOCK_RESERVATION_PAYMENT_SESSION_KEY] = StockReservation.PAYMENT_STRIPE
+ request.session["selected_shipping_address_id"] = shipping_address.id
+ request.session["stripe_save_card"] = bool(payload.get("save_card", False))
+
+ return JsonResponse({
+ "client_secret": payment_intent.client_secret,
+ "payment_intent_id": payment_intent.id,
+ })
+
+ except Exception as e:
+ logger.exception("CREATE_PAYMENT_INTENT_ERROR user_id=%s error=%s", request.user.id, str(e))
+ return JsonResponse({"error": "Error al crear el pago. Por favor inténtalo de nuevo."}, status=500)
+
+
+@login_required
+def confirmar_pago_tarjeta(request: HttpRequest):
+ """
+ Verificar que el PaymentIntent fue exitoso y crear el pedido.
+ Acepta JSON: { payment_intent_id, payment_method_id (si nueva tarjeta) }
+ """
+ if request.method != "POST":
+ return JsonResponse({"error": "Método no permitido"}, status=405)
+
+ try:
+ payload = json.loads(request.body.decode("utf-8") or "{}")
+ except (json.JSONDecodeError, UnicodeDecodeError):
+ return JsonResponse({"error": "Cuerpo de la petición inválido"}, status=400)
+
+ payment_intent_id = payload.get("payment_intent_id")
+ if not payment_intent_id:
+ return JsonResponse({"error": "Falta el ID del intento de pago"}, status=400)
+
+ try:
+ stripe.api_key = settings.STRIPE_SECRET_KEY
+ payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id)
+ except Exception as e:
+ logger.exception("RETRIEVE_PAYMENT_INTENT_ERROR user_id=%s error=%s", request.user.id, str(e))
+ return JsonResponse({"error": "Error al verificar el pago"}, status=500)
+
+ if payment_intent.status != "succeeded":
+ return JsonResponse({"error": f"El pago no fue completado (estado: {payment_intent.status})"}, status=400)
+
+ shipping_address_id = request.session.get("selected_shipping_address_id")
+ shipping_address = ShippingAddress.objects.filter(id=shipping_address_id, user=request.user).first()
+ reservation = _get_session_stock_reservation(request, StockReservation.PAYMENT_STRIPE)
+
+ order, order_error = create_order_from_cart(
+ request,
+ Order.PAYMENT_STRIPE,
+ payment_intent_id,
+ shipping_address,
+ stock_reservation=reservation,
+ )
+
+ if order is None:
+ return JsonResponse({"error": order_error}, status=400)
+
+ # Optionally save the card for future use
+ save_card = request.session.pop("stripe_save_card", False)
+ new_payment_method_id = payload.get("payment_method_id")
+ if save_card and new_payment_method_id:
+ try:
+ customer_id = _get_or_create_stripe_customer(request.user)
+ pm = stripe.PaymentMethod.retrieve(new_payment_method_id)
+ stripe.PaymentMethod.attach(new_payment_method_id, customer=customer_id)
+ card = pm.card
+ label = f"{card.brand.capitalize()} •••• {card.last4}"
+ SavedPaymentMethod.objects.create(
+ user=request.user,
+ method_type=SavedPaymentMethod.TYPE_CARD,
+ label=label,
+ stripe_customer_id=customer_id,
+ stripe_payment_method_id=new_payment_method_id,
+ is_default=not SavedPaymentMethod.objects.filter(user=request.user).exists(),
+ )
+ except Exception as e:
+ logger.warning("SAVE_CARD_ERROR user_id=%s error=%s", request.user.id, str(e))
+
+ if "selected_shipping_address_id" in request.session:
+ del request.session["selected_shipping_address_id"]
+ _clear_stock_reservation_session(request)
+
+ return JsonResponse({"success": True, "order_id": order.id, "transaction_code": order.transaction_code})
+
+
+# ==================== PAYPAL ORDERS API ====================
+
+@login_required
+def crear_orden_paypal(request: HttpRequest):
+ """
+ Crea una orden de PayPal con el total del carrito actual (Orders API v2).
+ Acepta JSON: { shipping_address_id }
+ Retorna: { id: paypal_order_id }
+ """
+ if request.method != "POST":
+ return JsonResponse({"error": "Método no permitido"}, status=405)
+
+ shipping_address = _get_selected_shipping_address(request)
+ if shipping_address is None:
+ return JsonResponse({"error": "Debes seleccionar una dirección de envío válida."}, status=400)
+
+ cart = get_or_create_cart(request)
+ cart_items = list(cart.items.select_related("product"))
+
+ if not cart_items:
+ return JsonResponse({"error": "El carrito está vacío"}, status=400)
+
+ active_reservation_ids = _get_active_reservation_ids_for_request(request)
+ stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids)
+ if stock_issues:
+ return JsonResponse({"error": _build_stock_issue_message(stock_issues[0])}, status=400)
+
+ reservation, reservation_issues = _create_stock_reservation_for_cart(
+ request, cart_items, StockReservation.PAYMENT_PAYPAL,
+ )
+ if reservation is None:
+ return JsonResponse({"error": reservation_issues[0]}, status=400)
+
+ try:
+ order_total = sum(
+ get_price_with_vat_decimal(item.product.price) * item.quantity
+ for item in cart_items
+ )
+ order_total = order_total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
+
+ paypal_order = _paypal_create_order(order_total)
+ paypal_order_id = paypal_order.get("id")
+
+ request.session["paypal_order_id"] = paypal_order_id
+ request.session["selected_shipping_address_id"] = shipping_address.id
+ request.session[STOCK_RESERVATION_SESSION_KEY] = reservation.id
+ request.session[STOCK_RESERVATION_PAYMENT_SESSION_KEY] = StockReservation.PAYMENT_PAYPAL
+
+ return JsonResponse({"id": paypal_order_id})
+
+ except Exception as e:
+ logger.exception("CREAR_ORDEN_PAYPAL_ERROR user_id=%s error=%s", request.user.id, str(e))
+ return JsonResponse({"error": "Error al crear la orden de PayPal. Por favor inténtalo de nuevo."}, status=500)
+
+
+@login_required
+def capturar_orden_paypal(request: HttpRequest):
+ """
+ Captura una orden de PayPal aprobada y crea el pedido en nuestra BD.
+ Acepta JSON: { orderID }
+ """
+ if request.method != "POST":
+ return JsonResponse({"error": "Método no permitido"}, status=405)
+
+ try:
+ payload = json.loads(request.body.decode("utf-8") or "{}")
+ except (json.JSONDecodeError, UnicodeDecodeError):
+ return JsonResponse({"error": "Cuerpo de la petición inválido"}, status=400)
+
+ paypal_order_id = payload.get("orderID")
+ if not paypal_order_id:
+ return JsonResponse({"error": "Falta el ID de la orden de PayPal"}, status=400)
+
+ # Verify this order belongs to this session
+ session_order_id = request.session.get("paypal_order_id")
+ if session_order_id != paypal_order_id:
+ logger.warning(
+ "PAYPAL_ORDER_MISMATCH user_id=%s session=%s received=%s",
+ request.user.id, session_order_id, paypal_order_id,
+ )
+ return JsonResponse({"error": "ID de orden inválido"}, status=400)
+
+ try:
+ capture_data = _paypal_capture_order(paypal_order_id)
+ except Exception as e:
+ logger.exception("CAPTURAR_ORDEN_PAYPAL_ERROR user_id=%s error=%s", request.user.id, str(e))
+ return JsonResponse({"error": "Error al capturar el pago de PayPal. Por favor inténtalo de nuevo."}, status=500)
+
+ capture_status = capture_data.get("status")
+ if capture_status != "COMPLETED":
+ return JsonResponse({"error": f"El pago de PayPal no fue completado (estado: {capture_status})"}, status=400)
+
+ # Extract payer info to optionally save as payment method
+ payer = capture_data.get("payer", {})
+ payer_email = payer.get("email_address", "")
+ payer_id = payer.get("payer_id", "")
+
+ shipping_address_id = request.session.get("selected_shipping_address_id")
+ shipping_address = ShippingAddress.objects.filter(id=shipping_address_id, user=request.user).first()
+ reservation = _get_session_stock_reservation(request, StockReservation.PAYMENT_PAYPAL)
+
+ order, order_error = create_order_from_cart(
+ request,
+ Order.PAYMENT_PAYPAL,
+ paypal_order_id,
+ shipping_address,
+ stock_reservation=reservation,
+ )
+
+ if order is None:
+ return JsonResponse({"error": order_error}, status=400)
+
+ # Save payer info if they want to store the PayPal account (offered in the template)
+ save_paypal = payload.get("save_paypal", False)
+ if save_paypal and payer_email:
+ already_saved = SavedPaymentMethod.objects.filter(
+ user=request.user,
+ method_type=SavedPaymentMethod.TYPE_PAYPAL,
+ paypal_email=payer_email,
+ ).exists()
+ if not already_saved:
+ SavedPaymentMethod.objects.create(
+ user=request.user,
+ method_type=SavedPaymentMethod.TYPE_PAYPAL,
+ label=payer_email,
+ paypal_email=payer_email,
+ paypal_payer_id=payer_id,
+ is_default=not SavedPaymentMethod.objects.filter(user=request.user).exists(),
+ )
+
+ if "paypal_order_id" in request.session:
+ del request.session["paypal_order_id"]
+ if "selected_shipping_address_id" in request.session:
+ del request.session["selected_shipping_address_id"]
+ _clear_stock_reservation_session(request)
+
+ return JsonResponse({
+ "success": True,
+ "order_id": order.id,
+ "transaction_code": order.transaction_code,
+ "payer_email": payer_email,
+ })
+
+
+# ==================== MÉTODOS DE PAGO DEL USUARIO ====================
+
+@login_required
+def metodos_pago(request: HttpRequest):
+ """Lista los métodos de pago guardados del usuario."""
+ metodos = SavedPaymentMethod.objects.filter(user=request.user)
+ return render(request, "tienda/metodos_pago.html", {
+ "metodos": metodos,
+ "cards_exist": metodos.filter(method_type=SavedPaymentMethod.TYPE_CARD).exists(),
+ "paypal_exist": metodos.filter(method_type=SavedPaymentMethod.TYPE_PAYPAL).exists(),
+ })
+
+
+@login_required
+def agregar_tarjeta(request: HttpRequest):
+ """Página para añadir una nueva tarjeta usando Stripe SetupIntent."""
+ return render(request, "tienda/agregar_tarjeta.html", {
+ "stripe_publishable_key": settings.STRIPE_PUBLISHABLE_KEY,
+ })
+
+
+@login_required
+def crear_setup_intent(request: HttpRequest):
+ """
+ Crea un Stripe SetupIntent y retorna el client_secret para que el frontend
+ pueda montar el Card Element y confirmar sin realizar un cobro.
+ """
+ if request.method != "POST":
+ return JsonResponse({"error": "Método no permitido"}, status=405)
+ try:
+ stripe.api_key = settings.STRIPE_SECRET_KEY
+ customer_id = _get_or_create_stripe_customer(request.user)
+ setup_intent = stripe.SetupIntent.create(
+ customer=customer_id,
+ payment_method_types=["card"],
+ )
+ return JsonResponse({
+ "client_secret": setup_intent.client_secret,
+ "customer_id": customer_id,
+ })
+ except Exception as e:
+ logger.exception("CREATE_SETUP_INTENT_ERROR user_id=%s error=%s", request.user.id, str(e))
+ return JsonResponse({"error": "Error al iniciar el proceso de configuración. Por favor inténtalo de nuevo."}, status=500)
+
+
+@login_required
+def confirmar_setup_intent(request: HttpRequest):
+ """
+ Tras la confirmación del SetupIntent en el frontend, guarda la tarjeta.
+ Acepta JSON: { payment_method_id, setup_intent_id }
+ """
+ if request.method != "POST":
+ return JsonResponse({"error": "Método no permitido"}, status=405)
+
+ try:
+ payload = json.loads(request.body.decode("utf-8") or "{}")
+ except (json.JSONDecodeError, UnicodeDecodeError):
+ return JsonResponse({"error": "Cuerpo de la petición inválido"}, status=400)
+
+ payment_method_id = payload.get("payment_method_id")
+ if not payment_method_id:
+ return JsonResponse({"error": "Falta el ID del método de pago"}, status=400)
+
+ try:
+ stripe.api_key = settings.STRIPE_SECRET_KEY
+ customer_id = _get_or_create_stripe_customer(request.user)
+
+ # Attach the PaymentMethod to the customer
+ stripe.PaymentMethod.attach(payment_method_id, customer=customer_id)
+
+ pm = stripe.PaymentMethod.retrieve(payment_method_id)
+ card = pm.card
+ label = f"{card.brand.capitalize()} •••• {card.last4} (exp. {card.exp_month:02d}/{card.exp_year})"
+
+ has_existing = SavedPaymentMethod.objects.filter(user=request.user).exists()
+ saved = SavedPaymentMethod.objects.create(
+ user=request.user,
+ method_type=SavedPaymentMethod.TYPE_CARD,
+ label=label,
+ stripe_customer_id=customer_id,
+ stripe_payment_method_id=payment_method_id,
+ is_default=not has_existing,
+ )
+
+ return JsonResponse({"success": True, "label": label, "id": saved.id})
+
+ except Exception as e:
+ logger.exception("CONFIRMAR_SETUP_INTENT_ERROR user_id=%s error=%s", request.user.id, str(e))
+ return JsonResponse({"error": "Error al guardar la tarjeta. Por favor inténtalo de nuevo."}, status=500)
+
+
+@login_required
+def eliminar_metodo_pago(request: HttpRequest, id: int):
+ """Elimina un método de pago guardado del usuario."""
+ if request.method != "POST":
+ messages.error(request, "Acción no permitida.")
+ return redirect("metodos_pago")
+
+ metodo = get_object_or_404(SavedPaymentMethod, id=id, user=request.user)
+
+ # If it's a Stripe card, detach from Stripe too
+ if metodo.method_type == SavedPaymentMethod.TYPE_CARD and metodo.stripe_payment_method_id:
+ try:
+ stripe.api_key = settings.STRIPE_SECRET_KEY
+ stripe.PaymentMethod.detach(metodo.stripe_payment_method_id)
+ except Exception as e:
+ logger.warning("DETACH_PAYMENT_METHOD_ERROR user_id=%s error=%s", request.user.id, str(e))
+
+ metodo.delete()
+ messages.success(request, "Método de pago eliminado correctamente.")
+ return redirect("metodos_pago")
+
+
+@login_required
+def agregar_paypal(request: HttpRequest):
+ """Página para guardar una cuenta de PayPal como método de pago (usa un pago de verificación de 0.01 €)."""
+ return render(request, "tienda/agregar_paypal.html", {
+ "paypal_client_id": settings.PAYPAL_CLIENT_ID,
+ })
+
+
+@login_required
+def crear_orden_paypal_setup(request: HttpRequest):
+ """
+ Crea una orden PayPal de 0.01 € para verificar/guardar la cuenta.
+ Retorna { id: paypal_order_id }
+ """
+ if request.method != "POST":
+ return JsonResponse({"error": "Método no permitido"}, status=405)
+ try:
+ paypal_order = _paypal_create_order(Decimal("0.01"))
+ return JsonResponse({"id": paypal_order.get("id")})
+ except Exception as e:
+ logger.exception("CREAR_ORDEN_PAYPAL_SETUP_ERROR user_id=%s error=%s", request.user.id, str(e))
+ return JsonResponse({"error": "Error al iniciar la verificación de PayPal. Por favor inténtalo de nuevo."}, status=500)
+
+
+@login_required
+def capturar_orden_paypal_setup(request: HttpRequest):
+ """
+ Captura la orden de verificación de PayPal y guarda la cuenta del usuario.
+ Acepta JSON: { orderID }
+ """
+ if request.method != "POST":
+ return JsonResponse({"error": "Método no permitido"}, status=405)
+
+ try:
+ payload = json.loads(request.body.decode("utf-8") or "{}")
+ except (json.JSONDecodeError, UnicodeDecodeError):
+ return JsonResponse({"error": "Cuerpo de la petición inválido"}, status=400)
+
+ paypal_order_id = payload.get("orderID")
+ if not paypal_order_id:
+ return JsonResponse({"error": "Falta el ID de la orden"}, status=400)
+
+ try:
+ capture_data = _paypal_capture_order(paypal_order_id)
+ except Exception as e:
+ logger.exception("CAPTURAR_PAYPAL_SETUP_ERROR user_id=%s error=%s", request.user.id, str(e))
+ return JsonResponse({"error": "Error al verificar la cuenta de PayPal. Por favor inténtalo de nuevo."}, status=500)
+
+ if capture_data.get("status") != "COMPLETED":
+ return JsonResponse({"error": "No se pudo verificar la cuenta de PayPal"}, status=400)
+
+ payer = capture_data.get("payer", {})
+ payer_email = payer.get("email_address", "")
+ payer_id = payer.get("payer_id", "")
+
+ if not payer_email:
+ return JsonResponse({"error": "No se pudo obtener el email de PayPal"}, status=400)
+
+ already_saved = SavedPaymentMethod.objects.filter(
+ user=request.user,
+ method_type=SavedPaymentMethod.TYPE_PAYPAL,
+ paypal_email=payer_email,
+ ).exists()
+
+ if not already_saved:
+ has_existing = SavedPaymentMethod.objects.filter(user=request.user).exists()
+ SavedPaymentMethod.objects.create(
+ user=request.user,
+ method_type=SavedPaymentMethod.TYPE_PAYPAL,
+ label=payer_email,
+ paypal_email=payer_email,
+ paypal_payer_id=payer_id,
+ is_default=not has_existing,
+ )
+ return JsonResponse({"success": True, "email": payer_email, "already_existed": False})
+ else:
+ return JsonResponse({"success": True, "email": payer_email, "already_existed": True})
# ==================== PORTAL DE USUARIO ====================