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