diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py
index 3845f531..d63c3107 100644
--- a/app/database/crud/promo_group.py
+++ b/app/database/crud/promo_group.py
@@ -1,5 +1,5 @@
import logging
-from typing import List, Optional, Tuple
+from typing import Dict, List, Optional, Tuple
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -7,6 +7,24 @@ from sqlalchemy.orm import selectinload
from app.database.models import PromoGroup, User
+
+def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> Dict[int, int]:
+ if not period_discounts:
+ return {}
+
+ normalized: Dict[int, int] = {}
+
+ for key, value in period_discounts.items():
+ try:
+ period = int(key)
+ percent = int(value)
+ except (TypeError, ValueError):
+ continue
+
+ normalized[period] = max(0, min(100, percent))
+
+ return normalized
+
logger = logging.getLogger(__name__)
@@ -40,12 +58,16 @@ async def create_promo_group(
server_discount_percent: int,
traffic_discount_percent: int,
device_discount_percent: int,
+ period_discounts: Optional[Dict[int, int]] = None,
) -> PromoGroup:
+ normalized_period_discounts = _normalize_period_discounts(period_discounts)
+
promo_group = PromoGroup(
name=name.strip(),
server_discount_percent=max(0, min(100, server_discount_percent)),
traffic_discount_percent=max(0, min(100, traffic_discount_percent)),
device_discount_percent=max(0, min(100, device_discount_percent)),
+ period_discounts=normalized_period_discounts or None,
is_default=False,
)
@@ -54,11 +76,12 @@ async def create_promo_group(
await db.refresh(promo_group)
logger.info(
- "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%)",
+ "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s)",
promo_group.name,
promo_group.server_discount_percent,
promo_group.traffic_discount_percent,
promo_group.device_discount_percent,
+ normalized_period_discounts,
)
return promo_group
@@ -72,6 +95,7 @@ async def update_promo_group(
server_discount_percent: Optional[int] = None,
traffic_discount_percent: Optional[int] = None,
device_discount_percent: Optional[int] = None,
+ period_discounts: Optional[Dict[int, int]] = None,
) -> PromoGroup:
if name is not None:
group.name = name.strip()
@@ -81,6 +105,9 @@ async def update_promo_group(
group.traffic_discount_percent = max(0, min(100, traffic_discount_percent))
if device_discount_percent is not None:
group.device_discount_percent = max(0, min(100, device_discount_percent))
+ if period_discounts is not None:
+ normalized_period_discounts = _normalize_period_discounts(period_discounts)
+ group.period_discounts = normalized_period_discounts or None
await db.commit()
await db.refresh(group)
diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py
index 8898b220..051c2369 100644
--- a/app/database/crud/subscription.py
+++ b/app/database/crud/subscription.py
@@ -531,7 +531,15 @@ async def calculate_subscription_total_cost(
months_in_period = calculate_months_from_days(period_days)
- base_price = PERIOD_PRICES.get(period_days, 0)
+ base_price_original = PERIOD_PRICES.get(period_days, 0)
+ period_discount_percent = _get_discount_percent(
+ user,
+ promo_group,
+ "period",
+ period_days=period_days,
+ )
+ base_discount_total = base_price_original * period_discount_percent // 100
+ base_price = base_price_original - base_discount_total
promo_group = promo_group or (user.promo_group if user else None)
@@ -577,6 +585,9 @@ async def calculate_subscription_total_cost(
details = {
'base_price': base_price,
+ 'base_price_original': base_price_original,
+ 'base_discount_percent': period_discount_percent,
+ 'base_discount_total': base_discount_total,
'traffic_price_per_month': traffic_price_per_month,
'traffic_discount_percent': traffic_discount_percent,
'traffic_discount_total': total_traffic_discount,
diff --git a/app/database/models.py b/app/database/models.py
index 1c67713f..9cdeaa86 100644
--- a/app/database/models.py
+++ b/app/database/models.py
@@ -1,5 +1,5 @@
from datetime import datetime, timedelta
-from typing import Optional, List
+from typing import Optional, List, Dict
from enum import Enum
from sqlalchemy import (
@@ -270,13 +270,59 @@ class PromoGroup(Base):
server_discount_percent = Column(Integer, nullable=False, default=0)
traffic_discount_percent = Column(Integer, nullable=False, default=0)
device_discount_percent = Column(Integer, nullable=False, default=0)
+ period_discounts = Column(JSON, nullable=True, default=dict)
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())
users = relationship("User", back_populates="promo_group")
+ def _get_period_discounts_map(self) -> Dict[int, int]:
+ raw_discounts = self.period_discounts or {}
+
+ if isinstance(raw_discounts, dict):
+ items = raw_discounts.items()
+ else:
+ items = []
+
+ normalized: Dict[int, int] = {}
+
+ for key, value in items:
+ try:
+ period = int(key)
+ percent = int(value)
+ except (TypeError, ValueError):
+ continue
+
+ normalized[period] = max(0, min(100, percent))
+
+ return normalized
+
+ def _get_period_discount(self, period_days: Optional[int]) -> int:
+ if not period_days:
+ return 0
+
+ discounts = self._get_period_discounts_map()
+
+ if period_days in discounts:
+ return discounts[period_days]
+
+ if self.is_default:
+ try:
+ from app.config import settings
+
+ if settings.is_base_promo_group_period_discount_enabled():
+ config_discounts = settings.get_base_promo_group_period_discounts()
+ return config_discounts.get(period_days, 0)
+ except Exception:
+ return 0
+
+ return 0
+
def get_discount_percent(self, category: str, period_days: Optional[int] = None) -> int:
+ if category == "period":
+ return max(0, min(100, self._get_period_discount(period_days)))
+
mapping = {
"servers": self.server_discount_percent,
"traffic": self.traffic_discount_percent,
@@ -284,18 +330,6 @@ class PromoGroup(Base):
}
percent = mapping.get(category, 0)
- if self.is_default and period_days is not None:
- try:
- from app.config import settings
-
- if settings.is_base_promo_group_period_discount_enabled():
- discounts = settings.get_base_promo_group_period_discounts()
- if period_days in discounts:
- period_discount = discounts[period_days]
- percent = period_discount
- except Exception:
- pass
-
return max(0, min(100, percent))
diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py
index 6c3c7853..ce270f13 100644
--- a/app/database/universal_migration.py
+++ b/app/database/universal_migration.py
@@ -681,6 +681,46 @@ async def ensure_promo_groups_setup():
f"Не удалось добавить уникальное ограничение uq_promo_groups_name: {e}"
)
+ period_discounts_column_exists = await check_column_exists(
+ "promo_groups", "period_discounts"
+ )
+
+ if not period_discounts_column_exists:
+ if db_type == "sqlite":
+ await conn.execute(
+ text("ALTER TABLE promo_groups ADD COLUMN period_discounts JSON")
+ )
+ await conn.execute(
+ text("UPDATE promo_groups SET period_discounts = '{}' WHERE period_discounts IS NULL")
+ )
+ elif db_type == "postgresql":
+ await conn.execute(
+ text(
+ "ALTER TABLE promo_groups ADD COLUMN period_discounts JSONB"
+ )
+ )
+ await conn.execute(
+ text(
+ "UPDATE promo_groups SET period_discounts = '{}'::jsonb WHERE period_discounts IS NULL"
+ )
+ )
+ elif db_type == "mysql":
+ await conn.execute(
+ text("ALTER TABLE promo_groups ADD COLUMN period_discounts JSON")
+ )
+ await conn.execute(
+ text(
+ "UPDATE promo_groups SET period_discounts = JSON_OBJECT() WHERE period_discounts IS NULL"
+ )
+ )
+ else:
+ logger.error(
+ f"Неподдерживаемый тип БД для promo_groups.period_discounts: {db_type}"
+ )
+ return False
+
+ logger.info("Добавлена колонка promo_groups.period_discounts")
+
column_exists = await check_column_exists("users", "promo_group_id")
if not column_exists:
@@ -1543,7 +1583,8 @@ async def check_migration_status():
"subscription_duplicates": False,
"subscription_conversions_table": False,
"promo_groups_table": False,
- "users_promo_group_column": False
+ "users_promo_group_column": False,
+ "promo_groups_period_discounts_column": False,
}
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
@@ -1556,6 +1597,7 @@ async def check_migration_status():
status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled')
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')
media_fields_exist = (
await check_column_exists('broadcast_history', 'has_media') and
@@ -1587,7 +1629,8 @@ async def check_migration_status():
"subscription_conversions_table": "Таблица конверсий подписок",
"subscription_duplicates": "Отсутствие дубликатов подписок",
"promo_groups_table": "Таблица промо-групп",
- "users_promo_group_column": "Колонка promo_group_id у пользователей"
+ "users_promo_group_column": "Колонка promo_group_id у пользователей",
+ "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп",
}
for check_key, check_status in status.items():
diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py
index ef927f94..0546550e 100644
--- a/app/handlers/admin/promo_groups.py
+++ b/app/handlers/admin/promo_groups.py
@@ -1,10 +1,12 @@
import logging
-from typing import Optional
+import logging
+from typing import Dict, Optional
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
+from app.config import settings
from app.database.crud.promo_group import (
get_promo_groups_with_counts,
get_promo_group_by_id,
@@ -22,7 +24,7 @@ from app.keyboards.admin import (
get_admin_pagination_keyboard,
get_confirmation_keyboard,
)
-
+from app.utils.pricing_utils import format_period_description
logger = logging.getLogger(__name__)
@@ -37,6 +39,128 @@ def _format_discount_line(texts, group) -> str:
)
+def _normalize_periods_dict(raw: Optional[Dict]) -> Dict[int, int]:
+ if not raw or not isinstance(raw, dict):
+ return {}
+
+ normalized: Dict[int, int] = {}
+
+ for key, value in raw.items():
+ try:
+ period = int(key)
+ percent = int(value)
+ except (TypeError, ValueError):
+ continue
+
+ normalized[period] = max(0, min(100, percent))
+
+ return normalized
+
+
+def _collect_period_discounts(group: PromoGroup) -> Dict[int, int]:
+ discounts = _normalize_periods_dict(getattr(group, "period_discounts", None))
+
+ if discounts:
+ return dict(sorted(discounts.items()))
+
+ if group.is_default and settings.is_base_promo_group_period_discount_enabled():
+ try:
+ base_discounts = settings.get_base_promo_group_period_discounts()
+ normalized = _normalize_periods_dict(base_discounts)
+ return dict(sorted(normalized.items()))
+ except Exception:
+ return {}
+
+ return {}
+
+
+def _format_period_discounts_lines(texts, group: PromoGroup, language: str) -> list:
+ discounts = _collect_period_discounts(group)
+
+ if not discounts:
+ return []
+
+ header = texts.t(
+ "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER",
+ "⏳ Скидки по периодам:",
+ )
+
+ lines = [header]
+
+ for period_days, percent in discounts.items():
+ period_display = format_period_description(period_days, language)
+ lines.append(
+ texts.t("PROMO_GROUP_PERIOD_DISCOUNT_ITEM", "{period} — {percent}%").format(
+ period=period_display,
+ percent=percent,
+ )
+ )
+
+ return lines
+
+
+def _format_period_discounts_value(discounts: Dict[int, int]) -> str:
+ if not discounts:
+ return "0"
+
+ return ", ".join(
+ f"{period}:{percent}"
+ for period, percent in sorted(discounts.items())
+ )
+
+
+def _parse_period_discounts_input(value: str) -> Dict[int, int]:
+ cleaned = (value or "").strip()
+
+ if not cleaned or cleaned in {"0", "-"}:
+ return {}
+
+ cleaned = cleaned.replace(";", ",").replace("\n", ",")
+ parts = [part.strip() for part in cleaned.split(",") if part.strip()]
+
+ if not parts:
+ return {}
+
+ discounts: Dict[int, int] = {}
+
+ for part in parts:
+ if ":" not in part:
+ raise ValueError
+
+ period_raw, percent_raw = part.split(":", 1)
+
+ period = int(period_raw.strip())
+ percent = int(percent_raw.strip())
+
+ if period <= 0:
+ raise ValueError
+
+ discounts[period] = max(0, min(100, percent))
+
+ return discounts
+
+
+async def _prompt_for_period_discounts(
+ message: types.Message,
+ state: FSMContext,
+ prompt_key: str,
+ default_text: str,
+ *,
+ current_value: Optional[str] = None,
+):
+ data = await state.get_data()
+ texts = get_texts(data.get("language", "ru"))
+ prompt_text = texts.t(prompt_key, default_text)
+
+ if current_value is not None:
+ try:
+ prompt_text = prompt_text.format(current=current_value)
+ except KeyError:
+ pass
+
+ await message.answer(prompt_text)
+
+
@admin_required
@error_handler
async def show_promo_groups_menu(
@@ -64,17 +188,20 @@ async def show_promo_groups_menu(
if group.is_default
else ""
)
- lines.extend(
- [
- f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}",
- _format_discount_line(texts, group),
- texts.t(
- "ADMIN_PROMO_GROUPS_MEMBERS_COUNT",
- "Участников: {count}",
- ).format(count=member_count),
- "",
- ]
- )
+ group_lines = [
+ f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}",
+ _format_discount_line(texts, group),
+ texts.t(
+ "ADMIN_PROMO_GROUPS_MEMBERS_COUNT",
+ "Участников: {count}",
+ ).format(count=member_count),
+ ]
+
+ period_lines = _format_period_discounts_lines(texts, group, db_user.language)
+ group_lines.extend(period_lines)
+ group_lines.append("")
+
+ lines.extend(group_lines)
keyboard_rows.append([
types.InlineKeyboardButton(
text=f"{'⭐' if group.is_default else '🎯'} {group.name}",
@@ -127,25 +254,30 @@ async def show_promo_group_details(
member_count = await count_promo_group_members(db, group.id)
default_note = (
- "\n" + texts.t("ADMIN_PROMO_GROUP_DETAILS_DEFAULT", "Это базовая группа.")
+ texts.t("ADMIN_PROMO_GROUP_DETAILS_DEFAULT", "Это базовая группа.")
if group.is_default
else ""
)
- text = "\n".join(
- [
- texts.t(
- "ADMIN_PROMO_GROUP_DETAILS_TITLE",
- "💳 Промогруппа: {name}",
- ).format(name=group.name),
- _format_discount_line(texts, group),
- texts.t(
- "ADMIN_PROMO_GROUP_DETAILS_MEMBERS",
- "Участников: {count}",
- ).format(count=member_count),
- default_note,
- ]
- )
+ lines = [
+ texts.t(
+ "ADMIN_PROMO_GROUP_DETAILS_TITLE",
+ "💳 Промогруппа: {name}",
+ ).format(name=group.name),
+ _format_discount_line(texts, group),
+ texts.t(
+ "ADMIN_PROMO_GROUP_DETAILS_MEMBERS",
+ "Участников: {count}",
+ ).format(count=member_count),
+ ]
+
+ period_lines = _format_period_discounts_lines(texts, group, db_user.language)
+ lines.extend(period_lines)
+
+ if default_note:
+ lines.append(default_note)
+
+ text = "\n".join(line for line in lines if line)
keyboard_rows = []
if member_count > 0:
@@ -299,13 +431,47 @@ async def process_create_group_devices(
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
return
+ await state.update_data(new_group_devices=devices_discount)
+ await state.set_state(AdminStates.creating_promo_group_period_discount)
+
+ await _prompt_for_period_discounts(
+ message,
+ state,
+ "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT",
+ "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.",
+ )
+
+
+@admin_required
+@error_handler
+async def process_create_group_period_discounts(
+ message: types.Message,
+ state: FSMContext,
+ db_user,
+ db: AsyncSession,
+):
+ data = await state.get_data()
+ texts = get_texts(data.get("language", db_user.language))
+
+ try:
+ period_discounts = _parse_period_discounts_input(message.text)
+ except ValueError:
+ await message.answer(
+ texts.t(
+ "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS",
+ "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.",
+ )
+ )
+ return
+
try:
group = await create_promo_group(
db,
data["new_group_name"],
traffic_discount_percent=data["new_group_traffic"],
server_discount_percent=data["new_group_servers"],
- device_discount_percent=devices_discount,
+ device_discount_percent=data["new_group_devices"],
+ period_discounts=period_discounts,
)
except Exception as e:
logger.error(f"Не удалось создать промогруппу: {e}")
@@ -440,13 +606,55 @@ async def process_edit_group_devices(
await state.clear()
return
+ await state.update_data(edit_group_devices=devices_discount)
+ await state.set_state(AdminStates.editing_promo_group_period_discount)
+
+ current_discounts = _normalize_periods_dict(getattr(group, "period_discounts", None))
+ await _prompt_for_period_discounts(
+ message,
+ state,
+ "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT",
+ "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.",
+ current_value=_format_period_discounts_value(current_discounts),
+ )
+
+
+@admin_required
+@error_handler
+async def process_edit_group_period_discounts(
+ message: types.Message,
+ state: FSMContext,
+ db_user,
+ db: AsyncSession,
+):
+ data = await state.get_data()
+ texts = get_texts(data.get("language", db_user.language))
+
+ try:
+ period_discounts = _parse_period_discounts_input(message.text)
+ except ValueError:
+ await message.answer(
+ texts.t(
+ "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS",
+ "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.",
+ )
+ )
+ return
+
+ group = await get_promo_group_by_id(db, data["edit_group_id"])
+ if not group:
+ await message.answer("❌ Промогруппа не найдена")
+ await state.clear()
+ return
+
await update_promo_group(
db,
group,
name=data["edit_group_name"],
traffic_discount_percent=data["edit_group_traffic"],
server_discount_percent=data["edit_group_servers"],
- device_discount_percent=devices_discount,
+ device_discount_percent=data["edit_group_devices"],
+ period_discounts=period_discounts,
)
await state.clear()
@@ -616,6 +824,10 @@ def register_handlers(dp: Dispatcher):
process_create_group_devices,
AdminStates.creating_promo_group_device_discount,
)
+ dp.message.register(
+ process_create_group_period_discounts,
+ AdminStates.creating_promo_group_period_discount,
+ )
dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name)
dp.message.register(
@@ -630,3 +842,7 @@ def register_handlers(dp: Dispatcher):
process_edit_group_devices,
AdminStates.editing_promo_group_device_discount,
)
+ dp.message.register(
+ process_edit_group_period_discounts,
+ AdminStates.editing_promo_group_period_discount,
+ )
diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py
index 9e924020..4e56123e 100644
--- a/app/handlers/subscription.py
+++ b/app/handlers/subscription.py
@@ -103,7 +103,15 @@ async def _prepare_subscription_summary(
months_in_period = calculate_months_from_days(summary_data['period_days'])
period_display = format_period_description(summary_data['period_days'], db_user.language)
- base_price = PERIOD_PRICES[summary_data['period_days']]
+ base_price_original = PERIOD_PRICES[summary_data['period_days']]
+ period_discount_percent = db_user.get_promo_discount(
+ "period",
+ summary_data['period_days'],
+ )
+ base_price, base_discount_total = apply_percentage_discount(
+ base_price_original,
+ period_discount_percent,
+ )
if settings.is_traffic_fixed():
traffic_limit = settings.get_fixed_traffic_limit()
@@ -195,6 +203,9 @@ async def _prepare_subscription_summary(
summary_data['server_prices_for_period'] = selected_server_prices
summary_data['months_in_period'] = months_in_period
summary_data['base_price'] = base_price
+ summary_data['base_price_original'] = base_price_original
+ summary_data['base_discount_percent'] = period_discount_percent
+ summary_data['base_discount_total'] = base_discount_total
summary_data['final_traffic_gb'] = final_traffic_gb
summary_data['traffic_price_per_month'] = traffic_price_per_month
summary_data['traffic_discount_percent'] = traffic_component["discount_percent"]
@@ -226,7 +237,15 @@ async def _prepare_subscription_summary(
else:
traffic_display = f"{summary_data.get('traffic_gb', 0)} ГБ"
- details_lines = [f"- Базовый период: {texts.format_price(base_price)}"]
+ base_line = f"- Базовый период: {texts.format_price(base_price_original)}"
+ if base_discount_total > 0:
+ base_line += (
+ f" → {texts.format_price(base_price)}"
+ f" (скидка {period_discount_percent}%:"
+ f" -{texts.format_price(base_discount_total)})"
+ )
+
+ details_lines = [base_line]
if total_traffic_price > 0:
traffic_line = (
@@ -317,26 +336,29 @@ def _build_promo_group_discount_text(
period_lines: List[str] = []
- if (
- promo_group.is_default
- and periods
- and settings.is_base_promo_group_period_discount_enabled()
- ):
- discounts = settings.get_base_promo_group_period_discounts()
+ period_candidates: set[int] = set(periods or [])
- for period_days in periods:
- percent = discounts.get(period_days, 0)
-
- if percent <= 0:
+ raw_period_discounts = getattr(promo_group, "period_discounts", None)
+ if isinstance(raw_period_discounts, dict):
+ for key in raw_period_discounts.keys():
+ try:
+ period_candidates.add(int(key))
+ except (TypeError, ValueError):
continue
- period_display = format_period_description(period_days, db_user.language)
- period_lines.append(
- texts.PROMO_GROUP_PERIOD_DISCOUNT_ITEM.format(
- period=period_display,
- percent=percent,
- )
+ for period_days in sorted(period_candidates):
+ percent = promo_group.get_discount_percent("period", period_days)
+
+ if percent <= 0:
+ continue
+
+ period_display = format_period_description(period_days, db_user.language)
+ period_lines.append(
+ texts.PROMO_GROUP_PERIOD_DISCOUNT_ITEM.format(
+ period=period_display,
+ percent=percent,
)
+ )
if not service_lines and not period_lines:
return ""
@@ -666,8 +688,26 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
subscription_service = SubscriptionService()
- base_cost = PERIOD_PRICES.get(30, 0)
-
+ base_cost_original = PERIOD_PRICES.get(30, 0)
+ try:
+ owner = subscription.user
+ except AttributeError:
+ owner = None
+
+ period_discount_percent = 0
+ if owner:
+ try:
+ period_discount_percent = owner.get_promo_discount("period", 30)
+ except AttributeError:
+ period_discount_percent = 0
+
+ from app.utils.pricing_utils import apply_percentage_discount
+
+ base_cost, _ = apply_percentage_discount(
+ base_cost_original,
+ period_discount_percent,
+ )
+
try:
servers_cost, _ = await subscription_service.get_countries_price_by_uuids(
subscription.connected_squads, db
@@ -683,7 +723,14 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
total_cost = base_cost + servers_cost + traffic_cost + devices_cost
logger.info(f"📊 Месячная стоимость конфигурации подписки {subscription.id}:")
- logger.info(f" 📅 Базовый тариф (30 дней): {base_cost/100}₽")
+ base_log = f" 📅 Базовый тариф (30 дней): {base_cost_original/100}₽"
+ if period_discount_percent > 0:
+ discount_value = base_cost_original * period_discount_percent // 100
+ base_log += (
+ f" → {base_cost/100}₽"
+ f" (скидка {period_discount_percent}%: -{discount_value/100}₽)"
+ )
+ logger.info(base_log)
if servers_cost > 0:
logger.info(f" 🌍 Серверы: {servers_cost/100}₽")
if traffic_cost > 0:
@@ -1801,7 +1848,14 @@ async def handle_extend_subscription(
months_in_period = calculate_months_from_days(days)
from app.config import PERIOD_PRICES
- base_price = PERIOD_PRICES.get(days, 0)
+ from app.utils.pricing_utils import apply_percentage_discount
+
+ base_price_original = PERIOD_PRICES.get(days, 0)
+ period_discount_percent = db_user.get_promo_discount("period", days)
+ base_price, _ = apply_percentage_discount(
+ base_price_original,
+ period_discount_percent,
+ )
servers_price_per_month, _ = await subscription_service.get_countries_price_by_uuids(
subscription.connected_squads, db
@@ -2015,7 +2069,11 @@ async def confirm_extend_subscription(
db_user: User,
db: AsyncSession
):
- from app.utils.pricing_utils import calculate_months_from_days, validate_pricing_calculation
+ from app.utils.pricing_utils import (
+ calculate_months_from_days,
+ validate_pricing_calculation,
+ apply_percentage_discount,
+ )
from app.services.admin_notification_service import AdminNotificationService
days = int(callback.data.split('_')[2])
@@ -2032,8 +2090,14 @@ async def confirm_extend_subscription(
try:
from app.config import PERIOD_PRICES
+ from app.utils.pricing_utils import apply_percentage_discount
- base_price = PERIOD_PRICES.get(days, 0)
+ base_price_original = PERIOD_PRICES.get(days, 0)
+ period_discount_percent = db_user.get_promo_discount("period", days)
+ base_price, base_discount_total = apply_percentage_discount(
+ base_price_original,
+ period_discount_percent,
+ )
subscription_service = SubscriptionService()
servers_price_per_month, per_server_monthly_prices = await subscription_service.get_countries_price_by_uuids(
@@ -2091,7 +2155,13 @@ async def confirm_extend_subscription(
return
logger.info(f"💰 Расчет продления подписки {subscription.id} на {days} дней ({months_in_period} мес):")
- logger.info(f" 📅 Период {days} дней: {base_price/100}₽")
+ base_log = f" 📅 Период {days} дней: {base_price_original/100}₽"
+ if base_discount_total > 0:
+ base_log += (
+ f" → {base_price/100}₽"
+ f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
+ )
+ logger.info(base_log)
if total_servers_price > 0:
logger.info(
f" 🌐 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period}"
@@ -2543,7 +2613,15 @@ async def select_country(
countries = await _get_available_countries()
- base_price = PERIOD_PRICES[data['period_days']] + settings.get_traffic_price(data['traffic_gb'])
+ period_base_price = PERIOD_PRICES[data['period_days']]
+ from app.utils.pricing_utils import apply_percentage_discount
+
+ discounted_base_price, _ = apply_percentage_discount(
+ period_base_price,
+ db_user.get_promo_discount("period", data['period_days']),
+ )
+
+ base_price = discounted_base_price + settings.get_traffic_price(data['traffic_gb'])
try:
subscription_service = SubscriptionService()
@@ -2683,7 +2761,34 @@ async def confirm_purchase(
'months_in_period', calculate_months_from_days(data['period_days'])
)
- base_price = data.get('base_price', PERIOD_PRICES[data['period_days']])
+ base_price = data.get('base_price')
+ base_price_original = data.get('base_price_original')
+ base_discount_percent = data.get('base_discount_percent')
+ base_discount_total = data.get('base_discount_total')
+
+ if base_price is None:
+ base_price_original = PERIOD_PRICES[data['period_days']]
+ base_discount_percent = db_user.get_promo_discount(
+ "period",
+ data['period_days'],
+ )
+ base_price, base_discount_total = apply_percentage_discount(
+ base_price_original,
+ base_discount_percent,
+ )
+ else:
+ if base_price_original is None:
+ base_price_original = PERIOD_PRICES[data['period_days']]
+ if base_discount_percent is None:
+ base_discount_percent = db_user.get_promo_discount(
+ "period",
+ data['period_days'],
+ )
+ if base_discount_total is None:
+ _, base_discount_total = apply_percentage_discount(
+ base_price_original,
+ base_discount_percent,
+ )
server_prices = data.get('server_prices_for_period', [])
if not server_prices:
@@ -2812,7 +2917,13 @@ async def confirm_purchase(
return
logger.info(f"Расчет покупки подписки на {data['period_days']} дней ({months_in_period} мес):")
- logger.info(f" Период: {base_price/100}₽")
+ base_log = f" Период: {base_price_original/100}₽"
+ if base_discount_total and base_discount_total > 0:
+ base_log += (
+ f" → {base_price/100}₽"
+ f" (скидка {base_discount_percent}%: -{base_discount_total/100}₽)"
+ )
+ logger.info(base_log)
if total_traffic_price > 0:
message = (
f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period}"
diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py
index 1b380dba..e21e259c 100644
--- a/app/services/subscription_service.py
+++ b/app/services/subscription_service.py
@@ -299,7 +299,15 @@ class SubscriptionService:
if settings.MAX_DEVICES_LIMIT > 0 and devices > settings.MAX_DEVICES_LIMIT:
raise ValueError(f"Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}")
- base_price = PERIOD_PRICES.get(period_days, 0)
+ base_price_original = PERIOD_PRICES.get(period_days, 0)
+ period_discount_percent = _resolve_discount_percent(
+ user,
+ promo_group,
+ "period",
+ period_days=period_days,
+ )
+ base_discount_total = base_price_original * period_discount_percent // 100
+ base_price = base_price_original - base_discount_total
promo_group = promo_group or (user.promo_group if user else None)
@@ -353,7 +361,13 @@ class SubscriptionService:
total_price = base_price + discounted_traffic_price + total_servers_price + discounted_devices_price
logger.info(f"Расчет стоимости новой подписки:")
- logger.info(f" Период {period_days} дней: {base_price/100}₽")
+ base_log = f" Период {period_days} дней: {base_price_original/100}₽"
+ if base_discount_total > 0:
+ base_log += (
+ f" → {base_price/100}₽"
+ f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
+ )
+ logger.info(base_log)
if discounted_traffic_price > 0:
message = f" Трафик {traffic_gb} ГБ: {traffic_price/100}₽"
if traffic_discount > 0:
@@ -391,7 +405,7 @@ class SubscriptionService:
try:
from app.config import PERIOD_PRICES
- base_price = PERIOD_PRICES.get(period_days, 0)
+ base_price_original = PERIOD_PRICES.get(period_days, 0)
if user is None:
user = getattr(subscription, "user", None)
@@ -430,6 +444,15 @@ class SubscriptionService:
traffic_discount = traffic_price * traffic_discount_percent // 100
discounted_traffic_price = traffic_price - traffic_discount
+ period_discount_percent = _resolve_discount_percent(
+ user,
+ promo_group,
+ "period",
+ period_days=period_days,
+ )
+ base_discount_total = base_price_original * period_discount_percent // 100
+ base_price = base_price_original - base_discount_total
+
total_price = (
base_price
+ discounted_servers_price
@@ -438,7 +461,13 @@ class SubscriptionService:
)
logger.info(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
- logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽")
+ base_log = f" 📅 Период {period_days} дней: {base_price_original/100}₽"
+ if base_discount_total > 0:
+ base_log += (
+ f" → {base_price/100}₽"
+ f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
+ )
+ logger.info(base_log)
if servers_price > 0:
message = f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {discounted_servers_price/100}₽"
if servers_discount > 0:
@@ -577,7 +606,15 @@ class SubscriptionService:
months_in_period = calculate_months_from_days(period_days)
- base_price = PERIOD_PRICES.get(period_days, 0)
+ base_price_original = PERIOD_PRICES.get(period_days, 0)
+ period_discount_percent = _resolve_discount_percent(
+ user,
+ promo_group,
+ "period",
+ period_days=period_days,
+ )
+ base_discount_total = base_price_original * period_discount_percent // 100
+ base_price = base_price_original - base_discount_total
promo_group = promo_group or (user.promo_group if user else None)
@@ -637,7 +674,13 @@ class SubscriptionService:
total_price = base_price + total_traffic_price + total_servers_price + total_devices_price
logger.info(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
- logger.info(f" Период {period_days} дней: {base_price/100}₽")
+ base_log = f" Период {period_days} дней: {base_price_original/100}₽"
+ if base_discount_total > 0:
+ base_log += (
+ f" → {base_price/100}₽"
+ f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
+ )
+ logger.info(base_log)
if total_traffic_price > 0:
message = (
f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽"
@@ -681,7 +724,7 @@ class SubscriptionService:
months_in_period = calculate_months_from_days(period_days)
- base_price = PERIOD_PRICES.get(period_days, 0)
+ base_price_original = PERIOD_PRICES.get(period_days, 0)
if user is None:
user = getattr(subscription, "user", None)
@@ -723,10 +766,25 @@ class SubscriptionService:
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
total_traffic_price = discounted_traffic_per_month * months_in_period
+ period_discount_percent = _resolve_discount_percent(
+ user,
+ promo_group,
+ "period",
+ period_days=period_days,
+ )
+ base_discount_total = base_price_original * period_discount_percent // 100
+ base_price = base_price_original - base_discount_total
+
total_price = base_price + total_servers_price + total_devices_price + total_traffic_price
logger.info(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
- logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽")
+ base_log = f" 📅 Период {period_days} дней: {base_price_original/100}₽"
+ if base_discount_total > 0:
+ base_log += (
+ f" → {base_price/100}₽"
+ f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
+ )
+ logger.info(base_log)
if total_servers_price > 0:
message = (
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}₽"
diff --git a/app/states.py b/app/states.py
index 2207c448..0073a4d4 100644
--- a/app/states.py
+++ b/app/states.py
@@ -68,11 +68,13 @@ class AdminStates(StatesGroup):
creating_promo_group_traffic_discount = State()
creating_promo_group_server_discount = State()
creating_promo_group_device_discount = State()
+ creating_promo_group_period_discount = State()
editing_promo_group_name = State()
editing_promo_group_traffic_discount = State()
editing_promo_group_server_discount = State()
editing_promo_group_device_discount = State()
+ editing_promo_group_period_discount = State()
editing_squad_price = State()
editing_traffic_price = State()
diff --git a/locales/en.json b/locales/en.json
index a38f1744..419dbe95 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -137,6 +137,7 @@
"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_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}",
"ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.",
@@ -224,13 +225,16 @@
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):",
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):",
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):",
+ "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.",
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.",
+ "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.",
"ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.",
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups",
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):",
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):",
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):",
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):",
+ "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.",
"ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.",
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}",
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.",
diff --git a/locales/ru.json b/locales/ru.json
index eeb76649..e69b9d92 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -15,6 +15,7 @@
"ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы",
"ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}",
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
+ "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}",
"ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.",
@@ -102,13 +103,16 @@
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):",
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):",
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):",
+ "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.",
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.",
+ "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.",
"ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.",
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам",
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):",
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):",
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):",
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):",
+ "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.",
"ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.",
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}",
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.",
diff --git a/migrations/alembic/versions/4b6b0f58c8f9_add_period_discounts_to_promo_groups.py b/migrations/alembic/versions/4b6b0f58c8f9_add_period_discounts_to_promo_groups.py
new file mode 100644
index 00000000..4f3518cd
--- /dev/null
+++ b/migrations/alembic/versions/4b6b0f58c8f9_add_period_discounts_to_promo_groups.py
@@ -0,0 +1,29 @@
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = "4b6b0f58c8f9"
+down_revision: Union[str, None] = "1f5f3a3f5a4d"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ dialect = bind.dialect.name if bind else ""
+
+ op.add_column(
+ "promo_groups",
+ sa.Column("period_discounts", sa.JSON(), nullable=True),
+ )
+
+ if dialect == "postgresql":
+ op.execute("UPDATE promo_groups SET period_discounts = '{}'::jsonb WHERE period_discounts IS NULL")
+ else:
+ op.execute("UPDATE promo_groups SET period_discounts = '{}' WHERE period_discounts IS NULL")
+
+
+def downgrade() -> None:
+ op.drop_column("promo_groups", "period_discounts")