From f65fac966108c25a32275ffaba78cb14515ab7b7 Mon Sep 17 00:00:00 2001 From: Razmig Sarkissian Date: Tue, 24 Jun 2025 07:37:30 +0200 Subject: [PATCH] add multi currency layer for tournament fee --- tournaments/admin.py | 2 +- .../0129_tournament_currency_code.py | 18 ++ tournaments/models/team_registration.py | 8 + tournaments/models/tournament.py | 14 +- tournaments/services/currency_service.py | 189 ++++++++++++++++++ tournaments/services/email_service.py | 39 +++- tournaments/services/payment_service.py | 31 +-- .../services/tournament_registration.py | 15 ++ .../templates/register_tournament.html | 2 +- .../tournaments/tournament_info.html | 4 +- 10 files changed, 291 insertions(+), 31 deletions(-) create mode 100644 tournaments/migrations/0129_tournament_currency_code.py create mode 100644 tournaments/services/currency_service.py diff --git a/tournaments/admin.py b/tournaments/admin.py index 3b67f56..708fd9d 100644 --- a/tournaments/admin.py +++ b/tournaments/admin.py @@ -90,7 +90,7 @@ class TournamentAdmin(SyncedObjectAdmin): list_display = ['display_name', 'event', 'is_private', 'start_date', 'payment', 'creator', 'is_canceled'] list_filter = [StartDateRangeFilter, 'is_deleted', 'event__creator'] ordering = ['-start_date'] - search_fields = ['id'] + search_fields = ['id', 'display_name', 'federal_level_category'] def dashboard_view(self, request): """Tournament dashboard view with comprehensive statistics""" diff --git a/tournaments/migrations/0129_tournament_currency_code.py b/tournaments/migrations/0129_tournament_currency_code.py new file mode 100644 index 0000000..c1cdd58 --- /dev/null +++ b/tournaments/migrations/0129_tournament_currency_code.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1 on 2025-06-23 16:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tournaments', '0128_club_data_access_ids_court_data_access_ids_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='tournament', + name='currency_code', + field=models.CharField(blank=True, default='EUR', max_length=3, null=True), + ), + ] diff --git a/tournaments/models/team_registration.py b/tournaments/models/team_registration.py index 5e19969..deb7ed3 100644 --- a/tournaments/models/team_registration.py +++ b/tournaments/models/team_registration.py @@ -6,6 +6,7 @@ from .enums import RegistrationStatus from .player_enums import PlayerPaymentType from ..services.email_service import TournamentEmailService, TeamEmailType from ..utils.extensions import format_seconds +from ..services.currency_service import CurrencyService import uuid @@ -412,6 +413,13 @@ class TeamRegistration(TournamentSubModel): payment_statuses = [player.get_remaining_fee() for player in player_registrations] return sum(payment_statuses) + def get_remaining_fee_formatted(self): + """Get the remaining fee formatted with the tournament's currency.""" + remaining_fee = self.get_remaining_fee() + tournament = self.get_tournament() + currency_code = tournament.currency_code if tournament and tournament.currency_code else 'EUR' + return CurrencyService.format_amount(remaining_fee, currency_code) + def is_confirmation_expired(self): """ Check if the confirmation deadline has expired. diff --git a/tournaments/models/tournament.py b/tournaments/models/tournament.py index d653089..bfdaa0c 100644 --- a/tournaments/models/tournament.py +++ b/tournaments/models/tournament.py @@ -12,6 +12,7 @@ from django.utils.formats import date_format from ..utils.licence_validator import LicenseValidator from django.apps import apps from django.conf import settings +from tournaments.services.currency_service import CurrencyService class TeamSortingType(models.IntegerChoices): RANK = 1, 'Rank' @@ -94,6 +95,7 @@ class Tournament(BaseModel): show_teams_in_prog = models.BooleanField(default=False) club_member_fee_deduction = models.FloatField(null=True, blank=True) unregister_delta_in_hours = models.IntegerField(default=24) + currency_code = models.CharField(null=True, blank=True, max_length=3, default='EUR') def delete_dependencies(self): for team_registration in self.team_registrations.all(): @@ -1106,18 +1108,18 @@ class Tournament(BaseModel): return True def options_fee(self): - def format_currency(amount): - """Format currency amount, removing unnecessary decimals""" - return f"{amount:g}" if amount % 1 == 0 else f"{amount:.2f}" - options = [] + currency_service = CurrencyService() + # Entry fee if self.entry_fee is not None and self.entry_fee > 0: - options.append(f"Frais d'inscription: {format_currency(self.entry_fee)} € par joueur") + formatted_fee = currency_service.format_amount(self.entry_fee, self.currency_code) + options.append(f"Frais d'inscription: {formatted_fee} par joueur") # Club member fee reduction if self.club_member_fee_deduction and self.club_member_fee_deduction > 0: - options.append(f"Réduction de {format_currency(self.club_member_fee_deduction)} € pour les membres du club") + formatted_deduction = currency_service.format_amount(self.club_member_fee_deduction, self.currency_code) + options.append(f"Réduction de {formatted_deduction} pour les membres du club") return options diff --git a/tournaments/services/currency_service.py b/tournaments/services/currency_service.py new file mode 100644 index 0000000..69952c5 --- /dev/null +++ b/tournaments/services/currency_service.py @@ -0,0 +1,189 @@ +""" +Currency service for handling multi-currency support in tournaments. +""" + +class CurrencyService: + """Service for handling currency formatting, symbols, and conversions.""" + + # Currency symbols mapping + CURRENCY_SYMBOLS = { + 'EUR': '€', + 'USD': '$', + 'GBP': '£', + 'CHF': 'CHF', + 'CAD': 'C$', + 'AUD': 'A$', + 'JPY': '¥', + 'CNY': '¥', + 'SEK': 'kr', + 'NOK': 'kr', + 'DKK': 'kr', + 'PLN': 'zł', + 'CZK': 'Kč', + 'HUF': 'Ft', + 'RON': 'lei', + 'BGN': 'лв', + 'HRK': 'kn', + 'RSD': 'RSD', + 'BAM': 'KM', + 'MKD': 'ден', + 'ALL': 'L', + 'MDL': 'L', + 'UAH': '₴', + 'RUB': '₽', + 'BYN': 'Br', + 'LTL': 'Lt', + 'LVL': 'Ls', + 'EEK': 'kr', + 'ISK': 'kr', + 'TRY': '₺', + 'ILS': '₪', + 'AED': 'د.إ', + 'SAR': 'ر.س', + 'QAR': 'ر.ق', + 'KWD': 'د.ك', + 'BHD': '.د.ب', + 'OMR': 'ر.ع.', + 'JOD': 'د.أ', + 'LBP': 'ل.ل', + 'EGP': 'ج.م', + 'MAD': 'د.م.', + 'TND': 'د.ت', + 'DZD': 'د.ج', + 'LYD': 'ل.د', + 'ZAR': 'R', + 'NGN': '₦', + 'GHS': '₵', + 'KES': 'KSh', + 'UGX': 'USh', + 'TZS': 'TSh', + 'ETB': 'Br', + 'XOF': 'CFA', + 'XAF': 'FCFA', + 'MZN': 'MT', + 'BWP': 'P', + 'ZMW': 'ZK', + 'INR': '₹', + 'PKR': '₨', + 'BDT': '৳', + 'LKR': '₨', + 'MVR': 'Rf', + 'NPR': '₨', + 'BTN': 'Nu.', + 'THB': '฿', + 'MYR': 'RM', + 'SGD': 'S$', + 'IDR': 'Rp', + 'PHP': '₱', + 'VND': '₫', + 'KHR': '៛', + 'LAK': '₭', + 'MMK': 'K', + 'KRW': '₩', + 'TWD': 'NT$', + 'HKD': 'HK$', + 'MOP': 'MOP$', + 'BND': 'B$', + 'FJD': 'FJ$', + 'PGK': 'K', + 'WST': 'WS$', + 'TOP': 'T$', + 'VUV': 'VT', + 'SBD': 'SI$', + 'MXN': '$', + 'GTQ': 'Q', + 'BZD': 'BZ$', + 'SVC': '$', + 'HNL': 'L', + 'NIO': 'C$', + 'CRC': '₡', + 'PAB': 'B/.', + 'COP': '$', + 'VES': 'Bs.S', + 'GYD': 'GY$', + 'SRD': 'Sr$', + 'UYU': '$U', + 'PYG': '₲', + 'BOB': 'Bs.', + 'BRL': 'R$', + 'PEN': 'S/', + 'ECU': '$', + 'CLP': '$', + 'ARS': '$', + 'FKP': '£', + 'XPF': '₣' + } + + # Zero-decimal currencies (amounts are in their base unit, not subdivided) + ZERO_DECIMAL_CURRENCIES = { + 'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', + 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF' + } + + @classmethod + def get_currency_symbol(cls, currency_code): + """Get the currency symbol for a given currency code.""" + if not currency_code: + return '€' # Default to Euro symbol + return cls.CURRENCY_SYMBOLS.get(currency_code.upper(), currency_code.upper()) + + @classmethod + def format_amount(cls, amount, currency_code, show_symbol=True): + """Format amount with proper currency symbol and decimal places.""" + if amount is None: + return "0" + + currency_code = currency_code.upper() if currency_code else 'EUR' + + # Handle zero-decimal currencies + if currency_code in cls.ZERO_DECIMAL_CURRENCIES: + formatted_amount = f"{int(amount)}" + else: + # Format with 2 decimal places, removing unnecessary zeros + if amount == int(amount): + formatted_amount = f"{int(amount)}" + else: + formatted_amount = f"{amount:.2f}" + + if show_symbol: + symbol = cls.get_currency_symbol(currency_code) + return f"{formatted_amount} {symbol}" + + return formatted_amount + + @classmethod + def convert_to_stripe_amount(cls, amount, currency_code): + """Convert amount to Stripe's expected format (minor units).""" + if not amount: + return 0 + + currency_code = currency_code.upper() if currency_code else 'EUR' + + # Zero-decimal currencies don't need conversion + if currency_code in cls.ZERO_DECIMAL_CURRENCIES: + return int(amount) + + # Two-decimal currencies need to be multiplied by 100 + return int(amount * 100) + + @classmethod + def convert_from_stripe_amount(cls, stripe_amount, currency_code): + """Convert Stripe amount (minor units) back to standard decimal format.""" + if not stripe_amount: + return 0.0 + + currency_code = currency_code.upper() if currency_code else 'EUR' + + # Zero-decimal currencies are already in the correct format + if currency_code in cls.ZERO_DECIMAL_CURRENCIES: + return float(stripe_amount) + + # Two-decimal currencies need to be divided by 100 + return float(stripe_amount) / 100 + + @classmethod + def validate_currency_code(cls, currency_code): + """Validate if a currency code is supported.""" + if not currency_code: + return False + return currency_code.upper() in cls.CURRENCY_SYMBOLS diff --git a/tournaments/services/email_service.py b/tournaments/services/email_service.py index 9d732a8..70fbb75 100644 --- a/tournaments/services/email_service.py +++ b/tournaments/services/email_service.py @@ -3,6 +3,7 @@ from enum import Enum from ..models.enums import RegistrationStatus, FederalLevelCategory from ..models.tournament import TeamSortingType from django.utils import timezone +from tournaments.services.currency_service import CurrencyService class TeamEmailType(Enum): REGISTERED = "registered" @@ -666,14 +667,16 @@ class TournamentEmailService: if payment_status == 'PAID': return "\n\n✅ Le paiement de votre inscription a bien été reçu." - # If the team is on the waiting list, don't mention payment if team_registration.is_in_waiting_list() >= 0: return "" + currency_service = CurrencyService() + # For unpaid teams, add payment instructions + formatted_fee = currency_service.format_amount(tournament.entry_fee, tournament.currency_code) payment_info = [ "\n\n⚠️ Paiement des frais d'inscription requis", - f"Les frais d'inscription de {tournament.entry_fee:.2f}€ par joueur doivent être payés pour confirmer votre participation.", + f"Les frais d'inscription de {formatted_fee} par joueur doivent être payés pour confirmer votre participation.", "Vous pouvez effectuer le paiement en vous connectant à votre compte Padel Club.", f"Lien pour payer: https://padelclub.app/tournament/{tournament.id}/info" ] @@ -694,12 +697,17 @@ class TournamentEmailService: # Calculate payment amount payment_amount = None - if payment and 'amount' in payment: - # Convert cents to euros - payment_amount = payment['amount'] / 100 + currency_service = CurrencyService() - if payment_amount is None: - payment_amount = team_registration.get_remaining_fee() + if payment and 'amount' in payment: + # Get currency from payment metadata or tournament + payment_currency = payment.get('currency', tournament.currency_code or 'EUR') + # Convert cents/minor units to standard format + payment_amount = currency_service.convert_from_stripe_amount(payment['amount'], payment_currency) + formatted_amount = currency_service.format_amount(payment_amount, payment_currency) + else: + # Fallback to tournament fee + formatted_amount = currency_service.format_amount(team_registration.get_team_registration_fee(), tournament.currency_code) federal_level_category = FederalLevelCategory(tournament.federal_level_category) tournament_word = federal_level_category.localized_word() @@ -725,7 +733,7 @@ class TournamentEmailService: # Add payment details body_parts.append( - f"\n\nMontant payé : {payment_amount:.2f}€" + f"\n\nMontant payé : {formatted_amount}" ) payment_date = timezone.now().strftime("%d/%m/%Y") @@ -768,6 +776,19 @@ class TournamentEmailService: """ player_registrations = team_registration.players_sorted_by_rank refund_amount = None + + currency_service = CurrencyService() + + if refund_details and 'amount' in refund_details: + # Get currency from refund details or tournament + refund_currency = refund_details.get('currency', tournament.currency_code or 'EUR') + # Convert cents/minor units to standard format + refund_amount = currency_service.convert_from_stripe_amount(refund_details['amount'], refund_currency) + formatted_amount = currency_service.format_amount(refund_amount, refund_currency) + else: + # Fallback to tournament fee + formatted_amount = currency_service.format_amount(tournament.entry_fee, tournament.currency_code) + if refund_details and 'amount' in refund_details: # Convert cents to euros refund_amount = refund_details['amount'] / 100 @@ -803,7 +824,7 @@ class TournamentEmailService: # Add refund details body_parts.append( - f"\n\nMontant remboursé : {refund_amount:.2f}€" + f"\n\nMontant remboursé : {formatted_amount}" ) refund_date = timezone.now().strftime("%d/%m/%Y") diff --git a/tournaments/services/payment_service.py b/tournaments/services/payment_service.py index 2b5d51d..064d732 100644 --- a/tournaments/services/payment_service.py +++ b/tournaments/services/payment_service.py @@ -11,6 +11,7 @@ from ..models.player_registration import PlayerPaymentType from .email_service import TournamentEmailService from .tournament_registration import RegistrationCartManager from ..utils.extensions import is_not_sqlite_backend +from tournaments.services.currency_service import CurrencyService class PaymentService: """ @@ -28,10 +29,20 @@ class PaymentService: stripe.api_key = self.stripe_api_key tournament = get_object_or_404(Tournament, id=tournament_id) + currency_service = CurrencyService() + # 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 currency code and validate it + currency_code = tournament.currency_code or 'EUR' + if not currency_service.validate_currency_code(currency_code): + raise Exception(f"Devise non supportée: {currency_code}") + + # Convert amount to Stripe format + stripe_amount = currency_service.convert_to_stripe_amount(team_fee, currency_code) + # Get user email if authenticated customer_email = self.request.user.email if self.request.user.is_authenticated else None @@ -52,6 +63,7 @@ class PaymentService: 'user_id': str(self.request.user.id) if self.request.user.is_authenticated else None, 'payment_source': 'tournament', # Identify payment source 'source_page': 'tournament_info' if team_registration_id else 'register_tournament', + 'currency_code': currency_code, # Store currency for later reference } if tournament.is_corporate_tournament: @@ -98,12 +110,12 @@ class PaymentService: 'payment_method_types': ['card'], 'line_items': [{ 'price_data': { - 'currency': 'eur', + 'currency': currency_code.lower(), # Use tournament currency 'product_data': { 'name': f'{tournament.broadcast_display_name()} du {tournament.formatted_start_date()}', 'description': f'Lieu {tournament.event.club.name}', }, - 'unit_amount': int(team_fee * 100), # Amount in cents + 'unit_amount': stripe_amount, # Amount in proper currency format }, 'quantity': 1, }], @@ -122,18 +134,20 @@ class PaymentService: # Calculate commission commission_rate = tournament.event.creator.effective_commission_rate() - platform_amount = int((team_fee * commission_rate) * 100) # Commission in cents + platform_amount = currency_service.convert_to_stripe_amount( + team_fee * commission_rate, currency_code + ) checkout_session_params = { 'payment_method_types': ['card'], 'line_items': [{ 'price_data': { - 'currency': 'eur', + 'currency': currency_code.lower(), # Use tournament currency 'product_data': { 'name': f'{tournament.broadcast_display_name()} du {tournament.formatted_start_date()}', 'description': f'Lieu {tournament.event.club.name}', }, - 'unit_amount': int(team_fee * 100), # Amount in cents + 'unit_amount': stripe_amount, # Amount in proper currency format }, 'quantity': 1, }], @@ -151,13 +165,6 @@ class PaymentService: 'metadata': metadata } - # # Add cart or team data to metadata based on payment context - # if cart_data: - # checkout_session_params['metadata']['registration_cart_id'] = str(cart_data['cart_id']) # Convert to string - # elif team_registration_id: - # checkout_session_params['metadata']['team_registration_id'] = str(team_registration_id) # Convert to string - # self.request.session['team_registration_id'] = str(team_registration_id) # Convert to string - # Add customer_email if available if customer_email: checkout_session_params['customer_email'] = customer_email diff --git a/tournaments/services/tournament_registration.py b/tournaments/services/tournament_registration.py index c3accba..21fe47b 100644 --- a/tournaments/services/tournament_registration.py +++ b/tournaments/services/tournament_registration.py @@ -9,6 +9,7 @@ from ..models.player_enums import PlayerSexType, PlayerDataSource from django.contrib.auth import get_user_model from django.conf import settings from django.utils.dateparse import parse_datetime +from .currency_service import CurrencyService class RegistrationCartManager: """ @@ -126,6 +127,7 @@ class RegistrationCartManager: 'expiry': expiry_datetime, # Now a datetime object, not a string 'is_cart_expired': self.is_cart_expired(), 'team_fee_from_cart_players': self.team_fee_from_cart_players(), + 'team_fee_from_cart_players_formatted': self.team_fee_from_cart_players_formatted(), 'mobile_number': self.session.get('registration_mobile_number', user_phone) } @@ -156,6 +158,19 @@ class RegistrationCartManager: else: return 0 + def team_fee_from_cart_players_formatted(self): + """Get the team fee formatted with the tournament's currency.""" + team_fee = self.team_fee_from_cart_players() + tournament_id = self.session.get('registration_tournament_id') + + try: + tournament = Tournament.objects.get(id=tournament_id) + currency_code = tournament.currency_code if tournament and tournament.currency_code else 'EUR' + except Tournament.DoesNotExist: + currency_code = 'EUR' + + return CurrencyService.format_amount(team_fee, currency_code) + def add_player(self, player_data): """Add a player to the registration cart""" print("add_player", player_data) diff --git a/tournaments/templates/register_tournament.html b/tournaments/templates/register_tournament.html index eda6feb..ace6a93 100644 --- a/tournaments/templates/register_tournament.html +++ b/tournaments/templates/register_tournament.html @@ -197,7 +197,7 @@ Confirmer votre inscription en payant immédiatement : {% endif %} {% if tournament.should_request_payment is False or tournament.online_payment_is_mandatory is False or cart_data.waiting_list_position >= 0 %} diff --git a/tournaments/templates/tournaments/tournament_info.html b/tournaments/templates/tournaments/tournament_info.html index 3613823..5d1a09d 100644 --- a/tournaments/templates/tournaments/tournament_info.html +++ b/tournaments/templates/tournaments/tournament_info.html @@ -102,9 +102,9 @@ {% else %} {% if team.needs_confirmation %} - Confirmer en payant {{ team.get_remaining_fee|floatformat:2 }}€ + Confirmer en payant {{ team.get_remaining_fee_formatted }} {% else %} - Procéder au paiement de {{ team.get_remaining_fee|floatformat:2 }}€ + Procéder au paiement de {{ team.get_remaining_fee_formatted }} {% endif %} {% endif %}