add coupon in shop

timetoconfirm
Raz 8 months ago
parent 7574087bf8
commit dbe470c7ff
  1. 32
      shop/admin.py
  2. 4
      shop/forms.py
  3. 54
      shop/migrations/0024_coupon_order_discount_amount_order_coupon_and_more.py
  4. 48
      shop/models.py
  5. 97
      shop/signals.py
  6. 15
      shop/static/shop/css/shop.css
  7. 40
      shop/stripe_utils.py
  8. 149
      shop/templates/shop/cart.html
  9. 17
      shop/templates/shop/payment_success.html
  10. 2
      shop/urls.py
  11. 168
      shop/views.py

@ -1,5 +1,5 @@
from django.contrib import admin from django.contrib import admin
from .models import Product, Color, Size, Order, OrderItem, GuestUser from .models import Product, Color, Size, Order, OrderItem, GuestUser, Coupon, CouponUsage
from django.utils.html import format_html from django.utils.html import format_html
@admin.register(Product) @admin.register(Product)
@ -53,3 +53,33 @@ class GuestUserOrderInline(admin.TabularInline):
class GuestUserAdmin(admin.ModelAdmin): class GuestUserAdmin(admin.ModelAdmin):
list_display = ('email', 'phone') list_display = ('email', 'phone')
inlines = [GuestUserOrderInline] inlines = [GuestUserOrderInline]
@admin.register(Coupon)
class CouponAdmin(admin.ModelAdmin):
list_display = ('code', 'discount_amount', 'discount_percent', 'is_active',
'valid_from', 'valid_to', 'current_uses', 'max_uses')
list_filter = ('is_active', 'valid_from', 'valid_to')
search_fields = ('code', 'description')
readonly_fields = ('current_uses', 'created_at', 'stripe_coupon_id')
fieldsets = (
('Basic Information', {
'fields': ('code', 'description', 'is_active')
}),
('Discount', {
'fields': ('discount_amount', 'discount_percent')
}),
('Validity', {
'fields': ('valid_from', 'valid_to', 'max_uses', 'current_uses')
}),
('Stripe Information', {
'fields': ('stripe_coupon_id',),
'classes': ('collapse',)
}),
)
@admin.register(CouponUsage)
class CouponUsageAdmin(admin.ModelAdmin):
list_display = ('coupon', 'user', 'guest_email', 'order', 'used_at')
list_filter = ('used_at',)
search_fields = ('coupon__code', 'user__username', 'user__email', 'guest_email')
readonly_fields = ('used_at',)

@ -1,5 +1,9 @@
from django import forms from django import forms
from .models import Coupon
class GuestCheckoutForm(forms.Form): class GuestCheckoutForm(forms.Form):
email = forms.EmailField(required=True) email = forms.EmailField(required=True)
phone = forms.CharField(max_length=20, required=True, label="Téléphone portable") phone = forms.CharField(max_length=20, required=True, label="Téléphone portable")
class CouponApplyForm(forms.Form):
code = forms.CharField(max_length=50)

@ -0,0 +1,54 @@
# Generated by Django 5.1 on 2025-03-27 14:34
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0023_alter_color_options_color_ordering'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Coupon',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50, unique=True)),
('description', models.CharField(blank=True, max_length=255)),
('discount_amount', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
('discount_percent', models.DecimalField(decimal_places=2, default=0, max_digits=5)),
('is_active', models.BooleanField(default=True)),
('valid_from', models.DateTimeField()),
('valid_to', models.DateTimeField()),
('max_uses', models.IntegerField(default=0)),
('current_uses', models.IntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('stripe_coupon_id', models.CharField(blank=True, max_length=100, null=True)),
],
),
migrations.AddField(
model_name='order',
name='discount_amount',
field=models.DecimalField(decimal_places=2, default=0.0, max_digits=10),
),
migrations.AddField(
model_name='order',
name='coupon',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.coupon'),
),
migrations.CreateModel(
name='CouponUsage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('guest_email', models.EmailField(blank=True, max_length=254, null=True)),
('used_at', models.DateTimeField(auto_now_add=True)),
('coupon', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='usages', to='shop.coupon')),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='coupon_usages', to='shop.order')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

@ -76,6 +76,38 @@ class GuestUser(models.Model):
def __str__(self): def __str__(self):
return f"{self.email}" return f"{self.email}"
class Coupon(models.Model):
code = models.CharField(max_length=50, unique=True)
description = models.CharField(max_length=255, blank=True)
discount_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0)
discount_percent = models.DecimalField(max_digits=5, decimal_places=2, default=0)
is_active = models.BooleanField(default=True)
valid_from = models.DateTimeField()
valid_to = models.DateTimeField()
max_uses = models.IntegerField(default=0) # 0 for unlimited
current_uses = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
stripe_coupon_id = models.CharField(max_length=100, blank=True, null=True)
def __str__(self):
return self.code
def is_valid(self):
from django.utils import timezone
now = timezone.now()
if not self.is_active:
return False
if now < self.valid_from or now > self.valid_to:
return False
if self.max_uses > 0 and self.current_uses >= self.max_uses:
return False
return True
def get_discount_amount_for_total(self, total_price):
"""Calculate the discount amount for a given total price"""
if self.discount_percent > 0:
return (self.discount_percent / 100) * total_price
return self.discount_amount
class Order(models.Model): class Order(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True)
@ -95,10 +127,15 @@ class Order(models.Model):
('test', 'Test Mode'), ('test', 'Test Mode'),
('live', 'Live Mode'), ('live', 'Live Mode'),
]) ])
coupon = models.ForeignKey(Coupon, on_delete=models.SET_NULL, null=True, blank=True)
discount_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0.00)
def __str__(self): def __str__(self):
return f"Order #{self.id} - {self.status}" return f"Order #{self.id} - {self.status}"
def get_total_after_discount(self):
return max(self.total_price - self.discount_amount, 0)
class OrderItem(models.Model): class OrderItem(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items') order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items')
product = models.ForeignKey(Product, on_delete=models.CASCADE) product = models.ForeignKey(Product, on_delete=models.CASCADE)
@ -115,3 +152,14 @@ class OrderItem(models.Model):
def get_total_price(self): def get_total_price(self):
return self.price * self.quantity return self.price * self.quantity
class CouponUsage(models.Model):
coupon = models.ForeignKey(Coupon, on_delete=models.CASCADE, related_name='usages')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True)
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='coupon_usages')
guest_email = models.EmailField(blank=True, null=True)
used_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
user_identifier = self.user.username if self.user else self.guest_email
return f"{self.coupon.code} - {user_identifier} - {self.used_at}"

@ -66,6 +66,21 @@ def _get_order_details(instance):
"UNPAID": "NON PAYÉE", "PAID": "PAYÉE", "FAILED": "ÉCHOUÉE" "UNPAID": "NON PAYÉE", "PAID": "PAYÉE", "FAILED": "ÉCHOUÉE"
} }
# Calculate discount information
has_coupon = instance.coupon is not None
coupon_info = ""
final_price = instance.total_price
if has_coupon:
coupon_code = instance.coupon.code
discount_amount = instance.discount_amount
final_price = instance.get_total_after_discount()
if instance.coupon.discount_percent > 0:
coupon_info = f"Code promo: {coupon_code} ({instance.coupon.discount_percent}%)"
else:
coupon_info = f"Code promo: {coupon_code} (€{discount_amount})"
return { return {
'order_id': instance.id, 'order_id': instance.id,
'status': instance.status, 'status': instance.status,
@ -73,6 +88,10 @@ def _get_order_details(instance):
'payment_status': instance.payment_status, 'payment_status': instance.payment_status,
'payment_status_fr': payment_status_fr_map.get(instance.payment_status, instance.payment_status), 'payment_status_fr': payment_status_fr_map.get(instance.payment_status, instance.payment_status),
'total_price': instance.total_price, 'total_price': instance.total_price,
'has_coupon': has_coupon,
'coupon_info': coupon_info,
'discount_amount': instance.discount_amount if has_coupon else 0,
'final_price': final_price,
'customer_info': customer_info, 'customer_info': customer_info,
'customer_email': customer_email, 'customer_email': customer_email,
'date_ordered': instance.date_ordered, 'date_ordered': instance.date_ordered,
@ -101,13 +120,22 @@ def _send_internal_notification(instance, action, order_details, items_list):
"""Send notification email to shop managers.""" """Send notification email to shop managers."""
action_fr = _translate_action(action) action_fr = _translate_action(action)
# Build price information with coupon details if applicable
price_info = f"Prix total: {order_details['total_price']}"
if order_details['has_coupon']:
price_info = f"""
Prix total: {order_details['total_price']}
{order_details['coupon_info']}
Réduction: -{order_details['discount_amount']}
Montant payé: {order_details['final_price']}"""
subject = f"Commande #{order_details['order_id']} {action_fr}: {order_details['status_fr']}" subject = f"Commande #{order_details['order_id']} {action_fr}: {order_details['status_fr']}"
message = f""" message = f"""
La commande #{order_details['order_id']} a été {action_fr.lower()} La commande #{order_details['order_id']} a été {action_fr.lower()}
Statut: {order_details['status_fr']} Statut: {order_details['status_fr']}
Statut de paiement: {order_details['payment_status_fr']} Statut de paiement: {order_details['payment_status_fr']}
Prix total: {order_details['total_price']} {price_info}
{order_details['customer_info']} {order_details['customer_info']}
@ -147,6 +175,10 @@ def _send_customer_notification(instance, order_details, items_list):
date_formatted, date_formatted,
order_details['status_fr'], order_details['status_fr'],
order_details['total_price'], order_details['total_price'],
order_details['has_coupon'],
order_details['coupon_info'],
order_details['discount_amount'],
order_details['final_price'],
items_list, items_list,
contact_email, contact_email,
shop_url shop_url
@ -166,15 +198,24 @@ def _send_customer_notification(instance, order_details, items_list):
) )
def _get_customer_email_content(status, payment_status, order_id, date, status_fr, def _get_customer_email_content(status, payment_status, order_id, date, status_fr,
total_price, items_list, contact_email, shop_url): total_price, has_coupon, coupon_info, discount_amount,
final_price, items_list, contact_email, shop_url):
"""Get the appropriate customer email content based on order status.""" """Get the appropriate customer email content based on order status."""
# Build price information with coupon details if applicable
price_info = f"Prix total: {total_price}"
if has_coupon:
price_info = f"""Prix total: {total_price}
{coupon_info}
Réduction: -{discount_amount}
Montant payé: {final_price}"""
# Payment confirmation email # Payment confirmation email
if status == OrderStatus.PAID and payment_status == "PAID": if status == OrderStatus.PAID and payment_status == "PAID":
return { return {
'subject': f"Confirmation de votre commande #{order_id} - PadelClub", 'subject': f"Confirmation de votre commande #{order_id} - Padel Club",
'message': _build_payment_confirmation_email(order_id, date, status_fr, 'message': _build_payment_confirmation_email(order_id, date, status_fr,
total_price, items_list, price_info, items_list,
contact_email, shop_url) contact_email, shop_url)
} }
@ -187,41 +228,41 @@ def _get_customer_email_content(status, payment_status, order_id, date, status_f
}.get(status, "") }.get(status, "")
return { return {
'subject': f"Mise à jour de votre commande #{order_id} - PadelClub", 'subject': f"Mise à jour de votre commande #{order_id} - Padel Club",
'message': _build_status_update_email(order_id, date, status_message, status_fr, 'message': _build_status_update_email(order_id, date, status_message, status_fr,
total_price, items_list, contact_email) price_info, items_list, contact_email)
} }
# Payment issue notification # Payment issue notification
elif payment_status == "FAILED": elif payment_status == "FAILED":
return { return {
'subject': f"Problème de paiement pour votre commande #{order_id} - PadelClub", 'subject': f"Problème de paiement pour votre commande #{order_id} - Padel Club",
'message': _build_payment_issue_email(order_id, date, total_price, 'message': _build_payment_issue_email(order_id, date, price_info,
items_list, contact_email, shop_url) items_list, contact_email, shop_url)
} }
# Payment reminder for unpaid orders # Payment reminder for unpaid orders
elif payment_status == "UNPAID" and status != OrderStatus.PENDING: elif payment_status == "UNPAID" and status != OrderStatus.PENDING:
return { return {
'subject': f"Rappel de paiement pour votre commande #{order_id} - PadelClub", 'subject': f"Rappel de paiement pour votre commande #{order_id} - Padel Club",
'message': _build_payment_reminder_email(order_id, date, total_price, 'message': _build_payment_reminder_email(order_id, date, price_info,
items_list, contact_email) items_list, contact_email)
} }
# No email needed # No email needed
return None return None
def _build_payment_confirmation_email(order_id, date, status_fr, total_price, items_list, contact_email, shop_url): def _build_payment_confirmation_email(order_id, date, status_fr, price_info, items_list, contact_email, shop_url):
"""Build payment confirmation email message.""" """Build payment confirmation email message."""
return f""" return f"""
Bonjour, Bonjour,
Nous vous remercions pour votre commande sur PadelClub ! Nous vous remercions pour votre commande sur Padel Club !
Récapitulatif de votre commande #{order_id} du {date} : Récapitulatif de votre commande #{order_id} du {date} :
Statut: {status_fr} Statut: {status_fr}
Prix total: {total_price} {price_info}
Détail de votre commande : Détail de votre commande :
{items_list} {items_list}
@ -234,22 +275,22 @@ Pour toute question concernant votre commande, n'hésitez pas à contacter notre
Visitez notre boutique pour découvrir d'autres produits : Visitez notre boutique pour découvrir d'autres produits :
{shop_url} {shop_url}
Merci de votre confiance et à bientôt sur PadelClub ! Merci de votre confiance et à bientôt sur Padel Club !
L'équipe PadelClub L'équipe Padel Club
""" """
def _build_status_update_email(order_id, date, status_message, status_fr, total_price, items_list, contact_email): def _build_status_update_email(order_id, date, status_message, status_fr, price_info, items_list, contact_email):
"""Build status update email message.""" """Build status update email message."""
return f""" return f"""
Bonjour, Bonjour,
Mise à jour concernant votre commande PadelClub #{order_id} du {date} : Mise à jour concernant votre commande Padel Club #{order_id} du {date} :
{status_message} {status_message}
Statut actuel: {status_fr} Statut actuel: {status_fr}
Prix total: {total_price} {price_info}
Détail de votre commande : Détail de votre commande :
{items_list} {items_list}
@ -257,21 +298,21 @@ Détail de votre commande :
Pour toute question concernant votre commande, n'hésitez pas à contacter notre service client : Pour toute question concernant votre commande, n'hésitez pas à contacter notre service client :
{contact_email} {contact_email}
Merci de votre confiance et à bientôt sur PadelClub ! Merci de votre confiance et à bientôt sur Padel Club !
L'équipe PadelClub L'équipe Padel Club
""" """
def _build_payment_issue_email(order_id, date, total_price, items_list, contact_email, shop_url): def _build_payment_issue_email(order_id, date, price_info, items_list, contact_email, shop_url):
"""Build payment issue email message.""" """Build payment issue email message."""
return f""" return f"""
Bonjour, Bonjour,
Nous avons rencontré un problème lors du traitement du paiement de votre commande PadelClub #{order_id}. Nous avons rencontré un problème lors du traitement du paiement de votre commande Padel Club #{order_id}.
Détails de la commande : Détails de la commande :
Date: {date} Date: {date}
Prix total: {total_price} {price_info}
Articles: Articles:
{items_list} {items_list}
@ -284,18 +325,18 @@ Vous pouvez également visiter notre boutique pour finaliser votre achat :
Merci de votre compréhension. Merci de votre compréhension.
L'équipe PadelClub L'équipe Padel Club
""" """
def _build_payment_reminder_email(order_id, date, total_price, items_list, contact_email): def _build_payment_reminder_email(order_id, date, price_info, items_list, contact_email):
"""Build payment reminder email message.""" """Build payment reminder email message."""
return f""" return f"""
Bonjour, Bonjour,
Nous vous rappelons que votre commande PadelClub #{order_id} du {date} n'a pas encore été payée. Nous vous rappelons que votre commande Padel Club #{order_id} du {date} n'a pas encore été payée.
Détails de la commande : Détails de la commande :
Prix total: {total_price} {price_info}
Articles: Articles:
{items_list} {items_list}
@ -307,7 +348,7 @@ Si vous rencontrez des difficultés ou si vous avez des questions, n'hésitez pa
Merci de votre confiance. Merci de votre confiance.
L'équipe PadelClub L'équipe Padel Club
""" """
@receiver(user_logged_in) @receiver(user_logged_in)

@ -119,13 +119,28 @@
height: 36px; height: 36px;
} }
.coupon-section {
color: #707070;
font-size: 12px;
font-weight: 600;
text-decoration: none;
margin-top: 20px;
}
.confirm-nav-button { .confirm-nav-button {
background-color: #90ee90; background-color: #90ee90;
color: #707070;
font-size: 12px;
font-weight: 600;
text-decoration: none;
} }
.cancel-nav-button { .cancel-nav-button {
background-color: #e84039; background-color: #e84039;
color: white; color: white;
font-size: 12px;
font-weight: 600;
text-decoration: none;
} }
.remove-btn { .remove-btn {

@ -25,21 +25,31 @@ class StripeService:
mode_str = "TEST" if self.is_test_mode else "LIVE" mode_str = "TEST" if self.is_test_mode else "LIVE"
logger.debug(f"Initialized StripeService in {mode_str} mode") logger.debug(f"Initialized StripeService in {mode_str} mode")
def create_checkout_session(self, customer_email, line_items, success_url, cancel_url, metadata=None): def create_checkout_session(self, customer_email=None, line_items=None,
"""Create a Stripe Checkout Session for one-time payments""" success_url=None, cancel_url=None, metadata=None,
if self.is_test_mode: discounts=None):
logger.info(f"Creating checkout session in TEST mode with metadata: {metadata}") """Create a Stripe checkout session with the given parameters"""
session = stripe.checkout.Session.create( # Initialize session parameters
customer_email=customer_email, session_params = {
payment_method_types=['card'], 'payment_method_types': ['card'],
line_items=line_items, 'line_items': line_items,
mode='payment', 'mode': 'payment',
success_url=success_url, 'success_url': success_url,
cancel_url=cancel_url, 'cancel_url': cancel_url,
metadata=metadata or {}, 'metadata': metadata or {},
) }
return session
# Add customer email if provided
if customer_email:
session_params['customer_email'] = customer_email
# Add discounts if provided
if discounts:
session_params['discounts'] = discounts
# Create and return the session
return stripe.checkout.Session.create(**session_params)
def verify_webhook_signature(self, payload, signature): def verify_webhook_signature(self, payload, signature):
"""Verify webhook signature using mode-appropriate secret""" """Verify webhook signature using mode-appropriate secret"""

@ -31,6 +31,32 @@
{% if display_data.items %} {% if display_data.items %}
{% include 'shop/partials/order_items_display.html' with items=display_data.items total_quantity=display_data.total_quantity total_price=display_data.total_price edit_mode=True %} {% include 'shop/partials/order_items_display.html' with items=display_data.items total_quantity=display_data.total_quantity total_price=display_data.total_price edit_mode=True %}
<div class="coupon-section">
<div>Avez-vous un code de réduction?</div>
<div class="coupon-form" style="display: flex;">
<input type="text" id="coupon-code" style="flex-grow: 1; margin-right: 10px; border-radius: 12px" placeholder="Entrez votre code">
<button id="apply-coupon" class="confirm-nav-button" style="min-width: 100px; border-radius: 12px; height: 40px;">Appliquer</button>
<button id="remove-coupon" class="cancel-nav-button" style="min-width: 100px; border-radius: 12px; height: 40px; display: none;">Supprimer</button>
</div>
<div id="coupon-message"></div>
<!-- Discount display -->
<div id="discount-info" style="margin-top: 10px; display: none;">
<div class="discount-row" style="display: flex; justify-content: space-between; margin-bottom: 5px;">
<span>Sous-total:</span>
<span id="subtotal-amount">€{{ display_data.total_price }}</span>
</div>
<div class="discount-row" style="display: flex; justify-content: space-between; margin-bottom: 5px;">
<span>Réduction:</span>
<span id="discount-amount" style="color: #e67e22;">-€0.00</span>
</div>
<div class="discount-row" style="display: flex; justify-content: space-between; font-weight: bold;">
<span>Total:</span>
<span id="final-total">€{{ display_data.total_price }}</span>
</div>
</div>
</div>
<div class="cart-summary"> <div class="cart-summary">
{% if user.is_authenticated %} {% if user.is_authenticated %}
@ -79,6 +105,129 @@
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const checkoutButton = document.getElementById('checkout-button'); const checkoutButton = document.getElementById('checkout-button');
const applyButton = document.getElementById('apply-coupon');
const removeButton = document.getElementById('remove-coupon');
const codeInput = document.getElementById('coupon-code');
const messageElement = document.getElementById('coupon-message');
const discountInfo = document.getElementById('discount-info');
const subtotalAmount = document.getElementById('subtotal-amount');
const discountAmount = document.getElementById('discount-amount');
const finalTotal = document.getElementById('final-total');
// Initial values
const originalTotal = parseFloat('{{ display_data.total_price }}');
// Function to get CSRF token
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue; // FIX: Proper closing of the function here
}
// Apply coupon
applyButton.addEventListener('click', function() {
const code = codeInput.value.trim();
if (!code) {
messageElement.textContent = 'Veuillez entrer un code';
messageElement.style.color = '#e74c3c';
return;
}
// Show loading state
applyButton.textContent = 'Chargement...';
applyButton.disabled = true;
// Send request to server
fetch('{% url "shop:apply_coupon" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': getCookie('csrftoken')
},
body: `code=${encodeURIComponent(code)}`
})
.then(response => response.json())
.then(data => {
// Reset button state
applyButton.textContent = 'Appliquer';
applyButton.disabled = false;
// Show message
messageElement.textContent = data.message;
if (data.status === 'success') {
// Update UI for success
messageElement.style.color = '#27ae60';
applyButton.style.display = 'none';
removeButton.style.display = 'block';
codeInput.disabled = true;
// Update price display
discountInfo.style.display = 'block';
discountAmount.textContent = `-€${data.discount.toFixed(2)}`;
finalTotal.textContent = `€${data.new_total.toFixed(2)}`;
} else {
// Update UI for error
messageElement.style.color = '#e74c3c';
}
})
.catch(error => {
console.error('Error:', error);
applyButton.textContent = 'Appliquer';
applyButton.disabled = false;
messageElement.textContent = 'Une erreur est survenue. Veuillez réessayer.';
messageElement.style.color = '#e74c3c';
});
});
// Remove coupon
removeButton.addEventListener('click', function() {
// Show loading state
removeButton.textContent = 'Chargement...';
removeButton.disabled = true;
// Send request to server
fetch('{% url "shop:remove_coupon" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': getCookie('csrftoken')
}
})
.then(response => response.json())
.then(data => {
// Reset button state
removeButton.textContent = 'Supprimer';
removeButton.disabled = false;
if (data.status === 'success') {
// Reset UI
messageElement.textContent = '';
codeInput.value = '';
codeInput.disabled = false;
applyButton.style.display = 'block';
removeButton.style.display = 'none';
discountInfo.style.display = 'none';
finalTotal.textContent = `€${originalTotal.toFixed(2)}`;
}
})
.catch(error => {
console.error('Error:', error);
removeButton.textContent = 'Supprimer';
removeButton.disabled = false;
messageElement.textContent = 'Une erreur est survenue. Veuillez réessayer.';
messageElement.style.color = '#e74c3c';
});
});
checkoutButton.addEventListener('click', function() { checkoutButton.addEventListener('click', function() {
// Show a loading indicator // Show a loading indicator

@ -29,6 +29,23 @@
<h3>Détails de la commande</h3> <h3>Détails de la commande</h3>
{% include 'shop/partials/order_items_display.html' with items=display_data.items total_quantity=display_data.total_quantity total_price=display_data.total_price edit_mode=False %} {% include 'shop/partials/order_items_display.html' with items=display_data.items total_quantity=display_data.total_quantity total_price=display_data.total_price edit_mode=False %}
{% if has_discount %}
<div class="order-summary" style="margin-top: 20px;">
<div class="order-row" style="display: flex; justify-content: space-between;">
<span>Sous-total:</span>
<span>€{{ order.total_price }}</span>
</div>
<div class="order-row" style="display: flex; justify-content: space-between; color: #e67e22;">
<span>Réduction:</span>
<span>-€{{ discount_amount }}</span>
</div>
<div class="order-row" style="display: flex; justify-content: space-between; font-weight: bold;">
<span>Total payé:</span>
<span>€{{ total_after_discount }}</span>
</div>
</div>
{% endif %}
<div class="cart-summary"> <div class="cart-summary">
<a class="confirm-nav-button checkout-button" href="{% url 'shop:product_list' %}">Retour à la boutique</a> <a class="confirm-nav-button checkout-button" href="{% url 'shop:product_list' %}">Retour à la boutique</a>
</div> </div>

@ -22,5 +22,7 @@ urlpatterns = [
path('cart/remove-item/', views.remove_from_cart, name='remove_from_cart'), path('cart/remove-item/', views.remove_from_cart, name='remove_from_cart'),
path('debug/simulate-payment-success/', views.simulate_payment_success, name='simulate_payment_success'), path('debug/simulate-payment-success/', views.simulate_payment_success, name='simulate_payment_success'),
path('debug/simulate-payment-failure/', views.simulate_payment_failure, name='simulate_payment_failure'), path('debug/simulate-payment-failure/', views.simulate_payment_failure, name='simulate_payment_failure'),
path('apply-coupon/', views.apply_coupon, name='apply_coupon'),
path('remove-coupon/', views.remove_coupon, name='remove_coupon'),
] ]

@ -1,7 +1,7 @@
from .stripe_utils import stripe_service from .stripe_utils import stripe_service
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.contrib import messages from django.contrib import messages
from .models import Product, Order, OrderItem, GuestUser, OrderStatus from .models import Product, Order, OrderItem, GuestUser, OrderStatus, Coupon, CouponUsage
from django.db.models import Sum from django.db.models import Sum
from .forms import GuestCheckoutForm from .forms import GuestCheckoutForm
import stripe import stripe
@ -12,6 +12,7 @@ from django.views.decorators.http import require_POST
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import ensure_csrf_cookie
from django.http import HttpResponse from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.utils import timezone
from . import cart from . import cart
@ -64,16 +65,57 @@ def _create_stripe_checkout_session(request, order, line_items):
metadata['guest_email'] = request.session.get('guest_email', '') metadata['guest_email'] = request.session.get('guest_email', '')
customer_email = request.session.get('guest_email', '') customer_email = request.session.get('guest_email', '')
# Handle coupon discount in Stripe
discounts = None
if order.coupon and order.discount_amount > 0:
try: try:
# Use the service to create the session # Create or retrieve a Stripe coupon
checkout_session = stripe_service.create_checkout_session( if order.coupon.stripe_coupon_id:
customer_email=customer_email, # Use existing Stripe coupon
line_items=line_items, stripe_coupon_id = order.coupon.stripe_coupon_id
success_url=success_url, else:
cancel_url=cancel_url, # Create a new Stripe coupon
metadata=metadata, if order.coupon.discount_percent > 0:
stripe_coupon = stripe.Coupon.create(
percent_off=float(order.coupon.discount_percent),
duration='once',
name=order.coupon.code
)
else:
stripe_coupon = stripe.Coupon.create(
amount_off=int(float(order.coupon.discount_amount) * 100), # Convert to cents
currency='eur',
duration='once',
name=order.coupon.code
) )
# Save the Stripe coupon ID for future use
stripe_coupon_id = stripe_coupon.id
order.coupon.stripe_coupon_id = stripe_coupon_id
order.coupon.save()
# Add the coupon to the checkout session
discounts = [{'coupon': stripe_coupon_id}]
except Exception as e:
print(f"Error creating Stripe coupon: {str(e)}")
# Continue without the discount if there was an error
session_params = {
'customer_email': customer_email,
'line_items': line_items,
'success_url': success_url,
'cancel_url': cancel_url,
'metadata': metadata,
}
# Add discounts if available
if discounts:
session_params['discounts'] = discounts
try:
# Use the service to create the session
checkout_session = stripe_service.create_checkout_session(**session_params)
# Save the checkout session ID to the order # Save the checkout session ID to the order
order.stripe_checkout_session_id = checkout_session.id order.stripe_checkout_session_id = checkout_session.id
order.save() order.save()
@ -172,11 +214,34 @@ def create_order(request):
if total_price <= 0: if total_price <= 0:
return None return None
# Check for applied coupon
coupon = None
discount_amount = 0
if 'coupon_id' in request.session:
try:
coupon_id = request.session['coupon_id']
coupon = Coupon.objects.get(id=coupon_id)
# Validate coupon
if coupon.is_valid():
# Calculate discount
discount_amount = coupon.get_discount_amount_for_total(total_price)
else:
# Coupon is no longer valid, remove from session
del request.session['coupon_id']
coupon = None
except Coupon.DoesNotExist:
# Invalid coupon in session, remove it
del request.session['coupon_id']
if request.user.is_authenticated: if request.user.is_authenticated:
# Authenticated user order # Authenticated user order
order = Order.objects.create( order = Order.objects.create(
user=request.user, user=request.user,
total_price=total_price, total_price=total_price,
coupon=coupon,
discount_amount=discount_amount,
stripe_mode=stripe_service.mode # Add this line stripe_mode=stripe_service.mode # Add this line
) )
@ -187,6 +252,8 @@ def create_order(request):
order = Order.objects.create( order = Order.objects.create(
guest_user=guest_user, guest_user=guest_user,
total_price=total_price, total_price=total_price,
coupon=coupon,
discount_amount=discount_amount,
stripe_mode=stripe_service.mode # Add this line stripe_mode=stripe_service.mode # Add this line
) )
@ -194,6 +261,8 @@ def create_order(request):
# No guest user information, create order without user # No guest user information, create order without user
order = Order.objects.create( order = Order.objects.create(
total_price=total_price, total_price=total_price,
coupon=coupon,
discount_amount=discount_amount,
stripe_mode=stripe_service.mode # Add this line stripe_mode=stripe_service.mode # Add this line
) )
@ -208,6 +277,27 @@ def create_order(request):
price=cart_item.product.price price=cart_item.product.price
) )
# Record coupon usage if applicable
if coupon:
guest_email = None
if not request.user.is_authenticated and 'guest_email' in request.session:
guest_email = request.session['guest_email']
CouponUsage.objects.create(
coupon=coupon,
user=request.user if request.user.is_authenticated else None,
order=order,
guest_email=guest_email
)
# Update coupon usage count
coupon.current_uses += 1
coupon.save()
# Remove coupon from session after using it
if 'coupon_id' in request.session:
del request.session['coupon_id']
# Note: Cart is not cleared here, only after successful payment # Note: Cart is not cleared here, only after successful payment
return order return order
@ -334,10 +424,16 @@ def payment_success(request, order_id):
order_items = order.items.all() order_items = order.items.all()
display_data = prepare_item_display_data(order_items, is_cart=False) display_data = prepare_item_display_data(order_items, is_cart=False)
return render(request, 'shop/payment_success.html', { # Add discount information to the context
context = {
'order': order, 'order': order,
'display_data': display_data, 'display_data': display_data,
}) 'has_discount': order.discount_amount > 0,
'discount_amount': order.discount_amount,
'total_after_discount': order.get_total_after_discount(),
}
return render(request, 'shop/payment_success.html', context)
def payment_cancel(request, order_id): def payment_cancel(request, order_id):
"""Handle cancelled payment""" """Handle cancelled payment"""
@ -604,3 +700,55 @@ def handle_payment_intent_failed(payment_intent):
except Order.DoesNotExist: except Order.DoesNotExist:
print(f"Error: Order {order_id} not found for payment intent {payment_intent.id}") print(f"Error: Order {order_id} not found for payment intent {payment_intent.id}")
@require_POST
def apply_coupon(request):
"""Appliquer un code promo à la session actuelle"""
code = request.POST.get('code')
try:
coupon = Coupon.objects.get(code__iexact=code, is_active=True)
# Vérifier si le coupon est valide
now = timezone.now()
if now < coupon.valid_from or now > coupon.valid_to:
return JsonResponse({'status': 'error', 'message': 'Ce code promo a expiré'})
# Vérifier la limite d'utilisation
if coupon.max_uses > 0 and coupon.current_uses >= coupon.max_uses:
return JsonResponse({'status': 'error', 'message': 'Ce code promo a atteint sa limite d\'utilisation'})
# Stocker le coupon dans la session
request.session['coupon_id'] = coupon.id
# Calculer la remise et le nouveau total
cart_items = cart.get_cart_items(request)
total_price = cart.get_cart_total(request)
if not cart_items.exists() or total_price <= 0:
return JsonResponse({'status': 'error', 'message': 'Votre panier est vide'})
discount_amount = coupon.get_discount_amount_for_total(total_price)
new_total = max(total_price - discount_amount, 0)
# Formater le message en fonction du type de remise
if coupon.discount_percent > 0:
message = f"Remise de {coupon.discount_percent}% appliquée"
else:
message = f"Remise de {coupon.discount_amount} € appliquée"
return JsonResponse({
'status': 'success',
'message': message,
'discount': float(discount_amount),
'new_total': float(new_total)
})
except Coupon.DoesNotExist:
return JsonResponse({'status': 'error', 'message': 'Code promo invalide'})
@require_POST
def remove_coupon(request):
"""Remove the applied coupon from the session"""
if 'coupon_id' in request.session:
del request.session['coupon_id']
return JsonResponse({'status': 'success', 'message': 'Coupon supprimé'})

Loading…
Cancel
Save