Merge pull request #8 from dsaub/copilot/update-payment-system-to-paypal-and-card
feat: Replace Stripe Checkout with Stripe Elements + PayPal JS SDK v2 (in-page payments)
This commit is contained in:
+8
-1
@@ -1,5 +1,5 @@
|
|||||||
from django.contrib import admin
|
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.
|
# Register your models here.
|
||||||
|
|
||||||
admin.site.register(Category)
|
admin.site.register(Category)
|
||||||
@@ -87,3 +87,10 @@ class StockReservationAdmin(admin.ModelAdmin):
|
|||||||
list_filter = ('status', 'payment_method', 'created_at')
|
list_filter = ('status', 'payment_method', 'created_at')
|
||||||
search_fields = ('user__username', 'user__email', 'session_key')
|
search_fields = ('user__username', 'user__email', 'session_key')
|
||||||
inlines = [StockReservationItemInline]
|
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')
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -243,6 +243,41 @@ class OrderMessage(models.Model):
|
|||||||
return f"Mensaje de {self.sender} - {self.created_at}"
|
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):
|
class ShippingAddress(models.Model):
|
||||||
"""Direcciones de entrega de los usuarios"""
|
"""Direcciones de entrega de los usuarios"""
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='shipping_addresses')
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='shipping_addresses')
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{% extends "tienda/base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<script src="https://www.paypal.com/sdk/js?client-id={{ paypal_client_id }}¤cy=EUR"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<h2>Añadir cuenta de PayPal</h2>
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'portal_usuario' %}">Portal de Usuario</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'metodos_pago' %}">Métodos de Pago</a></li>
|
||||||
|
<li class="breadcrumb-item active">Añadir PayPal</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-3 justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Se realizará un pequeño pago de verificación de <strong>0,01 €</strong> para confirmar
|
||||||
|
tu cuenta de PayPal. Tu cuenta quedará guardada para futuras compras.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="paypal-button-container" class="mb-3"></div>
|
||||||
|
|
||||||
|
<div id="paypal-success" class="alert alert-success d-none">
|
||||||
|
✅ Cuenta de PayPal guardada correctamente.
|
||||||
|
<a href="{% url 'metodos_pago' %}" class="alert-link">Ver mis métodos de pago</a>
|
||||||
|
</div>
|
||||||
|
<div id="paypal-error" class="alert alert-danger d-none"></div>
|
||||||
|
|
||||||
|
<a href="{% url 'metodos_pago' %}" class="btn btn-outline-secondary w-100 mt-3">Cancelar</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const CSRF_TOKEN = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||||
|
|
||||||
|
paypal.Buttons({
|
||||||
|
createOrder: async () => {
|
||||||
|
const resp = await fetch('{% url "crear_orden_paypal_setup" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) throw new Error(data.error || 'Error al iniciar la verificación');
|
||||||
|
return data.id;
|
||||||
|
},
|
||||||
|
onApprove: async (data) => {
|
||||||
|
const resp = await fetch('{% url "capturar_orden_paypal_setup" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN },
|
||||||
|
body: JSON.stringify({ orderID: data.orderID }),
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
document.getElementById('paypal-error').textContent = result.error || 'Error al guardar la cuenta';
|
||||||
|
document.getElementById('paypal-error').classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const successDiv = document.getElementById('paypal-success');
|
||||||
|
if (result.already_existed) {
|
||||||
|
successDiv.textContent = `La cuenta ${result.email} ya estaba guardada.`;
|
||||||
|
} else {
|
||||||
|
successDiv.textContent = `✅ Cuenta ${result.email} guardada correctamente.`;
|
||||||
|
}
|
||||||
|
successDiv.innerHTML += ' <a href="{% url "metodos_pago" %}" class="alert-link">Ver mis métodos de pago</a>';
|
||||||
|
successDiv.classList.remove('d-none');
|
||||||
|
document.getElementById('paypal-button-container').style.display = 'none';
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
document.getElementById('paypal-error').textContent = 'Error en PayPal: ' + err;
|
||||||
|
document.getElementById('paypal-error').classList.remove('d-none');
|
||||||
|
},
|
||||||
|
}).render('#paypal-button-container');
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
{% extends "tienda/base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<script src="https://js.stripe.com/v3/"></script>
|
||||||
|
<style>
|
||||||
|
#card-element {
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 12px;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
#card-element.StripeElement--focus {
|
||||||
|
border-color: #86b7fe;
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, .25);
|
||||||
|
}
|
||||||
|
#card-element.StripeElement--invalid { border-color: #dc3545; }
|
||||||
|
#card-errors { color: #dc3545; font-size: 0.875rem; margin-top: 4px; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<h2>Añadir Tarjeta</h2>
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'portal_usuario' %}">Portal de Usuario</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'metodos_pago' %}">Métodos de Pago</a></li>
|
||||||
|
<li class="breadcrumb-item active">Añadir Tarjeta</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-3 justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Datos de la tarjeta</label>
|
||||||
|
<div id="card-element"></div>
|
||||||
|
<div id="card-errors" role="alert"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="save-card-btn" class="btn btn-primary w-100">
|
||||||
|
💳 Guardar tarjeta
|
||||||
|
</button>
|
||||||
|
<div id="save-spinner" class="text-center mt-3 d-none">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
<p class="text-muted mt-2">Procesando...</p>
|
||||||
|
</div>
|
||||||
|
<div id="save-success" class="alert alert-success mt-3 d-none">
|
||||||
|
✅ Tarjeta guardada correctamente.
|
||||||
|
<a href="{% url 'metodos_pago' %}" class="alert-link">Ver mis métodos de pago</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="{% url 'metodos_pago' %}" class="btn btn-outline-secondary w-100 mt-3">Cancelar</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const CSRF_TOKEN = document.querySelector('[name=csrfmiddlewaretoken]') ?
|
||||||
|
document.querySelector('[name=csrfmiddlewaretoken]').value :
|
||||||
|
document.cookie.split('; ').find(r => r.startsWith('csrftoken='))?.split('=')[1] || '';
|
||||||
|
|
||||||
|
const stripe = Stripe('{{ stripe_publishable_key }}');
|
||||||
|
const elements = stripe.elements();
|
||||||
|
const cardElement = elements.create('card', { hidePostalCode: true });
|
||||||
|
cardElement.mount('#card-element');
|
||||||
|
|
||||||
|
cardElement.on('change', e => {
|
||||||
|
document.getElementById('card-errors').textContent = e.error ? e.error.message : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('save-card-btn').addEventListener('click', async () => {
|
||||||
|
const btn = document.getElementById('save-card-btn');
|
||||||
|
const spinner = document.getElementById('save-spinner');
|
||||||
|
const errDiv = document.getElementById('card-errors');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
spinner.classList.remove('d-none');
|
||||||
|
errDiv.textContent = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Get SetupIntent client_secret from backend
|
||||||
|
const siResp = await fetch('{% url "crear_setup_intent" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const siData = await siResp.json();
|
||||||
|
if (!siResp.ok) throw new Error(siData.error || 'Error al iniciar el proceso');
|
||||||
|
|
||||||
|
// 2. Confirm SetupIntent with the card element
|
||||||
|
const { setupIntent, error } = await stripe.confirmCardSetup(siData.client_secret, {
|
||||||
|
payment_method: { card: cardElement },
|
||||||
|
});
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
|
||||||
|
// 3. Save to database
|
||||||
|
const saveResp = await fetch('{% url "confirmar_setup_intent" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN },
|
||||||
|
body: JSON.stringify({ payment_method_id: setupIntent.payment_method }),
|
||||||
|
});
|
||||||
|
const saveData = await saveResp.json();
|
||||||
|
if (!saveResp.ok) throw new Error(saveData.error || 'Error al guardar la tarjeta');
|
||||||
|
|
||||||
|
// 4. Success
|
||||||
|
spinner.classList.add('d-none');
|
||||||
|
document.getElementById('save-success').classList.remove('d-none');
|
||||||
|
btn.style.display = 'none';
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
errDiv.textContent = err.message;
|
||||||
|
btn.disabled = false;
|
||||||
|
spinner.classList.add('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -4,43 +4,31 @@
|
|||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<script src="https://js.stripe.com/v3/"></script>
|
<script src="https://js.stripe.com/v3/"></script>
|
||||||
<script src="{% static 'js/checkout.js' %}"></script>
|
<script src="https://www.paypal.com/sdk/js?client-id={{ paypal_client_id }}¤cy=EUR" defer></script>
|
||||||
<script defer src="https://use.fontawesome.com/releases/v6.4.0/js/all.js"></script>
|
|
||||||
<style>
|
<style>
|
||||||
.payment-methods {
|
#card-element {
|
||||||
display: flex;
|
border: 1px solid #ced4da;
|
||||||
gap: 15px;
|
border-radius: 0.375rem;
|
||||||
flex-wrap: wrap;
|
padding: 12px;
|
||||||
margin-top: 20px;
|
background-color: #fff;
|
||||||
}
|
}
|
||||||
|
#card-element.StripeElement--focus {
|
||||||
.payment-btn {
|
border-color: #86b7fe;
|
||||||
flex: 1;
|
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, .25);
|
||||||
min-width: 200px;
|
|
||||||
padding: 12px 20px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
#card-element.StripeElement--invalid {
|
||||||
.payment-section {
|
border-color: #dc3545;
|
||||||
background-color: #f8f9fa;
|
|
||||||
padding: 30px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payment-section h3 {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
color: #212529;
|
|
||||||
}
|
}
|
||||||
|
#card-errors { color: #dc3545; font-size: 0.875rem; margin-top: 4px; }
|
||||||
|
.payment-tab-content { display: none; }
|
||||||
|
.payment-tab-content.active { display: block; }
|
||||||
|
#paypal-button-container { min-height: 50px; }
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row mt-2">
|
<div class="row mt-2">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<!-- Token CSRF para requests AJAX -->
|
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
@@ -66,6 +54,7 @@
|
|||||||
Si el pago no se completa en ese tiempo, la reserva se cancelará automáticamente.
|
Si el pago no se completa en ese tiempo, la reserva se cancelará automáticamente.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1: Dirección de envío -->
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title mb-3">1) Selecciona la dirección de envío</h5>
|
<h5 class="card-title mb-3">1) Selecciona la dirección de envío</h5>
|
||||||
@@ -90,6 +79,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Resumen del pedido -->
|
||||||
<div class="table-responsive mb-4">
|
<div class="table-responsive mb-4">
|
||||||
<table class="table table-striped align-middle">
|
<table class="table table-striped align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -114,42 +104,110 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<th colspan="2" class="text-end">Subtotal:</th>
|
<th colspan="4" class="text-end">Subtotal:</th>
|
||||||
<th colspan="2" class="text-end">{{ cart.get_total|format_price }}€</th>
|
<th class="text-end">{{ cart.get_total|format_price }}€</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th colspan="2" class="text-end">IVA (21%):</th>
|
<th colspan="4" class="text-end">IVA (21%):</th>
|
||||||
<th colspan="2" class="text-end text-success">+{{ cart.get_vat_amount|format_price }}€</th>
|
<th class="text-end text-success">+{{ cart.get_vat_amount|format_price }}€</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr style="background-color: #f8f9fa;">
|
<tr style="background-color: #f8f9fa;">
|
||||||
<th colspan="2" class="text-end" style="font-size: 1.1rem;">Total:</th>
|
<th colspan="4" class="text-end" style="font-size: 1.1rem;">Total:</th>
|
||||||
<th colspan="2" class="text-end" style="font-size: 1.1rem;">{{ cart.get_total_with_vat|format_price }}€</th>
|
<th class="text-end" style="font-size: 1.1rem;">{{ cart.get_total_with_vat|format_price }}€</th>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="payment-section">
|
<!-- Step 2: Método de pago -->
|
||||||
<h3>2) Selecciona tu método de pago</h3>
|
<div class="card mb-4">
|
||||||
<div class="payment-methods">
|
<div class="card-body">
|
||||||
<button
|
<h5 class="card-title mb-3">2) Selecciona tu método de pago</h5>
|
||||||
id="checkout-button"
|
|
||||||
class="btn btn-primary payment-btn"
|
<!-- Tabs -->
|
||||||
data-config-url="/tienda/config/"
|
<ul class="nav nav-tabs mb-3" id="paymentTabs" role="tablist">
|
||||||
data-session-url="/tienda/create-checkout-session/"
|
<li class="nav-item" role="presentation">
|
||||||
{% if not addresses or stock_issues %}disabled{% endif %}>
|
<button class="nav-link active" id="tab-card" data-tab="pane-card" type="button" role="tab">
|
||||||
💳 Pagar con Stripe
|
💳 Tarjeta
|
||||||
</button>
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="tab-paypal" data-tab="pane-paypal" type="button" role="tab">
|
||||||
|
🅿️ PayPal
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Tarjeta tab -->
|
||||||
|
<div id="pane-card" class="payment-tab-content active">
|
||||||
|
{% if saved_cards %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<p class="fw-semibold">Tarjetas guardadas:</p>
|
||||||
|
{% for card in saved_cards %}
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input class="form-check-input" type="radio" name="saved_card_choice" id="card-{{ card.id }}" value="{{ card.id }}" data-pm-id="{{ card.stripe_payment_method_id }}" {% if card.is_default %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="card-{{ card.id }}">
|
||||||
|
{{ card.label }}
|
||||||
|
{% if card.is_default %}<span class="badge bg-secondary ms-1">Predeterminada</span>{% endif %}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="radio" name="saved_card_choice" id="card-new" value="new">
|
||||||
|
<label class="form-check-label" for="card-new">Usar nueva tarjeta</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div id="new-card-section" {% if saved_cards %}style="display:none;"{% endif %}>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Número de tarjeta</label>
|
||||||
|
<div id="card-element"></div>
|
||||||
|
<div id="card-errors" role="alert"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="save-card-checkbox">
|
||||||
|
<label class="form-check-label" for="save-card-checkbox">
|
||||||
|
Guardar esta tarjeta para futuras compras
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="paypal-button"
|
id="pay-card-btn"
|
||||||
class="btn btn-warning payment-btn"
|
class="btn btn-primary"
|
||||||
data-payment-url="{% url 'create_paypal_payment' %}"
|
|
||||||
{% if not addresses or stock_issues %}disabled{% endif %}>
|
{% if not addresses or stock_issues %}disabled{% endif %}>
|
||||||
🅿️ Pagar con PayPal
|
💳 Pagar con tarjeta
|
||||||
</button>
|
</button>
|
||||||
|
<div id="card-spinner" class="spinner-border spinner-border-sm ms-2 text-primary d-none" role="status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PayPal tab -->
|
||||||
|
<div id="pane-paypal" class="payment-tab-content">
|
||||||
|
{% if saved_paypal %}
|
||||||
|
<div class="alert alert-light border mb-3">
|
||||||
|
<small class="text-muted">Cuenta PayPal guardada:</small>
|
||||||
|
<strong class="d-block">{{ saved_paypal.paypal_email }}</strong>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="save-paypal-checkbox">
|
||||||
|
<label class="form-check-label" for="save-paypal-checkbox">
|
||||||
|
Guardar esta cuenta de PayPal para futuras compras
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="paypal-button-container"></div>
|
||||||
|
{% if not addresses or stock_issues %}
|
||||||
|
<div class="alert alert-warning mt-2">Selecciona una dirección de envío válida para activar el pago.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Resultado inline -->
|
||||||
|
<div id="payment-result" class="d-none"></div>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="alert alert-info">Tu carrito está vacío.</div>
|
<div class="alert alert-info">Tu carrito está vacío.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -157,72 +215,169 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Manejo del botón de PayPal
|
const CSRF_TOKEN = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||||
const paypalButton = document.getElementById('paypal-button');
|
const STRIPE_KEY = '{{ stripe_publishable_key }}';
|
||||||
if (paypalButton) {
|
const HAS_STOCK_ISSUES = {{ stock_issues|yesno:"true,false" }};
|
||||||
paypalButton.addEventListener('click', async function(e) {
|
const HAS_ADDRESS = {{ addresses|yesno:"true,false" }};
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const shippingAddressSelect = document.getElementById('shipping-address');
|
// ---- Tab switching ----
|
||||||
const selectedShippingAddress = shippingAddressSelect ? shippingAddressSelect.value : '';
|
document.querySelectorAll('#paymentTabs .nav-link').forEach(btn => {
|
||||||
if (!selectedShippingAddress) {
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('#paymentTabs .nav-link').forEach(b => b.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.payment-tab-content').forEach(p => p.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
document.getElementById(btn.dataset.tab).classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Saved card / new card toggle ----
|
||||||
|
document.querySelectorAll('input[name="saved_card_choice"]').forEach(radio => {
|
||||||
|
radio.addEventListener('change', () => {
|
||||||
|
const newSection = document.getElementById('new-card-section');
|
||||||
|
if (newSection) {
|
||||||
|
newSection.style.display = radio.value === 'new' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Stripe ----
|
||||||
|
const stripe = Stripe(STRIPE_KEY);
|
||||||
|
const elements = stripe.elements();
|
||||||
|
const cardElement = elements.create('card', { hidePostalCode: true });
|
||||||
|
cardElement.mount('#card-element');
|
||||||
|
cardElement.on('change', e => {
|
||||||
|
document.getElementById('card-errors').textContent = e.error ? e.error.message : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('pay-card-btn').addEventListener('click', async () => {
|
||||||
|
if (HAS_STOCK_ISSUES || !HAS_ADDRESS) return;
|
||||||
|
|
||||||
|
const addressId = document.getElementById('shipping-address').value;
|
||||||
|
if (!addressId) {
|
||||||
alert('Selecciona una dirección de envío para continuar.');
|
alert('Selecciona una dirección de envío para continuar.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const button = this;
|
const btn = document.getElementById('pay-card-btn');
|
||||||
const originalText = button.innerHTML;
|
const spinner = document.getElementById('card-spinner');
|
||||||
button.disabled = true;
|
btn.disabled = true;
|
||||||
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Procesando...';
|
spinner.classList.remove('d-none');
|
||||||
|
|
||||||
|
// Determine if using saved card or new card
|
||||||
|
const savedCardRadio = document.querySelector('input[name="saved_card_choice"]:checked');
|
||||||
|
const usingSavedCard = savedCardRadio && savedCardRadio.value !== 'new';
|
||||||
|
const savedPmId = usingSavedCard ? savedCardRadio.dataset.pmId : null;
|
||||||
|
const saveCard = !usingSavedCard && document.getElementById('save-card-checkbox')?.checked;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Obtener CSRF token de múltiples formas
|
// 1. Create PaymentIntent
|
||||||
let csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value;
|
const piResp = await fetch('{% url "crear_payment_intent" %}', {
|
||||||
if (!csrfToken) {
|
|
||||||
csrfToken = document.querySelector('input[name="csrfmiddlewaretoken"]')?.value;
|
|
||||||
}
|
|
||||||
if (!csrfToken) {
|
|
||||||
csrfToken = document.cookie.split('; ').find(row => row.startsWith('csrftoken='))?.split('=')[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('CSRF Token encontrado:', csrfToken ? 'Sí' : 'No');
|
|
||||||
|
|
||||||
const response = await fetch(button.dataset.paymentUrl, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN },
|
||||||
'X-CSRFToken': csrfToken || '',
|
body: JSON.stringify({
|
||||||
'Content-Type': 'application/json',
|
shipping_address_id: addressId,
|
||||||
},
|
saved_payment_method_id: usingSavedCard ? savedCardRadio.value : null,
|
||||||
body: JSON.stringify({ shipping_address_id: selectedShippingAddress })
|
save_card: saveCard,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
const piData = await piResp.json();
|
||||||
|
if (!piResp.ok) { throw new Error(piData.error || 'Error al crear el pago'); }
|
||||||
|
|
||||||
console.log('Response status:', response.status);
|
const clientSecret = piData.client_secret;
|
||||||
|
|
||||||
const data = await response.json();
|
// 2. Confirm payment
|
||||||
console.log('Response data:', data);
|
let confirmResult;
|
||||||
|
if (usingSavedCard) {
|
||||||
if (response.ok && data.redirect) {
|
confirmResult = await stripe.confirmCardPayment(clientSecret, {
|
||||||
// Redirigir a PayPal
|
payment_method: savedPmId,
|
||||||
console.log('Redirigiendo a:', data.redirect);
|
});
|
||||||
window.location.href = data.redirect;
|
|
||||||
} else if (data.error) {
|
|
||||||
console.error('Error en respuesta:', data.error);
|
|
||||||
alert('Error: ' + data.error);
|
|
||||||
button.disabled = false;
|
|
||||||
button.innerHTML = originalText;
|
|
||||||
} else {
|
} else {
|
||||||
console.error('Respuesta inesperada:', data);
|
confirmResult = await stripe.confirmCardPayment(clientSecret, {
|
||||||
alert('Error inesperado al procesar el pago');
|
payment_method: { card: cardElement },
|
||||||
button.disabled = false;
|
});
|
||||||
button.innerHTML = originalText;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error en fetch:', error);
|
if (confirmResult.error) {
|
||||||
alert('Error al procesar el pago con PayPal: ' + error.message);
|
throw new Error(confirmResult.error.message);
|
||||||
button.disabled = false;
|
}
|
||||||
button.innerHTML = originalText;
|
|
||||||
|
const pi = confirmResult.paymentIntent;
|
||||||
|
if (pi.status !== 'succeeded') {
|
||||||
|
throw new Error('El pago no fue completado.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Confirm on server
|
||||||
|
const confirmPayload = { payment_intent_id: pi.id };
|
||||||
|
if (!usingSavedCard && saveCard) {
|
||||||
|
confirmPayload.payment_method_id = pi.payment_method;
|
||||||
|
}
|
||||||
|
const confirmResp = await fetch('{% url "confirmar_pago_tarjeta" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN },
|
||||||
|
body: JSON.stringify(confirmPayload),
|
||||||
|
});
|
||||||
|
const confirmData = await confirmResp.json();
|
||||||
|
if (!confirmResp.ok) { throw new Error(confirmData.error || 'Error al confirmar el pedido'); }
|
||||||
|
|
||||||
|
// 4. Show success
|
||||||
|
showSuccess(confirmData.transaction_code);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('card-errors').textContent = err.message;
|
||||||
|
btn.disabled = false;
|
||||||
|
spinner.classList.add('d-none');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- PayPal ----
|
||||||
|
{% if not stock_issues and addresses %}
|
||||||
|
paypal.Buttons({
|
||||||
|
createOrder: async () => {
|
||||||
|
const addressId = document.getElementById('shipping-address').value;
|
||||||
|
if (!addressId) {
|
||||||
|
alert('Selecciona una dirección de envío para continuar.');
|
||||||
|
return Promise.reject(new Error('Sin dirección'));
|
||||||
|
}
|
||||||
|
const resp = await fetch('{% url "crear_orden_paypal" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN },
|
||||||
|
body: JSON.stringify({ shipping_address_id: addressId }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) { throw new Error(data.error || 'Error al crear la orden'); }
|
||||||
|
return data.id;
|
||||||
|
},
|
||||||
|
onApprove: async (data) => {
|
||||||
|
const savePaypal = document.getElementById('save-paypal-checkbox')?.checked || false;
|
||||||
|
const resp = await fetch('{% url "capturar_orden_paypal" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN },
|
||||||
|
body: JSON.stringify({ orderID: data.orderID, save_paypal: savePaypal }),
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
if (!resp.ok) { throw new Error(result.error || 'Error al capturar el pago'); }
|
||||||
|
showSuccess(result.transaction_code);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
alert('Error en el pago con PayPal: ' + err);
|
||||||
|
},
|
||||||
|
}).render('#paypal-button-container');
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
// ---- Success helper ----
|
||||||
|
function showSuccess(transactionCode) {
|
||||||
|
const resultDiv = document.getElementById('payment-result');
|
||||||
|
resultDiv.classList.remove('d-none');
|
||||||
|
resultDiv.innerHTML = `
|
||||||
|
<div class="alert alert-success p-4 text-center">
|
||||||
|
<h4>¡Pago completado!</h4>
|
||||||
|
<p>Tu pedido ha sido procesado correctamente.</p>
|
||||||
|
${transactionCode ? `<p><strong>Código de transacción:</strong> ${transactionCode}</p>` : ''}
|
||||||
|
<a href="{% url 'index' %}" class="btn btn-primary mt-2">Volver a la tienda</a>
|
||||||
|
<a href="{% url 'mis_compras' %}" class="btn btn-outline-primary mt-2 ms-2">Ver mis compras</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
window.scrollTo({ top: resultDiv.offsetTop - 20, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
<a href="{% url 'portal_usuario' %}" class="btn btn-outline-primary">Inicio</a>
|
<a href="{% url 'portal_usuario' %}" class="btn btn-outline-primary">Inicio</a>
|
||||||
<a href="{% url 'editar_perfil' %}" class="btn btn-outline-primary">Mi Perfil</a>
|
<a href="{% url 'editar_perfil' %}" class="btn btn-outline-primary">Mi Perfil</a>
|
||||||
<a href="{% url 'direcciones_usuario' %}" class="btn btn-primary">Direcciones</a>
|
<a href="{% url 'direcciones_usuario' %}" class="btn btn-primary">Direcciones</a>
|
||||||
|
<a href="{% url 'metodos_pago' %}" class="btn btn-outline-primary">Métodos de Pago</a>
|
||||||
<a href="{% url 'mensajes_comprador' %}" class="btn btn-outline-primary">Mensajes</a>
|
<a href="{% url 'mensajes_comprador' %}" class="btn btn-outline-primary">Mensajes</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<a href="{% url 'portal_usuario' %}" class="btn btn-outline-primary">Inicio</a>
|
<a href="{% url 'portal_usuario' %}" class="btn btn-outline-primary">Inicio</a>
|
||||||
<a href="{% url 'editar_perfil' %}" class="btn btn-primary">Mi Perfil</a>
|
<a href="{% url 'editar_perfil' %}" class="btn btn-primary">Mi Perfil</a>
|
||||||
<a href="{% url 'direcciones_usuario' %}" class="btn btn-outline-primary">Direcciones</a>
|
<a href="{% url 'direcciones_usuario' %}" class="btn btn-outline-primary">Direcciones</a>
|
||||||
|
<a href="{% url 'metodos_pago' %}" class="btn btn-outline-primary">Métodos de Pago</a>
|
||||||
<a href="{% url 'mensajes_comprador' %}" class="btn btn-outline-primary">Mensajes</a>
|
<a href="{% url 'mensajes_comprador' %}" class="btn btn-outline-primary">Mensajes</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<a href="{% url 'portal_usuario' %}" class="btn btn-outline-primary">Inicio</a>
|
<a href="{% url 'portal_usuario' %}" class="btn btn-outline-primary">Inicio</a>
|
||||||
<a href="{% url 'editar_perfil' %}" class="btn btn-outline-primary">Mi Perfil</a>
|
<a href="{% url 'editar_perfil' %}" class="btn btn-outline-primary">Mi Perfil</a>
|
||||||
<a href="{% url 'direcciones_usuario' %}" class="btn btn-outline-primary">Direcciones</a>
|
<a href="{% url 'direcciones_usuario' %}" class="btn btn-outline-primary">Direcciones</a>
|
||||||
|
<a href="{% url 'metodos_pago' %}" class="btn btn-outline-primary">Métodos de Pago</a>
|
||||||
<a href="{% url 'mensajes_comprador' %}" class="btn btn-primary">Mensajes</a>
|
<a href="{% url 'mensajes_comprador' %}" class="btn btn-primary">Mensajes</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{% extends "tienda/base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<h2>Métodos de Pago</h2>
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'portal_usuario' %}">Portal de Usuario</a></li>
|
||||||
|
<li class="breadcrumb-item active">Métodos de Pago</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Menú de navegación del portal -->
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<a href="{% url 'portal_usuario' %}" class="btn btn-outline-primary">Inicio</a>
|
||||||
|
<a href="{% url 'mis_compras' %}" class="btn btn-outline-primary">Compras</a>
|
||||||
|
<a href="{% url 'mis_recibos' %}" class="btn btn-outline-primary">Recibos</a>
|
||||||
|
<a href="{% url 'editar_perfil' %}" class="btn btn-outline-primary">Mi Perfil</a>
|
||||||
|
<a href="{% url 'direcciones_usuario' %}" class="btn btn-outline-primary">Direcciones</a>
|
||||||
|
<a href="{% url 'metodos_pago' %}" class="btn btn-primary">Métodos de Pago</a>
|
||||||
|
<a href="{% url 'mensajes_comprador' %}" class="btn btn-outline-primary">Mensajes</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-4">
|
||||||
|
<!-- Tarjetas guardadas -->
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">💳 Tarjetas</h5>
|
||||||
|
<a href="{% url 'agregar_tarjeta' %}" class="btn btn-sm btn-success">➕ Añadir tarjeta</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% with has_card=False %}
|
||||||
|
{% for metodo in metodos %}{% if metodo.method_type == 'card' %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center border rounded p-3 mb-2">
|
||||||
|
<div>
|
||||||
|
<span class="fw-semibold">{{ metodo.label }}</span>
|
||||||
|
{% if metodo.is_default %}<span class="badge bg-secondary ms-2">Predeterminada</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{% url 'eliminar_metodo_pago' metodo.id %}" onsubmit="return confirm('¿Eliminar esta tarjeta?');">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger">Eliminar</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
{% if not cards_exist %}
|
||||||
|
<p class="text-muted mb-0">No tienes tarjetas guardadas.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cuentas PayPal guardadas -->
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">🅿️ PayPal</h5>
|
||||||
|
<a href="{% url 'agregar_paypal' %}" class="btn btn-sm btn-warning">➕ Añadir PayPal</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% for metodo in metodos %}{% if metodo.method_type == 'paypal' %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center border rounded p-3 mb-2">
|
||||||
|
<div>
|
||||||
|
<span class="fw-semibold">{{ metodo.paypal_email }}</span>
|
||||||
|
{% if metodo.is_default %}<span class="badge bg-secondary ms-2">Predeterminada</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{% url 'eliminar_metodo_pago' metodo.id %}" onsubmit="return confirm('¿Eliminar esta cuenta de PayPal?');">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger">Eliminar</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}{% endfor %}
|
||||||
|
{% if not paypal_exist %}
|
||||||
|
<p class="text-muted mb-0">No tienes cuentas de PayPal guardadas.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
<a href="{% url 'portal_usuario' %}" class="btn btn-outline-primary">Inicio</a>
|
<a href="{% url 'portal_usuario' %}" class="btn btn-outline-primary">Inicio</a>
|
||||||
<a href="{% url 'mis_compras' %}" class="btn btn-primary">Compras</a>
|
<a href="{% url 'mis_compras' %}" class="btn btn-primary">Compras</a>
|
||||||
<a href="{% url 'mis_recibos' %}" class="btn btn-outline-primary">Recibos</a>
|
<a href="{% url 'mis_recibos' %}" class="btn btn-outline-primary">Recibos</a>
|
||||||
|
<a href="{% url 'metodos_pago' %}" class="btn btn-outline-primary">Métodos de Pago</a>
|
||||||
<a href="{% url 'mensajes_comprador' %}" class="btn btn-outline-primary">Mensajes</a>
|
<a href="{% url 'mensajes_comprador' %}" class="btn btn-outline-primary">Mensajes</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<a href="{% url 'portal_usuario' %}" class="btn btn-outline-primary">Inicio</a>
|
<a href="{% url 'portal_usuario' %}" class="btn btn-outline-primary">Inicio</a>
|
||||||
<a href="{% url 'mis_compras' %}" class="btn btn-outline-primary">Compras</a>
|
<a href="{% url 'mis_compras' %}" class="btn btn-outline-primary">Compras</a>
|
||||||
<a href="{% url 'mis_recibos' %}" class="btn btn-primary">Recibos</a>
|
<a href="{% url 'mis_recibos' %}" class="btn btn-primary">Recibos</a>
|
||||||
|
<a href="{% url 'metodos_pago' %}" class="btn btn-outline-primary">Métodos de Pago</a>
|
||||||
<a href="{% url 'mensajes_comprador' %}" class="btn btn-outline-primary">Mensajes</a>
|
<a href="{% url 'mensajes_comprador' %}" class="btn btn-outline-primary">Mensajes</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<a href="{% url 'mis_recibos' %}" class="btn btn-outline-primary">Recibos</a>
|
<a href="{% url 'mis_recibos' %}" class="btn btn-outline-primary">Recibos</a>
|
||||||
<a href="{% url 'editar_perfil' %}" class="btn btn-outline-primary">Mi Perfil</a>
|
<a href="{% url 'editar_perfil' %}" class="btn btn-outline-primary">Mi Perfil</a>
|
||||||
<a href="{% url 'direcciones_usuario' %}" class="btn btn-outline-primary">Direcciones</a>
|
<a href="{% url 'direcciones_usuario' %}" class="btn btn-outline-primary">Direcciones</a>
|
||||||
|
<a href="{% url 'metodos_pago' %}" class="btn btn-outline-primary">Métodos de Pago</a>
|
||||||
<a href="{% url 'mensajes_comprador' %}" class="btn btn-outline-primary">Mensajes</a>
|
<a href="{% url 'mensajes_comprador' %}" class="btn btn-outline-primary">Mensajes</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,6 +68,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h5 class="card-title">💳 Métodos de Pago</h5>
|
||||||
|
<p class="text-muted">gestiona tarjetas y cuentas PayPal</p>
|
||||||
|
<a href="{% url 'metodos_pago' %}" class="btn btn-sm btn-primary">Gestionar</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pedidos recientes -->
|
<!-- Pedidos recientes -->
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+16
-4
@@ -25,12 +25,15 @@ urlpatterns = [
|
|||||||
path("cart/remove/<int:item_id>/", views.remove_from_cart, name="remove_from_cart"),
|
path("cart/remove/<int:item_id>/", views.remove_from_cart, name="remove_from_cart"),
|
||||||
path("cart/clear/", views.clear_cart, name="clear_cart"),
|
path("cart/clear/", views.clear_cart, name="clear_cart"),
|
||||||
path("checkout/", views.checkout, name="checkout"),
|
path("checkout/", views.checkout, name="checkout"),
|
||||||
# Stripe
|
# Stripe Payment Intents (nuevo sistema)
|
||||||
path("config/", views.stripe_config, name="stripe_config"),
|
path("checkout/crear-payment-intent/", views.crear_payment_intent, name="crear_payment_intent"),
|
||||||
path("create-checkout-session/", views.create_checkout_session, name="create_checkout_session"),
|
path("checkout/confirmar-pago-tarjeta/", views.confirmar_pago_tarjeta, name="confirmar_pago_tarjeta"),
|
||||||
path("checkout/success/", views.checkout_success, name="checkout_success"),
|
path("checkout/success/", views.checkout_success, name="checkout_success"),
|
||||||
path("checkout/cancel/", views.checkout_cancel, name="checkout_cancel"),
|
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/create-payment/", views.create_paypal_payment, name="create_paypal_payment"),
|
||||||
path("paypal/execute/", views.paypal_execute, name="paypal_execute"),
|
path("paypal/execute/", views.paypal_execute, name="paypal_execute"),
|
||||||
# Portal de usuario
|
# Portal de usuario
|
||||||
@@ -44,6 +47,15 @@ urlpatterns = [
|
|||||||
path("usuario/direcciones/<int:id>/editar/", views.editar_direccion, name="editar_direccion"),
|
path("usuario/direcciones/<int:id>/editar/", views.editar_direccion, name="editar_direccion"),
|
||||||
path("usuario/direcciones/<int:id>/eliminar/", views.eliminar_direccion, name="eliminar_direccion"),
|
path("usuario/direcciones/<int:id>/eliminar/", views.eliminar_direccion, name="eliminar_direccion"),
|
||||||
path("usuario/mensajes/", views.mensajes_comprador, name="mensajes_comprador"),
|
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/<int:id>/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/<str:code>", views.verify, name="verify"),
|
path("verify/<str:code>", views.verify, name="verify"),
|
||||||
path("rgpd", views.rgpd, name="rgpd"),
|
path("rgpd", views.rgpd, name="rgpd"),
|
||||||
path("privacidad", views.rgpd, name="privacidad"),
|
path("privacidad", views.rgpd, name="privacidad"),
|
||||||
|
|||||||
+599
-19
@@ -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.auth.decorators import login_required
|
||||||
from django.contrib import messages
|
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 . import tasks
|
||||||
from .vars import (
|
from .vars import (
|
||||||
PAGE_SIZE,
|
PAGE_SIZE,
|
||||||
@@ -28,6 +28,7 @@ import unicodedata
|
|||||||
import json
|
import json
|
||||||
import random, string
|
import random, string
|
||||||
import logging
|
import logging
|
||||||
|
import requests
|
||||||
# Create your views here.
|
# Create your views here.
|
||||||
|
|
||||||
|
|
||||||
@@ -97,6 +98,88 @@ def _get_client_ip(request: HttpRequest) -> str:
|
|||||||
return request.META.get("REMOTE_ADDR", "")
|
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:
|
def get_price_with_vat_decimal(price) -> Decimal:
|
||||||
"""Retorna un precio con IVA aplicado y redondeado a 2 decimales."""
|
"""Retorna un precio con IVA aplicado y redondeado a 2 decimales."""
|
||||||
return (Decimal(str(price)) * (Decimal("1") + Decimal(str(VAT_RATE)))).quantize(
|
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)
|
active_reservation_ids = _get_active_reservation_ids_for_request(request)
|
||||||
stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids)
|
stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids)
|
||||||
addresses = ShippingAddress.objects.filter(user=request.user)
|
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", {
|
return render(request, "tienda/checkout.html", {
|
||||||
"cart": cart,
|
"cart": cart,
|
||||||
"cart_items": cart_items,
|
"cart_items": cart_items,
|
||||||
"addresses": addresses,
|
"addresses": addresses,
|
||||||
"stock_issues": stock_issues,
|
"stock_issues": stock_issues,
|
||||||
"reservation_minutes": STOCK_RESERVATION_MINUTES,
|
"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
|
@csrf_exempt
|
||||||
@@ -1110,7 +1199,7 @@ def create_checkout_session(request: HttpRequest):
|
|||||||
return JsonResponse({"sessionId": session.id})
|
return JsonResponse({"sessionId": session.id})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("STRIPE_CHECKOUT_SESSION_ERROR user_id=%s error=%s", request.user.id, str(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
|
@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
|
@login_required
|
||||||
def create_paypal_payment(request: HttpRequest):
|
def create_paypal_payment(request: HttpRequest):
|
||||||
@@ -1354,26 +1464,496 @@ def paypal_execute(request: HttpRequest):
|
|||||||
return redirect("checkout")
|
return redirect("checkout")
|
||||||
|
|
||||||
|
|
||||||
def search_suggestions(request: HttpRequest):
|
# ==================== STRIPE PAYMENT INTENTS ====================
|
||||||
"""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
|
@login_required
|
||||||
# Buscar en nombre (primario) y descripción
|
def crear_payment_intent(request: HttpRequest):
|
||||||
products = Product.objects.filter(
|
"""
|
||||||
models.Q(name__icontains=query) |
|
Crea un Stripe PaymentIntent para el carrito actual.
|
||||||
models.Q(briefdesc__icontains=query)
|
Acepta JSON: { shipping_address_id, saved_payment_method_id (opcional), save_card (bool) }
|
||||||
).values_list('name', 'id', 'price', 'primary_image_id').distinct()[:8] # Máximo 8 sugerencias
|
"""
|
||||||
|
if request.method != "POST":
|
||||||
|
return JsonResponse({"error": "Método no permitido"}, status=405)
|
||||||
|
|
||||||
for name, product_id, price, image_id in products:
|
try:
|
||||||
suggestions.append({
|
payload = json.loads(request.body.decode("utf-8") or "{}")
|
||||||
'name': name,
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
'id': product_id,
|
return JsonResponse({"error": "Cuerpo de la petición inválido"}, status=400)
|
||||||
'price': float(price)
|
|
||||||
|
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,
|
||||||
})
|
})
|
||||||
|
|
||||||
return JsonResponse({'suggestions': suggestions})
|
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 ====================
|
# ==================== PORTAL DE USUARIO ====================
|
||||||
|
|||||||
Reference in New Issue
Block a user