Files
bakery-ia/shared/subscription/coupons.py

124 lines
3.8 KiB
Python
Raw Permalink Normal View History

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"