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..cefcd2c 100644
--- a/bot/handlers/user/promo_user.py
+++ b/bot/handlers/user/promo_user.py
@@ -190,9 +190,19 @@ 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
+ 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
)
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/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..3fb90ad 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
@@ -206,40 +206,88 @@ 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)
+ 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, 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
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..dc34b6e 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
@@ -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(
@@ -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..569380b 100644
--- a/db/dal/promo_code_dal.py
+++ b/db/dal/promo_code_dal.py
@@ -159,22 +159,51 @@ 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(*conditions)
+ .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
+ 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
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 +259,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)
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())
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": "Ввод промокода отменен.",