diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py
index 3bc093f2..9296dd48 100644
--- a/app/database/crud/promo_group.py
+++ b/app/database/crud/promo_group.py
@@ -60,6 +60,7 @@ async def create_promo_group(
device_discount_percent: int,
period_discounts: Optional[Dict[int, int]] = None,
auto_assign_total_spent_kopeks: Optional[int] = None,
+ apply_discounts_to_addons: bool = True,
) -> PromoGroup:
normalized_period_discounts = _normalize_period_discounts(period_discounts)
@@ -76,6 +77,7 @@ async def create_promo_group(
device_discount_percent=max(0, min(100, device_discount_percent)),
period_discounts=normalized_period_discounts or None,
auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks,
+ apply_discounts_to_addons=bool(apply_discounts_to_addons),
is_default=False,
)
@@ -84,13 +86,14 @@ async def create_promo_group(
await db.refresh(promo_group)
logger.info(
- "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽",
+ "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽, скидки на доп. услуги: %s",
promo_group.name,
promo_group.server_discount_percent,
promo_group.traffic_discount_percent,
promo_group.device_discount_percent,
normalized_period_discounts,
(auto_assign_total_spent_kopeks or 0) / 100,
+ "on" if promo_group.apply_discounts_to_addons else "off",
)
return promo_group
@@ -106,6 +109,7 @@ async def update_promo_group(
device_discount_percent: Optional[int] = None,
period_discounts: Optional[Dict[int, int]] = None,
auto_assign_total_spent_kopeks: Optional[int] = None,
+ apply_discounts_to_addons: Optional[bool] = None,
) -> PromoGroup:
if name is not None:
group.name = name.strip()
@@ -120,6 +124,8 @@ async def update_promo_group(
group.period_discounts = normalized_period_discounts or None
if auto_assign_total_spent_kopeks is not None:
group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks)
+ if apply_discounts_to_addons is not None:
+ group.apply_discounts_to_addons = bool(apply_discounts_to_addons)
await db.commit()
await db.refresh(group)
diff --git a/app/database/models.py b/app/database/models.py
index 0a3ad865..278178ff 100644
--- a/app/database/models.py
+++ b/app/database/models.py
@@ -292,6 +292,7 @@ class PromoGroup(Base):
device_discount_percent = Column(Integer, nullable=False, default=0)
period_discounts = Column(JSON, nullable=True, default=dict)
auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None)
+ apply_discounts_to_addons = Column(Boolean, nullable=False, default=True)
is_default = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py
index b123c750..8f5648c5 100644
--- a/app/database/universal_migration.py
+++ b/app/database/universal_migration.py
@@ -931,6 +931,54 @@ async def ensure_promo_groups_setup():
"Добавлена колонка promo_groups.auto_assign_total_spent_kopeks"
)
+ addon_discount_column_exists = await check_column_exists(
+ "promo_groups", "apply_discounts_to_addons"
+ )
+
+ if not addon_discount_column_exists:
+ if db_type == "sqlite":
+ await conn.execute(
+ text(
+ "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons BOOLEAN NOT NULL DEFAULT 1"
+ )
+ )
+ await conn.execute(
+ text(
+ "UPDATE promo_groups SET apply_discounts_to_addons = 1 WHERE apply_discounts_to_addons IS NULL"
+ )
+ )
+ elif db_type == "postgresql":
+ await conn.execute(
+ text(
+ "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons BOOLEAN NOT NULL DEFAULT TRUE"
+ )
+ )
+ await conn.execute(
+ text(
+ "UPDATE promo_groups SET apply_discounts_to_addons = TRUE WHERE apply_discounts_to_addons IS NULL"
+ )
+ )
+ elif db_type == "mysql":
+ await conn.execute(
+ text(
+ "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons TINYINT(1) NOT NULL DEFAULT 1"
+ )
+ )
+ await conn.execute(
+ text(
+ "UPDATE promo_groups SET apply_discounts_to_addons = 1 WHERE apply_discounts_to_addons IS NULL"
+ )
+ )
+ else:
+ logger.error(
+ f"Неподдерживаемый тип БД для promo_groups.apply_discounts_to_addons: {db_type}"
+ )
+ return False
+
+ logger.info(
+ "Добавлена колонка promo_groups.apply_discounts_to_addons"
+ )
+
column_exists = await check_column_exists("users", "promo_group_id")
if not column_exists:
@@ -1994,6 +2042,7 @@ async def check_migration_status():
"users_promo_group_column": False,
"promo_groups_period_discounts_column": False,
"promo_groups_auto_assign_column": False,
+ "promo_groups_addon_discount_column": False,
"users_auto_promo_group_assigned_column": False,
"subscription_crypto_link_column": False,
}
@@ -2011,6 +2060,7 @@ async def check_migration_status():
status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id')
status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts')
status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks')
+ status["promo_groups_addon_discount_column"] = await check_column_exists('promo_groups', 'apply_discounts_to_addons')
status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned')
status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link')
@@ -2048,6 +2098,7 @@ async def check_migration_status():
"users_promo_group_column": "Колонка promo_group_id у пользователей",
"promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп",
"promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп",
+ "promo_groups_addon_discount_column": "Колонка apply_discounts_to_addons у промо-групп",
"users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей",
"subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions",
}
diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py
index 917f673f..d21b9249 100644
--- a/app/handlers/admin/promo_groups.py
+++ b/app/handlers/admin/promo_groups.py
@@ -39,6 +39,32 @@ def _format_discount_line(texts, group) -> str:
)
+def _format_addon_discounts_line(texts, group: PromoGroup) -> str:
+ enabled = getattr(group, "apply_discounts_to_addons", True)
+ if enabled:
+ return texts.t(
+ "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED",
+ "Скидки на доп. услуги: включены",
+ )
+ return texts.t(
+ "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED",
+ "Скидки на доп. услуги: отключены",
+ )
+
+
+def _get_addon_discounts_button_text(texts, group: PromoGroup) -> str:
+ enabled = getattr(group, "apply_discounts_to_addons", True)
+ if enabled:
+ return texts.t(
+ "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE",
+ "🧩 Отключить скидки на доп. услуги",
+ )
+ return texts.t(
+ "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE",
+ "🧩 Включить скидки на доп. услуги",
+ )
+
+
def _normalize_periods_dict(raw: Optional[Dict]) -> Dict[int, int]:
if not raw or not isinstance(raw, dict):
return {}
@@ -257,6 +283,7 @@ def _build_edit_menu_content(
lines = [
header,
_format_discount_line(texts, group),
+ _format_addon_discounts_line(texts, group),
_format_auto_assign_line(texts, group),
]
@@ -318,6 +345,12 @@ def _build_edit_menu_content(
callback_data=f"promo_group_edit_field_{group.id}_periods",
)
],
+ [
+ types.InlineKeyboardButton(
+ text=_get_addon_discounts_button_text(texts, group),
+ callback_data=f"promo_group_toggle_addons_{group.id}",
+ )
+ ],
[
types.InlineKeyboardButton(
text=texts.t(
@@ -1192,6 +1225,45 @@ async def delete_promo_group_confirmed(
await callback.answer()
+@admin_required
+@error_handler
+async def toggle_promo_group_addon_discounts(
+ callback: types.CallbackQuery,
+ db_user,
+ db: AsyncSession,
+):
+ group = await _get_group_or_alert(callback, db)
+ if not group:
+ return
+
+ texts = get_texts(db_user.language)
+
+ new_value = not getattr(group, "apply_discounts_to_addons", True)
+
+ group = await update_promo_group(
+ db,
+ group,
+ apply_discounts_to_addons=new_value,
+ )
+
+ status_text = texts.t(
+ "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED"
+ if new_value
+ else "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED",
+ "Скидки на докупку доп. услуг {status}.",
+ ).format(status="включены" if new_value else "отключены")
+
+ await _send_edit_menu_after_update(
+ callback.message,
+ texts,
+ group,
+ db_user.language,
+ status_text,
+ )
+
+ await callback.answer()
+
+
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_promo_groups_menu, F.data == "admin_promo_groups")
dp.callback_query.register(show_promo_group_details, F.data.startswith("promo_group_manage_"))
@@ -1200,6 +1272,10 @@ def register_handlers(dp: Dispatcher):
prompt_edit_promo_group_field,
F.data.startswith("promo_group_edit_field_"),
)
+ dp.callback_query.register(
+ toggle_promo_group_addon_discounts,
+ F.data.startswith("promo_group_toggle_addons_"),
+ )
dp.callback_query.register(
start_edit_promo_group,
F.data.regexp(r"^promo_group_edit_\d+$"),
diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json
index 98b41a5b..e2aa4e2a 100644
--- a/app/localization/locales/en.json
+++ b/app/localization/locales/en.json
@@ -138,6 +138,12 @@
"ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups",
"ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}",
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
+ "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Add-on discounts: enabled",
+ "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Add-on discounts: disabled",
+ "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts",
+ "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts",
+ "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Add-on purchase discounts have been enabled.",
+ "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Add-on purchase discounts have been disabled.",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}",
"ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.",
diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json
index 831a55d1..669987a3 100644
--- a/app/localization/locales/ru.json
+++ b/app/localization/locales/ru.json
@@ -15,6 +15,12 @@
"ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы",
"ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}",
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
+ "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Скидки на доп. услуги: включены",
+ "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Скидки на доп. услуги: отключены",
+ "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги",
+ "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги",
+ "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Скидки на докупку доп. услуг включены.",
+ "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Скидки на докупку доп. услуг отключены.",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}",
"ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.",
diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py
index 190a9470..54ba6720 100644
--- a/app/services/subscription_service.py
+++ b/app/services/subscription_service.py
@@ -38,6 +38,26 @@ def _resolve_discount_percent(
return 0
+
+def _resolve_addon_discount_percent(
+ user: Optional[User],
+ promo_group: Optional[PromoGroup],
+ category: str,
+ *,
+ period_days: Optional[int] = None,
+) -> int:
+ group = promo_group or (getattr(user, "promo_group", None) if user else None)
+
+ if group is not None and not getattr(group, "apply_discounts_to_addons", True):
+ return 0
+
+ return _resolve_discount_percent(
+ user,
+ promo_group,
+ category,
+ period_days=period_days,
+ )
+
def get_traffic_reset_strategy():
from app.config import settings
strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper()
@@ -858,7 +878,7 @@ class SubscriptionService:
if additional_traffic_gb > 0:
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
- traffic_discount_percent = _resolve_discount_percent(
+ traffic_discount_percent = _resolve_addon_discount_percent(
user,
promo_group,
"traffic",
@@ -881,7 +901,7 @@ class SubscriptionService:
if additional_devices > 0:
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
- devices_discount_percent = _resolve_discount_percent(
+ devices_discount_percent = _resolve_addon_discount_percent(
user,
promo_group,
"devices",
@@ -908,7 +928,7 @@ class SubscriptionService:
server = await get_server_squad_by_id(db, server_id)
if server and server.is_available:
server_price_per_month = server.price_kopeks
- servers_discount_percent = _resolve_discount_percent(
+ servers_discount_percent = _resolve_addon_discount_percent(
user,
promo_group,
"servers",
diff --git a/locales/en.json b/locales/en.json
index f217ed28..110c9ae9 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -151,6 +151,12 @@
"ADMIN_PROMO_GROUPS_TITLE": "💳 Promo groups",
"ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}",
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
+ "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Add-on discounts: enabled",
+ "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Add-on discounts: disabled",
+ "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts",
+ "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts",
+ "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Add-on purchase discounts have been enabled.",
+ "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Add-on purchase discounts have been disabled.",
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}",
diff --git a/locales/ru.json b/locales/ru.json
index 2524c1d4..51eab11b 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -17,6 +17,12 @@
"ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы",
"ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}",
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
+ "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "Скидки на доп. услуги: включены",
+ "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "Скидки на доп. услуги: отключены",
+ "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги",
+ "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги",
+ "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "Скидки на докупку доп. услуг включены.",
+ "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "Скидки на докупку доп. услуг отключены.",
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}",