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] 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)