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_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"""

@ -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 ..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.

@ -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

@ -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.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")

@ -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

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

@ -197,7 +197,7 @@
Confirmer votre inscription en payant immédiatement :
</div>
<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>
{% endif %}
{% 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 %}
<a href="{% url 'proceed_to_payment' tournament.id %}" class="rounded-button positive-button">
{% 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 %}
</a>
{% endif %}

Loading…
Cancel
Save