Merge pull request #22 from dsaub/development

Enhance stock management, payment systems, and testing coverage
This commit is contained in:
Daniel (elordenador)
2026-04-20 12:25:33 +02:00
committed by GitHub
34 changed files with 3790 additions and 288 deletions
+59
View File
@@ -0,0 +1,59 @@
name: Build and Push Docker Image
on:
push:
branches:
- '**' # Esto aplica para cualquier rama
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout del código
uses: actions/checkout@v6
- name: Configurar Python
uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: Instalar dependencias
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Ejecutar tests
env:
DJANGO_SETTINGS_MODULE: proyecto.settings
run: |
python manage.py test
docker:
runs-on: ubuntu-latest
needs: test
permissions:
contents: read
packages: write # Necesario para subir a GHCR
steps:
- name: Checkout del código
uses: actions/checkout@v6
- name: Configurar Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login en GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Preparar tag de imagen
run: |
TAG=$(echo "${{ github.ref_name }}" | sed 's/\//-/g')
echo "IMAGE_TAG=$TAG" >> $GITHUB_ENV
- name: Build y Push
uses: docker/build-push-action@v6
with:
context: .
push: true
# Sanitizamos el nombre de la rama (reemplazamos / por -)
tags: ghcr.io/dsaub/proyecto-mvc:${{ env.IMAGE_TAG }}
+1 -10
View File
@@ -4,14 +4,9 @@ ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
WORKDIR /app WORKDIR /app
RUN adduser -D -S app
COPY requirements.txt /app/ COPY requirements.txt /app/
RUN apk --no-cache update && apk --no-cache upgrade RUN apk --no-cache update && apk --no-cache upgrade
RUN apk add --no-cache --virtual .build-deps build-base mariadb-dev libffi-dev \ RUN pip install --no-cache-dir -r requirements.txt
&& apk add --no-cache mariadb-connector-c \
&& pip install --no-cache-dir -r requirements.txt \
&& apk del .build-deps
COPY . /app/ COPY . /app/
RUN chmod +x /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh
@@ -21,8 +16,4 @@ EXPOSE 8000
RUN mkdir -pv /fonts RUN mkdir -pv /fonts
COPY tienda/static/fonts/ /fonts/ COPY tienda/static/fonts/ /fonts/
RUN chown -R app: /app /fonts
RUN chmod 770 /app/entrypoint.sh
USER app
ENTRYPOINT ["/bin/sh", "/app/entrypoint.sh"] ENTRYPOINT ["/bin/sh", "/app/entrypoint.sh"]
+6
View File
@@ -0,0 +1,6 @@
docker:
docker build -t ghcr.io/dsaub/proyecto-mvc:development .
docker push ghcr.io/dsaub/proyecto-mvc:development
test:
./manage.py test
+3 -1
View File
@@ -1,11 +1,13 @@
#!/bin/sh #!/bin/sh
set -eu
echo "Sleeping due to mysql..." echo "Sleeping due to mysql..."
sleep 10 sleep 10
echo "Running DB migrations..." echo "Running DB migrations..."
python manage.py migrate python manage.py migrate
echo "Collecting STATIC..." echo "Collecting STATIC..."
python manage.py collectstatic --noinput python manage.py collectstatic --noinput --clear
echo "Running server!" echo "Running server!"
+5 -1
View File
@@ -203,7 +203,11 @@ STORAGES = {
'BACKEND': 'django.core.files.storage.FileSystemStorage', 'BACKEND': 'django.core.files.storage.FileSystemStorage',
}, },
'staticfiles': { 'staticfiles': {
'BACKEND': 'whitenoise.storage.CompressedManifestStaticFilesStorage', 'BACKEND': (
'django.contrib.staticfiles.storage.StaticFilesStorage'
if DEBUG
else 'whitenoise.storage.CompressedManifestStaticFilesStorage'
),
}, },
} }
+8 -1
View File
@@ -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'],
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.4 on 2026-04-17 07:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tienda', '0005_savedpaymentmethod'),
]
operations = [
migrations.AlterField(
model_name='category',
name='name',
field=models.CharField(max_length=200, unique=True),
),
]
+37
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
from django.db import models from django.db import models
from django.contrib.auth.models import User, AbstractUser from django.contrib.auth.models import User, AbstractUser
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
@@ -253,6 +255,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')
+40
View File
@@ -234,6 +234,46 @@ p.price {
object-position: center; object-position: center;
} }
/* Estilos para el footer */
.footer-link {
color: inherit;
text-decoration: none;
}
.footer-link:hover {
text-decoration: underline;
}
/* Estilos para páginas legales / informativas */
.legal-container {
max-width: 860px;
margin: 2rem auto;
padding: 0 1rem;
}
.legal-container h1 {
margin-bottom: 1.5rem;
}
.legal-section {
margin-bottom: 2rem;
}
.legal-section h2 {
font-size: 1.2rem;
margin-bottom: 0.75rem;
border-bottom: 1px solid #dee2e6;
padding-bottom: 0.25rem;
}
.legal-section h3 {
font-size: 1rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.4rem;
}
.legal-footer {
margin-top: 2rem;
color: #6c757d;
}
.texto-ajustado { .texto-ajustado {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@@ -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 %}
+49
View File
@@ -0,0 +1,49 @@
{% extends "tienda/base.html" %}
{% load static %}
{% block content %}
<div class="row">
<div class="col-md-12">
<article class="legal-container">
<h1>Aviso Legal</h1>
<p>El presente Aviso Legal regula el acceso y uso del sitio web <strong>Comercialmeria</strong>, de conformidad con la Ley 34/2002, de 11 de julio, de Servicios de la Sociedad de la Información y de Comercio Electrónico (LSSICE).</p>
<section class="legal-section">
<h2>1. Datos del Titular</h2>
<address>
<strong>Denominación:</strong> Comercialmeria<br>
<strong>NIF/CIF:</strong> [00000000X]<br>
<strong>Domicilio social:</strong> (Dirección de la empresa), Almería, España<br>
<strong>Email de contacto:</strong> <a href="mailto:example@example.com">example@example.com</a><br>
<strong>Inscripción registral:</strong> [Registro Mercantil de Almería]
</address>
</section>
<section class="legal-section">
<h2>2. Objeto y Ámbito de Aplicación</h2>
<p>Este sitio web ofrece servicios de comercio electrónico local dentro de la provincia de Almería. El acceso y uso de esta web implica la aceptación plena de las condiciones recogidas en este Aviso Legal.</p>
</section>
<section class="legal-section">
<h2>3. Propiedad Intelectual e Industrial</h2>
<p>Todos los contenidos de esta web —textos, imágenes, logotipos, gráficos, código fuente y diseño— son propiedad de <strong>Comercialmeria</strong> o de sus respectivos titulares, y están protegidos por la legislación vigente en materia de propiedad intelectual e industrial. Queda expresamente prohibida su reproducción, distribución o modificación sin autorización previa por escrito.</p>
</section>
<section class="legal-section">
<h2>4. Exclusión de Responsabilidad</h2>
<p>Comercialmeria no se responsabiliza de los daños derivados del uso incorrecto del sitio, de interrupciones en el servicio por causas ajenas a nuestra voluntad, ni del contenido de páginas externas enlazadas desde nuestra web.</p>
</section>
<section class="legal-section">
<h2>5. Legislación Aplicable y Jurisdicción</h2>
<p>Las relaciones establecidas entre el usuario y Comercialmeria se regirán por la legislación española vigente. Para la resolución de cualquier conflicto, ambas partes se someten a los Juzgados y Tribunales de Almería, con renuncia expresa a cualquier otro fuero.</p>
</section>
<footer class="legal-footer">
<p><small>Última actualización: Marzo 2026. Comercialmeria — Almería, España.</small></p>
</footer>
</article>
</div>
</div>
{% endblock %}
+82
View File
@@ -0,0 +1,82 @@
{% extends "tienda/base.html" %}
{% load static %}
{% block content %}
<div class="row">
<div class="col-md-12">
<article class="legal-container">
<h1>Centro de Ayuda</h1>
<p>¿Tienes alguna duda? Aquí encontrarás respuesta a las preguntas más frecuentes sobre cómo funciona <strong>Comercialmeria</strong>.</p>
<section class="legal-section">
<h2>🛒 Compras</h2>
<h3>¿Cómo realizo un pedido?</h3>
<p>Busca el producto que deseas, añádelo al carrito y sigue los pasos del proceso de compra. Necesitarás una cuenta registrada y una dirección de envío en la provincia de Almería.</p>
<h3>¿Qué métodos de pago se aceptan?</h3>
<p>Aceptamos <strong>tarjeta de crédito/débito</strong> a través de Stripe y <strong>PayPal</strong>. Todos los pagos están protegidos con cifrado SSL.</p>
<h3>¿Puedo modificar un pedido tras confirmarlo?</h3>
<p>Una vez confirmado el pedido, no es posible modificarlo directamente. Si necesitas hacer cambios antes de que se envíe, contacta con nosotros lo antes posible en <a href="mailto:example@example.com">example@example.com</a>.</p>
<h3>¿Cuándo recibiré mi pedido?</h3>
<p>El plazo de entrega depende del vendedor. Puedes consultar el estado de tu pedido desde <strong>Mi cuenta &rarr; Mis compras</strong>.</p>
</section>
<section class="legal-section">
<h2>📦 Envíos</h2>
<h3>¿A qué zonas se realizan envíos?</h3>
<p>Comercialmeria envía únicamente dentro de la <strong>provincia de Almería</strong> (códigos postales que empiecen por 04).</p>
<h3>¿Cuánto cuesta el envío?</h3>
<p>Los gastos de envío se calculan en el momento del checkout y dependen del vendedor y la dirección de entrega.</p>
</section>
<section class="legal-section">
<h2>🔄 Devoluciones</h2>
<h3>¿Puedo devolver un producto?</h3>
<p>Sí. Tienes 14 días naturales desde la recepción para solicitar una devolución. Consulta nuestra <a href="{% url 'devoluciones' %}">Política de Devoluciones</a> para más detalles.</p>
<h3>¿Cuándo recibiré el reembolso?</h3>
<p>Una vez aceptada la devolución, el reembolso se tramita en un máximo de 14 días al mismo método de pago utilizado.</p>
</section>
<section class="legal-section">
<h2>👤 Mi Cuenta</h2>
<h3>¿Cómo creo una cuenta?</h3>
<p>Haz clic en <strong>Registrarse</strong> en la parte superior de la página, rellena el formulario y verifica tu correo electrónico.</p>
<h3>He olvidado mi contraseña, ¿qué hago?</h3>
<p>Haz clic en <strong>¿Olvidaste tu contraseña?</strong> en la pantalla de inicio de sesión. Recibirás un email con instrucciones para restablecerla.</p>
<h3>¿Cómo cambio mis datos personales?</h3>
<p>Accede a <strong>Mi cuenta &rarr; Editar perfil</strong> para actualizar tu nombre, email o contraseña.</p>
</section>
<section class="legal-section">
<h2>🏪 Vender en Comercialmeria</h2>
<h3>¿Cómo puedo vender mis productos?</h3>
<p>Regístrate, accede al <strong>Panel Vendedor</strong> desde la barra de navegación y crea tu primer producto. ¡Es gratis y sencillo!</p>
<h3>¿Hay comisiones?</h3>
<p>Actualmente no aplicamos comisiones sobre las ventas. Consulta nuestra sección de <a href="{% url 'terminos' %}">Términos y Condiciones</a> para más información.</p>
</section>
<section class="legal-section">
<h2>¿No encuentras respuesta?</h2>
<p>Escríbenos a <a href="mailto:example@example.com">example@example.com</a> y te ayudaremos en el menor tiempo posible.</p>
</section>
<footer class="legal-footer">
<p><small>Última actualización: Marzo 2026. Comercialmeria — Almería, España.</small></p>
</footer>
</article>
</div>
</div>
{% endblock %}
+33 -20
View File
@@ -77,7 +77,7 @@
</style> </style>
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body class="d-flex flex-column min-vh-100">
{% cache 500 sidebar request.user.username %} {% cache 500 sidebar request.user.username %}
<nav class="navbar navbar-expand-md header" role="banner"> <nav class="navbar navbar-expand-md header" role="banner">
<div class="container-fluid"> <div class="container-fluid">
@@ -135,7 +135,7 @@
</nav> </nav>
{% endcache %} {% endcache %}
<div class="container-fluid" role="main"> <div class="container-fluid flex-grow-1 d-flex flex-column" role="main">
<!-- Mensajes --> <!-- Mensajes -->
{% if messages %} {% if messages %}
<div class="row mt-3"> <div class="row mt-3">
@@ -155,26 +155,39 @@
{% cache 500 footer %} {% cache 500 footer %}
<!-- Footer--> <!-- Footer-->
<div id="footer" class="row pt-2 pb-2 mt-5" role="contentinfo"> <footer id="footer" class="row pt-4 pb-3 mt-auto" role="contentinfo">
<div class="col-md-12 grid"> <div class="col-12">
<p class="text-center">Enlace 1</p> <div class="row text-center gy-3">
<p class="text-center">Enlace 2</p> <div class="col-12 col-md-4">
<p class="text-center">Enlace 3</p> <h6 class="fw-semibold text-uppercase mb-2">Información Legal</h6>
<p class="text-center">Enlace 4</p> <ul class="list-unstyled mb-0">
<p class="text-center">Enlace 5</p> <li><a href="{% url 'privacidad' %}" class="footer-link">Política de Privacidad</a></li>
<p class="text-center">Enlace 6</p> <li><a href="{% url 'aviso_legal' %}" class="footer-link">Aviso Legal</a></li>
<p class="text-center">Enlace 7</p> <li><a href="{% url 'terminos' %}" class="footer-link">Términos y Condiciones</a></li>
<p class="text-center">Enlace 8</p> <li><a href="{% url 'cookies' %}" class="footer-link">Política de Cookies</a></li>
<p class="text-center">Enlace 9</p> </ul>
<p class="text-center">Enlace 10</p> </div>
<p class="text-center">Enlace 11</p> <div class="col-12 col-md-4">
<p class="text-center">Enlace 12</p> <h6 class="fw-semibold text-uppercase mb-2">Compras</h6>
<p class="text-center">Enlace 13</p> <ul class="list-unstyled mb-0">
<p class="text-center">Enlace 14</p> <li><a href="{% url 'devoluciones' %}" class="footer-link">Política de Devoluciones</a></li>
<p class="text-center">Enlace 15</p> <li><a href="{% url 'ayuda' %}" class="footer-link">Centro de Ayuda</a></li>
<p class="text-center">Enlace 16</p> <li><a href="{% url 'ayuda' %}#envios" class="footer-link">Información de Envíos</a></li>
</ul>
</div>
<div class="col-12 col-md-4">
<h6 class="fw-semibold text-uppercase mb-2">Comercialmeria</h6>
<ul class="list-unstyled mb-0">
<li><a href="{% url 'sobre_nosotros' %}" class="footer-link">Sobre Nosotros</a></li>
<li><a href="{% url 'mis_productos' %}" class="footer-link">Vende con Nosotros</a></li>
<li><a href="mailto:example@example.com" class="footer-link">Contacto</a></li>
</ul>
</div> </div>
</div> </div>
<hr class="mt-3 mb-2">
<p class="text-center mb-0" style="font-size: 0.85rem;">&copy; 2026 Comercialmeria — Comercio local en Almería, España</p>
</div>
</footer>
{% endcache %} {% endcache %}
</div> </div>
{% cache 500 scripts %} {% cache 500 scripts %}
+253 -98
View File
@@ -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 }}&currency=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 %}
+80
View File
@@ -0,0 +1,80 @@
{% extends "tienda/base.html" %}
{% load static %}
{% block content %}
<div class="row">
<div class="col-md-12">
<article class="legal-container">
<h1>Política de Cookies</h1>
<p>Esta Política de Cookies explica qué son las cookies, cómo las utiliza <strong>Comercialmeria</strong> y cómo puedes gestionarlas, en cumplimiento del artículo 22.2 de la Ley 34/2002 (LSSICE) y el RGPD.</p>
<section class="legal-section">
<h2>1. ¿Qué son las Cookies?</h2>
<p>Las cookies son pequeños ficheros de texto que se almacenan en tu dispositivo (ordenador, tablet o móvil) cuando visitas un sitio web. Su función principal es recordar tus preferencias y mejorar tu experiencia de navegación.</p>
</section>
<section class="legal-section">
<h2>2. Tipos de Cookies que Utilizamos</h2>
<table class="table table-bordered">
<thead class="table-light">
<tr>
<th>Nombre</th>
<th>Tipo</th>
<th>Finalidad</th>
<th>Duración</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>sessionid</code></td>
<td>Técnica / Sesión</td>
<td>Mantiene tu sesión iniciada mientras navegas.</td>
<td>Sesión (se elimina al cerrar el navegador)</td>
</tr>
<tr>
<td><code>csrftoken</code></td>
<td>Seguridad</td>
<td>Protege los formularios contra ataques CSRF.</td>
<td>1 año</td>
</tr>
<tr>
<td><code>cart_id</code></td>
<td>Funcional</td>
<td>Recuerda los productos añadidos al carrito.</td>
<td>Sesión</td>
</tr>
</tbody>
</table>
<p>No utilizamos cookies publicitarias ni de seguimiento de terceros.</p>
</section>
<section class="legal-section">
<h2>3. Cookies de Terceros</h2>
<p>Al procesar pagos, servicios como <strong>Stripe</strong> o <strong>PayPal</strong> pueden instalar sus propias cookies técnicas para garantizar la seguridad de la transacción. Consulta sus políticas de privacidad para más información.</p>
</section>
<section class="legal-section">
<h2>4. ¿Cómo Gestionar las Cookies?</h2>
<p>Puedes configurar tu navegador para aceptar, rechazar o eliminar cookies en cualquier momento:</p>
<ul>
<li><a href="https://support.google.com/chrome/answer/95647" target="_blank" rel="noopener">Google Chrome</a></li>
<li><a href="https://support.mozilla.org/es/kb/habilitar-y-deshabilitar-cookies-sitios-web" target="_blank" rel="noopener">Mozilla Firefox</a></li>
<li><a href="https://support.apple.com/es-es/guide/safari/sfri11471/mac" target="_blank" rel="noopener">Safari</a></li>
<li><a href="https://support.microsoft.com/es-es/windows/eliminar-y-administrar-cookies-168dab11-0753-043d-7c16-ede5947fc64d" target="_blank" rel="noopener">Microsoft Edge</a></li>
</ul>
<p>Ten en cuenta que deshabilitar ciertas cookies puede afectar a la funcionalidad del sitio (por ejemplo, no podrás mantener la sesión iniciada).</p>
</section>
<section class="legal-section">
<h2>5. Consentimiento</h2>
<p>Al continuar navegando por nuestra web, aceptas el uso de las cookies técnicas y funcionales descritas anteriormente, necesarias para el correcto funcionamiento del sitio.</p>
</section>
<footer class="legal-footer">
<p><small>Última actualización: Marzo 2026. Comercialmeria — Almería, España.</small></p>
</footer>
</article>
</div>
</div>
{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
{% extends "tienda/base.html" %}
{% load static %}
{% block content %}
<div class="row">
<div class="col-md-12">
<article class="legal-container">
<h1>Política de Devoluciones</h1>
<p>En <strong>Comercialmeria</strong> queremos que estés completamente satisfecho con tu compra. Si por cualquier motivo no es así, aquí te explicamos cómo proceder.</p>
<section class="legal-section">
<h2>1. Plazo de Devolución</h2>
<p>Tienes hasta <strong>14 días naturales</strong> desde la recepción del pedido para solicitar una devolución, conforme a lo establecido en el Real Decreto Legislativo 1/2007 (Ley General para la Defensa de los Consumidores y Usuarios).</p>
</section>
<section class="legal-section">
<h2>2. Condiciones del Producto</h2>
<p>Para que la devolución sea aceptada, el artículo deberá:</p>
<ul>
<li>Estar en su estado original, sin usar ni manipular.</li>
<li>Conservar el embalaje y etiquetas originales.</li>
<li>Incluir todos los accesorios, manuales o complementos recibidos.</li>
</ul>
<p>No se aceptarán devoluciones de productos personalizados, perecederos o descargables una vez utilizados.</p>
</section>
<section class="legal-section">
<h2>3. Proceso de Devolución</h2>
<ol>
<li>Contacta con nosotros por email a <a href="mailto:example@example.com">example@example.com</a> indicando tu número de pedido y el motivo de la devolución.</li>
<li>Te enviaremos instrucciones de envío en un plazo máximo de 48 horas laborables.</li>
<li>Una vez recibido e inspeccionado el artículo, procesaremos el reembolso.</li>
</ol>
</section>
<section class="legal-section">
<h2>4. Gastos de Envío en Devoluciones</h2>
<p>Los gastos de devolución corren a cargo del comprador, salvo en los siguientes casos:</p>
<ul>
<li>El producto recibido es defectuoso o incorrecto.</li>
<li>Se ha producido un error en el pedido por nuestra parte.</li>
</ul>
</section>
<section class="legal-section">
<h2>5. Reembolsos</h2>
<p>Una vez aceptada la devolución, el reembolso se realizará en un plazo máximo de <strong>14 días</strong> utilizando el mismo método de pago original. El tiempo de abono dependerá de tu entidad bancaria.</p>
</section>
<section class="legal-section">
<h2>6. Productos con Defecto o Daño</h2>
<p>Si recibes un producto en mal estado o diferente al pedido, contáctanos en un plazo de <strong>48 horas</strong> tras la entrega adjuntando fotografías. Gestionaremos el reenvío o reembolso sin coste para ti.</p>
</section>
<footer class="legal-footer">
<p><small>Última actualización: Marzo 2026. Comercialmeria — Almería, España.</small></p>
</footer>
</article>
</div>
</div>
{% endblock %}
+1
View File
@@ -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>
+4
View File
@@ -128,9 +128,11 @@
<a href="{% url 'productos' %}" class="btn btn-light btn-lg"> <a href="{% url 'productos' %}" class="btn btn-light btn-lg">
🛍️ Explorar Productos 🛍️ Explorar Productos
</a> </a>
{% if not user.is_authenticated %}
<a href="{% url 'register' %}" class="btn btn-outline-light btn-lg"> <a href="{% url 'register' %}" class="btn btn-outline-light btn-lg">
📝 Registrarse 📝 Registrarse
</a> </a>
{% endif %}
</div> </div>
</div> </div>
@@ -219,6 +221,7 @@
{% endif %} {% endif %}
<!-- Call to Action --> <!-- Call to Action -->
{% if not user.is_authenticated %}
<div class="row mt-5 mb-5"> <div class="row mt-5 mb-5">
<div class="col-md-12"> <div class="col-md-12">
<div style="background-color: #f8f9fa; padding: 40px; border-radius: 8px; text-align: center;"> <div style="background-color: #f8f9fa; padding: 40px; border-radius: 8px; text-align: center;">
@@ -232,4 +235,5 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
{% 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-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>
+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 '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>
+1
View File
@@ -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 -->
+1 -1
View File
@@ -1,5 +1,5 @@
{% load static %}
{% extends "tienda/base.html" %} {% extends "tienda/base.html" %}
{% load static %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
@@ -0,0 +1,44 @@
{% extends "tienda/base.html" %}
{% load static %}
{% block content %}
<div class="row">
<div class="col-md-12">
<article class="legal-container">
<h1>Sobre Nosotros</h1>
<p><strong>Comercialmeria</strong> es una plataforma de comercio electrónico local nacida con el objetivo de conectar a compradores y vendedores de la provincia de Almería. Creemos en el comercio de proximidad como motor de la economía local.</p>
<section class="legal-section">
<h2>Nuestra Misión</h2>
<p>Facilitar que cualquier comerciante o particular de Almería pueda vender sus productos en línea de forma sencilla, segura y sin intermediarios innecesarios, mientras los compradores disfrutan de la comodidad del comercio online con la confianza del trato local.</p>
</section>
<section class="legal-section">
<h2>¿Por qué Comercialmeria?</h2>
<ul>
<li>🌍 <strong>100% local:</strong> Todos los vendedores y envíos son dentro de la provincia de Almería.</li>
<li>🔒 <strong>Pagos seguros:</strong> Stripe y PayPal para transacciones protegidas.</li>
<li>🚀 <strong>Fácil de usar:</strong> Crea tu tienda en minutos y empieza a vender.</li>
<li>💬 <strong>Comunicación directa:</strong> Sistema de mensajería entre comprador y vendedor.</li>
<li>📦 <strong>Seguimiento del pedido:</strong> Mantente informado en cada paso del envío.</li>
</ul>
</section>
<section class="legal-section">
<h2>Zona de Servicio</h2>
<p>Actualmente operamos exclusivamente dentro de la <strong>provincia de Almería</strong>. Esto nos permite garantizar tiempos de entrega más cortos y apoyar directamente al tejido económico local.</p>
</section>
<section class="legal-section">
<h2>Contacto</h2>
<p>¿Tienes alguna pregunta o propuesta? Escríbenos a <a href="mailto:example@example.com">example@example.com</a> y te responderemos lo antes posible.</p>
</section>
<footer class="legal-footer">
<p><small>Comercialmeria — Almería, España. Proyecto Final DAW.</small></p>
</footer>
</article>
</div>
</div>
{% endblock %}
+69
View File
@@ -0,0 +1,69 @@
{% extends "tienda/base.html" %}
{% load static %}
{% block content %}
<div class="row">
<div class="col-md-12">
<article class="legal-container">
<h1>Términos y Condiciones de Compra</h1>
<p>Las presentes Condiciones Generales de Contratación regulan las transacciones comerciales realizadas a través de <strong>Comercialmeria</strong>, plataforma de comercio local en Almería.</p>
<section class="legal-section">
<h2>1. Proceso de Compra</h2>
<ol>
<li>Selecciona los productos y añádelos al carrito.</li>
<li>Accede a tu cuenta o regístrate para continuar.</li>
<li>Revisa el resumen del pedido, incluyendo precios con IVA y gastos de envío.</li>
<li>Elige el método de pago y confirma el pedido.</li>
<li>Recibirás un email de confirmación con el número de pedido.</li>
</ol>
</section>
<section class="legal-section">
<h2>2. Precios e IVA</h2>
<p>Todos los precios mostrados en la web incluyen el <strong>IVA (21%)</strong> aplicable según la legislación española. Comercialmeria se reserva el derecho a modificar los precios en cualquier momento, si bien los pedidos ya confirmados no se verán afectados.</p>
</section>
<section class="legal-section">
<h2>3. Disponibilidad y Stock</h2>
<p>La disponibilidad de los productos está sujeta al stock existente en el momento de la compra. En caso de que un artículo no esté disponible tras la confirmación del pedido, te lo comunicaremos de inmediato y procederemos al reembolso íntegro.</p>
</section>
<section class="legal-section">
<h2>4. Zona de Envío</h2>
<p>Actualmente, Comercialmeria <strong>solo realiza envíos dentro de la provincia de Almería</strong> (códigos postales 04xxx). No se tramitarán pedidos con direcciones de envío fuera de este ámbito.</p>
</section>
<section class="legal-section">
<h2>5. Métodos de Pago</h2>
<p>Aceptamos los siguientes métodos de pago:</p>
<ul>
<li>Tarjeta de crédito/débito (Stripe)</li>
<li>PayPal</li>
</ul>
<p>Todos los pagos se procesan a través de plataformas seguras con cifrado SSL. Comercialmeria no almacena datos de tarjetas bancarias.</p>
</section>
<section class="legal-section">
<h2>6. Cancelación de Pedidos</h2>
<p>Puedes cancelar tu pedido antes de que el vendedor lo marque como enviado. Para ello, contacta con nosotros lo antes posible en <a href="mailto:example@example.com">example@example.com</a>.</p>
</section>
<section class="legal-section">
<h2>7. Responsabilidad del Vendedor</h2>
<p>Cada producto es ofrecido por vendedores independientes dentro de la plataforma. Comercialmeria actúa como intermediario y no se responsabiliza de la calidad del producto más allá de las garantías legales aplicables.</p>
</section>
<section class="legal-section">
<h2>8. Modificación de las Condiciones</h2>
<p>Comercialmeria se reserva el derecho a modificar estas condiciones en cualquier momento. Los cambios serán publicados en esta página y resultarán de aplicación desde el momento de su publicación.</p>
</section>
<footer class="legal-footer">
<p><small>Última actualización: Marzo 2026. Comercialmeria — Almería, España.</small></p>
</footer>
</article>
</div>
</div>
{% endblock %}
+1848 -93
View File
File diff suppressed because it is too large Load Diff
+23 -4
View File
@@ -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,8 +47,24 @@ 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("devoluciones", views.devoluciones, name="devoluciones"),
path("aviso-legal", views.aviso_legal, name="aviso_legal"),
path("terminos", views.terminos, name="terminos"),
path("cookies", views.cookies, name="cookies"),
path("sobre-nosotros", views.sobre_nosotros, name="sobre_nosotros"),
path("ayuda", views.ayuda, name="ayuda"),
path("reset-password", views.reset_password, name="reset_password"), path("reset-password", views.reset_password, name="reset_password"),
path("reset-password-phase2/<str:code>", views.reset_password_phase2, name="reset_password_phase2") path("reset-password-phase2/<str:code>", views.reset_password_phase2, name="reset_password_phase2")
] ]
+669 -21
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.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.
@@ -37,6 +38,13 @@ STOCK_RESERVATION_SESSION_KEY = "stock_reservation_id"
STOCK_RESERVATION_PAYMENT_SESSION_KEY = "stock_reservation_payment_method" STOCK_RESERVATION_PAYMENT_SESSION_KEY = "stock_reservation_payment_method"
def _invalidate_product_cache(product_ids):
unique_product_ids = {product_id for product_id in product_ids if product_id is not None}
if not unique_product_ids:
return
cache.delete_many([f"product_{product_id}" for product_id in unique_product_ids])
def _normalize_location_text(value: str) -> str: def _normalize_location_text(value: str) -> str:
normalized = unicodedata.normalize("NFD", (value or "")) normalized = unicodedata.normalize("NFD", (value or ""))
without_accents = "".join(char for char in normalized if unicodedata.category(char) != "Mn") without_accents = "".join(char for char in normalized if unicodedata.category(char) != "Mn")
@@ -90,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(
@@ -442,6 +532,8 @@ def _create_stock_reservation_for_cart(request: HttpRequest, cart_items, payment
for item in cart_items for item in cart_items
]) ])
_invalidate_product_cache(product_ids)
return reservation, [] return reservation, []
@@ -593,6 +685,8 @@ def create_order_from_cart(request, payment_method, payment_reference="", shippi
product_row.stock -= item.quantity product_row.stock -= item.quantity
product_row.save(update_fields=["stock"]) product_row.save(update_fields=["stock"])
_invalidate_product_cache(product_ids)
cart.items.all().delete() cart.items.all().delete()
if locked_reservation is not None: if locked_reservation is not None:
@@ -763,10 +857,11 @@ def pedidos_vendedor(request: HttpRequest):
pedidos = OrderItem.objects.filter(seller=request.user).select_related( pedidos = OrderItem.objects.filter(seller=request.user).select_related(
'order', 'product', 'order__buyer', 'order__shipping_address' 'order', 'product', 'order__buyer', 'order__shipping_address'
).prefetch_related('messages__sender').order_by('-created_at') ).prefetch_related('messages__sender').order_by('-created_at')
total_pedidos_por_enviar = pedidos.exclude(status=OrderItem.STATUS_SHIPPED).count()
return render(request, "tienda/pedidos_vendedor.html", { return render(request, "tienda/pedidos_vendedor.html", {
"pedidos": pedidos, "pedidos": pedidos,
"total_pedidos": pedidos.count() "total_pedidos": total_pedidos_por_enviar
}) })
@@ -865,7 +960,10 @@ def crear_producto(request: HttpRequest):
name=f"{name}_principal", name=f"{name}_principal",
image=primary_image_file image=primary_image_file
) )
if stock > 4294967295:
messages.error(request, "No se puede tener mas de 4294967295 existencias. Por favor, intentelo de nuevo")
categories = Category.objects.all()
return render(request, "tienda/crear_producto.html", {"categories": categories})
# Crear producto # Crear producto
producto = Product.objects.create( producto = Product.objects.create(
name=name, name=name,
@@ -877,6 +975,7 @@ def crear_producto(request: HttpRequest):
primary_image=primary_image, primary_image=primary_image,
creator=request.user creator=request.user
) )
_invalidate_product_cache([producto.id])
# Agregar imágenes secundarias si se proporcionan # Agregar imágenes secundarias si se proporcionan
if secondary_images_files: if secondary_images_files:
@@ -934,6 +1033,13 @@ def editar_producto(request: HttpRequest, id: int):
stock = int(stock) stock = int(stock)
if stock < 0: if stock < 0:
raise ValueError("El stock no puede ser negativo") raise ValueError("El stock no puede ser negativo")
if stock > 4294967295:
messages.error(request, "No se puede tener mas de 4294967295 de stock.")
categories = Category.objects.all()
return render(request, "tienda/editar_producto.html", {
"categories": categories,
"producto": producto
})
except ValueError: except ValueError:
messages.error(request, "El stock debe ser un número entero válido.") messages.error(request, "El stock debe ser un número entero válido.")
categories = Category.objects.all() categories = Category.objects.all()
@@ -967,6 +1073,7 @@ def editar_producto(request: HttpRequest, id: int):
producto.primary_image = primary_image producto.primary_image = primary_image
producto.save() producto.save()
_invalidate_product_cache([producto.id])
if secondary_images_files: if secondary_images_files:
producto.secondary_images.clear() producto.secondary_images.clear()
@@ -996,6 +1103,7 @@ def borrar_producto(request: HttpRequest, id: int):
producto = get_object_or_404(Product, id=id, creator=request.user) producto = get_object_or_404(Product, id=id, creator=request.user)
nombre = producto.name nombre = producto.name
_invalidate_product_cache([producto.id])
producto.delete() producto.delete()
messages.success(request, f"Producto '{nombre}' eliminado correctamente.") messages.success(request, f"Producto '{nombre}' eliminado correctamente.")
return redirect("mis_productos") return redirect("mis_productos")
@@ -1007,12 +1115,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
@@ -1096,7 +1210,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
@@ -1155,7 +1269,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):
@@ -1340,26 +1475,521 @@ 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
try:
stripe.PaymentMethod.attach(payment_method_id, customer=customer_id)
except stripe.error.InvalidRequestError as attach_err:
# The payment method may already be attached to a customer
pm_check = stripe.PaymentMethod.retrieve(payment_method_id)
if pm_check.get("customer") == customer_id:
# Already attached to this same customer continue normally
pass
else:
logger.warning(
"CONFIRMAR_SETUP_INTENT_ALREADY_ATTACHED user_id=%s pm=%s error=%s",
request.user.id, payment_method_id, str(attach_err),
)
return JsonResponse(
{"error": "Este método de pago ya está asociado a otra cuenta. "
"Por favor, usa una tarjeta diferente."},
status=400,
)
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})"
# Avoid saving duplicates in our database
existing = SavedPaymentMethod.objects.filter(
user=request.user,
stripe_payment_method_id=payment_method_id,
).first()
if existing:
return JsonResponse({"success": True, "label": existing.label, "id": existing.id})
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 ====================
@@ -1633,6 +2263,24 @@ def reset_password(request: HttpRequest):
def rgpd(request: HttpRequest): def rgpd(request: HttpRequest):
return render(request, "tienda/rgpd.html", {}) return render(request, "tienda/rgpd.html", {})
def devoluciones(request: HttpRequest):
return render(request, "tienda/devoluciones.html", {})
def aviso_legal(request: HttpRequest):
return render(request, "tienda/aviso_legal.html", {})
def terminos(request: HttpRequest):
return render(request, "tienda/terminos.html", {})
def cookies(request: HttpRequest):
return render(request, "tienda/cookies.html", {})
def sobre_nosotros(request: HttpRequest):
return render(request, "tienda/sobre_nosotros.html", {})
def ayuda(request: HttpRequest):
return render(request, "tienda/ayuda.html", {})
def reset_password(request: HttpRequest): def reset_password(request: HttpRequest):
if request.method == "GET": if request.method == "GET":
return render(request, "tienda/reset_password.html", {}) return render(request, "tienda/reset_password.html", {})