Files
remnawave-minishop/bot/services/promo_code_service.py
T
kavore 3c84007f17 fix(promo): count activations on payment
Allow promo usage increments to overflow the max limit when a payment completes so counters reflect paid activations only.
2026-02-03 12:05:16 +03:00

294 lines
11 KiB
Python

import logging
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional, Tuple, Dict
from aiogram import Bot
from config.settings import Settings
from db.dal import promo_code_dal, user_dal, active_discount_dal, payment_dal
from db.models import PromoCode, User
from .subscription_service import SubscriptionService
from bot.middlewares.i18n import JsonI18n
from .notification_service import NotificationService
class PromoCodeService:
def __init__(self, settings: Settings,
subscription_service: SubscriptionService, bot: Bot,
i18n: JsonI18n):
self.settings = settings
self.subscription_service = subscription_service
self.bot = bot
self.i18n = i18n
async def apply_promo_code(
self,
session: AsyncSession,
user_id: int,
code_input: str,
user_lang: str,
) -> Tuple[bool, datetime | str]:
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
code_input_upper = code_input.strip().upper()
promo_data = await promo_code_dal.get_active_bonus_promo_code_by_code_str(
session, code_input_upper)
if not promo_data:
return False, _("promo_code_not_found", code=code_input_upper)
existing_activation = await promo_code_dal.get_user_activation_for_promo(
session, promo_data.promo_code_id, user_id)
if existing_activation:
return False, _("promo_code_already_used_by_user",
code=code_input_upper)
bonus_days = promo_data.bonus_days
new_end_date = await self.subscription_service.extend_active_subscription_days(
session=session,
user_id=user_id,
bonus_days=bonus_days,
reason=f"promo code {code_input_upper}")
if new_end_date:
activation_recorded = await promo_code_dal.record_promo_activation(
session, promo_data.promo_code_id, user_id, payment_id=None)
promo_incremented = await promo_code_dal.increment_promo_code_usage(
session, promo_data.promo_code_id)
if activation_recorded and promo_incremented:
# Send notification about promo activation
try:
notification_service = NotificationService(self.bot, self.settings, self.i18n)
user = await user_dal.get_user_by_id(session, user_id)
await notification_service.notify_promo_activation(
user_id=user_id,
promo_code=code_input_upper,
bonus_days=bonus_days,
username=user.username if user else None
)
except Exception as e:
logging.error(f"Failed to send promo activation notification: {e}")
return True, new_end_date
else:
logging.error(
f"Failed to record activation or increment usage for promo {promo_data.code} by user {user_id}"
)
return False, _("error_applying_promo_bonus")
else:
return False, _("error_applying_promo_bonus")
async def apply_discount_promo_code(
self,
session: AsyncSession,
user_id: int,
code_input: str,
user_lang: str,
) -> Tuple[bool, int | str]:
"""
Apply a discount promo code (sets active discount for user).
Returns: (success: bool, discount_percentage or error_message)
"""
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
code_input_upper = code_input.strip().upper()
# Check if user already has an active discount
existing_discount = await active_discount_dal.get_active_discount(session, user_id)
if existing_discount:
# Get the promo code for the existing discount
existing_promo = await promo_code_dal.get_promo_code_by_id(
session, existing_discount.promo_code_id
)
if existing_promo:
return False, _("discount_promo_already_active",
code=existing_promo.code,
discount_pct=existing_discount.discount_percentage)
else:
# Existing discount but promo not found - clear it and continue
await active_discount_dal.clear_active_discount(session, user_id)
# Get discount promo code
promo_data = await promo_code_dal.get_active_discount_promo_code_by_code_str(
session, code_input_upper
)
if not promo_data:
return False, _("promo_code_not_found_or_not_discount", code=code_input_upper)
# Check if user already used this code
existing_activation = await promo_code_dal.get_user_activation_for_promo(
session, promo_data.promo_code_id, user_id
)
if existing_activation:
return False, _("promo_code_already_used_by_user", code=code_input_upper)
# Set active discount
active_discount = await active_discount_dal.set_active_discount(
session,
user_id=user_id,
promo_code_id=promo_data.promo_code_id,
discount_percentage=promo_data.discount_percentage
)
if not active_discount:
# This shouldn't happen since we checked above, but just in case
return False, _("error_applying_promo_discount")
logging.info(
f"Discount promo code {code_input_upper} activated for user {user_id}: "
f"{promo_data.discount_percentage}% off"
)
return True, promo_data.discount_percentage
async def get_user_active_discount(
self,
session: AsyncSession,
user_id: int
) -> Optional[Tuple[int, str]]:
"""
Get user's active discount if any.
Returns: (discount_percentage, promo_code) or None
"""
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if not active_discount:
return None
# Fetch promo code for code string
promo = await promo_code_dal.get_promo_code_by_id(
session, active_discount.promo_code_id
)
if not promo:
# Discount exists but promo not found - clear it
await active_discount_dal.clear_active_discount(session, user_id)
return None
# Check if promo code has expired
if promo.valid_until and promo.valid_until <= datetime.now(timezone.utc):
# Promo code expired - clear the discount
logging.info(
f"Promo code {promo.code} expired (valid_until: {promo.valid_until}). "
f"Clearing active discount for user {user_id}"
)
await active_discount_dal.clear_active_discount(session, user_id)
return None
return (active_discount.discount_percentage, promo.code)
def calculate_discounted_price(
self,
original_price: float,
discount_percentage: int
) -> Tuple[float, float]:
"""
Calculate discounted price and discount amount.
Returns: (final_price, discount_amount)
"""
discount_amount = round(original_price * (discount_percentage / 100), 2)
final_price = round(original_price - discount_amount, 2)
# Ensure price doesn't go negative
if final_price < 0:
final_price = 0
discount_amount = original_price
return final_price, discount_amount
async def consume_discount(
self,
session: AsyncSession,
user_id: int,
payment_id: int
) -> bool:
"""
Consume active discount: link activation to payment, increment usage, clear active discount.
Call this AFTER successful payment.
"""
payment_record = await payment_dal.get_payment_by_db_id(session, payment_id)
if not payment_record:
logging.warning(
"Payment %s not found for discount consumption (user %s).",
payment_id,
user_id,
)
return False
if not payment_record.discount_applied:
return False
promo_code_id = payment_record.promo_code_id
if not promo_code_id:
logging.warning(
"Payment %s for user %s has discount_applied but no promo_code_id.",
payment_id,
user_id,
)
return False
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount and active_discount.promo_code_id != promo_code_id:
logging.info(
"Active discount promo %s differs from payment promo %s; leaving active discount intact.",
active_discount.promo_code_id,
promo_code_id,
)
active_discount = None
existing_activation = await promo_code_dal.get_user_activation_for_promo(
session, promo_code_id, user_id
)
if existing_activation:
if existing_activation.payment_id is None:
updated_payment = await promo_code_dal.set_activation_payment_id(
session, promo_code_id, user_id, payment_id
)
if updated_payment:
logging.info(
"Linked discount promo %s activation to payment %s for user %s.",
promo_code_id,
payment_id,
user_id,
)
else:
activation_recorded = await promo_code_dal.record_promo_activation(
session,
promo_code_id,
user_id,
payment_id=payment_id,
)
if not activation_recorded:
logging.error(
"Failed to record discount activation for user %s, promo %s.",
user_id,
promo_code_id,
)
return False
promo_incremented = await promo_code_dal.increment_promo_code_usage(
session, promo_code_id, allow_overflow=True
)
if not promo_incremented:
logging.error(
"Failed to increment discount usage for user %s, promo %s.",
user_id,
promo_code_id,
)
return False
if active_discount and active_discount.promo_code_id == promo_code_id:
await active_discount_dal.clear_active_discount(session, user_id)
await session.flush()
logging.info(
"Discount consumed for user %s, promo %s, payment %s",
user_id,
promo_code_id,
payment_id,
)
return True