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:
Daniel (elordenador)
2026-04-10 09:33:45 +02:00
committed by GitHub
23 changed files with 1293 additions and 146 deletions
+9 -2
View File
@@ -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]
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'],
},
),
]
+35
View File
@@ -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')
@@ -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 }}&currency=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 %}
+272 -117
View File
@@ -4,45 +4,33 @@
{% block head %}
<script src="https://js.stripe.com/v3/"></script>
<script src="{% static 'js/checkout.js' %}"></script>
<script defer src="https://use.fontawesome.com/releases/v6.4.0/js/all.js"></script>
<script src="https://www.paypal.com/sdk/js?client-id={{ paypal_client_id }}&currency=EUR" defer></script>
<style>
.payment-methods {
display: flex;
gap: 15px;
flex-wrap: wrap;
margin-top: 20px;
#card-element {
border: 1px solid #ced4da;
border-radius: 0.375rem;
padding: 12px;
background-color: #fff;
}
.payment-btn {
flex: 1;
min-width: 200px;
padding: 12px 20px;
font-size: 16px;
font-weight: 500;
#card-element.StripeElement--focus {
border-color: #86b7fe;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, .25);
}
.payment-section {
background-color: #f8f9fa;
padding: 30px;
border-radius: 8px;
margin-top: 20px;
}
.payment-section h3 {
margin-bottom: 20px;
color: #212529;
#card-element.StripeElement--invalid {
border-color: #dc3545;
}
#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>
{% endblock %}
{% block content %}
<div class="row mt-2">
<div class="col-md-12">
<!-- Token CSRF para requests AJAX -->
{% csrf_token %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>Checkout</h1>
<a href="{% url 'view_cart' %}" class="btn btn-outline-secondary">← Volver al carrito</a>
@@ -66,6 +54,7 @@
Si el pago no se completa en ese tiempo, la reserva se cancelará automáticamente.
</div>
<!-- Step 1: Dirección de envío -->
<div class="card mb-4">
<div class="card-body">
<h5 class="card-title mb-3">1) Selecciona la dirección de envío</h5>
@@ -90,6 +79,7 @@
</div>
</div>
<!-- Resumen del pedido -->
<div class="table-responsive mb-4">
<table class="table table-striped align-middle">
<thead>
@@ -114,42 +104,110 @@
</tbody>
<tfoot>
<tr>
<th colspan="2" class="text-end">Subtotal:</th>
<th colspan="2" class="text-end">{{ cart.get_total|format_price }}€</th>
<th colspan="4" class="text-end">Subtotal:</th>
<th class="text-end">{{ cart.get_total|format_price }}€</th>
</tr>
<tr>
<th colspan="2" class="text-end">IVA (21%):</th>
<th colspan="2" class="text-end text-success">+{{ cart.get_vat_amount|format_price }}€</th>
<th colspan="4" class="text-end">IVA (21%):</th>
<th class="text-end text-success">+{{ cart.get_vat_amount|format_price }}€</th>
</tr>
<tr style="background-color: #f8f9fa;">
<th colspan="2" 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 colspan="4" class="text-end" style="font-size: 1.1rem;">Total:</th>
<th class="text-end" style="font-size: 1.1rem;">{{ cart.get_total_with_vat|format_price }}€</th>
</tr>
</tfoot>
</table>
</div>
<div class="payment-section">
<h3>2) Selecciona tu método de pago</h3>
<div class="payment-methods">
<button
id="checkout-button"
class="btn btn-primary payment-btn"
data-config-url="/tienda/config/"
data-session-url="/tienda/create-checkout-session/"
{% if not addresses or stock_issues %}disabled{% endif %}>
💳 Pagar con Stripe
</button>
<button
id="paypal-button"
class="btn btn-warning payment-btn"
data-payment-url="{% url 'create_paypal_payment' %}"
{% if not addresses or stock_issues %}disabled{% endif %}>
🅿️ Pagar con PayPal
</button>
<!-- Step 2: Método de pago -->
<div class="card mb-4">
<div class="card-body">
<h5 class="card-title mb-3">2) Selecciona tu método de pago</h5>
<!-- Tabs -->
<ul class="nav nav-tabs mb-3" id="paymentTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-card" data-tab="pane-card" type="button" role="tab">
💳 Tarjeta
</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
id="pay-card-btn"
class="btn btn-primary"
{% if not addresses or stock_issues %}disabled{% endif %}>
💳 Pagar con tarjeta
</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>
<!-- Resultado inline -->
<div id="payment-result" class="d-none"></div>
{% else %}
<div class="alert alert-info">Tu carrito está vacío.</div>
{% endif %}
@@ -157,72 +215,169 @@
</div>
<script>
// Manejo del botón de PayPal
const paypalButton = document.getElementById('paypal-button');
if (paypalButton) {
paypalButton.addEventListener('click', async function(e) {
e.preventDefault();
const CSRF_TOKEN = document.querySelector('[name=csrfmiddlewaretoken]').value;
const STRIPE_KEY = '{{ stripe_publishable_key }}';
const HAS_STOCK_ISSUES = {{ stock_issues|yesno:"true,false" }};
const HAS_ADDRESS = {{ addresses|yesno:"true,false" }};
const shippingAddressSelect = document.getElementById('shipping-address');
const selectedShippingAddress = shippingAddressSelect ? shippingAddressSelect.value : '';
if (!selectedShippingAddress) {
alert('Selecciona una dirección de envío para continuar.');
return;
}
const button = this;
const originalText = button.innerHTML;
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Procesando...';
try {
// Obtener CSRF token de múltiples formas
let csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value;
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',
headers: {
'X-CSRFToken': csrfToken || '',
'Content-Type': 'application/json',
},
body: JSON.stringify({ shipping_address_id: selectedShippingAddress })
});
console.log('Response status:', response.status);
const data = await response.json();
console.log('Response data:', data);
if (response.ok && data.redirect) {
// Redirigir a PayPal
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 {
console.error('Respuesta inesperada:', data);
alert('Error inesperado al procesar el pago');
button.disabled = false;
button.innerHTML = originalText;
}
} catch (error) {
console.error('Error en fetch:', error);
alert('Error al procesar el pago con PayPal: ' + error.message);
button.disabled = false;
button.innerHTML = originalText;
// ---- Tab switching ----
document.querySelectorAll('#paymentTabs .nav-link').forEach(btn => {
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.');
return;
}
const btn = document.getElementById('pay-card-btn');
const spinner = document.getElementById('card-spinner');
btn.disabled = true;
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 {
// 1. Create PaymentIntent
const piResp = await fetch('{% url "crear_payment_intent" %}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN },
body: JSON.stringify({
shipping_address_id: addressId,
saved_payment_method_id: usingSavedCard ? savedCardRadio.value : null,
save_card: saveCard,
}),
});
const piData = await piResp.json();
if (!piResp.ok) { throw new Error(piData.error || 'Error al crear el pago'); }
const clientSecret = piData.client_secret;
// 2. Confirm payment
let confirmResult;
if (usingSavedCard) {
confirmResult = await stripe.confirmCardPayment(clientSecret, {
payment_method: savedPmId,
});
} else {
confirmResult = await stripe.confirmCardPayment(clientSecret, {
payment_method: { card: cardElement },
});
}
if (confirmResult.error) {
throw new Error(confirmResult.error.message);
}
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>
{% endblock %}
{% endblock %}
+1
View File
@@ -21,6 +21,7 @@
<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 '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>
</div>
</div>
@@ -21,6 +21,7 @@
<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 '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>
</div>
</div>
@@ -21,6 +21,7 @@
<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 '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>
</div>
</div>
+90
View File
@@ -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 %}
+1
View File
@@ -20,6 +20,7 @@
<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_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>
</div>
</div>
+1
View File
@@ -20,6 +20,7 @@
<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-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>
</div>
</div>
@@ -18,6 +18,7 @@
<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-outline-primary">Métodos de Pago</a>
<a href="{% url 'mensajes_comprador' %}" class="btn btn-outline-primary">Mensajes</a>
</div>
</div>
@@ -67,6 +68,15 @@
</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>
<!-- Pedidos recientes -->
+16 -4
View File
@@ -25,12 +25,15 @@ urlpatterns = [
path("cart/remove/<int:item_id>/", 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/<int:id>/editar/", views.editar_direccion, name="editar_direccion"),
path("usuario/direcciones/<int:id>/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/<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("rgpd", views.rgpd, name="rgpd"),
path("privacidad", views.rgpd, name="privacidad"),
+603 -23
View File
@@ -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 ====================