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 %} +
+
+

Añadir cuenta de PayPal

+ +
+
+ +
+
+
+
+

+ 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. +

+ +
+ +
+ ✅ Cuenta de PayPal guardada correctamente. + Ver mis métodos de pago +
+
+ + 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 %} +
+
+

Añadir Tarjeta

+ +
+
+ +
+
+
+
+

+ 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. +

+ +
+ +
+ +
+ + +
+
+

Procesando...

+
+
+ ✅ Tarjeta guardada correctamente. + Ver mis métodos de pago +
+ + 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

-
- - - + +
+
+
2) Selecciona tu método de pago
+ + + + + +
+ {% if saved_cards %} +
+

Tarjetas guardadas:

+ {% for card in saved_cards %} +
+ + +
+ {% endfor %} +
+ + +
+
+ {% endif %} + +
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+ {% if saved_paypal %} +
+ Cuenta PayPal guardada: + {{ saved_paypal.paypal_email }} +
+ {% endif %} +
+ + +
+
+ {% 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 %} +
+
+

Métodos de Pago

+ +
+
+ + +
+
+ +
+
+ +
+ +
+
+
+
💳 Tarjetas
+ ➕ Añadir tarjeta +
+
+ {% with has_card=False %} + {% for metodo in metodos %}{% if metodo.method_type == 'card' %} +
+
+ {{ metodo.label }} + {% if metodo.is_default %}Predeterminada{% endif %} +
+
+ {% csrf_token %} + +
+
+ {% endif %}{% endfor %} + {% endwith %} + {% if not cards_exist %} +

No tienes tarjetas guardadas.

+ {% endif %} +
+
+
+ + +
+
+
+
🅿️ PayPal
+ ➕ Añadir PayPal +
+
+ {% for metodo in metodos %}{% if metodo.method_type == 'paypal' %} +
+
+ {{ metodo.paypal_email }} + {% if metodo.is_default %}Predeterminada{% endif %} +
+
+ {% csrf_token %} + +
+
+ {% 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 ====================