add multi currency layer for tournament fee

apikeys
Razmig Sarkissian 5 months ago
parent 17f59c1fcb
commit f65fac9661
  1. 2
      tournaments/admin.py
  2. 18
      tournaments/migrations/0129_tournament_currency_code.py
  3. 8
      tournaments/models/team_registration.py
  4. 14
      tournaments/models/tournament.py
  5. 189
      tournaments/services/currency_service.py
  6. 39
      tournaments/services/email_service.py
  7. 31
      tournaments/services/payment_service.py
  8. 15
      tournaments/services/tournament_registration.py
  9. 2
      tournaments/templates/register_tournament.html
  10. 4
      tournaments/templates/tournaments/tournament_info.html

@ -90,7 +90,7 @@ class TournamentAdmin(SyncedObjectAdmin):
list_display = ['display_name', 'event', 'is_private', 'start_date', 'payment', 'creator', 'is_canceled'] list_display = ['display_name', 'event', 'is_private', 'start_date', 'payment', 'creator', 'is_canceled']
list_filter = [StartDateRangeFilter, 'is_deleted', 'event__creator'] list_filter = [StartDateRangeFilter, 'is_deleted', 'event__creator']
ordering = ['-start_date'] ordering = ['-start_date']
search_fields = ['id'] search_fields = ['id', 'display_name', 'federal_level_category']
def dashboard_view(self, request): def dashboard_view(self, request):
"""Tournament dashboard view with comprehensive statistics""" """Tournament dashboard view with comprehensive statistics"""

@ -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),
),
]

@ -6,6 +6,7 @@ from .enums import RegistrationStatus
from .player_enums import PlayerPaymentType from .player_enums import PlayerPaymentType
from ..services.email_service import TournamentEmailService, TeamEmailType from ..services.email_service import TournamentEmailService, TeamEmailType
from ..utils.extensions import format_seconds from ..utils.extensions import format_seconds
from ..services.currency_service import CurrencyService
import uuid import uuid
@ -412,6 +413,13 @@ class TeamRegistration(TournamentSubModel):
payment_statuses = [player.get_remaining_fee() for player in player_registrations] payment_statuses = [player.get_remaining_fee() for player in player_registrations]
return sum(payment_statuses) 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): def is_confirmation_expired(self):
""" """
Check if the confirmation deadline has expired. Check if the confirmation deadline has expired.

@ -12,6 +12,7 @@ from django.utils.formats import date_format
from ..utils.licence_validator import LicenseValidator from ..utils.licence_validator import LicenseValidator
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from tournaments.services.currency_service import CurrencyService
class TeamSortingType(models.IntegerChoices): class TeamSortingType(models.IntegerChoices):
RANK = 1, 'Rank' RANK = 1, 'Rank'
@ -94,6 +95,7 @@ class Tournament(BaseModel):
show_teams_in_prog = models.BooleanField(default=False) show_teams_in_prog = models.BooleanField(default=False)
club_member_fee_deduction = models.FloatField(null=True, blank=True) club_member_fee_deduction = models.FloatField(null=True, blank=True)
unregister_delta_in_hours = models.IntegerField(default=24) 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): def delete_dependencies(self):
for team_registration in self.team_registrations.all(): for team_registration in self.team_registrations.all():
@ -1106,18 +1108,18 @@ class Tournament(BaseModel):
return True return True
def options_fee(self): 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 = [] options = []
currency_service = CurrencyService()
# Entry fee # Entry fee
if self.entry_fee is not None and self.entry_fee > 0: 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 # Club member fee reduction
if self.club_member_fee_deduction and self.club_member_fee_deduction > 0: 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 return options

@ -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': '',
'CZK': '',
'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

@ -3,6 +3,7 @@ from enum import Enum
from ..models.enums import RegistrationStatus, FederalLevelCategory from ..models.enums import RegistrationStatus, FederalLevelCategory
from ..models.tournament import TeamSortingType from ..models.tournament import TeamSortingType
from django.utils import timezone from django.utils import timezone
from tournaments.services.currency_service import CurrencyService
class TeamEmailType(Enum): class TeamEmailType(Enum):
REGISTERED = "registered" REGISTERED = "registered"
@ -666,14 +667,16 @@ class TournamentEmailService:
if payment_status == 'PAID': if payment_status == 'PAID':
return "\n\n✅ Le paiement de votre inscription a bien été reçu." 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: if team_registration.is_in_waiting_list() >= 0:
return "" return ""
currency_service = CurrencyService()
# For unpaid teams, add payment instructions # For unpaid teams, add payment instructions
formatted_fee = currency_service.format_amount(tournament.entry_fee, tournament.currency_code)
payment_info = [ payment_info = [
"\n\n Paiement des frais d'inscription requis", "\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.", "Vous pouvez effectuer le paiement en vous connectant à votre compte Padel Club.",
f"Lien pour payer: https://padelclub.app/tournament/{tournament.id}/info" f"Lien pour payer: https://padelclub.app/tournament/{tournament.id}/info"
] ]
@ -694,12 +697,17 @@ class TournamentEmailService:
# Calculate payment amount # Calculate payment amount
payment_amount = None payment_amount = None
if payment and 'amount' in payment: currency_service = CurrencyService()
# Convert cents to euros
payment_amount = payment['amount'] / 100
if payment_amount is None: if payment and 'amount' in payment:
payment_amount = team_registration.get_remaining_fee() # 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) federal_level_category = FederalLevelCategory(tournament.federal_level_category)
tournament_word = federal_level_category.localized_word() tournament_word = federal_level_category.localized_word()
@ -725,7 +733,7 @@ class TournamentEmailService:
# Add payment details # Add payment details
body_parts.append( body_parts.append(
f"\n\nMontant payé : {payment_amount:.2f}" f"\n\nMontant payé : {formatted_amount}"
) )
payment_date = timezone.now().strftime("%d/%m/%Y") payment_date = timezone.now().strftime("%d/%m/%Y")
@ -768,6 +776,19 @@ class TournamentEmailService:
""" """
player_registrations = team_registration.players_sorted_by_rank player_registrations = team_registration.players_sorted_by_rank
refund_amount = None 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: if refund_details and 'amount' in refund_details:
# Convert cents to euros # Convert cents to euros
refund_amount = refund_details['amount'] / 100 refund_amount = refund_details['amount'] / 100
@ -803,7 +824,7 @@ class TournamentEmailService:
# Add refund details # Add refund details
body_parts.append( body_parts.append(
f"\n\nMontant remboursé : {refund_amount:.2f}" f"\n\nMontant remboursé : {formatted_amount}"
) )
refund_date = timezone.now().strftime("%d/%m/%Y") refund_date = timezone.now().strftime("%d/%m/%Y")

@ -11,6 +11,7 @@ from ..models.player_registration import PlayerPaymentType
from .email_service import TournamentEmailService from .email_service import TournamentEmailService
from .tournament_registration import RegistrationCartManager from .tournament_registration import RegistrationCartManager
from ..utils.extensions import is_not_sqlite_backend from ..utils.extensions import is_not_sqlite_backend
from tournaments.services.currency_service import CurrencyService
class PaymentService: class PaymentService:
""" """
@ -28,10 +29,20 @@ class PaymentService:
stripe.api_key = self.stripe_api_key stripe.api_key = self.stripe_api_key
tournament = get_object_or_404(Tournament, id=tournament_id) tournament = get_object_or_404(Tournament, id=tournament_id)
currency_service = CurrencyService()
# Check if payments are enabled for this tournament # Check if payments are enabled for this tournament
if not tournament.should_request_payment(): if not tournament.should_request_payment():
raise Exception("Les paiements ne sont pas activés pour ce tournoi.") 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 # Get user email if authenticated
customer_email = self.request.user.email if self.request.user.is_authenticated else None 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, 'user_id': str(self.request.user.id) if self.request.user.is_authenticated else None,
'payment_source': 'tournament', # Identify payment source 'payment_source': 'tournament', # Identify payment source
'source_page': 'tournament_info' if team_registration_id else 'register_tournament', '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: if tournament.is_corporate_tournament:
@ -98,12 +110,12 @@ class PaymentService:
'payment_method_types': ['card'], 'payment_method_types': ['card'],
'line_items': [{ 'line_items': [{
'price_data': { 'price_data': {
'currency': 'eur', 'currency': currency_code.lower(), # Use tournament currency
'product_data': { 'product_data': {
'name': f'{tournament.broadcast_display_name()} du {tournament.formatted_start_date()}', 'name': f'{tournament.broadcast_display_name()} du {tournament.formatted_start_date()}',
'description': f'Lieu {tournament.event.club.name}', '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, 'quantity': 1,
}], }],
@ -122,18 +134,20 @@ class PaymentService:
# Calculate commission # Calculate commission
commission_rate = tournament.event.creator.effective_commission_rate() 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 = { checkout_session_params = {
'payment_method_types': ['card'], 'payment_method_types': ['card'],
'line_items': [{ 'line_items': [{
'price_data': { 'price_data': {
'currency': 'eur', 'currency': currency_code.lower(), # Use tournament currency
'product_data': { 'product_data': {
'name': f'{tournament.broadcast_display_name()} du {tournament.formatted_start_date()}', 'name': f'{tournament.broadcast_display_name()} du {tournament.formatted_start_date()}',
'description': f'Lieu {tournament.event.club.name}', '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, 'quantity': 1,
}], }],
@ -151,13 +165,6 @@ class PaymentService:
'metadata': metadata '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 # Add customer_email if available
if customer_email: if customer_email:
checkout_session_params['customer_email'] = customer_email checkout_session_params['customer_email'] = customer_email

@ -9,6 +9,7 @@ from ..models.player_enums import PlayerSexType, PlayerDataSource
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.conf import settings from django.conf import settings
from django.utils.dateparse import parse_datetime from django.utils.dateparse import parse_datetime
from .currency_service import CurrencyService
class RegistrationCartManager: class RegistrationCartManager:
""" """
@ -126,6 +127,7 @@ class RegistrationCartManager:
'expiry': expiry_datetime, # Now a datetime object, not a string 'expiry': expiry_datetime, # Now a datetime object, not a string
'is_cart_expired': self.is_cart_expired(), 'is_cart_expired': self.is_cart_expired(),
'team_fee_from_cart_players': self.team_fee_from_cart_players(), '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) 'mobile_number': self.session.get('registration_mobile_number', user_phone)
} }
@ -156,6 +158,19 @@ class RegistrationCartManager:
else: else:
return 0 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): def add_player(self, player_data):
"""Add a player to the registration cart""" """Add a player to the registration cart"""
print("add_player", player_data) print("add_player", player_data)

@ -197,7 +197,7 @@
Confirmer votre inscription en payant immédiatement : Confirmer votre inscription en payant immédiatement :
</div> </div>
<button type="submit" name="proceed_to_payment" class="rounded-button"> <button type="submit" name="proceed_to_payment" class="rounded-button">
Procéder au paiement de {{ cart_data.team_fee_from_cart_players|floatformat:2 }}€ Procéder au paiement de {{ cart_data.team_fee_from_cart_players_formatted }}
</button> </button>
{% endif %} {% endif %}
{% if tournament.should_request_payment is False or tournament.online_payment_is_mandatory is False or cart_data.waiting_list_position >= 0 %} {% if tournament.should_request_payment is False or tournament.online_payment_is_mandatory is False or cart_data.waiting_list_position >= 0 %}

@ -102,9 +102,9 @@
{% else %} {% else %}
<a href="{% url 'proceed_to_payment' tournament.id %}" class="rounded-button positive-button"> <a href="{% url 'proceed_to_payment' tournament.id %}" class="rounded-button positive-button">
{% if team.needs_confirmation %} {% if team.needs_confirmation %}
Confirmer en payant {{ team.get_remaining_fee|floatformat:2 }}€ Confirmer en payant {{ team.get_remaining_fee_formatted }}
{% else %} {% else %}
Procéder au paiement de {{ team.get_remaining_fee|floatformat:2 }}€ Procéder au paiement de {{ team.get_remaining_fee_formatted }}
{% endif %} {% endif %}
</a> </a>
{% endif %} {% endif %}

Loading…
Cancel
Save