diff --git a/app/bot.py b/app/bot.py index 1be1fd65..51a24080 100644 --- a/app/bot.py +++ b/app/bot.py @@ -31,6 +31,7 @@ from app.handlers.admin import ( statistics as admin_statistics, servers as admin_servers, maintenance as admin_maintenance, + promo_groups as admin_promo_groups, campaigns as admin_campaigns, user_messages as admin_user_messages, updates as admin_updates, @@ -127,6 +128,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_rules.register_handlers(dp) admin_remnawave.register_handlers(dp) admin_statistics.register_handlers(dp) + admin_promo_groups.register_handlers(dp) admin_campaigns.register_handlers(dp) admin_maintenance.register_handlers(dp) admin_user_messages.register_handlers(dp) diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py new file mode 100644 index 00000000..3845f531 --- /dev/null +++ b/app/database/crud/promo_group.py @@ -0,0 +1,145 @@ +import logging +from typing import List, Optional, Tuple + +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import PromoGroup, User + +logger = logging.getLogger(__name__) + + +async def get_promo_groups_with_counts( + db: AsyncSession, +) -> List[Tuple[PromoGroup, int]]: + result = await db.execute( + select(PromoGroup, func.count(User.id)) + .outerjoin(User, User.promo_group_id == PromoGroup.id) + .group_by(PromoGroup.id) + .order_by(PromoGroup.is_default.desc(), PromoGroup.name) + ) + return result.all() + + +async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[PromoGroup]: + return await db.get(PromoGroup, group_id) + + +async def get_default_promo_group(db: AsyncSession) -> Optional[PromoGroup]: + result = await db.execute( + select(PromoGroup).where(PromoGroup.is_default.is_(True)) + ) + return result.scalars().first() + + +async def create_promo_group( + db: AsyncSession, + name: str, + *, + server_discount_percent: int, + traffic_discount_percent: int, + device_discount_percent: int, +) -> PromoGroup: + 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)), + is_default=False, + ) + + db.add(promo_group) + await db.commit() + await db.refresh(promo_group) + + logger.info( + "Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%)", + promo_group.name, + promo_group.server_discount_percent, + promo_group.traffic_discount_percent, + promo_group.device_discount_percent, + ) + + return promo_group + + +async def update_promo_group( + db: AsyncSession, + group: PromoGroup, + *, + name: Optional[str] = None, + server_discount_percent: Optional[int] = None, + traffic_discount_percent: Optional[int] = None, + device_discount_percent: Optional[int] = None, +) -> PromoGroup: + if name is not None: + group.name = name.strip() + if server_discount_percent is not None: + group.server_discount_percent = max(0, min(100, server_discount_percent)) + if traffic_discount_percent is not None: + 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)) + + await db.commit() + await db.refresh(group) + + logger.info( + "Обновлена промогруппа '%s' (id=%s)", + group.name, + group.id, + ) + return group + + +async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool: + if group.is_default: + logger.warning("Попытка удалить базовую промогруппу запрещена") + return False + + default_group = await get_default_promo_group(db) + if not default_group: + logger.error("Не найдена базовая промогруппа для reassignment") + return False + + await db.execute( + update(User) + .where(User.promo_group_id == group.id) + .values(promo_group_id=default_group.id) + ) + await db.delete(group) + await db.commit() + + logger.info( + "Промогруппа '%s' (id=%s) удалена, пользователи переведены в '%s'", + group.name, + group.id, + default_group.name, + ) + return True + + +async def get_promo_group_members( + db: AsyncSession, + group_id: int, + *, + offset: int = 0, + limit: int = 20, +) -> List[User]: + result = await db.execute( + select(User) + .options(selectinload(User.subscription)) + .where(User.promo_group_id == group_id) + .order_by(User.created_at.desc()) + .offset(offset) + .limit(limit) + ) + return result.scalars().all() + + +async def count_promo_group_members(db: AsyncSession, group_id: int) -> int: + result = await db.execute( + select(func.count(User.id)).where(User.promo_group_id == group_id) + ) + return result.scalar_one() diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 60bdb5cc..d9af6233 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -6,8 +6,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.database.models import ( - Subscription, SubscriptionStatus, User, - SubscriptionServer + Subscription, + SubscriptionStatus, + User, + SubscriptionServer, + PromoGroup, ) from app.database.crud.notification import clear_notifications from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months @@ -495,12 +498,32 @@ async def get_servers_monthly_prices( prices.append(price) return prices +def _get_discount_percent( + user: Optional[User], + promo_group: Optional[PromoGroup], + category: str, +) -> int: + if user is not None: + try: + return user.get_promo_discount(category) + except AttributeError: + pass + + if promo_group is not None: + return promo_group.get_discount_percent(category) + + return 0 + + async def calculate_subscription_total_cost( db: AsyncSession, period_days: int, traffic_gb: int, server_squad_ids: List[int], - devices: int + devices: int, + *, + user: Optional[User] = None, + promo_group: Optional[PromoGroup] = None, ) -> Tuple[int, dict]: from app.config import PERIOD_PRICES @@ -508,39 +531,83 @@ async def calculate_subscription_total_cost( base_price = PERIOD_PRICES.get(period_days, 0) + promo_group = promo_group or (user.promo_group if user else None) + traffic_price_per_month = settings.get_traffic_price(traffic_gb) - total_traffic_price = traffic_price_per_month * months_in_period - + traffic_discount_percent = _get_discount_percent(user, promo_group, "traffic") + traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 + discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month + total_traffic_price = discounted_traffic_per_month * months_in_period + total_traffic_discount = traffic_discount_per_month * months_in_period + servers_prices = await get_servers_monthly_prices(db, server_squad_ids) servers_price_per_month = sum(servers_prices) - total_servers_price = servers_price_per_month * months_in_period - + servers_discount_percent = _get_discount_percent(user, promo_group, "servers") + servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100 + discounted_servers_per_month = servers_price_per_month - servers_discount_per_month + total_servers_price = discounted_servers_per_month * months_in_period + total_servers_discount = servers_discount_per_month * months_in_period + additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - total_devices_price = devices_price_per_month * months_in_period - + devices_discount_percent = _get_discount_percent(user, promo_group, "devices") + devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 + discounted_devices_per_month = devices_price_per_month - devices_discount_per_month + total_devices_price = discounted_devices_per_month * months_in_period + total_devices_discount = devices_discount_per_month * months_in_period + total_cost = base_price + total_traffic_price + total_servers_price + total_devices_price - + details = { 'base_price': base_price, 'traffic_price_per_month': traffic_price_per_month, + 'traffic_discount_percent': traffic_discount_percent, + 'traffic_discount_total': total_traffic_discount, 'total_traffic_price': total_traffic_price, 'servers_price_per_month': servers_price_per_month, + 'servers_discount_percent': servers_discount_percent, + 'servers_discount_total': total_servers_discount, 'total_servers_price': total_servers_price, 'devices_price_per_month': devices_price_per_month, + 'devices_discount_percent': devices_discount_percent, + 'devices_discount_total': total_devices_discount, 'total_devices_price': total_devices_price, 'months_in_period': months_in_period, - 'servers_individual_prices': [price * months_in_period for price in servers_prices] + 'servers_individual_prices': [ + (price - (price * servers_discount_percent // 100)) * months_in_period + for price in servers_prices + ] } - + logger.info(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):") logger.info(f" Базовый период: {base_price/100}₽") if total_traffic_price > 0: - logger.info(f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽") + message = ( + f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽" + ) + if total_traffic_discount > 0: + message += ( + f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)" + ) + logger.info(message) if total_servers_price > 0: - logger.info(f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽") + message = ( + f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽" + ) + if total_servers_discount > 0: + message += ( + f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)" + ) + logger.info(message) if total_devices_price > 0: - logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽") + message = ( + f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽" + ) + if total_devices_discount > 0: + message += ( + f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)" + ) + logger.info(message) logger.info(f" ИТОГО: {total_cost/100}₽") return total_cost, details @@ -614,19 +681,33 @@ async def remove_subscription_servers( async def get_subscription_renewal_cost( db: AsyncSession, subscription_id: int, - period_days: int + period_days: int, + *, + user: Optional[User] = None, + promo_group: Optional[PromoGroup] = None, ) -> int: try: from app.config import PERIOD_PRICES - + months_in_period = calculate_months_from_days(period_days) - + base_price = PERIOD_PRICES.get(period_days, 0) - - subscription = await db.get(Subscription, subscription_id) + + result = await db.execute( + select(Subscription) + .options( + selectinload(Subscription.user).selectinload(User.promo_group), + ) + .where(Subscription.id == subscription_id) + ) + subscription = result.scalar_one_or_none() if not subscription: return base_price - + + if user is None: + user = subscription.user + promo_group = promo_group or (user.promo_group if user else None) + servers_info = await get_subscription_servers(db, subscription_id) servers_price_per_month = 0 for server_info in servers_info: @@ -637,26 +718,59 @@ async def get_subscription_renewal_cost( ) current_server_price = result.scalar() or 0 servers_price_per_month += current_server_price - - total_servers_cost = servers_price_per_month * months_in_period - + + servers_discount_percent = _get_discount_percent(user, promo_group, "servers") + servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100 + discounted_servers_per_month = servers_price_per_month - servers_discount_per_month + total_servers_cost = discounted_servers_per_month * months_in_period + total_servers_discount = servers_discount_per_month * months_in_period + traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb) - total_traffic_cost = traffic_price_per_month * months_in_period - + traffic_discount_percent = _get_discount_percent(user, promo_group, "traffic") + traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 + discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month + total_traffic_cost = discounted_traffic_per_month * months_in_period + total_traffic_discount = traffic_discount_per_month * months_in_period + additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - total_devices_cost = devices_price_per_month * months_in_period - + devices_discount_percent = _get_discount_percent(user, promo_group, "devices") + devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 + discounted_devices_per_month = devices_price_per_month - devices_discount_per_month + total_devices_cost = discounted_devices_per_month * months_in_period + total_devices_discount = devices_discount_per_month * months_in_period + total_cost = base_price + total_servers_cost + total_traffic_cost + total_devices_cost - + logger.info(f"💰 Расчет продления подписки {subscription_id} на {period_days} дней ({months_in_period} мес):") logger.info(f" 📅 Период: {base_price/100}₽") if total_servers_cost > 0: - logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}₽") + message = ( + f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}₽" + ) + if total_servers_discount > 0: + message += ( + f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)" + ) + logger.info(message) if total_traffic_cost > 0: - logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}₽") + message = ( + f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}₽" + ) + if total_traffic_discount > 0: + message += ( + f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)" + ) + logger.info(message) if total_devices_cost > 0: - logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}₽") + message = ( + f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}₽" + ) + if total_devices_discount > 0: + message += ( + f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)" + ) + logger.info(message) logger.info(f" 💎 ИТОГО: {total_cost/100}₽") return total_cost @@ -671,27 +785,54 @@ async def calculate_addon_cost_for_remaining_period( subscription: Subscription, additional_traffic_gb: int = 0, additional_devices: int = 0, - additional_server_ids: List[int] = None + additional_server_ids: List[int] = None, + *, + user: Optional[User] = None, + promo_group: Optional[PromoGroup] = None, ) -> int: if additional_server_ids is None: additional_server_ids = [] - + months_to_pay = get_remaining_months(subscription.end_date) - + total_cost = 0 - + + if user is None: + user = getattr(subscription, "user", None) + promo_group = promo_group or (user.promo_group if user else None) + if additional_traffic_gb > 0: traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb) - traffic_total_cost = traffic_price_per_month * months_to_pay + traffic_discount_percent = _get_discount_percent(user, promo_group, "traffic") + traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 + discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month + traffic_total_cost = discounted_traffic_per_month * months_to_pay total_cost += traffic_total_cost - logger.info(f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {months_to_pay} = {traffic_total_cost/100}₽") - + message = ( + f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {months_to_pay} = {traffic_total_cost/100}₽" + ) + if traffic_discount_per_month > 0: + message += ( + f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_to_pay/100}₽)" + ) + logger.info(message) + if additional_devices > 0: devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_total_cost = devices_price_per_month * months_to_pay + devices_discount_percent = _get_discount_percent(user, promo_group, "devices") + devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 + discounted_devices_per_month = devices_price_per_month - devices_discount_per_month + devices_total_cost = discounted_devices_per_month * months_to_pay total_cost += devices_total_cost - logger.info(f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес × {months_to_pay} = {devices_total_cost/100}₽") - + message = ( + f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес × {months_to_pay} = {devices_total_cost/100}₽" + ) + if devices_discount_per_month > 0: + message += ( + f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_to_pay/100}₽)" + ) + logger.info(message) + if additional_server_ids: from app.database.models import ServerSquad for server_id in additional_server_ids: @@ -702,9 +843,19 @@ async def calculate_addon_cost_for_remaining_period( server_data = result.first() if server_data: server_price_per_month, server_name = server_data - server_total_cost = server_price_per_month * months_to_pay + servers_discount_percent = _get_discount_percent(user, promo_group, "servers") + server_discount_per_month = server_price_per_month * servers_discount_percent // 100 + discounted_server_per_month = server_price_per_month - server_discount_per_month + server_total_cost = discounted_server_per_month * months_to_pay total_cost += server_total_cost - logger.info(f"Сервер {server_name}: {server_price_per_month/100}₽/мес × {months_to_pay} = {server_total_cost/100}₽") + message = ( + f"Сервер {server_name}: {server_price_per_month/100}₽/мес × {months_to_pay} = {server_total_cost/100}₽" + ) + if server_discount_per_month > 0: + message += ( + f" (скидка {servers_discount_percent}%: -{server_discount_per_month * months_to_pay/100}₽)" + ) + logger.info(message) logger.info(f"💰 Итого доплата за {months_to_pay} мес: {total_cost/100}₽") return total_cost diff --git a/app/database/crud/user.py b/app/database/crud/user.py index ff25714b..1abb0055 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -7,8 +7,9 @@ from sqlalchemy import select, and_, or_, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database.models import User, UserStatus, Subscription, Transaction +from app.database.models import User, UserStatus, Subscription, Transaction, PromoGroup from app.config import settings +from app.database.crud.promo_group import get_default_promo_group logger = logging.getLogger(__name__) @@ -22,7 +23,10 @@ def generate_referral_code() -> str: async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]: result = await db.execute( select(User) - .options(selectinload(User.subscription)) + .options( + selectinload(User.subscription), + selectinload(User.promo_group), + ) .where(User.id == user_id) ) user = result.scalar_one_or_none() @@ -36,7 +40,10 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]: async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optional[User]: result = await db.execute( select(User) - .options(selectinload(User.subscription)) + .options( + selectinload(User.subscription), + selectinload(User.promo_group), + ) .where(User.telegram_id == telegram_id) ) user = result.scalar_one_or_none() @@ -49,7 +56,9 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]: result = await db.execute( - select(User).where(User.referral_code == referral_code) + select(User) + .options(selectinload(User.promo_group)) + .where(User.referral_code == referral_code) ) return result.scalar_one_or_none() @@ -82,6 +91,20 @@ async def create_user( from app.utils.user_utils import generate_unique_referral_code referral_code = await generate_unique_referral_code(db, telegram_id) + default_group = await get_default_promo_group(db) + if not default_group: + default_group = PromoGroup( + name="Базовый юзер", + server_discount_percent=0, + traffic_discount_percent=0, + device_discount_percent=0, + is_default=True, + ) + db.add(default_group) + await db.flush() + + promo_group_id = default_group.id + user = User( telegram_id=telegram_id, username=username, @@ -92,15 +115,19 @@ async def create_user( referral_code=referral_code, balance_kopeks=0, has_had_paid_subscription=False, - has_made_first_topup=False + has_made_first_topup=False, + promo_group_id=promo_group_id, ) db.add(user) await db.commit() await db.refresh(user) + if default_group: + user.promo_group = default_group + logger.info(f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}") - + return user @@ -281,7 +308,10 @@ async def get_users_count( async def get_referrals(db: AsyncSession, user_id: int) -> List[User]: result = await db.execute( select(User) - .options(selectinload(User.subscription)) + .options( + selectinload(User.subscription), + selectinload(User.promo_group), + ) .where(User.referred_by_id == user_id) .order_by(User.created_at.desc()) ) @@ -293,7 +323,10 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]: result = await db.execute( select(User) - .options(selectinload(User.subscription)) + .options( + selectinload(User.subscription), + selectinload(User.promo_group), + ) .where( and_( User.last_activity < threshold_date, diff --git a/app/database/models.py b/app/database/models.py index 285eeb08..c12aaae2 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -155,6 +155,29 @@ class CryptoBotPayment(Base): return f"" +class PromoGroup(Base): + __tablename__ = "promo_groups" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), unique=True, nullable=False) + 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) + 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_discount_percent(self, category: str) -> int: + mapping = { + "servers": self.server_discount_percent, + "traffic": self.traffic_discount_percent, + "devices": self.device_discount_percent, + } + return max(0, min(100, mapping.get(category, 0))) + + class User(Base): __tablename__ = "users" @@ -185,6 +208,8 @@ class User(Base): vless_uuid = Column(String(255), nullable=True) ss_password = Column(String(255), nullable=True) has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True) + promo_group = relationship("PromoGroup", back_populates="users") @property def balance_rubles(self) -> float: @@ -194,6 +219,11 @@ class User(Base): def full_name(self) -> str: parts = [self.first_name, self.last_name] return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}" + + def get_promo_discount(self, category: str) -> int: + if not self.promo_group: + return 0 + return self.promo_group.get_discount_percent(category) def add_balance(self, kopeks: int) -> None: self.balance_kopeks += kopeks diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py new file mode 100644 index 00000000..2c33c518 --- /dev/null +++ b/app/handlers/admin/promo_groups.py @@ -0,0 +1,613 @@ +import logging +from typing import Optional + +from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.promo_group import ( + get_promo_groups_with_counts, + get_promo_group_by_id, + create_promo_group, + update_promo_group, + delete_promo_group, + get_promo_group_members, + count_promo_group_members, +) +from app.database.models import PromoGroup +from app.localization.texts import get_texts +from app.states import AdminStates +from app.utils.decorators import admin_required, error_handler +from app.keyboards.admin import ( + get_admin_pagination_keyboard, + get_confirmation_keyboard, +) + +logger = logging.getLogger(__name__) + + +def _format_discount_line(texts, group) -> str: + return texts.t( + "ADMIN_PROMO_GROUPS_DISCOUNTS", + "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", + ).format( + servers=group.server_discount_percent, + traffic=group.traffic_discount_percent, + devices=group.device_discount_percent, + ) + + +@admin_required +@error_handler +async def show_promo_groups_menu( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, +): + texts = get_texts(db_user.language) + groups = await get_promo_groups_with_counts(db) + + total_members = sum(count for _, count in groups) + header = texts.t("ADMIN_PROMO_GROUPS_TITLE", "💳 Промогруппы") + + if groups: + summary = texts.t( + "ADMIN_PROMO_GROUPS_SUMMARY", + "Всего групп: {count}\nВсего участников: {members}", + ).format(count=len(groups), members=total_members) + lines = [header, "", summary, ""] + + keyboard_rows = [] + for group, member_count in groups: + default_suffix = ( + texts.t("ADMIN_PROMO_GROUPS_DEFAULT_LABEL", " (базовая)") + 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), + "", + ] + ) + keyboard_rows.append([ + types.InlineKeyboardButton( + text=f"{'⭐' if group.is_default else '🎯'} {group.name}", + callback_data=f"promo_group_manage_{group.id}", + ) + ]) + else: + lines = [header, "", texts.t("ADMIN_PROMO_GROUPS_EMPTY", "Промогруппы не найдены.")] + keyboard_rows = [] + + keyboard_rows.append( + [types.InlineKeyboardButton(text="➕ Создать", callback_data="admin_promo_group_create")] + ) + keyboard_rows.append( + [types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo")] + ) + + await callback.message.edit_text( + "\n".join(line for line in lines if line is not None), + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), + parse_mode="HTML", + ) + await callback.answer() + + +async def _get_group_or_alert( + callback: types.CallbackQuery, + db: AsyncSession, +) -> Optional[PromoGroup]: + group_id = int(callback.data.split("_")[-1]) + group = await get_promo_group_by_id(db, group_id) + if not group: + await callback.answer("❌ Промогруппа не найдена", show_alert=True) + return None + return group + + +@admin_required +@error_handler +async def show_promo_group_details( + 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) + member_count = await count_promo_group_members(db, group.id) + + default_note = ( + "\n" + 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, + ] + ) + + keyboard_rows = [] + if member_count > 0: + keyboard_rows.append( + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_PROMO_GROUP_MEMBERS_BUTTON", "👥 Участники"), + callback_data=f"promo_group_members_{group.id}_page_1", + ) + ] + ) + + keyboard_rows.append( + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_PROMO_GROUP_EDIT_BUTTON", "✏️ Изменить"), + callback_data=f"promo_group_edit_{group.id}", + ) + ] + ) + + if not group.is_default: + keyboard_rows.append( + [ + types.InlineKeyboardButton( + text=texts.t("ADMIN_PROMO_GROUP_DELETE_BUTTON", "🗑️ Удалить"), + callback_data=f"promo_group_delete_{group.id}", + ) + ] + ) + + keyboard_rows.append( + [types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_groups")] + ) + + await callback.message.edit_text( + text.strip(), + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), + parse_mode="HTML", + ) + await callback.answer() + + +def _validate_percent(value: str) -> int: + percent = int(value) + if percent < 0 or percent > 100: + raise ValueError + return percent + + +async def _prompt_for_discount( + message: types.Message, + state: FSMContext, + prompt_key: str, + default_text: str, +): + data = await state.get_data() + texts = get_texts(data.get("language", "ru")) + await message.answer(texts.t(prompt_key, default_text)) + + +@admin_required +@error_handler +async def start_create_promo_group( + callback: types.CallbackQuery, + db_user, + state: FSMContext, + db: AsyncSession, +): + texts = get_texts(db_user.language) + await state.set_state(AdminStates.creating_promo_group_name) + await state.update_data(language=db_user.language) + await callback.message.edit_text( + texts.t("ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT", "Введите название новой промогруппы:"), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_groups")] + ] + ), + ) + await callback.answer() + + +async def process_create_group_name(message: types.Message, state: FSMContext): + name = message.text.strip() + if not name: + texts = get_texts((await state.get_data()).get("language", "ru")) + await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_NAME", "Название не может быть пустым.")) + return + + await state.update_data(new_group_name=name) + await state.set_state(AdminStates.creating_promo_group_traffic_discount) + await _prompt_for_discount( + message, + state, + "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT", + "Введите скидку на трафик (0-100):", + ) + + +async def process_create_group_traffic(message: types.Message, state: FSMContext): + texts = get_texts((await state.get_data()).get("language", "ru")) + try: + value = _validate_percent(message.text) + except (ValueError, TypeError): + await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) + return + + await state.update_data(new_group_traffic=value) + await state.set_state(AdminStates.creating_promo_group_server_discount) + await _prompt_for_discount( + message, + state, + "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT", + "Введите скидку на серверы (0-100):", + ) + + +async def process_create_group_servers(message: types.Message, state: FSMContext): + texts = get_texts((await state.get_data()).get("language", "ru")) + try: + value = _validate_percent(message.text) + except (ValueError, TypeError): + await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) + return + + await state.update_data(new_group_servers=value) + await state.set_state(AdminStates.creating_promo_group_device_discount) + await _prompt_for_discount( + message, + state, + "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT", + "Введите скидку на устройства (0-100):", + ) + + +@admin_required +@error_handler +async def process_create_group_devices( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + devices_discount = _validate_percent(message.text) + except (ValueError, TypeError): + await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) + 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, + ) + except Exception as e: + logger.error(f"Не удалось создать промогруппу: {e}") + await message.answer(texts.ERROR) + await state.clear() + return + + await state.clear() + await message.answer( + texts.t("ADMIN_PROMO_GROUP_CREATED", "Промогруппа «{name}» создана.").format(name=group.name) + ) + + +@admin_required +@error_handler +async def start_edit_promo_group( + callback: types.CallbackQuery, + db_user, + state: FSMContext, + db: AsyncSession, +): + group = await _get_group_or_alert(callback, db) + if not group: + return + + texts = get_texts(db_user.language) + await state.set_state(AdminStates.editing_promo_group_name) + await state.update_data(edit_group_id=group.id, language=db_user.language) + + await callback.message.edit_text( + texts.t( + "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT", + "Введите новое название промогруппы (текущее: {name}):", + ).format(name=group.name), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_group_manage_{group.id}")] + ] + ), + ) + await callback.answer() + + +async def process_edit_group_name(message: types.Message, state: FSMContext): + name = message.text.strip() + if not name: + texts = get_texts((await state.get_data()).get("language", "ru")) + await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_NAME", "Название не может быть пустым.")) + return + + await state.update_data(edit_group_name=name) + await state.set_state(AdminStates.editing_promo_group_traffic_discount) + await _prompt_for_discount( + message, + state, + "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT", + "Введите новую скидку на трафик (0-100):", + ) + + +async def process_edit_group_traffic(message: types.Message, state: FSMContext): + texts = get_texts((await state.get_data()).get("language", "ru")) + try: + value = _validate_percent(message.text) + except (ValueError, TypeError): + await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) + return + + await state.update_data(edit_group_traffic=value) + await state.set_state(AdminStates.editing_promo_group_server_discount) + await _prompt_for_discount( + message, + state, + "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT", + "Введите новую скидку на серверы (0-100):", + ) + + +async def process_edit_group_servers(message: types.Message, state: FSMContext): + texts = get_texts((await state.get_data()).get("language", "ru")) + try: + value = _validate_percent(message.text) + except (ValueError, TypeError): + await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) + return + + await state.update_data(edit_group_servers=value) + await state.set_state(AdminStates.editing_promo_group_device_discount) + await _prompt_for_discount( + message, + state, + "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT", + "Введите новую скидку на устройства (0-100):", + ) + + +@admin_required +@error_handler +async def process_edit_group_devices( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + devices_discount = _validate_percent(message.text) + except (ValueError, TypeError): + await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) + 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, + ) + + await state.clear() + await message.answer( + texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name) + ) + + +@admin_required +@error_handler +async def show_promo_group_members( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, +): + parts = callback.data.split("_") + group_id = int(parts[3]) + page = int(parts[-1]) + limit = 10 + offset = (page - 1) * limit + + group = await get_promo_group_by_id(db, group_id) + if not group: + await callback.answer("❌ Промогруппа не найдена", show_alert=True) + return + + texts = get_texts(db_user.language) + members = await get_promo_group_members(db, group_id, offset=offset, limit=limit) + total_members = await count_promo_group_members(db, group_id) + total_pages = max(1, (total_members + limit - 1) // limit) + + title = texts.t( + "ADMIN_PROMO_GROUP_MEMBERS_TITLE", + "👥 Участники группы {name}", + ).format(name=group.name) + + if not members: + body = texts.t("ADMIN_PROMO_GROUP_MEMBERS_EMPTY", "В этой группе пока нет участников.") + else: + lines = [] + for index, user in enumerate(members, start=offset + 1): + username = f"@{user.username}" if user.username else "—" + lines.append( + f"{index}. {user.full_name} (ID {user.id}, {username}, TG {user.telegram_id})" + ) + body = "\n".join(lines) + + keyboard = [] + if total_pages > 1: + pagination = get_admin_pagination_keyboard( + page, + total_pages, + f"promo_group_members_{group_id}", + f"promo_group_manage_{group_id}", + db_user.language, + ) + keyboard.extend(pagination.inline_keyboard) + + keyboard.append( + [types.InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_group_manage_{group_id}")] + ) + + await callback.message.edit_text( + f"{title}\n\n{body}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + ) + await callback.answer() + + +@admin_required +@error_handler +async def request_delete_promo_group( + 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) + + if group.is_default: + await callback.answer( + texts.t("ADMIN_PROMO_GROUP_DELETE_FORBIDDEN", "Базовую промогруппу нельзя удалить."), + show_alert=True, + ) + return + + confirm_text = texts.t( + "ADMIN_PROMO_GROUP_DELETE_CONFIRM", + "Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.", + ).format(name=group.name) + + await callback.message.edit_text( + confirm_text, + reply_markup=get_confirmation_keyboard( + confirm_action=f"promo_group_delete_confirm_{group.id}", + cancel_action=f"promo_group_manage_{group.id}", + language=db_user.language, + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def delete_promo_group_confirmed( + 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) + + success = await delete_promo_group(db, group) + if not success: + await callback.answer( + texts.t("ADMIN_PROMO_GROUP_DELETE_FORBIDDEN", "Базовую промогруппу нельзя удалить."), + show_alert=True, + ) + return + + await callback.message.edit_text( + texts.t("ADMIN_PROMO_GROUP_DELETED", "Промогруппа «{name}» удалена.").format(name=group.name), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_groups")] + ] + ), + ) + 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_")) + dp.callback_query.register(start_create_promo_group, F.data == "admin_promo_group_create") + dp.callback_query.register(start_edit_promo_group, F.data.startswith("promo_group_edit_")) + dp.callback_query.register(request_delete_promo_group, F.data.startswith("promo_group_delete_")) + dp.callback_query.register( + delete_promo_group_confirmed, + F.data.startswith("promo_group_delete_confirm_"), + ) + dp.callback_query.register( + show_promo_group_members, + F.data.regexp(r"^promo_group_members_\d+_page_\d+$"), + ) + + dp.message.register(process_create_group_name, AdminStates.creating_promo_group_name) + dp.message.register( + process_create_group_traffic, + AdminStates.creating_promo_group_traffic_discount, + ) + dp.message.register( + process_create_group_servers, + AdminStates.creating_promo_group_server_discount, + ) + dp.message.register( + process_create_group_devices, + AdminStates.creating_promo_group_device_discount, + ) + + dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name) + dp.message.register( + process_edit_group_traffic, + AdminStates.editing_promo_group_traffic_discount, + ) + dp.message.register( + process_edit_group_servers, + AdminStates.editing_promo_group_server_discount, + ) + dp.message.register( + process_edit_group_devices, + AdminStates.editing_promo_group_device_discount, + ) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 3e263b7b..437500bc 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -66,6 +66,25 @@ logger = logging.getLogger(__name__) TRAFFIC_PRICES = get_traffic_prices() +def _apply_discount_to_monthly_component( + amount_per_month: int, + percent: int, + months: int, +) -> Dict[str, int]: + from app.utils.pricing_utils import apply_percentage_discount + + discounted_per_month, discount_per_month = apply_percentage_discount(amount_per_month, percent) + + return { + "original_per_month": amount_per_month, + "discounted_per_month": discounted_per_month, + "discount_percent": max(0, min(100, percent)), + "discount_per_month": discount_per_month, + "total": discounted_per_month * months, + "discount_total": discount_per_month * months, + } + + async def _prepare_subscription_summary( db_user: User, data: Dict[str, Any], @@ -75,6 +94,7 @@ async def _prepare_subscription_summary( calculate_months_from_days, format_period_description, validate_pricing_calculation, + apply_percentage_discount, ) summary_data = dict(data) @@ -94,11 +114,18 @@ async def _prepare_subscription_summary( traffic_price_per_month = settings.get_traffic_price(traffic_gb) final_traffic_gb = traffic_gb - total_traffic_price = traffic_price_per_month * months_in_period + traffic_discount_percent = db_user.get_promo_discount("traffic") + traffic_component = _apply_discount_to_monthly_component( + traffic_price_per_month, + traffic_discount_percent, + months_in_period, + ) + total_traffic_price = traffic_component["total"] countries_price_per_month = 0 selected_countries_names: List[str] = [] selected_server_prices: List[int] = [] + server_monthly_prices: List[int] = [] selected_country_ids = set(summary_data.get('countries', [])) for country in countries: @@ -106,25 +133,78 @@ async def _prepare_subscription_summary( server_price_per_month = country['price_kopeks'] countries_price_per_month += server_price_per_month selected_countries_names.append(country['name']) - selected_server_prices.append(server_price_per_month * months_in_period) + server_monthly_prices.append(server_price_per_month) - total_countries_price = countries_price_per_month * months_in_period + servers_discount_percent = db_user.get_promo_discount("servers") + total_countries_price = 0 + total_servers_discount = 0 + discounted_servers_price_per_month = 0 + + for server_price_per_month in server_monthly_prices: + discounted_per_month, discount_per_month = apply_percentage_discount( + server_price_per_month, + servers_discount_percent, + ) + total_price_for_server = discounted_per_month * months_in_period + total_discount_for_server = discount_per_month * months_in_period + + discounted_servers_price_per_month += discounted_per_month + total_countries_price += total_price_for_server + total_servers_discount += total_discount_for_server + selected_server_prices.append(total_price_for_server) devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT) additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - total_devices_price = devices_price_per_month * months_in_period + devices_discount_percent = db_user.get_promo_discount("devices") + devices_component = _apply_discount_to_monthly_component( + devices_price_per_month, + devices_discount_percent, + months_in_period, + ) + total_devices_price = devices_component["total"] total_price = base_price + total_traffic_price + total_countries_price + total_devices_price - monthly_additions = countries_price_per_month + devices_price_per_month + traffic_price_per_month - is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, total_price) + discounted_monthly_additions = ( + traffic_component["discounted_per_month"] + + discounted_servers_price_per_month + + devices_component["discounted_per_month"] + ) + + is_valid = validate_pricing_calculation( + base_price, + discounted_monthly_additions, + months_in_period, + total_price, + ) if not is_valid: raise ValueError("Subscription price calculation validation failed") summary_data['total_price'] = total_price 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['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"] + summary_data['traffic_discount_total'] = traffic_component["discount_total"] + summary_data['traffic_discounted_price_per_month'] = traffic_component["discounted_per_month"] + summary_data['total_traffic_price'] = total_traffic_price + summary_data['servers_price_per_month'] = countries_price_per_month + summary_data['countries_price_per_month'] = countries_price_per_month + summary_data['servers_discount_percent'] = servers_discount_percent + summary_data['servers_discount_total'] = total_servers_discount + summary_data['servers_discounted_price_per_month'] = discounted_servers_price_per_month + summary_data['total_servers_price'] = total_countries_price + summary_data['total_countries_price'] = total_countries_price + summary_data['devices_price_per_month'] = devices_price_per_month + summary_data['devices_discount_percent'] = devices_component["discount_percent"] + summary_data['devices_discount_total'] = devices_component["discount_total"] + summary_data['devices_discounted_price_per_month'] = devices_component["discounted_per_month"] + summary_data['total_devices_price'] = total_devices_price + summary_data['discounted_monthly_additions'] = discounted_monthly_additions if settings.is_traffic_fixed(): if final_traffic_gb == 0: @@ -140,17 +220,38 @@ async def _prepare_subscription_summary( details_lines = [f"- Базовый период: {texts.format_price(base_price)}"] if total_traffic_price > 0: - details_lines.append( - f"- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_traffic_price)}" + traffic_line = ( + f"- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period}" + f" = {texts.format_price(total_traffic_price)}" ) + if traffic_component["discount_total"] > 0: + traffic_line += ( + f" (скидка {traffic_component['discount_percent']}%:" + f" -{texts.format_price(traffic_component['discount_total'])})" + ) + details_lines.append(traffic_line) if total_countries_price > 0: - details_lines.append( - f"- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_countries_price)}" + servers_line = ( + f"- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period}" + f" = {texts.format_price(total_countries_price)}" ) + if total_servers_discount > 0: + servers_line += ( + f" (скидка {servers_discount_percent}%:" + f" -{texts.format_price(total_servers_discount)})" + ) + details_lines.append(servers_line) if total_devices_price > 0: - details_lines.append( - f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_devices_price)}" + devices_line = ( + f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period}" + f" = {texts.format_price(total_devices_price)}" ) + if devices_component["discount_total"] > 0: + devices_line += ( + f" (скидка {devices_component['discount_percent']}%:" + f" -{texts.format_price(devices_component['discount_total'])})" + ) + details_lines.append(devices_line) details_text = "\n".join(details_lines) @@ -1601,15 +1702,21 @@ async def handle_extend_subscription( servers_price_per_month, _ = await subscription_service.get_countries_price_by_uuids( subscription.connected_squads, db ) - total_servers_price = servers_price_per_month * months_in_period - + servers_discount_percent = db_user.get_promo_discount("servers") + servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100 + total_servers_price = (servers_price_per_month - servers_discount_per_month) * months_in_period + additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - total_devices_price = devices_price_per_month * months_in_period - + devices_discount_percent = db_user.get_promo_discount("devices") + devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 + total_devices_price = (devices_price_per_month - devices_discount_per_month) * months_in_period + traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb) - total_traffic_price = traffic_price_per_month * months_in_period - + traffic_discount_percent = db_user.get_promo_discount("traffic") + traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 + total_traffic_price = (traffic_price_per_month - traffic_discount_per_month) * months_in_period + price = base_price + total_servers_price + total_devices_price + total_traffic_price renewal_prices[days] = price @@ -1783,62 +1890,112 @@ async def confirm_extend_subscription( ): from app.utils.pricing_utils import calculate_months_from_days, validate_pricing_calculation from app.services.admin_notification_service import AdminNotificationService - + days = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription - + if not subscription: await callback.answer("⚠ У вас нет активной подписки", show_alert=True) return - + months_in_period = calculate_months_from_days(days) - old_end_date = subscription.end_date - + server_uuid_prices: Dict[str, int] = {} + try: from app.config import PERIOD_PRICES - + base_price = PERIOD_PRICES.get(days, 0) - + subscription_service = SubscriptionService() - servers_price_per_month, _ = await subscription_service.get_countries_price_by_uuids( + servers_price_per_month, per_server_monthly_prices = await subscription_service.get_countries_price_by_uuids( subscription.connected_squads, db ) - total_servers_price = servers_price_per_month * months_in_period - + servers_discount_percent = db_user.get_promo_discount("servers") + total_servers_price = 0 + total_servers_discount = 0 + + for squad_uuid, server_monthly_price in zip(subscription.connected_squads, per_server_monthly_prices): + discount_per_month = server_monthly_price * servers_discount_percent // 100 + discounted_per_month = server_monthly_price - discount_per_month + total_servers_price += discounted_per_month * months_in_period + total_servers_discount += discount_per_month * months_in_period + server_uuid_prices[squad_uuid] = discounted_per_month * months_in_period + + discounted_servers_price_per_month = servers_price_per_month - ( + servers_price_per_month * servers_discount_percent // 100 + ) + additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - total_devices_price = devices_price_per_month * months_in_period - + devices_discount_percent = db_user.get_promo_discount("devices") + devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 + discounted_devices_price_per_month = devices_price_per_month - devices_discount_per_month + total_devices_price = discounted_devices_price_per_month * months_in_period + traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb) - total_traffic_price = traffic_price_per_month * months_in_period - + traffic_discount_percent = db_user.get_promo_discount("traffic") + traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 + discounted_traffic_price_per_month = traffic_price_per_month - traffic_discount_per_month + total_traffic_price = discounted_traffic_price_per_month * months_in_period + price = base_price + total_servers_price + total_devices_price + total_traffic_price - - monthly_additions = servers_price_per_month + devices_price_per_month + traffic_price_per_month + + monthly_additions = ( + discounted_servers_price_per_month + + discounted_devices_price_per_month + + discounted_traffic_price_per_month + ) is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, price) - + if not is_valid: logger.error(f"Ошибка в расчете цены продления для пользователя {db_user.telegram_id}") await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True) return - + logger.info(f"💰 Расчет продления подписки {subscription.id} на {days} дней ({months_in_period} мес):") logger.info(f" 📅 Период {days} дней: {base_price/100}₽") if total_servers_price > 0: - logger.info(f" 🌐 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽") + logger.info( + f" 🌐 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period}" + f" = {total_servers_price/100}₽" + + ( + f" (скидка {servers_discount_percent}%:" + f" -{total_servers_discount/100}₽)" + if total_servers_discount > 0 + else "" + ) + ) if total_devices_price > 0: - logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽") + logger.info( + f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period}" + f" = {total_devices_price/100}₽" + + ( + f" (скидка {devices_discount_percent}%:" + f" -{devices_discount_per_month * months_in_period/100}₽)" + if devices_discount_percent > 0 and devices_discount_per_month > 0 + else "" + ) + ) if total_traffic_price > 0: - logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽") + logger.info( + f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period}" + f" = {total_traffic_price/100}₽" + + ( + f" (скидка {traffic_discount_percent}%:" + f" -{traffic_discount_per_month * months_in_period/100}₽)" + if traffic_discount_percent > 0 and traffic_discount_per_month > 0 + else "" + ) + ) logger.info(f" 💎 ИТОГО: {price/100}₽") - + except Exception as e: logger.error(f"⚠ ОШИБКА РАСЧЕТА ЦЕНЫ: {e}") await callback.answer("⚠ Ошибка расчета стоимости", show_alert=True) return - + if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks await callback.message.edit_text( @@ -1847,48 +2004,59 @@ async def confirm_extend_subscription( ) await callback.answer() return - + try: success = await subtract_user_balance( db, db_user, price, f"Продление подписки на {days} дней" ) - + if not success: await callback.answer("⚠ Ошибка списания средств", show_alert=True) return - + current_time = datetime.utcnow() - + if subscription.end_date > current_time: subscription.end_date = subscription.end_date + timedelta(days=days) else: subscription.end_date = current_time + timedelta(days=days) - + subscription.status = SubscriptionStatus.ACTIVE.value subscription.updated_at = current_time - + await db.commit() await db.refresh(subscription) await db.refresh(db_user) - + from app.database.crud.server_squad import get_server_ids_by_uuids from app.database.crud.subscription import add_subscription_servers - + server_ids = await get_server_ids_by_uuids(db, subscription.connected_squads) if server_ids: - server_prices_for_period = [total_servers_price // len(server_ids)] * len(server_ids) + from sqlalchemy import select + from app.database.models import ServerSquad + + result = await db.execute( + select(ServerSquad.id, ServerSquad.squad_uuid).where(ServerSquad.id.in_(server_ids)) + ) + id_to_uuid = {row.id: row.squad_uuid for row in result} + default_price = total_servers_price // len(server_ids) if server_ids else 0 + server_prices_for_period = [ + server_uuid_prices.get(id_to_uuid.get(server_id, ""), default_price) + for server_id in server_ids + ] await add_subscription_servers(db, subscription, server_ids, server_prices_for_period) - + try: remnawave_result = await subscription_service.update_remnawave_user(db, subscription) if remnawave_result: - logger.info(f"✅ RemnaWave обновлен успешно") + logger.info("✅ RemnaWave обновлен успешно") else: - logger.error(f"⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE") + logger.error("⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE") except Exception as e: logger.error(f"⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE: {e}") - + transaction = await create_transaction( db=db, user_id=db_user.id, @@ -1896,7 +2064,7 @@ async def confirm_extend_subscription( amount_kopeks=price, description=f"Продление подписки на {days} дней ({months_in_period} мес)" ) - + try: notification_service = AdminNotificationService(callback.bot) await notification_service.send_subscription_extension_notification( @@ -1904,27 +2072,27 @@ async def confirm_extend_subscription( ) except Exception as e: logger.error(f"Ошибка отправки уведомления о продлении: {e}") - + await callback.message.edit_text( - f"✅ Подписка успешно продлена!\n\n" - f"⏰ Добавлено: {days} дней\n" - f"Действует до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')}\n\n" + f"✅ Подписка успешно продлена!\n\n", + f"⏰ Добавлено: {days} дней\n", + f"Действует до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')}\n\n", f"💰 Списано: {texts.format_price(price)}", reply_markup=get_back_keyboard(db_user.language) ) - + logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {days} дней за {price/100}₽") - + except Exception as e: logger.error(f"⚠ КРИТИЧЕСКАЯ ОШИБКА ПРОДЛЕНИЯ: {e}") import traceback logger.error(f"TRACEBACK: {traceback.format_exc()}") - + await callback.message.edit_text( "⚠ Произошла ошибка при продлении подписки. Обратитесь в поддержку.", reply_markup=get_back_keyboard(db_user.language) ) - + await callback.answer() @@ -2365,38 +2533,123 @@ async def confirm_purchase( countries = await _get_available_countries() - months_in_period = calculate_months_from_days(data['period_days']) - - base_price = PERIOD_PRICES[data['period_days']] - - countries_price_per_month = 0 - server_prices = [] - for country in countries: - if country['uuid'] in data['countries']: - server_price_per_month = country['price_kopeks'] - server_price_total = server_price_per_month * months_in_period - countries_price_per_month += server_price_per_month - server_prices.append(server_price_total) - - total_countries_price = countries_price_per_month * months_in_period - - additional_devices = max(0, data['devices'] - settings.DEFAULT_DEVICE_LIMIT) - devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - total_devices_price = devices_price_per_month * months_in_period - - if settings.is_traffic_fixed(): - traffic_price_per_month = settings.get_traffic_price(settings.get_fixed_traffic_limit()) - final_traffic_gb = settings.get_fixed_traffic_limit() + months_in_period = data.get( + 'months_in_period', calculate_months_from_days(data['period_days']) + ) + + base_price = data.get('base_price', PERIOD_PRICES[data['period_days']]) + server_prices = data.get('server_prices_for_period', []) + + if not server_prices: + countries_price_per_month = 0 + per_month_prices: List[int] = [] + for country in countries: + if country['uuid'] in data['countries']: + server_price_per_month = country['price_kopeks'] + countries_price_per_month += server_price_per_month + per_month_prices.append(server_price_per_month) + + servers_discount_percent = db_user.get_promo_discount("servers") + total_servers_price = 0 + total_servers_discount = 0 + discounted_servers_price_per_month = 0 + server_prices = [] + + from app.utils.pricing_utils import apply_percentage_discount + + for server_price_per_month in per_month_prices: + discounted_per_month, discount_per_month = apply_percentage_discount( + server_price_per_month, + servers_discount_percent, + ) + total_price_for_server = discounted_per_month * months_in_period + total_discount_for_server = discount_per_month * months_in_period + + discounted_servers_price_per_month += discounted_per_month + total_servers_price += total_price_for_server + total_servers_discount += total_discount_for_server + server_prices.append(total_price_for_server) + + total_countries_price = total_servers_price else: - traffic_price_per_month = settings.get_traffic_price(data['traffic_gb']) - final_traffic_gb = data['traffic_gb'] - - total_traffic_price = traffic_price_per_month * months_in_period - - final_price = base_price + total_traffic_price + total_countries_price + total_devices_price - - monthly_additions = countries_price_per_month + devices_price_per_month + traffic_price_per_month - is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, final_price) + total_countries_price = data.get('total_servers_price', sum(server_prices)) + countries_price_per_month = data.get('servers_price_per_month', 0) + discounted_servers_price_per_month = data.get('servers_discounted_price_per_month', countries_price_per_month) + total_servers_discount = data.get('servers_discount_total', 0) + servers_discount_percent = data.get('servers_discount_percent', 0) + + additional_devices = max(0, data['devices'] - settings.DEFAULT_DEVICE_LIMIT) + devices_price_per_month = data.get( + 'devices_price_per_month', additional_devices * settings.PRICE_PER_DEVICE + ) + if 'devices_discount_percent' in data: + devices_discount_percent = data.get('devices_discount_percent', 0) + discounted_devices_price_per_month = data.get( + 'devices_discounted_price_per_month', devices_price_per_month + ) + devices_discount_total = data.get('devices_discount_total', 0) + total_devices_price = data.get( + 'total_devices_price', discounted_devices_price_per_month * months_in_period + ) + else: + devices_discount_percent = db_user.get_promo_discount("devices") + from app.utils.pricing_utils import apply_percentage_discount + + discounted_devices_price_per_month, discount_per_month = apply_percentage_discount( + devices_price_per_month, + devices_discount_percent, + ) + devices_discount_total = discount_per_month * months_in_period + total_devices_price = discounted_devices_price_per_month * months_in_period + + if settings.is_traffic_fixed(): + final_traffic_gb = settings.get_fixed_traffic_limit() + traffic_price_per_month = data.get( + 'traffic_price_per_month', settings.get_traffic_price(final_traffic_gb) + ) + else: + final_traffic_gb = data.get('final_traffic_gb', data.get('traffic_gb')) + traffic_price_per_month = data.get( + 'traffic_price_per_month', settings.get_traffic_price(data['traffic_gb']) + ) + + if 'traffic_discount_percent' in data: + traffic_discount_percent = data.get('traffic_discount_percent', 0) + discounted_traffic_price_per_month = data.get( + 'traffic_discounted_price_per_month', traffic_price_per_month + ) + traffic_discount_total = data.get('traffic_discount_total', 0) + total_traffic_price = data.get( + 'total_traffic_price', discounted_traffic_price_per_month * months_in_period + ) + else: + traffic_discount_percent = db_user.get_promo_discount("traffic") + from app.utils.pricing_utils import apply_percentage_discount + + discounted_traffic_price_per_month, discount_per_month = apply_percentage_discount( + traffic_price_per_month, + traffic_discount_percent, + ) + traffic_discount_total = discount_per_month * months_in_period + total_traffic_price = discounted_traffic_price_per_month * months_in_period + + total_servers_price = data.get('total_servers_price', total_countries_price) + + final_price = data['total_price'] + + discounted_monthly_additions = data.get( + 'discounted_monthly_additions', + discounted_traffic_price_per_month + + discounted_servers_price_per_month + + discounted_devices_price_per_month, + ) + + is_valid = validate_pricing_calculation( + base_price, + discounted_monthly_additions, + months_in_period, + final_price, + ) if not is_valid: logger.error(f"Ошибка в расчете цены подписки для пользователя {db_user.telegram_id}") @@ -2406,11 +2659,38 @@ async def confirm_purchase( logger.info(f"Расчет покупки подписки на {data['period_days']} дней ({months_in_period} мес):") logger.info(f" Период: {base_price/100}₽") if total_traffic_price > 0: - logger.info(f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽") - if total_countries_price > 0: - logger.info(f" Серверы: {countries_price_per_month/100}₽/мес × {months_in_period} = {total_countries_price/100}₽") + message = ( + f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period}" + f" = {total_traffic_price/100}₽" + ) + if traffic_discount_total > 0: + message += ( + f" (скидка {traffic_discount_percent}%:" + f" -{traffic_discount_total/100}₽)" + ) + logger.info(message) + if total_servers_price > 0: + message = ( + f" Серверы: {countries_price_per_month/100}₽/мес × {months_in_period}" + f" = {total_servers_price/100}₽" + ) + if total_servers_discount > 0: + message += ( + f" (скидка {servers_discount_percent}%:" + f" -{total_servers_discount/100}₽)" + ) + logger.info(message) if total_devices_price > 0: - logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽") + message = ( + f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period}" + f" = {total_devices_price/100}₽" + ) + if devices_discount_total > 0: + message += ( + f" (скидка {devices_discount_percent}%:" + f" -{devices_discount_total/100}₽)" + ) + logger.info(message) logger.info(f" ИТОГО: {final_price/100}₽") if db_user.balance_kopeks < final_price: diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index d0ecf018..1553c349 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -45,6 +45,9 @@ def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark [ InlineKeyboardButton(text=texts.ADMIN_CAMPAIGNS, callback_data="admin_campaigns") ], + [ + InlineKeyboardButton(text=texts.ADMIN_PROMO_GROUPS, callback_data="admin_promo_groups") + ], [ InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") ] diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index ca3657af..a5664ce6 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -123,6 +123,36 @@ "ADMIN_REMNAWAVE": "🖥️ Remnawave", "ADMIN_RULES": "📋 Rules", "ADMIN_STATISTICS": "📊 Statistics", + "ADMIN_PROMO_GROUPS": "💳 Promo groups", + "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_GROUPS_DEFAULT_LABEL": " (default)", + "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", + "ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.", + "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Promo group: {name}", + "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}", + "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.", + "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members", + "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit", + "ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Delete", + "ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Enter a name for the new promo group:", + "ADMIN_PROMO_GROUP_INVALID_NAME": "Name cannot be empty.", + "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_INVALID_PERCENT": "Enter a number from 0 to 100.", + "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", + "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_UPDATED": "Promo group “{name}” updated.", + "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", + "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", + "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.", + "ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Delete promo group “{name}”? All users will be moved to the default group.", + "ADMIN_PROMO_GROUP_DELETED": "Promo group “{name}” deleted.", "ADMIN_SUBSCRIPTIONS": "📱 Subscriptions", "ADMIN_USERS": "👥 Users", "AUTOPAY_DISABLED_TEXT": "Disabled — don't forget to renew manually!", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index d6164852..90724196 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -11,6 +11,36 @@ "ADMIN_REMNAWAVE": "🖥️ Remnawave", "ADMIN_RULES": "📋 Правила", "ADMIN_STATISTICS": "📊 Статистика", + "ADMIN_PROMO_GROUPS": "💳 Промогруппы", + "ADMIN_PROMO_GROUPS_TITLE": "💳 Промогруппы", + "ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}", + "ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%", + "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", + "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", + "ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.", + "ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 Промогруппа: {name}", + "ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}", + "ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.", + "ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники", + "ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить", + "ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Удалить", + "ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Введите название новой промогруппы:", + "ADMIN_PROMO_GROUP_INVALID_NAME": "Название не может быть пустым.", + "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_INVALID_PERCENT": "Введите число от 0 до 100.", + "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", + "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_UPDATED": "Промогруппа «{name}» обновлена.", + "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", + "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", + "ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.", + "ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.", + "ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.", "ADMIN_SUBSCRIPTIONS": "📱 Подписки", "ADMIN_USERS": "👥 Пользователи", "AUTOPAY_BUTTON": "💳 Автоплатёж", diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index a46a840f..a4c599e6 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -4,7 +4,7 @@ from typing import Optional, List, Tuple from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.models import Subscription, User, SubscriptionStatus +from app.database.models import Subscription, User, SubscriptionStatus, PromoGroup from app.external.remnawave_api import ( RemnaWaveAPI, RemnaWaveUser, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError @@ -19,6 +19,23 @@ from app.utils.pricing_utils import ( logger = logging.getLogger(__name__) + +def _resolve_discount_percent( + user: Optional[User], + promo_group: Optional[PromoGroup], + category: str, +) -> int: + if user is not None: + try: + return user.get_promo_discount(category) + except AttributeError: + pass + + if promo_group is not None: + return promo_group.get_discount_percent(category) + + return 0 + def get_traffic_reset_strategy(): from app.config import settings strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper() @@ -266,9 +283,12 @@ class SubscriptionService: self, period_days: int, traffic_gb: int, - server_squad_ids: List[int], + server_squad_ids: List[int], devices: int, - db: AsyncSession + db: AsyncSession, + *, + user: Optional[User] = None, + promo_group: Optional[PromoGroup] = None, ) -> Tuple[int, List[int]]: from app.config import PERIOD_PRICES @@ -279,68 +299,137 @@ class SubscriptionService: base_price = PERIOD_PRICES.get(period_days, 0) + promo_group = promo_group or (user.promo_group if user else None) + traffic_price = settings.get_traffic_price(traffic_gb) - + traffic_discount_percent = _resolve_discount_percent(user, promo_group, "traffic") + traffic_discount = traffic_price * traffic_discount_percent // 100 + discounted_traffic_price = traffic_price - traffic_discount + server_prices = [] total_servers_price = 0 - + servers_discount_percent = _resolve_discount_percent(user, promo_group, "servers") + for server_id in server_squad_ids: server = await get_server_squad_by_id(db, server_id) if server and server.is_available and not server.is_full: - server_prices.append(server.price_kopeks) - total_servers_price += server.price_kopeks - logger.debug(f"Сервер {server.display_name}: {server.price_kopeks/100}₽") + server_price = server.price_kopeks + server_discount = server_price * servers_discount_percent // 100 + discounted_server_price = server_price - server_discount + server_prices.append(discounted_server_price) + total_servers_price += discounted_server_price + log_message = f"Сервер {server.display_name}: {server_price/100}₽" + if server_discount > 0: + log_message += ( + f" (скидка {servers_discount_percent}%: -{server_discount/100}₽ → {discounted_server_price/100}₽)" + ) + logger.debug(log_message) else: server_prices.append(0) logger.warning(f"Сервер ID {server_id} недоступен") - + devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE - - total_price = base_price + traffic_price + total_servers_price + devices_price - + devices_discount_percent = _resolve_discount_percent(user, promo_group, "devices") + devices_discount = devices_price * devices_discount_percent // 100 + discounted_devices_price = devices_price - devices_discount + + total_price = base_price + discounted_traffic_price + total_servers_price + discounted_devices_price + logger.info(f"Расчет стоимости новой подписки:") logger.info(f" Период {period_days} дней: {base_price/100}₽") - if traffic_price > 0: - logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price/100}₽") + if discounted_traffic_price > 0: + message = f" Трафик {traffic_gb} ГБ: {traffic_price/100}₽" + if traffic_discount > 0: + message += ( + f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ → {discounted_traffic_price/100}₽)" + ) + logger.info(message) if total_servers_price > 0: - logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽") - if devices_price > 0: - logger.info(f" Устройства ({devices}): {devices_price/100}₽") + message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽" + if servers_discount_percent > 0: + message += ( + f" (скидка {servers_discount_percent}% применяется ко всем серверам)" + ) + logger.info(message) + if discounted_devices_price > 0: + message = f" Устройства ({devices}): {devices_price/100}₽" + if devices_discount > 0: + message += ( + f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ → {discounted_devices_price/100}₽)" + ) + logger.info(message) logger.info(f" ИТОГО: {total_price/100}₽") - + return total_price, server_prices async def calculate_renewal_price( self, subscription: Subscription, period_days: int, - db: AsyncSession + db: AsyncSession, + *, + user: Optional[User] = None, + promo_group: Optional[PromoGroup] = None, ) -> int: try: from app.config import PERIOD_PRICES - + base_price = PERIOD_PRICES.get(period_days, 0) - + + if user is None: + user = getattr(subscription, "user", None) + promo_group = promo_group or (user.promo_group if user else None) + servers_price, _ = await self.get_countries_price_by_uuids( subscription.connected_squads, db ) - + + servers_discount_percent = _resolve_discount_percent(user, promo_group, "servers") + servers_discount = servers_price * servers_discount_percent // 100 + discounted_servers_price = servers_price - servers_discount + devices_price = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE - + devices_discount_percent = _resolve_discount_percent(user, promo_group, "devices") + devices_discount = devices_price * devices_discount_percent // 100 + discounted_devices_price = devices_price - devices_discount + traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb) - - total_price = base_price + servers_price + devices_price + traffic_price - + traffic_discount_percent = _resolve_discount_percent(user, promo_group, "traffic") + traffic_discount = traffic_price * traffic_discount_percent // 100 + discounted_traffic_price = traffic_price - traffic_discount + + total_price = ( + base_price + + discounted_servers_price + + discounted_devices_price + + discounted_traffic_price + ) + logger.info(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):") logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽") if servers_price > 0: - logger.info(f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {servers_price/100}₽") + message = f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {discounted_servers_price/100}₽" + if servers_discount > 0: + message += ( + f" (скидка {servers_discount_percent}%: -{servers_discount/100}₽ от {servers_price/100}₽)" + ) + logger.info(message) if devices_price > 0: - logger.info(f" 📱 Устройства ({subscription.device_limit}): {devices_price/100}₽") + message = f" 📱 Устройства ({subscription.device_limit}): {discounted_devices_price/100}₽" + if devices_discount > 0: + message += ( + f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ от {devices_price/100}₽)" + ) + logger.info(message) if traffic_price > 0: - logger.info(f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {traffic_price/100}₽") + message = f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {discounted_traffic_price/100}₽" + if traffic_discount > 0: + message += ( + f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ от {traffic_price/100}₽)" + ) + logger.info(message) logger.info(f" 💎 ИТОГО: {total_price/100}₽") - + return total_price except Exception as e: @@ -440,9 +529,12 @@ class SubscriptionService: self, period_days: int, traffic_gb: int, - server_squad_ids: List[int], + server_squad_ids: List[int], devices: int, - db: AsyncSession + db: AsyncSession, + *, + user: Optional[User] = None, + promo_group: Optional[PromoGroup] = None, ) -> Tuple[int, List[int]]: from app.config import PERIOD_PRICES @@ -455,89 +547,153 @@ class SubscriptionService: base_price = PERIOD_PRICES.get(period_days, 0) + promo_group = promo_group or (user.promo_group if user else None) + traffic_price_per_month = settings.get_traffic_price(traffic_gb) - total_traffic_price = traffic_price_per_month * months_in_period - + traffic_discount_percent = _resolve_discount_percent(user, promo_group, "traffic") + traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 + discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month + total_traffic_price = discounted_traffic_per_month * months_in_period + server_prices = [] total_servers_price = 0 - + servers_discount_percent = _resolve_discount_percent(user, promo_group, "servers") + for server_id in server_squad_ids: server = await get_server_squad_by_id(db, server_id) if server and server.is_available and not server.is_full: server_price_per_month = server.price_kopeks - server_price_total = server_price_per_month * months_in_period + server_discount_per_month = server_price_per_month * servers_discount_percent // 100 + discounted_server_per_month = server_price_per_month - server_discount_per_month + server_price_total = discounted_server_per_month * months_in_period server_prices.append(server_price_total) total_servers_price += server_price_total - logger.debug(f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_in_period} мес = {server_price_total/100}₽") + log_message = ( + f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_in_period} мес = {server_price_total/100}₽" + ) + if server_discount_per_month > 0: + log_message += ( + f" (скидка {servers_discount_percent}%: -{server_discount_per_month * months_in_period/100}₽)" + ) + logger.debug(log_message) else: server_prices.append(0) logger.warning(f"Сервер ID {server_id} недоступен") - + additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - total_devices_price = devices_price_per_month * months_in_period - + devices_discount_percent = _resolve_discount_percent(user, promo_group, "devices") + devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 + discounted_devices_per_month = devices_price_per_month - devices_discount_per_month + total_devices_price = discounted_devices_per_month * months_in_period + 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}₽") if total_traffic_price > 0: - logger.info( + message = ( f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽" ) + if traffic_discount_per_month > 0: + message += ( + f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)" + ) + logger.info(message) if total_servers_price > 0: - logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽") + message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽" + if servers_discount_percent > 0: + message += ( + f" (скидка {servers_discount_percent}% применяется ко всем серверам)" + ) + logger.info(message) if total_devices_price > 0: - logger.info( + message = ( f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽" ) + if devices_discount_per_month > 0: + message += ( + f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)" + ) + logger.info(message) logger.info(f" ИТОГО: {total_price/100}₽") - + return total_price, server_prices async def calculate_renewal_price_with_months( self, subscription: Subscription, period_days: int, - db: AsyncSession + db: AsyncSession, + *, + user: Optional[User] = None, + promo_group: Optional[PromoGroup] = None, ) -> int: try: from app.config import PERIOD_PRICES - + months_in_period = calculate_months_from_days(period_days) - + base_price = PERIOD_PRICES.get(period_days, 0) - + + if user is None: + user = getattr(subscription, "user", None) + promo_group = promo_group or (user.promo_group if user else None) + servers_price_per_month, _ = await self.get_countries_price_by_uuids( subscription.connected_squads, db ) - total_servers_price = servers_price_per_month * months_in_period - + servers_discount_percent = _resolve_discount_percent(user, promo_group, "servers") + servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100 + discounted_servers_per_month = servers_price_per_month - servers_discount_per_month + total_servers_price = discounted_servers_per_month * months_in_period + additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - total_devices_price = devices_price_per_month * months_in_period - + devices_discount_percent = _resolve_discount_percent(user, promo_group, "devices") + devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 + discounted_devices_per_month = devices_price_per_month - devices_discount_per_month + total_devices_price = discounted_devices_per_month * months_in_period + traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb) - total_traffic_price = traffic_price_per_month * months_in_period - + traffic_discount_percent = _resolve_discount_percent(user, promo_group, "traffic") + traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 + discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month + total_traffic_price = discounted_traffic_per_month * months_in_period + 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}₽") if total_servers_price > 0: - logger.info( + message = ( f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}₽" ) + if servers_discount_per_month > 0: + message += ( + f" (скидка {servers_discount_percent}%: -{servers_discount_per_month * months_in_period/100}₽)" + ) + logger.info(message) if total_devices_price > 0: - logger.info( + message = ( f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽" ) + if devices_discount_per_month > 0: + message += ( + f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)" + ) + logger.info(message) if total_traffic_price > 0: - logger.info( + message = ( f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽" ) + if traffic_discount_per_month > 0: + message += ( + f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)" + ) + logger.info(message) logger.info(f" 💎 ИТОГО: {total_price/100}₽") - + return total_price except Exception as e: diff --git a/app/states.py b/app/states.py index 01a14206..42b8a18c 100644 --- a/app/states.py +++ b/app/states.py @@ -59,10 +59,20 @@ class AdminStates(StatesGroup): editing_campaign_subscription_traffic = State() editing_campaign_subscription_devices = State() editing_campaign_subscription_servers = State() - + waiting_for_broadcast_message = State() waiting_for_broadcast_media = State() confirming_broadcast = State() + + creating_promo_group_name = State() + creating_promo_group_traffic_discount = State() + creating_promo_group_server_discount = State() + creating_promo_group_device_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_squad_price = State() editing_traffic_price = State() diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index ba42885c..cbf3a961 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta +from datetime import datetime from typing import Tuple import logging @@ -28,8 +29,8 @@ def calculate_period_multiplier(period_days: int) -> Tuple[int, float]: def calculate_prorated_price( - monthly_price: int, - end_date: datetime, + monthly_price: int, + end_date: datetime, min_charge_months: int = 1 ) -> Tuple[int, int]: months_remaining = get_remaining_months(end_date) @@ -42,6 +43,25 @@ def calculate_prorated_price( return total_price, months_to_charge +def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]: + if amount <= 0 or percent <= 0: + return amount, 0 + + clamped_percent = max(0, min(100, percent)) + discount_value = amount * clamped_percent // 100 + discounted_amount = amount - discount_value + + logger.debug( + "Применена скидка %s%%: %s → %s (скидка %s)", + clamped_percent, + amount, + discounted_amount, + discount_value, + ) + + return discounted_amount, discount_value + + def format_period_description(days: int, language: str = "ru") -> str: months = calculate_months_from_days(days)