diff --git a/.env.example b/.env.example index 50fec76..ac2c427 100644 --- a/.env.example +++ b/.env.example @@ -76,6 +76,8 @@ SUBSCRIPTION_NOTIFY_ON_EXPIRE=True SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 + +REFERRAL_ONE_BONUS_PER_REFEREE=False # Referral Bonus Days REFERRAL_BONUS_DAYS_1_MONTH=3 REFERRAL_BONUS_DAYS_3_MONTHS=7 diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index a8c2463..fcb479d 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -123,7 +123,12 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, "applied_promo_bonus_days", 0) referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment( - session, user_id, subscription_months) + session, + user_id, + subscription_months, + current_payment_db_id=payment_db_id, + skip_if_active_before_payment=True, + ) applied_referee_bonus_days_from_referral: Optional[int] = None if referral_bonus_info and referral_bonus_info.get( "referee_new_end_date"): diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py index d0fa7f8..e56079e 100644 --- a/bot/handlers/user/start.py +++ b/bot/handlers/user/start.py @@ -194,8 +194,15 @@ async def start_command_handler(message: types.Message, update_payload = {} if db_user.language_code != current_lang: update_payload["language_code"] = current_lang + # Set referral only if not already set AND user is not currently active. + # This allows previously subscribed but currently inactive users to be attributed. if referred_by_user_id and db_user.referred_by_id is None: - update_payload["referred_by_id"] = referred_by_user_id + try: + is_active_now = await subscription_service.has_active_subscription(session, user_id) + except Exception: + is_active_now = False + if not is_active_now: + update_payload["referred_by_id"] = referred_by_user_id if user.username != db_user.username: update_payload["username"] = user.username if user.first_name != db_user.first_name: diff --git a/bot/services/crypto_pay_service.py b/bot/services/crypto_pay_service.py index 8c67195..cdf0d78 100644 --- a/bot/services/crypto_pay_service.py +++ b/bot/services/crypto_pay_service.py @@ -142,7 +142,11 @@ class CryptoPayService: provider="cryptopay", ) referral_bonus = await referral_service.apply_referral_bonuses_for_payment( - session, user_id, months + session, + user_id, + months, + current_payment_db_id=payment_db_id, + skip_if_active_before_payment=True, ) await session.commit() except Exception as e: diff --git a/bot/services/referral_service.py b/bot/services/referral_service.py index 1be6063..82cb9a0 100644 --- a/bot/services/referral_service.py +++ b/bot/services/referral_service.py @@ -7,6 +7,7 @@ from datetime import datetime, timezone, timedelta from config.settings import Settings from db.dal import user_dal +from db.dal import payment_dal from db.models import User from db.dal import subscription_dal from bot.middlewares.i18n import JsonI18n @@ -24,8 +25,12 @@ class ReferralService: self.i18n = i18n async def apply_referral_bonuses_for_payment( - self, session: AsyncSession, referee_user_id: int, - purchased_subscription_months: int) -> Dict[str, Any]: + self, + session: AsyncSession, + referee_user_id: int, + purchased_subscription_months: int, + current_payment_db_id: Optional[int] = None, + skip_if_active_before_payment: bool = True) -> Dict[str, Any]: referee_final_end_date: Optional[datetime] = None referee_bonus_applied_days: Optional[int] = None @@ -43,6 +48,37 @@ class ReferralService: "referee_new_end_date": None } + # If configured to apply referral bonuses only once per invited user, + # check if the referee already has succeeded payments. + if self.settings.REFERRAL_ONE_BONUS_PER_REFEREE: + try: + succeeded_count = await payment_dal.count_user_succeeded_payments( + session, referee_user_id, exclude_payment_id=current_payment_db_id + ) + if succeeded_count and succeeded_count > 0: + logging.info( + f"Referral bonuses skipped for user {referee_user_id}: already has {succeeded_count} succeeded payments.") + return { + "referee_bonus_applied_days": None, + "referee_new_end_date": None + } + except Exception as e_cnt: + logging.error(f"Failed counting succeeded payments for user {referee_user_id}: {e_cnt}") + + # Additionally, do not award referral bonuses if the user was active at payment time + # (has an active subscription now). This avoids giving bonuses to already active users. + if skip_if_active_before_payment: + try: + if await self.subscription_service.has_active_subscription(session, referee_user_id): + logging.info( + f"Referral bonuses skipped for user {referee_user_id}: user currently has an active subscription.") + return { + "referee_bonus_applied_days": None, + "referee_new_end_date": None + } + except Exception as e_sub: + logging.error(f"Failed to check active subscription for {referee_user_id}: {e_sub}") + inviter_user_id = referee_user_model.referred_by_id inviter_user_model = await user_dal.get_user_by_id( session, inviter_user_id) diff --git a/bot/services/stars_service.py b/bot/services/stars_service.py index 548babe..a6327ee 100644 --- a/bot/services/stars_service.py +++ b/bot/services/stars_service.py @@ -96,7 +96,12 @@ class StarsService: return referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment( - session, message.from_user.id, months) + session, + message.from_user.id, + months, + current_payment_db_id=payment_db_id, + skip_if_active_before_payment=True, + ) await session.commit() applied_days = referral_bonus.get("referee_bonus_applied_days") if referral_bonus else None diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 9546175..f9557c2 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -37,6 +37,22 @@ class SubscriptionService: async def has_had_any_subscription(self, session: AsyncSession, user_id: int) -> bool: return await subscription_dal.has_any_subscription_for_user(session, user_id) + async def has_active_subscription(self, session: AsyncSession, user_id: int) -> bool: + """Return True if user currently has an active subscription (end_date in future).""" + try: + user_record = await user_dal.get_user_by_id(session, user_id) + if not user_record or not user_record.panel_user_uuid: + return False + active_sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, user_record.panel_user_uuid + ) + if not active_sub or not active_sub.end_date: + return False + from datetime import datetime, timezone + return active_sub.is_active and active_sub.end_date > datetime.now(timezone.utc) + except Exception: + return False + async def _notify_admin_panel_user_creation_failed(self, user_id: int): if not self.bot or not self.i18n or not self.settings.ADMIN_IDS: return diff --git a/bot/services/tribute_service.py b/bot/services/tribute_service.py index 559ce9f..8019263 100644 --- a/bot/services/tribute_service.py +++ b/bot/services/tribute_service.py @@ -157,7 +157,12 @@ class TributeService: provider="tribute", ) referral_bonus = await referral_service.apply_referral_bonuses_for_payment( - session, int(user_id), months) + session, + int(user_id), + months, + current_payment_db_id=payment_record.payment_id, + skip_if_active_before_payment=True, + ) await session.commit() db_user = await user_dal.get_user_by_id(session, int(user_id)) diff --git a/config/settings.py b/config/settings.py index 82e08ec..e65cdea 100644 --- a/config/settings.py +++ b/config/settings.py @@ -93,6 +93,12 @@ class Settings(BaseSettings): REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS: Optional[int] = Field( default=15, alias="REFEREE_BONUS_DAYS_12_MONTHS") + # Referral program configuration + REFERRAL_ONE_BONUS_PER_REFEREE: bool = Field( + default=True, + description="When true, referral bonuses (for inviter and referee) are applied only once per invited user – on their first successful payment." + ) + PANEL_API_URL: Optional[str] = None PANEL_API_KEY: Optional[str] = None USER_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=0.0) diff --git a/db/dal/payment_dal.py b/db/dal/payment_dal.py index a52b825..7da4cbb 100644 --- a/db/dal/payment_dal.py +++ b/db/dal/payment_dal.py @@ -138,6 +138,23 @@ async def get_all_succeeded_payments_with_user(session: AsyncSession) -> List[Pa return result.scalars().all() +async def count_user_succeeded_payments( + session: AsyncSession, user_id: int, exclude_payment_id: Optional[int] = None +) -> int: + """Count succeeded payments for a specific user. + + If exclude_payment_id is provided, that specific payment will be excluded + from the count. Useful to check "prior" payments while processing the + current payment in the same transaction. + """ + conditions = [Payment.user_id == user_id, Payment.status == 'succeeded'] + if exclude_payment_id is not None: + conditions.append(Payment.payment_id != exclude_payment_id) + stmt = select(func.count(Payment.payment_id)).where(and_(*conditions)) + result = await session.execute(stmt) + return result.scalar() or 0 + + async def update_provider_payment_and_status( session: AsyncSession, payment_db_id: int, provider_payment_id: str, new_status: str) -> Optional[Payment]: