diff --git a/tournaments/migrations/0121_tournament_stripe_account_id.py b/tournaments/migrations/0121_tournament_stripe_account_id.py new file mode 100644 index 0000000..ddf223b --- /dev/null +++ b/tournaments/migrations/0121_tournament_stripe_account_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1 on 2025-04-09 20:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tournaments', '0120_tournament_enable_online_payment_refund_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='tournament', + name='stripe_account_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/tournaments/models/tournament.py b/tournaments/models/tournament.py index 7ca313a..6ca06b1 100644 --- a/tournaments/models/tournament.py +++ b/tournaments/models/tournament.py @@ -83,6 +83,7 @@ class Tournament(BaseModel): online_payment_is_mandatory = models.BooleanField(default=False) enable_online_payment_refund = models.BooleanField(default=False) refund_date_limit = models.DateTimeField(null=True, blank=True) # Equivalent to Date? = nil + stripe_account_id = models.CharField(max_length=255, blank=True, null=True) def delete_dependencies(self): for team_registration in self.team_registrations.all(): @@ -1724,12 +1725,13 @@ class Tournament(BaseModel): return None def should_request_payment(self): - if self.entry_fee is not None and self.entry_fee > 0 and self.enable_online_payment: + if self.entry_fee is not None and self.entry_fee > 0 and self.enable_online_payment and self.stripe_account_id is not None: return True else: return False def is_refund_possible(self): + return True if self.enable_online_payment_refund: time = timezone.now() if self.refund_date_limit: @@ -1755,6 +1757,11 @@ class Tournament(BaseModel): else: return 0 + def effective_commission_rate(self): + """Get the commission rate for this tournament, falling back to the umpire default if not set""" + return 1.00 # Fallback default + + class MatchGroup: def __init__(self, name, matches, formatted_schedule, round_id=None): self.name = name diff --git a/tournaments/services/email_service.py b/tournaments/services/email_service.py index b234b95..c167065 100644 --- a/tournaments/services/email_service.py +++ b/tournaments/services/email_service.py @@ -2,6 +2,7 @@ from django.core.mail import EmailMessage from enum import Enum from ..models.player_registration import RegistrationStatus from ..models.tournament import TeamSortingType +import django.utils.timezone class TeamEmailType(Enum): REGISTERED = "registered" @@ -521,3 +522,67 @@ class TournamentEmailService: ] return "\n".join(payment_info) + + @staticmethod + def send_refund_confirmation(tournament, team_registration, refund_details): + """ + Send a refund confirmation email to team members + + Args: + tournament: The tournament + team_registration: The team registration + refund_details: The refund details from Stripe + """ + player_registrations = team_registration.player_registrations.all() + refund_amount = None + if refund_details and 'amount' in refund_details: + # Convert cents to euros + refund_amount = refund_details['amount'] / 100 + + + if refund_amount is None: + refund_amount = tournament.team_fee() + + for player in player_registrations: + if not player.email or not player.registered_online: + continue + + tournament_details_str = tournament.build_tournament_details_str() + other_player = team_registration.get_other_player(player) if len(player_registrations) > 1 else None + + body_parts = [ + "Bonjour,\n\n", + f"Votre remboursement pour le tournoi {tournament_details_str}, prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name} a été traité avec succès." + ] + + # Add information about the other player if available + if other_player: + body_parts.append( + f"\n\nVous étiez inscrit avec {other_player.name()}, n'oubliez pas de prévenir votre partenaire du remboursement." + ) + + # Add refund details + body_parts.append( + f"\n\nMontant remboursé : {refund_amount}€ par joueur" + ) + + refund_date = timezone.now().strftime("%d/%m/%Y") + body_parts.append( + f"\nDate du remboursement : {refund_date}" + ) + + absolute_url = f"https://padelclub.app/tournament/{tournament.id}/info" + link_text = "informations sur le tournoi" + absolute_url = f'{link_text}' + + body_parts.append(f"\n\nVoir les {absolute_url}") + + body_parts.extend([ + f"\n\n{TournamentEmailService._format_umpire_contact(tournament)}", + "\n\nCeci est un e-mail automatique, veuillez ne pas y répondre." + ]) + + email_body = "".join(body_parts) + + email_subject = TournamentEmailService.email_subject(tournament, "Confirmation de remboursement") + TournamentEmailService._send_email(player.email, email_subject, email_body) diff --git a/tournaments/services/payment_service.py b/tournaments/services/payment_service.py index d161a92..9053ce1 100644 --- a/tournaments/services/payment_service.py +++ b/tournaments/services/payment_service.py @@ -1,7 +1,6 @@ -from django.shortcuts import get_object_or_404, redirect +from django.shortcuts import get_object_or_404 from django.conf import settings from django.urls import reverse -from django.contrib import messages import stripe from ..models import TeamRegistration, PlayerRegistration, Tournament @@ -26,6 +25,19 @@ class PaymentService: stripe.api_key = self.stripe_api_key tournament = get_object_or_404(Tournament, id=tournament_id) + # Check if payments are enabled for this tournament + if not tournament.should_request_payment(): + raise Exception("Les paiements ne sont pas activés pour ce tournoi.") + + # Get the umpire's Stripe account ID + stripe_account_id = tournament.stripe_account_id + if not stripe_account_id: + raise Exception("L'arbitre n'a pas configuré son compte Stripe.") + + # Calculate commission + commission_rate = tournament.effective_commission_rate() / 100 + platform_amount = int((team_fee * commission_rate) * 100) # Commission in cents + # Get user email if authenticated customer_email = self.request.user.email if self.request.user.is_authenticated else None @@ -60,6 +72,12 @@ class PaymentService: reverse('tournament-payment-success', kwargs={'tournament_id': tournament_id}) ), 'cancel_url': cancel_url, + 'payment_intent_data': { + 'application_fee_amount': platform_amount, + 'transfer_data': { + 'destination': stripe_account_id, + }, + }, 'metadata': { 'tournament_id': str(tournament_id), # Convert UUID to string 'user_id': str(self.request.user.id) if self.request.user.is_authenticated else None, # Convert UUID to string @@ -79,14 +97,21 @@ class PaymentService: checkout_session_params['customer_email'] = customer_email # Create the checkout session - checkout_session = stripe.checkout.Session.create(**checkout_session_params) + try: + checkout_session = stripe.checkout.Session.create(**checkout_session_params) - # Store checkout session ID and source page in session - self.request.session['stripe_checkout_session_id'] = checkout_session.id - self.request.session['payment_source_page'] = 'tournament_info' if team_registration_id else 'register_tournament' - self.request.session.modified = True + # Store checkout session ID and source page in session + self.request.session['stripe_checkout_session_id'] = checkout_session.id + self.request.session['payment_source_page'] = 'tournament_info' if team_registration_id else 'register_tournament' + self.request.session.modified = True - return checkout_session + return checkout_session + except stripe.error.StripeError as e: + # Handle specific Stripe errors more gracefully + if 'destination' in str(e): + raise Exception("Erreur avec le compte Stripe de l'arbitre. Contactez l'administrateur.") + else: + raise Exception(f"Erreur Stripe: {str(e)}") def process_successful_payment(self, tournament_id, checkout_session): """ @@ -191,3 +216,53 @@ class PaymentService: player_reg.save() return True + + def process_refund(self, team_registration_id): + """ + Process a refund for a tournament registration as part of unregistration + Returns a tuple (success, message) + """ + stripe.api_key = self.stripe_api_key + + try: + # Get the team registration + team_registration = get_object_or_404(TeamRegistration, id=team_registration_id) + tournament = team_registration.tournament + + # Check if refund is possible for this tournament + if not tournament.is_refund_possible(): + return False, "Les remboursements ne sont plus possibles pour ce tournoi.", None + + # Get payment ID from player registrations + player_registrations = PlayerRegistration.objects.filter(team_registration=team_registration) + payment_id = None + + for player_reg in player_registrations: + # Find the first valid payment ID + if player_reg.payment_id and player_reg.payment_type == PlayerPaymentType.CREDIT_CARD: + payment_id = player_reg.payment_id + break + + if not payment_id: + return False, "Aucun paiement trouvé pour cette équipe.", None + + # Get the Stripe payment intent + payment_intent = stripe.PaymentIntent.retrieve(payment_id) + + if payment_intent.status != 'succeeded': + return False, "Le paiement n'a pas été complété, il ne peut pas être remboursé.", None + + # Process the refund + refund = stripe.Refund.create( + payment_intent=payment_id, + refund_application_fee=True, + reverse_transfer=True + ) + + # Return success with refund object + return True, "Votre inscription a été remboursée automatiquement.", refund + + except stripe.error.StripeError as e: + return False, f"Erreur de remboursement Stripe: {str(e)}", None + except Exception as e: + return False, f"Erreur lors du remboursement: {str(e)}", None diff --git a/tournaments/services/tournament_unregistration.py b/tournaments/services/tournament_unregistration.py index 4586b36..80d7653 100644 --- a/tournaments/services/tournament_unregistration.py +++ b/tournaments/services/tournament_unregistration.py @@ -1,7 +1,9 @@ from django.contrib import messages from django.utils import timezone -from ..models import PlayerRegistration, UnregisteredTeam, UnregisteredPlayer +from ..models import PlayerRegistration, UnregisteredTeam, UnregisteredPlayer, PlayerPaymentType from ..utils.licence_validator import LicenseValidator +from ..services.payment_service import PaymentService +from ..services.email_service import TournamentEmailService class TournamentUnregistrationService: def __init__(self, request, tournament): @@ -9,6 +11,7 @@ class TournamentUnregistrationService: self.tournament = tournament self.player_registration = None self.other_player = None + self.team_registration = None def can_unregister(self): if not self.tournament.is_unregistration_possible(): @@ -28,11 +31,50 @@ class TournamentUnregistrationService: "La désincription a échouée. Veuillez contacter le juge-arbitre.") return False + # Check if refund is possible and needed + if self.tournament.is_refund_possible() and self._team_has_paid(): + refund_processed, message, refund_details = self._process_refund() + if refund_processed: + # Refund successful, continue with unregistration process + messages.success(self.request, message) + TournamentEmailService.send_refund_confirmation(self.tournament, self.player_registration.team_registration, refund_details) + else: + # Refund failed, show error but continue with normal unregistration + messages.error(self.request, message) + + # Proceed with unregistration self._unregister_team() self._delete_registered_team() self._cleanup_session() + + messages.success(self.request, "Votre désinscription a été effectuée.") + return True + def _team_has_paid(self): + """Check if team has paid for registration""" + if not self.team_registration: + print("Team registration not found") + return False + + # Check if any player registration has a payment ID + player_registrations = PlayerRegistration.objects.filter(team_registration=self.team_registration) + for player_reg in player_registrations: + if player_reg.payment_id and player_reg.payment_type == PlayerPaymentType.CREDIT_CARD: + print("Player has paid") + return True + + print("No player has paid") + return False + + def _process_refund(self): + """Process refund for paid registration""" + try: + payment_service = PaymentService(self.request) + return payment_service.process_refund(self.team_registration.id) + except Exception as e: + return False, f"Erreur lors du remboursement: {str(e)}", None + def _unregister_team(self): # Create unregistered team record team_registration = self.player_registration.team_registration @@ -65,8 +107,8 @@ class TournamentUnregistrationService: ).first() if self.player_registration: - team_registration = self.player_registration.team_registration - self.other_player = team_registration.get_other_player(self.player_registration) + self.team_registration = self.player_registration.team_registration + self.other_player = self.team_registration.get_other_player(self.player_registration) return True return False diff --git a/tournaments/templates/tournaments/tournament_info.html b/tournaments/templates/tournaments/tournament_info.html index a37902e..9faabeb 100644 --- a/tournaments/templates/tournaments/tournament_info.html +++ b/tournaments/templates/tournaments/tournament_info.html @@ -115,14 +115,17 @@ {% if tournament.is_unregistration_possible %}
{% if tournament.is_refund_possible and team.is_paid %} -

- Votre inscription sera remboursé automatiquement. +

+ 💰 Si vous vous désinscrivez, votre inscription sera remboursée automatiquement sur votre carte bancaire. + {% if tournament.refund_date_limit %} +
Remboursement possible jusqu'au {{ tournament.refund_date_limit|date:"d/m/Y à H:i" }} + {% endif %}

{% endif %} + onclick="return confirm('Êtes-vous sûr de vouloir vous désinscrire ?{% if tournament.is_refund_possible and team.is_paid %} Votre inscription sera remboursée automatiquement.{% endif %}');"> {% if team.is_in_waiting_list >= 0 %} Se retirer de la liste d'attente {% else %} @@ -130,15 +133,8 @@ {% endif %}
- - {% endif %} + {% endif %}

{{ tournament.display_name }} {{ tournament.get_federal_age_category_display}}