From 484327a032ad79ba5dc13d30c4fb2de5cd408585 Mon Sep 17 00:00:00 2001 From: kavore <161734431+kavore@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:29:31 +0300 Subject: [PATCH 1/5] feat(promo): Enhance promo code functionality and discount consumption logic - Integrated promo code service into subscription service to streamline discount consumption during payment processing. - Improved error handling and logging for promo code activation and usage increment. - Updated payment services to handle discount calculations more robustly, including fallback mechanisms for invalid discount scenarios. - Added new methods for managing promo code activations and usage in the database layer. --- bot/app/factories/build_services.py | 2 + bot/handlers/user/promo_user.py | 5 +- .../user/subscription/payments_yookassa.py | 34 ++++- bot/services/crypto_pay_service.py | 34 ++++- bot/services/freekassa_service.py | 35 ++++- bot/services/platega_service.py | 35 ++++- bot/services/promo_code_service.py | 138 ++++++++++++++---- bot/services/severpay_service.py | 35 ++++- bot/services/subscription_service.py | 39 ++--- db/dal/promo_code_dal.py | 60 ++++++-- 10 files changed, 319 insertions(+), 98 deletions(-) diff --git a/bot/app/factories/build_services.py b/bot/app/factories/build_services.py index 620313a..f88a04f 100644 --- a/bot/app/factories/build_services.py +++ b/bot/app/factories/build_services.py @@ -81,6 +81,8 @@ def build_core_services( # Wire services that depend on each other try: + # Allow subscription service to consume promo codes + setattr(subscription_service, "promo_code_service", promo_code_service) # Attach YooKassa to subscription service for auto-renew charges setattr(subscription_service, "yookassa_service", yookassa_service) # Allow panel webhook to trigger renewals through subscription service diff --git a/bot/handlers/user/promo_user.py b/bot/handlers/user/promo_user.py index 2da7d16..b5db0c4 100644 --- a/bot/handlers/user/promo_user.py +++ b/bot/handlers/user/promo_user.py @@ -190,9 +190,10 @@ async def process_promo_code_input(message: types.Message, state: FSMContext, # Both failed await session.rollback() logging.info( - f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}" + f"Promo code '{code_input}' application failed for user {user.id}. " + f"Bonus reason: {result}. Discount reason: {result_discount}" ) - response_to_user_text = result # Original error message from bonus code attempt + response_to_user_text = result_discount # Prefer the discount attempt error reply_markup = get_back_to_main_menu_markup( current_lang, i18n ) diff --git a/bot/handlers/user/subscription/payments_yookassa.py b/bot/handlers/user/subscription/payments_yookassa.py index 807bcfd..218ee14 100644 --- a/bot/handlers/user/subscription/payments_yookassa.py +++ b/bot/handlers/user/subscription/payments_yookassa.py @@ -88,13 +88,35 @@ async def _initiate_yk_payment( if active_discount: # Price is already discounted, calculate original price backwards discount_pct = active_discount.discount_percentage - original_price = price_rub / (1 - discount_pct / 100) - discount_amount = original_price - price_rub active_promo_code_id = active_discount.promo_code_id - logging.info( - f"Recording {discount_pct}% discount for YooKassa payment: " - f"original {original_price:.2f} -> final {price_rub}" - ) + denominator = 1 - discount_pct / 100 + if denominator <= 0: + price_source = ( + getattr(settings, "traffic_packages", {}) or {} + if sale_mode == "traffic" + else (settings.subscription_options or {}) + ) + fallback_original = price_source.get(months) + if fallback_original is not None: + original_price = fallback_original + discount_amount = original_price - price_rub + logging.info( + f"Recording {discount_pct}% discount for YooKassa payment: " + f"original {original_price:.2f} -> final {price_rub}" + ) + else: + logging.warning( + "YooKassa discount %s%% has invalid denominator and no fallback price for months=%s.", + discount_pct, + months, + ) + else: + original_price = price_rub / denominator + discount_amount = original_price - price_rub + logging.info( + f"Recording {discount_pct}% discount for YooKassa payment: " + f"original {original_price:.2f} -> final {price_rub}" + ) payment_description = ( get_text("payment_description_traffic", traffic_gb=_format_value(months)) diff --git a/bot/services/crypto_pay_service.py b/bot/services/crypto_pay_service.py index 679024b..d98c453 100644 --- a/bot/services/crypto_pay_service.py +++ b/bot/services/crypto_pay_service.py @@ -82,13 +82,35 @@ class CryptoPayService: if active_discount: # Price is already discounted, calculate original price backwards discount_pct = active_discount.discount_percentage - original_amount = amount / (1 - discount_pct / 100) - discount_amount = original_amount - amount promo_code_id = active_discount.promo_code_id - logging.info( - f"Recording {discount_pct}% discount for CryptoPay payment: " - f"original {original_amount:.2f} -> final {amount}" - ) + denominator = 1 - discount_pct / 100 + if denominator <= 0: + price_source = ( + getattr(self.settings, "traffic_packages", {}) or {} + if sale_mode == "traffic" + else (self.settings.subscription_options or {}) + ) + fallback_original = price_source.get(months) + if fallback_original is not None: + original_amount = fallback_original + discount_amount = original_amount - amount + logging.info( + f"Recording {discount_pct}% discount for CryptoPay payment: " + f"original {original_amount:.2f} -> final {amount}" + ) + else: + logging.warning( + "CryptoPay discount %s%% has invalid denominator and no fallback price for months=%s.", + discount_pct, + months, + ) + else: + original_amount = amount / denominator + discount_amount = original_amount - amount + logging.info( + f"Recording {discount_pct}% discount for CryptoPay payment: " + f"original {original_amount:.2f} -> final {amount}" + ) # Create pending payment in DB and commit to persist try: diff --git a/bot/services/freekassa_service.py b/bot/services/freekassa_service.py index b50b515..2bcde66 100644 --- a/bot/services/freekassa_service.py +++ b/bot/services/freekassa_service.py @@ -96,13 +96,36 @@ class FreeKassaService: if active_discount: # Price is already discounted, calculate original price backwards discount_pct = active_discount.discount_percentage - original_amount = amount / (1 - discount_pct / 100) - discount_amount = original_amount - amount promo_code_id = active_discount.promo_code_id - logging.info( - f"Recording {discount_pct}% discount for FreeKassa payment: " - f"original {original_amount:.2f} -> final {amount}" - ) + denominator = 1 - discount_pct / 100 + if denominator <= 0: + traffic_mode = bool(getattr(self.settings, "traffic_sale_mode", False)) + price_source = ( + getattr(self.settings, "traffic_packages", {}) or {} + if traffic_mode + else (self.settings.subscription_options or {}) + ) + fallback_original = price_source.get(months) + if fallback_original is not None: + original_amount = fallback_original + discount_amount = original_amount - amount + logging.info( + f"Recording {discount_pct}% discount for FreeKassa payment: " + f"original {original_amount:.2f} -> final {amount}" + ) + else: + logging.warning( + "FreeKassa discount %s%% has invalid denominator and no fallback price for months=%s.", + discount_pct, + months, + ) + else: + original_amount = amount / denominator + discount_amount = original_amount - amount + logging.info( + f"Recording {discount_pct}% discount for FreeKassa payment: " + f"original {original_amount:.2f} -> final {amount}" + ) # Update payment record with discount metadata try: diff --git a/bot/services/platega_service.py b/bot/services/platega_service.py index 1fd8c8b..24ff38e 100644 --- a/bot/services/platega_service.py +++ b/bot/services/platega_service.py @@ -94,13 +94,36 @@ class PlategaService: if active_discount: # Price is already discounted, calculate original price backwards discount_pct = active_discount.discount_percentage - original_amount = amount / (1 - discount_pct / 100) - discount_amount = original_amount - amount promo_code_id = active_discount.promo_code_id - logging.info( - f"Recording {discount_pct}% discount for Platega payment: " - f"original {original_amount:.2f} -> final {amount}" - ) + denominator = 1 - discount_pct / 100 + if denominator <= 0: + traffic_mode = bool(getattr(self.settings, "traffic_sale_mode", False)) + price_source = ( + getattr(self.settings, "traffic_packages", {}) or {} + if traffic_mode + else (self.settings.subscription_options or {}) + ) + fallback_original = price_source.get(months) + if fallback_original is not None: + original_amount = fallback_original + discount_amount = original_amount - amount + logging.info( + f"Recording {discount_pct}% discount for Platega payment: " + f"original {original_amount:.2f} -> final {amount}" + ) + else: + logging.warning( + "Platega discount %s%% has invalid denominator and no fallback price for months=%s.", + discount_pct, + months, + ) + else: + original_amount = amount / denominator + discount_amount = original_amount - amount + logging.info( + f"Recording {discount_pct}% discount for Platega payment: " + f"original {original_amount:.2f} -> final {amount}" + ) # Update payment record with discount metadata try: diff --git a/bot/services/promo_code_service.py b/bot/services/promo_code_service.py index e56cea9..eb17aef 100644 --- a/bot/services/promo_code_service.py +++ b/bot/services/promo_code_service.py @@ -6,7 +6,7 @@ from aiogram import Bot from config.settings import Settings -from db.dal import promo_code_dal, user_dal, active_discount_dal +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 @@ -140,6 +140,36 @@ class PromoCodeService: # This shouldn't happen since we checked above, but just in case return False, _("error_applying_promo_discount") + promo_incremented = await promo_code_dal.increment_promo_code_usage( + session, promo_data.promo_code_id + ) + if not promo_incremented: + await active_discount_dal.clear_active_discount(session, user_id) + logging.info( + "Discount promo %s reached max activations during activation for user %s.", + promo_data.code, + user_id, + ) + return False, _("promo_code_not_found_or_not_discount", code=code_input_upper) + + activation_recorded = await promo_code_dal.record_promo_activation( + session, + promo_data.promo_code_id, + user_id, + payment_id=None, + ) + if not activation_recorded: + await active_discount_dal.clear_active_discount(session, user_id) + await promo_code_dal.decrement_promo_code_usage( + session, promo_data.promo_code_id + ) + logging.error( + "Failed to record discount activation for user %s, promo %s.", + user_id, + promo_data.promo_code_id, + ) + 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" @@ -209,37 +239,85 @@ class PromoCodeService: Consume active discount: record activation, 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 not active_discount: - return False - - # Record activation - activation_recorded = await promo_code_dal.record_promo_activation( - session, - active_discount.promo_code_id, - user_id, - payment_id=payment_id - ) - - # Increment usage - promo_incremented = await promo_code_dal.increment_promo_code_usage( - session, - active_discount.promo_code_id - ) - - # Clear active discount - await active_discount_dal.clear_active_discount(session, user_id) - - if activation_recorded and promo_incremented: - await session.flush() + if active_discount and active_discount.promo_code_id != promo_code_id: logging.info( - f"Discount consumed for user {user_id}, promo {active_discount.promo_code_id}, " - f"payment {payment_id}" + "Active discount promo %s differs from payment promo %s; leaving active discount intact.", + active_discount.promo_code_id, + promo_code_id, ) - return True + 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: - logging.error( - f"Failed to consume discount for user {user_id}, " - f"promo {active_discount.promo_code_id}" + activation_recorded = await promo_code_dal.record_promo_activation( + session, + promo_code_id, + user_id, + payment_id=payment_id, ) - return False + 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 + ) + 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 diff --git a/bot/services/severpay_service.py b/bot/services/severpay_service.py index 9efa1e5..dac68f7 100644 --- a/bot/services/severpay_service.py +++ b/bot/services/severpay_service.py @@ -117,13 +117,36 @@ class SeverPayService: if active_discount: # Price is already discounted, calculate original price backwards discount_pct = active_discount.discount_percentage - original_amount = amount / (1 - discount_pct / 100) - discount_amount = original_amount - amount promo_code_id = active_discount.promo_code_id - logging.info( - f"Recording {discount_pct}% discount for SeverPay payment: " - f"original {original_amount:.2f} -> final {amount}" - ) + denominator = 1 - discount_pct / 100 + if denominator <= 0: + traffic_mode = bool(getattr(self.settings, "traffic_sale_mode", False)) + price_source = ( + getattr(self.settings, "traffic_packages", {}) or {} + if traffic_mode + else (self.settings.subscription_options or {}) + ) + fallback_original = price_source.get(months) + if fallback_original is not None: + original_amount = fallback_original + discount_amount = original_amount - amount + logging.info( + f"Recording {discount_pct}% discount for SeverPay payment: " + f"original {original_amount:.2f} -> final {amount}" + ) + else: + logging.warning( + "SeverPay discount %s%% has invalid denominator and no fallback price for months=%s.", + discount_pct, + months, + ) + else: + original_amount = amount / denominator + discount_amount = original_amount - amount + logging.info( + f"Recording {discount_pct}% discount for SeverPay payment: " + f"original {original_amount:.2f} -> final {amount}" + ) # Update payment record with discount metadata try: diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 847df40..506256e 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -5,7 +5,7 @@ from typing import Optional, Dict, Any, List, Tuple from aiogram import Bot from bot.middlewares.i18n import JsonI18n -from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal, active_discount_dal +from db.dal import user_dal, subscription_dal, promo_code_dal, user_billing_dal from bot.utils.date_utils import add_months from bot.utils.config_link import prepare_config_links from db.models import User, Subscription @@ -691,33 +691,20 @@ class SubscriptionService: final_subscription_url = updated_panel_user.get("subscriptionUrl") final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid) - # NEW: Consume discount promo code if payment had one + # Consume discount promo code if payment had one try: - payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id) - if payment_record and payment_record.discount_applied: - # This payment had a discount applied - consume it - active_discount = await active_discount_dal.get_active_discount(session, user_id) - if active_discount: - # Record promo activation - await promo_code_dal.record_promo_activation( - session, - active_discount.promo_code_id, - user_id, - payment_id=payment_db_id - ) - # Increment usage - await promo_code_dal.increment_promo_code_usage( - session, - active_discount.promo_code_id - ) - # Clear active discount - await active_discount_dal.clear_active_discount(session, user_id) - logging.info( - f"Discount consumed for user {user_id}, promo {active_discount.promo_code_id}, " - f"payment {payment_db_id}" - ) + promo_code_service = getattr(self, "promo_code_service", None) + if not promo_code_service: + from .promo_code_service import PromoCodeService + + promo_code_service = PromoCodeService( + self.settings, self, self.bot, self.i18n + ) + await promo_code_service.consume_discount(session, user_id, payment_db_id) except Exception as e: - logging.error(f"Failed to consume discount for user {user_id}, payment {payment_db_id}: {e}") + logging.error( + f"Failed to consume discount for user {user_id}, payment {payment_db_id}: {e}" + ) # Don't fail the subscription activation if discount consumption fails return { diff --git a/db/dal/promo_code_dal.py b/db/dal/promo_code_dal.py index 1f1902e..e7223e5 100644 --- a/db/dal/promo_code_dal.py +++ b/db/dal/promo_code_dal.py @@ -160,21 +160,42 @@ async def delete_promo_code(session: AsyncSession, promo_id: int) -> Optional[Pr async def increment_promo_code_usage( session: AsyncSession, promo_code_id: int) -> Optional[PromoCode]: + stmt = ( + update(PromoCode) + .where( + PromoCode.promo_code_id == promo_code_id, + PromoCode.current_activations < PromoCode.max_activations, + ) + .values(current_activations=PromoCode.current_activations + 1) + ) + result = await session.execute(stmt) + if result.rowcount and result.rowcount > 0: + await session.flush() + return await get_promo_code_by_id(session, promo_code_id) + promo = await get_promo_code_by_id(session, promo_code_id) if promo: - if promo.current_activations < promo.max_activations: - promo.current_activations += 1 - await session.flush() - await session.refresh(promo) - return promo - else: - logging.warning( - f"Promo code {promo.code} (ID: {promo_code_id}) already reached max activations." - ) - return None + logging.warning( + f"Promo code {promo.code} (ID: {promo_code_id}) already reached max activations." + ) return None +async def decrement_promo_code_usage( + session: AsyncSession, promo_code_id: int) -> bool: + stmt = ( + update(PromoCode) + .where( + PromoCode.promo_code_id == promo_code_id, + PromoCode.current_activations > 0, + ) + .values(current_activations=PromoCode.current_activations - 1) + ) + result = await session.execute(stmt) + await session.flush() + return bool(result.rowcount and result.rowcount > 0) + + async def get_user_activation_for_promo( session: AsyncSession, promo_code_id: int, user_id: int) -> Optional[PromoCodeActivation]: @@ -230,3 +251,22 @@ async def record_promo_activation( f"Promo code {promo_code_id} activated by user {user_id}. Activation ID: {new_activation.activation_id}" ) return new_activation + + +async def set_activation_payment_id( + session: AsyncSession, + promo_code_id: int, + user_id: int, + payment_id: int) -> bool: + stmt = ( + update(PromoCodeActivation) + .where( + PromoCodeActivation.promo_code_id == promo_code_id, + PromoCodeActivation.user_id == user_id, + PromoCodeActivation.payment_id == None, + ) + .values(payment_id=payment_id) + ) + result = await session.execute(stmt) + await session.flush() + return bool(result.rowcount and result.rowcount > 0) From 5bb8f2add0e6dff550a77ac8664d0aa1d3c2679a Mon Sep 17 00:00:00 2001 From: kavore <161734431+kavore@users.noreply.github.com> Date: Mon, 2 Feb 2026 00:35:31 +0300 Subject: [PATCH 2/5] feat(promo): Integrate promo code service into subscription options - Enhanced the display_subscription_options and reshow_subscription_options_callback functions to accept a promo_code_service parameter for improved discount handling. - Updated the logic to calculate and display discounted prices based on active promo codes, including support for both regular and star-based pricing. - Modified relevant locales to include currency symbols in discount notices for better user clarity. --- bot/handlers/user/start.py | 2 +- bot/handlers/user/subscription/core.py | 50 +++++++++++++++++-- .../subscription/payments_subscription.py | 48 ++++++++++++------ locales/en.json | 2 +- locales/ru.json | 2 +- 5 files changed, 82 insertions(+), 22 deletions(-) diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py index cd2d980..5df4bce 100644 --- a/bot/handlers/user/start.py +++ b/bot/handlers/user/start.py @@ -675,7 +675,7 @@ async def main_action_callback_handler( if action == "subscribe": await user_subscription_handlers.display_subscription_options( - callback, i18n_data, settings, session) + callback, i18n_data, settings, session, promo_code_service=promo_code_service) elif action == "my_subscription": await user_subscription_handlers.my_subscription_command_handler( callback, i18n_data, settings, panel_service, subscription_service, diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index 60555ee..2f7e4db 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -1,5 +1,6 @@ import hashlib import logging +import math from aiogram import Router, F, types, Bot from aiogram.filters import Command from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo @@ -39,7 +40,13 @@ def _hwid_callback_token(hwid: Optional[str]) -> str: return hashlib.sha256(hwid_str.encode()).hexdigest()[:32] -async def display_subscription_options(event: Union[types.Message, types.CallbackQuery], i18n_data: dict, settings: Settings, session: AsyncSession): +async def display_subscription_options( + event: Union[types.Message, types.CallbackQuery], + i18n_data: dict, + settings: Settings, + session: AsyncSession, + promo_code_service=None, +): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") @@ -61,21 +68,46 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac stars_traffic_packages = getattr(settings, "stars_traffic_packages", {}) or {} traffic_mode = bool(getattr(settings, "traffic_sale_mode", False) or stars_traffic_packages) + options_are_stars = False if traffic_mode: if traffic_packages: options = traffic_packages elif stars_traffic_packages: options = stars_traffic_packages currency_symbol_val = "⭐" + options_are_stars = True else: options = {} else: options = settings.subscription_options - if options: + display_options = options + if options and promo_code_service: + try: + active_discount_info = await promo_code_service.get_user_active_discount( + session, event.from_user.id + ) + except Exception: + active_discount_info = None + if active_discount_info: + discount_pct, _promo_code = active_discount_info + discounted_options = {} + for period, price in options.items(): + if price is None: + discounted_options[period] = price + else: + discounted_price, _ = promo_code_service.calculate_discounted_price( + price, discount_pct + ) + if options_are_stars: + discounted_price = math.ceil(discounted_price) + discounted_options[period] = discounted_price + display_options = discounted_options + + if display_options: text_content = get_text("select_traffic_package") if traffic_mode else get_text("select_subscription_period") reply_markup = get_subscription_options_keyboard( - options, currency_symbol_val, current_lang, i18n, traffic_mode=traffic_mode + display_options, currency_symbol_val, current_lang, i18n, traffic_mode=traffic_mode ) else: text_content = get_text("no_subscription_options_available") @@ -104,8 +136,16 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac @router.callback_query(F.data == "main_action:subscribe") -async def reshow_subscription_options_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession): - await display_subscription_options(callback, i18n_data, settings, session) +async def reshow_subscription_options_callback( + callback: types.CallbackQuery, + i18n_data: dict, + settings: Settings, + session: AsyncSession, + promo_code_service=None, +): + await display_subscription_options( + callback, i18n_data, settings, session, promo_code_service=promo_code_service + ) async def my_subscription_command_handler( diff --git a/bot/handlers/user/subscription/payments_subscription.py b/bot/handlers/user/subscription/payments_subscription.py index 5cda93d..95470dd 100644 --- a/bot/handlers/user/subscription/payments_subscription.py +++ b/bot/handlers/user/subscription/payments_subscription.py @@ -1,4 +1,5 @@ import logging +import math from typing import Optional from aiogram import F, Router, types @@ -52,26 +53,45 @@ async def select_subscription_period_callback_handler( # Check for active discount and apply if exists discount_text = "" - if promo_code_service and price_rub: + if promo_code_service and (price_rub is not None or stars_price is not None): active_discount_info = await promo_code_service.get_user_active_discount( session, callback.from_user.id ) if active_discount_info: discount_pct, promo_code = active_discount_info - original_price_rub = price_rub - price_rub, discount_amt = promo_code_service.calculate_discounted_price( - price_rub, discount_pct - ) - discount_text = get_text( - "active_discount_notice", - code=promo_code, - discount_pct=discount_pct, - original_price=original_price_rub, - discounted_price=price_rub, - discount_amount=discount_amt - ) - # Note: Stars prices typically don't get discounts (can be added if needed) + if price_rub is not None: + original_price_rub = price_rub + price_rub, discount_amt = promo_code_service.calculate_discounted_price( + price_rub, discount_pct + ) + discount_text = get_text( + "active_discount_notice", + code=promo_code, + discount_pct=discount_pct, + original_price=original_price_rub, + discounted_price=price_rub, + discount_amount=discount_amt, + currency_symbol=currency_symbol_val, + ) + if stars_price is not None: + original_stars_price = stars_price + discounted_stars_price, _ = promo_code_service.calculate_discounted_price( + float(stars_price), discount_pct + ) + discounted_stars_price = math.ceil(discounted_stars_price) + stars_price = discounted_stars_price + if not discount_text: + discount_amt = original_stars_price - discounted_stars_price + discount_text = get_text( + "active_discount_notice", + code=promo_code, + discount_pct=discount_pct, + original_price=original_stars_price, + discounted_price=discounted_stars_price, + discount_amount=discount_amt, + currency_symbol="⭐", + ) if price_rub is None: if traffic_mode and not price_source and stars_price is not None: diff --git a/locales/en.json b/locales/en.json index d6a3b9f..65298d5 100644 --- a/locales/en.json +++ b/locales/en.json @@ -79,7 +79,7 @@ "discount_promo_code_applied_success": "✅ Promo code {code} activated!\n\n💰 A {discount}% discount will be applied to your next purchase.\n\nSelect a plan for payment.", "discount_promo_already_active": "❌ You already have an active discount promo code ({code}, -{discount_pct}%). Use it first or wait until the payment is complete.", "promo_code_not_found_or_not_discount": "❌ Promo code {code} not found or is invalid.", - "active_discount_notice": "🎁 Active discount: {code} (-{discount_pct}%)\n💵 Price: {original_price}{discounted_price}\n💰 Savings: {discount_amount}", + "active_discount_notice": "🎁 Active discount: {code} (-{discount_pct}%)\n💵 Price: {original_price}{currency_symbol}{discounted_price}{currency_symbol}\n💰 Savings: {discount_amount}{currency_symbol}", "error_applying_promo_bonus": "Failed to apply promo bonus. Please try again later or contact support.", "promo_input_cancelled_short": "Promo code entry cancelled.", "trial_feature_disabled": "Free trial is currently unavailable.", diff --git a/locales/ru.json b/locales/ru.json index a9de522..6d19cf1 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -79,7 +79,7 @@ "discount_promo_code_applied_success": "✅ Промокод {code} активирован!\n\n💰 Скидка {discount}% будет применена к вашей следующей покупке.\n\nВыберите тариф для оплаты.", "discount_promo_already_active": "❌ У вас уже есть активированный промокод на скидку ({code}, -{discount_pct}%). Используйте его сначала или дождитесь окончания платежа.", "promo_code_not_found_or_not_discount": "❌ Промокод {code} не найден или недействителен.", - "active_discount_notice": "🎁 Активна скидка: {code} (-{discount_pct}%)\n💵 Цена: {original_price}{discounted_price}\n💰 Экономия: {discount_amount}", + "active_discount_notice": "🎁 Активна скидка: {code} (-{discount_pct}%)\n💵 Цена: {original_price}{currency_symbol}{discounted_price}{currency_symbol}\n💰 Экономия: {discount_amount}{currency_symbol}", "error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.", "error_applying_promo_discount": "❌ Ошибка при активации промокода. Попробуйте позже.", "promo_input_cancelled_short": "Ввод промокода отменен.", From 4b5908d6cc045db7753b1a4d16f247b223799760 Mon Sep 17 00:00:00 2001 From: kavore <161734431+kavore@users.noreply.github.com> Date: Mon, 2 Feb 2026 23:55:04 +0300 Subject: [PATCH 3/5] fix(promo): Improve error handling for promo code application - Enhanced the logic for determining the response to users when a promo code application fails, ensuring clearer messaging based on the type of error encountered. - Introduced specific error messages for cases where the promo code is not found or not applicable as a discount, improving user experience and clarity. --- bot/handlers/user/promo_user.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/bot/handlers/user/promo_user.py b/bot/handlers/user/promo_user.py index b5db0c4..cefcd2c 100644 --- a/bot/handlers/user/promo_user.py +++ b/bot/handlers/user/promo_user.py @@ -193,7 +193,16 @@ async def process_promo_code_input(message: types.Message, state: FSMContext, f"Promo code '{code_input}' application failed for user {user.id}. " f"Bonus reason: {result}. Discount reason: {result_discount}" ) - response_to_user_text = result_discount # Prefer the discount attempt error + bonus_not_found_text = _( + "promo_code_not_found", code=code_input.upper() + ) + discount_not_found_text = _( + "promo_code_not_found_or_not_discount", code=code_input.upper() + ) + if result != bonus_not_found_text and result_discount == discount_not_found_text: + response_to_user_text = result + else: + response_to_user_text = result_discount # Prefer the discount attempt error reply_markup = get_back_to_main_menu_markup( current_lang, i18n ) From d11b0cabaaf681fc8efa1afee705e1ee940a9cd2 Mon Sep 17 00:00:00 2001 From: kavore <161734431+kavore@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:25:53 +0300 Subject: [PATCH 4/5] fix(users): handle active discounts on delete Ensure active_discounts cascades on user/promo removal, clean orphan rows during migration, and rely on DB cascade for user deletion. --- db/migrator.py | 47 +++++++++++++++++++++++++++++++++++++++++++++++ db/models.py | 12 ++++++++++-- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/db/migrator.py b/db/migrator.py index e385f3b..18bb209 100644 --- a/db/migrator.py +++ b/db/migrator.py @@ -168,6 +168,48 @@ def _migration_0004_add_discount_promo_codes(connection: Connection) -> None: ) ) + +def _migration_0005_fix_active_discounts_fk_cascade(connection: Connection) -> None: + inspector = inspect(connection) + if not inspector.has_table("active_discounts"): + return + + connection.execute( + text( + "DELETE FROM active_discounts ad " + "WHERE NOT EXISTS (SELECT 1 FROM users u WHERE u.user_id = ad.user_id) " + "OR NOT EXISTS (SELECT 1 FROM promo_codes p WHERE p.promo_code_id = ad.promo_code_id)" + ) + ) + + connection.execute( + text("ALTER TABLE active_discounts DROP CONSTRAINT IF EXISTS active_discounts_user_id_fkey") + ) + connection.execute( + text("ALTER TABLE active_discounts DROP CONSTRAINT IF EXISTS fk_active_discounts_user") + ) + connection.execute( + text("ALTER TABLE active_discounts DROP CONSTRAINT IF EXISTS active_discounts_promo_code_id_fkey") + ) + connection.execute( + text("ALTER TABLE active_discounts DROP CONSTRAINT IF EXISTS fk_active_discounts_promo_code") + ) + + connection.execute( + text( + "ALTER TABLE active_discounts " + "ADD CONSTRAINT fk_active_discounts_user " + "FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE" + ) + ) + connection.execute( + text( + "ALTER TABLE active_discounts " + "ADD CONSTRAINT fk_active_discounts_promo_code " + "FOREIGN KEY (promo_code_id) REFERENCES promo_codes (promo_code_id) ON DELETE CASCADE" + ) + ) + MIGRATIONS: List[Migration] = [ Migration( id="0001_add_channel_subscription_fields", @@ -189,6 +231,11 @@ MIGRATIONS: List[Migration] = [ description="Add support for percentage discount promo codes", upgrade=_migration_0004_add_discount_promo_codes, ), + Migration( + id="0005_fix_active_discounts_fk_cascade", + description="Ensure active_discounts FKs cascade on user/promo delete", + upgrade=_migration_0005_fix_active_discounts_fk_cascade, + ), ] diff --git a/db/models.py b/db/models.py index 5547f20..cc55132 100644 --- a/db/models.py +++ b/db/models.py @@ -210,8 +210,16 @@ class ActiveDiscount(Base): """Tracks pending discount promo codes awaiting payment (permanent until used)""" __tablename__ = "active_discounts" - user_id = Column(BigInteger, ForeignKey("users.user_id"), primary_key=True) - promo_code_id = Column(Integer, ForeignKey("promo_codes.promo_code_id"), nullable=False) + user_id = Column( + BigInteger, + ForeignKey("users.user_id", ondelete="CASCADE"), + primary_key=True, + ) + promo_code_id = Column( + Integer, + ForeignKey("promo_codes.promo_code_id", ondelete="CASCADE"), + nullable=False, + ) discount_percentage = Column(Integer, nullable=False) activated_at = Column(DateTime(timezone=True), server_default=func.now()) From 3c84007f17a39bb54d925bb53bf3ca004dc53388 Mon Sep 17 00:00:00 2001 From: kavore <161734431+kavore@users.noreply.github.com> Date: Tue, 3 Feb 2026 12:05:16 +0300 Subject: [PATCH 5/5] 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. --- bot/services/promo_code_service.py | 34 ++-------------------------- bot/services/subscription_service.py | 2 +- db/dal/promo_code_dal.py | 24 +++++++++++++------- 3 files changed, 19 insertions(+), 41 deletions(-) diff --git a/bot/services/promo_code_service.py b/bot/services/promo_code_service.py index eb17aef..3fb90ad 100644 --- a/bot/services/promo_code_service.py +++ b/bot/services/promo_code_service.py @@ -140,36 +140,6 @@ class PromoCodeService: # This shouldn't happen since we checked above, but just in case return False, _("error_applying_promo_discount") - promo_incremented = await promo_code_dal.increment_promo_code_usage( - session, promo_data.promo_code_id - ) - if not promo_incremented: - await active_discount_dal.clear_active_discount(session, user_id) - logging.info( - "Discount promo %s reached max activations during activation for user %s.", - promo_data.code, - user_id, - ) - return False, _("promo_code_not_found_or_not_discount", code=code_input_upper) - - activation_recorded = await promo_code_dal.record_promo_activation( - session, - promo_data.promo_code_id, - user_id, - payment_id=None, - ) - if not activation_recorded: - await active_discount_dal.clear_active_discount(session, user_id) - await promo_code_dal.decrement_promo_code_usage( - session, promo_data.promo_code_id - ) - logging.error( - "Failed to record discount activation for user %s, promo %s.", - user_id, - promo_data.promo_code_id, - ) - 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" @@ -236,7 +206,7 @@ class PromoCodeService: payment_id: int ) -> bool: """ - Consume active discount: record activation, increment usage, clear active discount. + 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) @@ -300,7 +270,7 @@ class PromoCodeService: return False promo_incremented = await promo_code_dal.increment_promo_code_usage( - session, promo_code_id + session, promo_code_id, allow_overflow=True ) if not promo_incremented: logging.error( diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 506256e..dc34b6e 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -615,7 +615,7 @@ class SubscriptionService: ) if activation: await promo_code_dal.increment_promo_code_usage( - session, promo_code_id_from_payment + session, promo_code_id_from_payment, allow_overflow=True ) else: logging.warning( diff --git a/db/dal/promo_code_dal.py b/db/dal/promo_code_dal.py index e7223e5..569380b 100644 --- a/db/dal/promo_code_dal.py +++ b/db/dal/promo_code_dal.py @@ -159,13 +159,16 @@ async def delete_promo_code(session: AsyncSession, promo_id: int) -> Optional[Pr async def increment_promo_code_usage( - session: AsyncSession, promo_code_id: int) -> Optional[PromoCode]: + session: AsyncSession, + promo_code_id: int, + allow_overflow: bool = False) -> Optional[PromoCode]: + conditions = [PromoCode.promo_code_id == promo_code_id] + if not allow_overflow: + conditions.append(PromoCode.current_activations < PromoCode.max_activations) + stmt = ( update(PromoCode) - .where( - PromoCode.promo_code_id == promo_code_id, - PromoCode.current_activations < PromoCode.max_activations, - ) + .where(*conditions) .values(current_activations=PromoCode.current_activations + 1) ) result = await session.execute(stmt) @@ -175,9 +178,14 @@ async def increment_promo_code_usage( promo = await get_promo_code_by_id(session, promo_code_id) if promo: - logging.warning( - f"Promo code {promo.code} (ID: {promo_code_id}) already reached max activations." - ) + if allow_overflow: + logging.warning( + f"Failed to increment promo usage for promo {promo.code} (ID: {promo_code_id})." + ) + else: + logging.warning( + f"Promo code {promo.code} (ID: {promo_code_id}) already reached max activations." + ) return None