2025-10-17 18:14:28 +02:00
|
|
|
"""
|
|
|
|
|
Coupon system for subscription discounts and promotions.
|
|
|
|
|
Supports trial extensions, percentage discounts, and fixed amount discounts.
|
|
|
|
|
"""
|
|
|
|
|
from dataclasses import dataclass
|
2026-01-13 22:22:38 +01:00
|
|
|
from datetime import datetime, timezone
|
2025-10-17 18:14:28 +02:00
|
|
|
from enum import Enum
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DiscountType(str, Enum):
|
|
|
|
|
"""Type of discount offered by a coupon"""
|
|
|
|
|
TRIAL_EXTENSION = "trial_extension" # Extends trial period by X days
|
|
|
|
|
PERCENTAGE = "percentage" # X% off subscription price
|
|
|
|
|
FIXED_AMOUNT = "fixed_amount" # Fixed amount off subscription price
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class Coupon:
|
|
|
|
|
"""Coupon configuration"""
|
|
|
|
|
id: str
|
|
|
|
|
code: str
|
|
|
|
|
discount_type: DiscountType
|
|
|
|
|
discount_value: int # Days for trial_extension, percentage for percentage, cents for fixed_amount
|
|
|
|
|
max_redemptions: Optional[int] # None = unlimited
|
|
|
|
|
current_redemptions: int
|
|
|
|
|
valid_from: datetime
|
|
|
|
|
valid_until: Optional[datetime] # None = no expiry
|
|
|
|
|
active: bool
|
|
|
|
|
created_at: datetime
|
|
|
|
|
extra_data: Optional[dict] = None
|
|
|
|
|
|
|
|
|
|
def is_valid(self) -> bool:
|
|
|
|
|
"""Check if coupon is currently valid"""
|
2026-01-13 22:22:38 +01:00
|
|
|
now = datetime.now(timezone.utc)
|
2025-10-17 18:14:28 +02:00
|
|
|
|
|
|
|
|
# Check if active
|
|
|
|
|
if not self.active:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
# Check if started
|
|
|
|
|
if now < self.valid_from:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
# Check if expired
|
|
|
|
|
if self.valid_until and now > self.valid_until:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
# Check if max redemptions reached
|
|
|
|
|
if self.max_redemptions and self.current_redemptions >= self.max_redemptions:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def can_be_redeemed(self) -> tuple[bool, str]:
|
|
|
|
|
"""
|
|
|
|
|
Check if coupon can be redeemed.
|
|
|
|
|
Returns (can_redeem, reason_if_not)
|
|
|
|
|
"""
|
|
|
|
|
if not self.active:
|
|
|
|
|
return False, "Coupon is inactive"
|
|
|
|
|
|
2026-01-13 22:22:38 +01:00
|
|
|
now = datetime.now(timezone.utc)
|
2025-10-17 18:14:28 +02:00
|
|
|
|
|
|
|
|
if now < self.valid_from:
|
|
|
|
|
return False, "Coupon is not yet valid"
|
|
|
|
|
|
|
|
|
|
if self.valid_until and now > self.valid_until:
|
|
|
|
|
return False, "Coupon has expired"
|
|
|
|
|
|
|
|
|
|
if self.max_redemptions and self.current_redemptions >= self.max_redemptions:
|
|
|
|
|
return False, "Coupon has reached maximum redemptions"
|
|
|
|
|
|
|
|
|
|
return True, ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class CouponRedemption:
|
|
|
|
|
"""Record of a coupon redemption"""
|
|
|
|
|
id: str
|
|
|
|
|
tenant_id: str
|
|
|
|
|
coupon_code: str
|
|
|
|
|
redeemed_at: datetime
|
|
|
|
|
discount_applied: dict # Details of the discount applied
|
|
|
|
|
extra_data: Optional[dict] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class CouponValidationResult:
|
|
|
|
|
"""Result of coupon validation"""
|
|
|
|
|
valid: bool
|
|
|
|
|
coupon: Optional[Coupon]
|
|
|
|
|
error_message: Optional[str]
|
|
|
|
|
discount_preview: Optional[dict] # Preview of discount to be applied
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def calculate_trial_end_date(base_trial_days: int, extension_days: int) -> datetime:
|
|
|
|
|
"""Calculate trial end date with coupon extension"""
|
|
|
|
|
from datetime import timedelta
|
|
|
|
|
total_days = base_trial_days + extension_days
|
2026-01-13 22:22:38 +01:00
|
|
|
return datetime.now(timezone.utc) + timedelta(days=total_days)
|
2025-10-17 18:14:28 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def format_discount_description(coupon: Coupon) -> str:
|
|
|
|
|
"""Format human-readable discount description"""
|
|
|
|
|
if coupon.discount_type == DiscountType.TRIAL_EXTENSION:
|
|
|
|
|
months = coupon.discount_value // 30
|
|
|
|
|
days = coupon.discount_value % 30
|
|
|
|
|
if months > 0 and days == 0:
|
|
|
|
|
return f"{months} {'mes' if months == 1 else 'meses'} gratis"
|
|
|
|
|
elif months > 0:
|
|
|
|
|
return f"{months} {'mes' if months == 1 else 'meses'} y {days} días gratis"
|
|
|
|
|
else:
|
|
|
|
|
return f"{coupon.discount_value} días gratis"
|
|
|
|
|
|
|
|
|
|
elif coupon.discount_type == DiscountType.PERCENTAGE:
|
|
|
|
|
return f"{coupon.discount_value}% de descuento"
|
|
|
|
|
|
|
|
|
|
elif coupon.discount_type == DiscountType.FIXED_AMOUNT:
|
|
|
|
|
amount = coupon.discount_value / 100 # Convert cents to euros
|
|
|
|
|
return f"€{amount:.2f} de descuento"
|
|
|
|
|
|
|
|
|
|
return "Descuento aplicado"
|