Refactor subscription handlers into modular package

This commit is contained in:
Egor
2025-10-13 21:21:26 +03:00
parent 893736b240
commit d082e8a61c
14 changed files with 7771 additions and 6841 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,211 @@
# Automatically generated module exports
from .autopay import (
handle_autopay_menu,
handle_subscription_cancel,
handle_subscription_config_back,
set_autopay_days,
show_autopay_days,
toggle_autopay,
)
from .common import (
build_redirect_link,
create_deep_link,
format_additional_section,
format_traffic_display,
get_apps_for_device,
get_confirm_switch_traffic_keyboard,
get_device_name,
get_localized_value,
get_reset_devices_confirm_keyboard,
get_step_description,
get_traffic_switch_keyboard,
load_app_config,
update_traffic_prices,
validate_traffic_price,
)
from .countries import (
apply_countries_changes,
confirm_add_countries_to_subscription,
countries_continue,
get_countries_price_by_uuids_fallback,
handle_add_countries,
handle_add_country_to_subscription,
handle_manage_country,
select_country,
)
from .devices import (
confirm_add_devices,
confirm_change_devices,
confirm_reset_devices,
execute_change_devices,
get_current_devices_count,
get_current_devices_detailed,
get_servers_display_names,
handle_all_devices_reset_from_management,
handle_app_selection,
handle_change_devices,
handle_device_guide,
handle_device_management,
handle_devices_page,
handle_reset_devices,
handle_single_device_reset,
handle_specific_app_guide,
show_device_connection_help,
show_devices_page,
)
from .happ import (
handle_happ_download_back,
handle_happ_download_close,
handle_happ_download_platform_choice,
handle_happ_download_request,
)
from .links import (
handle_connect_subscription,
handle_open_subscription_link,
)
from .notifications import (
send_extension_notification,
send_purchase_notification,
send_trial_notification,
)
from .pricing import (
get_subscription_cost,
get_subscription_info_text,
)
from .promo import (
claim_discount_offer,
handle_promo_offer_close,
)
from .purchase import (
activate_trial,
clear_saved_cart,
confirm_extend_subscription,
confirm_purchase,
create_paid_subscription_with_traffic_mode,
devices_continue,
handle_extend_subscription,
handle_subscription_settings,
register_handlers,
resume_subscription_checkout,
return_to_saved_cart,
save_cart_and_redirect_to_topup,
select_devices,
select_period,
show_subscription_info,
show_trial_offer,
start_subscription_purchase,
)
from .traffic import (
add_traffic,
confirm_reset_traffic,
confirm_switch_traffic,
execute_switch_traffic,
get_traffic_packages_info,
handle_add_traffic,
handle_no_traffic_packages,
handle_reset_traffic,
handle_switch_traffic,
refresh_traffic_config,
select_traffic,
)
__all__ = [
'activate_trial',
'add_traffic',
'apply_countries_changes',
'build_redirect_link',
'claim_discount_offer',
'clear_saved_cart',
'confirm_add_countries_to_subscription',
'confirm_add_devices',
'confirm_change_devices',
'confirm_extend_subscription',
'confirm_purchase',
'confirm_reset_devices',
'confirm_reset_traffic',
'confirm_switch_traffic',
'countries_continue',
'create_deep_link',
'create_paid_subscription_with_traffic_mode',
'devices_continue',
'execute_change_devices',
'execute_switch_traffic',
'format_additional_section',
'format_traffic_display',
'get_apps_for_device',
'get_confirm_switch_traffic_keyboard',
'get_countries_price_by_uuids_fallback',
'get_current_devices_count',
'get_current_devices_detailed',
'get_device_name',
'get_localized_value',
'get_reset_devices_confirm_keyboard',
'get_servers_display_names',
'get_step_description',
'get_subscription_cost',
'get_subscription_info_text',
'get_traffic_packages_info',
'get_traffic_switch_keyboard',
'handle_add_countries',
'handle_add_country_to_subscription',
'handle_add_traffic',
'handle_all_devices_reset_from_management',
'handle_app_selection',
'handle_autopay_menu',
'handle_change_devices',
'handle_connect_subscription',
'handle_device_guide',
'handle_device_management',
'handle_devices_page',
'handle_extend_subscription',
'handle_happ_download_back',
'handle_happ_download_close',
'handle_happ_download_platform_choice',
'handle_happ_download_request',
'handle_manage_country',
'handle_no_traffic_packages',
'handle_open_subscription_link',
'handle_promo_offer_close',
'handle_reset_devices',
'handle_reset_traffic',
'handle_single_device_reset',
'handle_specific_app_guide',
'handle_subscription_cancel',
'handle_subscription_config_back',
'handle_subscription_settings',
'handle_switch_traffic',
'load_app_config',
'refresh_traffic_config',
'register_handlers',
'resume_subscription_checkout',
'return_to_saved_cart',
'save_cart_and_redirect_to_topup',
'select_country',
'select_devices',
'select_period',
'select_traffic',
'send_extension_notification',
'send_purchase_notification',
'send_trial_notification',
'set_autopay_days',
'show_autopay_days',
'show_device_connection_help',
'show_devices_page',
'show_subscription_info',
'show_trial_offer',
'start_subscription_purchase',
'toggle_autopay',
'update_traffic_prices',
'validate_traffic_price',
]

View File

@@ -0,0 +1,255 @@
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, get_traffic_prices
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.subscription import (
create_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
update_subscription_autopay
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import (
User, TransactionType, SubscriptionStatus,
Subscription
)
from app.keyboards.inline import (
get_subscription_keyboard, get_trial_keyboard,
get_subscription_period_keyboard, get_traffic_packages_keyboard,
get_countries_keyboard, get_devices_keyboard,
get_subscription_confirm_keyboard, get_autopay_keyboard,
get_autopay_days_keyboard, get_back_keyboard,
get_add_traffic_keyboard,
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
get_app_selection_keyboard, get_specific_app_keyboard,
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
get_devices_management_keyboard, get_device_management_help_keyboard,
get_happ_cryptolink_keyboard,
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
get_happ_download_button_row,
get_payment_methods_keyboard_with_cart,
get_subscription_confirm_keyboard_with_cart,
get_insufficient_balance_keyboard_with_cart
)
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
save_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.promo_offer_service import promo_offer_service
from app.states import SubscriptionStates
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation,
format_period_description,
apply_percentage_discount,
)
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
convert_subscription_link_to_happ_scheme,
)
from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
from .countries import _get_available_countries, _should_show_countries_management
from .pricing import _build_subscription_period_prompt
async def handle_autopay_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription:
await callback.answer(
texts.t("SUBSCRIPTION_ACTIVE_REQUIRED", "⚠️ У вас нет активной подписки!"),
show_alert=True,
)
return
status = (
texts.t("AUTOPAY_STATUS_ENABLED", "включен")
if subscription.autopay_enabled
else texts.t("AUTOPAY_STATUS_DISABLED", "выключен")
)
days = subscription.autopay_days_before
text = texts.t(
"AUTOPAY_MENU_TEXT",
(
"💳 <b>Автоплатеж</b>\n\n"
"📊 <b>Статус:</b> {status}\n"
"⏰ <b>Списание за:</b> {days} дн. до окончания\n\n"
"Выберите действие:"
),
).format(status=status, days=days)
await callback.message.edit_text(
text,
reply_markup=get_autopay_keyboard(db_user.language),
parse_mode="HTML",
)
await callback.answer()
async def toggle_autopay(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
subscription = db_user.subscription
enable = callback.data == "autopay_enable"
await update_subscription_autopay(db, subscription, enable)
texts = get_texts(db_user.language)
status = (
texts.t("AUTOPAY_STATUS_ENABLED", "включен")
if enable
else texts.t("AUTOPAY_STATUS_DISABLED", "выключен")
)
await callback.answer(
texts.t("AUTOPAY_TOGGLE_SUCCESS", "✅ Автоплатеж {status}!").format(status=status)
)
await handle_autopay_menu(callback, db_user, db)
async def show_autopay_days(
callback: types.CallbackQuery,
db_user: User
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t(
"AUTOPAY_SELECT_DAYS_PROMPT",
"⏰ Выберите за сколько дней до окончания списывать средства:",
),
reply_markup=get_autopay_days_keyboard(db_user.language)
)
await callback.answer()
async def set_autopay_days(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
days = int(callback.data.split('_')[2])
subscription = db_user.subscription
await update_subscription_autopay(
db, subscription, subscription.autopay_enabled, days
)
texts = get_texts(db_user.language)
await callback.answer(
texts.t("AUTOPAY_DAYS_SET", "✅ Установлено {days} дней!").format(days=days)
)
await handle_autopay_menu(callback, db_user, db)
async def handle_subscription_config_back(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
current_state = await state.get_state()
texts = get_texts(db_user.language)
if current_state == SubscriptionStates.selecting_traffic.state:
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
elif current_state == SubscriptionStates.selecting_countries.state:
if settings.is_traffic_selectable():
await callback.message.edit_text(
texts.SELECT_TRAFFIC,
reply_markup=get_traffic_packages_keyboard(db_user.language)
)
await state.set_state(SubscriptionStates.selecting_traffic)
else:
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
elif current_state == SubscriptionStates.selecting_devices.state:
if await _should_show_countries_management(db_user):
countries = await _get_available_countries(db_user.promo_group_id)
data = await state.get_data()
selected_countries = data.get('countries', [])
await callback.message.edit_text(
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_countries)
elif settings.is_traffic_selectable():
await callback.message.edit_text(
texts.SELECT_TRAFFIC,
reply_markup=get_traffic_packages_keyboard(db_user.language)
)
await state.set_state(SubscriptionStates.selecting_traffic)
else:
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
else:
from app.handlers.menu import show_main_menu
await show_main_menu(callback, db_user, db)
await state.clear()
await callback.answer()
async def handle_subscription_cancel(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
await state.clear()
await clear_subscription_checkout_draft(db_user.id)
from app.handlers.menu import show_main_menu
await show_main_menu(callback, db_user, db)
await callback.answer("❌ Покупка отменена")

View File

@@ -0,0 +1,487 @@
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, get_traffic_prices
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.subscription import (
create_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
update_subscription_autopay
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import (
User, TransactionType, SubscriptionStatus,
Subscription
)
from app.keyboards.inline import (
get_subscription_keyboard, get_trial_keyboard,
get_subscription_period_keyboard, get_traffic_packages_keyboard,
get_countries_keyboard, get_devices_keyboard,
get_subscription_confirm_keyboard, get_autopay_keyboard,
get_autopay_days_keyboard, get_back_keyboard,
get_add_traffic_keyboard,
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
get_app_selection_keyboard, get_specific_app_keyboard,
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
get_devices_management_keyboard, get_device_management_help_keyboard,
get_happ_cryptolink_keyboard,
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
get_happ_download_button_row,
get_payment_methods_keyboard_with_cart,
get_subscription_confirm_keyboard_with_cart,
get_insufficient_balance_keyboard_with_cart
)
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
save_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.promo_offer_service import promo_offer_service
from app.states import SubscriptionStates
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation,
format_period_description,
apply_percentage_discount,
)
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
convert_subscription_link_to_happ_scheme,
)
from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
logger = logging.getLogger(__name__)
TRAFFIC_PRICES = get_traffic_prices()
class _SafeFormatDict(dict):
def __missing__(self, key: str) -> str: # pragma: no cover - defensive fallback
return "{" + key + "}"
def _format_text_with_placeholders(template: str, values: Dict[str, Any]) -> str:
if not isinstance(template, str):
return template
safe_values = _SafeFormatDict()
safe_values.update(values)
try:
return template.format_map(safe_values)
except Exception: # pragma: no cover - defensive logging
logger.warning("Failed to format template '%s' with values %s", template, values)
return template
def _get_addon_discount_percent_for_user(
user: Optional[User],
category: str,
period_days_hint: Optional[int] = None,
) -> int:
if user is None:
return 0
promo_group = getattr(user, "promo_group", None)
if promo_group is None:
return 0
if not getattr(promo_group, "apply_discounts_to_addons", True):
return 0
try:
return user.get_promo_discount(category, period_days_hint)
except AttributeError:
return 0
def _apply_addon_discount(
user: Optional[User],
category: str,
amount: int,
period_days_hint: Optional[int] = None,
) -> Dict[str, int]:
percent = _get_addon_discount_percent_for_user(user, category, period_days_hint)
discounted_amount, discount_value = apply_percentage_discount(amount, percent)
return {
"discounted": discounted_amount,
"discount": discount_value,
"percent": percent,
}
def _get_promo_offer_discount_percent(user: Optional[User]) -> int:
return get_user_active_promo_discount_percent(user)
def _apply_promo_offer_discount(user: Optional[User], amount: int) -> Dict[str, int]:
percent = _get_promo_offer_discount_percent(user)
if amount <= 0 or percent <= 0:
return {"discounted": amount, "discount": 0, "percent": 0}
discounted, discount_value = apply_percentage_discount(amount, percent)
return {"discounted": discounted, "discount": discount_value, "percent": percent}
def _get_period_hint_from_subscription(subscription: Optional[Subscription]) -> Optional[int]:
if not subscription:
return None
months_remaining = get_remaining_months(subscription.end_date)
if months_remaining <= 0:
return None
return months_remaining * 30
def _apply_discount_to_monthly_component(
amount_per_month: int,
percent: int,
months: int,
) -> Dict[str, int]:
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,
}
def update_traffic_prices():
from app.config import refresh_traffic_prices
refresh_traffic_prices()
logger.info("🔄 TRAFFIC_PRICES обновлены из конфигурации")
def format_traffic_display(traffic_gb: int, is_fixed_mode: bool = None) -> str:
if is_fixed_mode is None:
is_fixed_mode = settings.is_traffic_fixed()
if traffic_gb == 0:
if is_fixed_mode:
return "Безлимитный"
else:
return "Безлимитный"
else:
if is_fixed_mode:
return f"{traffic_gb} ГБ"
else:
return f"{traffic_gb} ГБ"
def validate_traffic_price(gb: int) -> bool:
from app.config import settings
price = settings.get_traffic_price(gb)
if gb == 0:
return True
return price > 0
def load_app_config() -> Dict[str, Any]:
try:
from app.config import settings
config_path = settings.get_app_config_path()
with open(config_path, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, dict):
return data
logger.error("Некорректный формат app-config.json: ожидается объект")
except Exception as e:
logger.error(f"Ошибка загрузки конфига приложений: {e}")
return {}
def get_localized_value(values: Any, language: str, default_language: str = "en") -> str:
if not isinstance(values, dict):
return ""
candidates: List[str] = []
normalized_language = (language or "").strip().lower()
if normalized_language:
candidates.append(normalized_language)
if "-" in normalized_language:
candidates.append(normalized_language.split("-")[0])
default_language = (default_language or "").strip().lower()
if default_language and default_language not in candidates:
candidates.append(default_language)
for candidate in candidates:
if not candidate:
continue
value = values.get(candidate)
if isinstance(value, str) and value.strip():
return value
for value in values.values():
if isinstance(value, str) and value.strip():
return value
return ""
def get_step_description(app: Dict[str, Any], step_key: str, language: str) -> str:
if not isinstance(app, dict):
return ""
step = app.get(step_key)
if not isinstance(step, dict):
return ""
description = step.get("description")
return get_localized_value(description, language)
def format_additional_section(additional: Any, texts, language: str) -> str:
if not isinstance(additional, dict):
return ""
title = get_localized_value(additional.get("title"), language)
description = get_localized_value(additional.get("description"), language)
parts: List[str] = []
if title:
parts.append(
texts.t(
"SUBSCRIPTION_ADDITIONAL_STEP_TITLE",
"<b>{title}:</b>",
).format(title=title)
)
if description:
parts.append(description)
return "\n".join(parts)
def build_redirect_link(target_link: Optional[str], template: Optional[str]) -> Optional[str]:
if not target_link or not template:
return None
normalized_target = str(target_link).strip()
normalized_template = str(template).strip()
if not normalized_target or not normalized_template:
return None
encoded_target = quote(normalized_target, safe="")
result = normalized_template
replaced = False
replacements = [
("{subscription_link}", encoded_target),
("{link}", encoded_target),
("{subscription_link_raw}", normalized_target),
("{link_raw}", normalized_target),
]
for placeholder, replacement in replacements:
if placeholder in result:
result = result.replace(placeholder, replacement)
replaced = True
if not replaced:
result = f"{result}{encoded_target}"
return result
def get_apps_for_device(device_type: str, language: str = "ru") -> List[Dict[str, Any]]:
config = load_app_config()
platforms = config.get("platforms", {}) if isinstance(config, dict) else {}
if not isinstance(platforms, dict):
return []
device_mapping = {
'ios': 'ios',
'android': 'android',
'windows': 'windows',
'mac': 'macos',
'tv': 'androidTV',
'appletv': 'appleTV',
'apple_tv': 'appleTV',
}
config_key = device_mapping.get(device_type, device_type)
apps = platforms.get(config_key, [])
return apps if isinstance(apps, list) else []
def get_device_name(device_type: str, language: str = "ru") -> str:
names = {
'ios': 'iPhone/iPad',
'android': 'Android',
'windows': 'Windows',
'mac': 'macOS',
'tv': 'Android TV',
'appletv': 'Apple TV',
'apple_tv': 'Apple TV',
}
return names.get(device_type, device_type)
def create_deep_link(app: Dict[str, Any], subscription_url: str) -> Optional[str]:
if not subscription_url:
return None
if not isinstance(app, dict):
return subscription_url
scheme = str(app.get("urlScheme", "")).strip()
payload = subscription_url
if app.get("isNeedBase64Encoding"):
try:
payload = base64.b64encode(subscription_url.encode("utf-8")).decode("utf-8")
except Exception as exc:
logger.warning(
"Не удалось закодировать ссылку подписки в base64 для приложения %s: %s",
app.get("id"),
exc,
)
payload = subscription_url
scheme_link = f"{scheme}{payload}" if scheme else None
template = settings.get_happ_cryptolink_redirect_template()
redirect_link = build_redirect_link(scheme_link, template) if scheme_link and template else None
return redirect_link or scheme_link or subscription_url
def get_reset_devices_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="✅ Да, сбросить все устройства",
callback_data="confirm_reset_devices"
)
],
[
InlineKeyboardButton(text="❌ Отмена", callback_data="menu_subscription")
]
])
def get_traffic_switch_keyboard(
current_traffic_gb: int,
language: str = "ru",
subscription_end_date: datetime = None,
discount_percent: int = 0,
) -> InlineKeyboardMarkup:
from app.config import settings
months_multiplier = 1
period_text = ""
if subscription_end_date:
months_multiplier = get_remaining_months(subscription_end_date)
if months_multiplier > 1:
period_text = f" (за {months_multiplier} мес)"
packages = settings.get_traffic_packages()
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
current_price_per_month = settings.get_traffic_price(current_traffic_gb)
discounted_current_per_month, _ = apply_percentage_discount(
current_price_per_month,
discount_percent,
)
buttons = []
for package in enabled_packages:
gb = package['gb']
price_per_month = package['price']
discounted_price_per_month, _ = apply_percentage_discount(
price_per_month,
discount_percent,
)
price_diff_per_month = discounted_price_per_month - discounted_current_per_month
total_price_diff = price_diff_per_month * months_multiplier
if gb == current_traffic_gb:
emoji = ""
action_text = " (текущий)"
price_text = ""
elif total_price_diff > 0:
emoji = "⬆️"
action_text = ""
price_text = f" (+{total_price_diff // 100}{period_text})"
if discount_percent > 0:
discount_total = (
(price_per_month - current_price_per_month) * months_multiplier
- total_price_diff
)
if discount_total > 0:
price_text += f" (скидка {discount_percent}%: -{discount_total // 100}₽)"
elif total_price_diff < 0:
emoji = "⬇️"
action_text = ""
price_text = " (без возврата)"
else:
emoji = "🔄"
action_text = ""
price_text = " (бесплатно)"
if gb == 0:
traffic_text = "Безлимит"
else:
traffic_text = f"{gb} ГБ"
button_text = f"{emoji} {traffic_text}{action_text}{price_text}"
buttons.append([
InlineKeyboardButton(text=button_text, callback_data=f"switch_traffic_{gb}")
])
buttons.append([
InlineKeyboardButton(
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
callback_data="subscription_settings"
)
])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_confirm_switch_traffic_keyboard(
new_traffic_gb: int,
price_difference: int,
language: str = "ru"
) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="✅ Подтвердить переключение",
callback_data=f"confirm_switch_traffic_{new_traffic_gb}_{price_difference}"
)
],
[
InlineKeyboardButton(
text="❌ Отмена",
callback_data="subscription_settings"
)
]
])

View File

@@ -0,0 +1,958 @@
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, get_traffic_prices
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.subscription import (
create_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
update_subscription_autopay
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import (
User, TransactionType, SubscriptionStatus,
Subscription
)
from app.keyboards.inline import (
get_subscription_keyboard, get_trial_keyboard,
get_subscription_period_keyboard, get_traffic_packages_keyboard,
get_countries_keyboard, get_devices_keyboard,
get_subscription_confirm_keyboard, get_autopay_keyboard,
get_autopay_days_keyboard, get_back_keyboard,
get_add_traffic_keyboard,
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
get_app_selection_keyboard, get_specific_app_keyboard,
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
get_devices_management_keyboard, get_device_management_help_keyboard,
get_happ_cryptolink_keyboard,
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
get_happ_download_button_row,
get_payment_methods_keyboard_with_cart,
get_subscription_confirm_keyboard_with_cart,
get_insufficient_balance_keyboard_with_cart
)
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
save_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.promo_offer_service import promo_offer_service
from app.states import SubscriptionStates
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation,
format_period_description,
apply_percentage_discount,
)
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
convert_subscription_link_to_happ_scheme,
)
from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, logger
async def handle_add_countries(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
if not await _should_show_countries_management(db_user):
texts = get_texts(db_user.language)
await callback.answer(
texts.t(
"COUNTRY_MANAGEMENT_UNAVAILABLE",
" Управление серверами недоступно - доступен только один сервер",
),
show_alert=True,
)
return
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription or subscription.is_trial:
await callback.answer(
texts.t("PAID_FEATURE_ONLY", "⚠ Эта функция доступна только для платных подписок"),
show_alert=True,
)
return
countries = await _get_available_countries(db_user.promo_group_id)
current_countries = subscription.connected_squads
period_hint_days = _get_period_hint_from_subscription(subscription)
servers_discount_percent = _get_addon_discount_percent_for_user(
db_user,
"servers",
period_hint_days,
)
current_countries_names = []
for country in countries:
if country['uuid'] in current_countries:
current_countries_names.append(country['name'])
current_list = (
"\n".join(f"{name}" for name in current_countries_names)
if current_countries_names
else texts.t("COUNTRY_MANAGEMENT_NONE", "Нет подключенных стран")
)
text = texts.t(
"COUNTRY_MANAGEMENT_PROMPT",
(
"🌍 <b>Управление странами подписки</b>\n\n"
"📋 <b>Текущие страны ({current_count}):</b>\n"
"{current_list}\n\n"
"💡 <b>Инструкция:</b>\n"
"✅ - страна подключена\n"
" - будет добавлена (платно)\n"
" - будет отключена (бесплатно)\n"
"⚪ - не выбрана\n\n"
"⚠️ <b>Важно:</b> Повторное подключение отключенных стран будет платным!"
),
).format(
current_count=len(current_countries),
current_list=current_list,
)
await state.update_data(countries=current_countries.copy())
await callback.message.edit_text(
text,
reply_markup=get_manage_countries_keyboard(
countries,
current_countries.copy(),
current_countries,
db_user.language,
subscription.end_date,
servers_discount_percent,
),
parse_mode="HTML"
)
await callback.answer()
async def get_countries_price_by_uuids_fallback(
country_uuids: List[str],
db: AsyncSession,
promo_group_id: Optional[int] = None,
) -> Tuple[int, List[int]]:
try:
from app.database.crud.server_squad import get_server_squad_by_uuid
total_price = 0
prices_list = []
for country_uuid in country_uuids:
try:
server = await get_server_squad_by_uuid(db, country_uuid)
is_allowed = True
if promo_group_id is not None and server:
allowed_ids = {pg.id for pg in server.allowed_promo_groups}
is_allowed = promo_group_id in allowed_ids
if server and server.is_available and not server.is_full and is_allowed:
price = server.price_kopeks
total_price += price
prices_list.append(price)
else:
default_price = 0
total_price += default_price
prices_list.append(default_price)
except Exception:
default_price = 0
total_price += default_price
prices_list.append(default_price)
return total_price, prices_list
except Exception as e:
logger.error(f"Ошибка fallback функции: {e}")
default_prices = [0] * len(country_uuids)
return sum(default_prices), default_prices
async def handle_manage_country(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
logger.info(f"🔍 Управление страной: {callback.data}")
country_uuid = callback.data.split('_')[2]
subscription = db_user.subscription
if not subscription or subscription.is_trial:
texts = get_texts(db_user.language)
await callback.answer(
texts.t("PAID_FEATURE_ONLY_SHORT", "⚠ Только для платных подписок"),
show_alert=True,
)
return
data = await state.get_data()
current_selected = data.get('countries', subscription.connected_squads.copy())
countries = await _get_available_countries(db_user.promo_group_id)
allowed_country_ids = {country['uuid'] for country in countries}
if country_uuid not in allowed_country_ids and country_uuid not in current_selected:
texts = get_texts(db_user.language)
await callback.answer(
texts.t(
"COUNTRY_NOT_AVAILABLE_PROMOGROUP",
"❌ Сервер недоступен для вашей промогруппы",
),
show_alert=True,
)
return
if country_uuid in current_selected:
current_selected.remove(country_uuid)
action = "removed"
else:
current_selected.append(country_uuid)
action = "added"
logger.info(f"🔍 Страна {country_uuid} {action}")
await state.update_data(countries=current_selected)
period_hint_days = _get_period_hint_from_subscription(subscription)
servers_discount_percent = _get_addon_discount_percent_for_user(
db_user,
"servers",
period_hint_days,
)
try:
await callback.message.edit_reply_markup(
reply_markup=get_manage_countries_keyboard(
countries,
current_selected,
subscription.connected_squads,
db_user.language,
subscription.end_date,
servers_discount_percent,
)
)
logger.info(f"✅ Клавиатура обновлена")
except Exception as e:
logger.error(f"⚠ Ошибка обновления клавиатуры: {e}")
await callback.answer()
async def apply_countries_changes(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
logger.info(f"🔧 Применение изменений стран")
data = await state.get_data()
texts = get_texts(db_user.language)
await save_subscription_checkout_draft(db_user.id, dict(data))
resume_callback = (
"subscription_resume_checkout"
if should_offer_checkout_resume(db_user, True)
else None
)
subscription = db_user.subscription
selected_countries = data.get('countries', [])
current_countries = subscription.connected_squads
countries = await _get_available_countries(db_user.promo_group_id)
allowed_country_ids = {country['uuid'] for country in countries}
selected_countries = [
country_uuid
for country_uuid in selected_countries
if country_uuid in allowed_country_ids or country_uuid in current_countries
]
added = [c for c in selected_countries if c not in current_countries]
removed = [c for c in current_countries if c not in selected_countries]
if not added and not removed:
await callback.answer(
texts.t("COUNTRY_CHANGES_NOT_FOUND", "⚠️ Изменения не обнаружены"),
show_alert=True,
)
return
logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}")
months_to_pay = get_remaining_months(subscription.end_date)
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
servers_discount_percent = _get_addon_discount_percent_for_user(
db_user,
"servers",
period_hint_days,
)
cost_per_month = 0
added_names = []
removed_names = []
added_server_components: List[Dict[str, int]] = []
for country in countries:
if not country.get('is_available', True):
continue
if country['uuid'] in added:
server_price_per_month = country['price_kopeks']
discounted_per_month, discount_per_month = apply_percentage_discount(
server_price_per_month,
servers_discount_percent,
)
cost_per_month += discounted_per_month
added_names.append(country['name'])
added_server_components.append(
{
"discounted_per_month": discounted_per_month,
"discount_per_month": discount_per_month,
"original_per_month": server_price_per_month,
}
)
if country['uuid'] in removed:
removed_names.append(country['name'])
total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date)
added_server_prices = [
component["discounted_per_month"] * charged_months
for component in added_server_components
]
total_discount = sum(
component["discount_per_month"] * charged_months
for component in added_server_components
)
if added_names:
logger.info(
"Стоимость новых серверов: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)",
cost_per_month / 100,
charged_months,
total_cost / 100,
total_discount / 100,
)
if total_cost > 0 and db_user.balance_kopeks < total_cost:
missing_kopeks = total_cost - db_user.balance_kopeks
required_text = f"{texts.format_price(total_cost)} (за {charged_months} мес)"
message_text = texts.t(
"ADDON_INSUFFICIENT_FUNDS_MESSAGE",
(
"⚠️ <b>Недостаточно средств</b>\n\n"
"Стоимость услуги: {required}\n"
"На балансе: {balance}\n"
"Не хватает: {missing}\n\n"
"Выберите способ пополнения. Сумма подставится автоматически."
),
).format(
required=required_text,
balance=texts.format_price(db_user.balance_kopeks),
missing=texts.format_price(missing_kopeks),
)
await callback.message.answer(
message_text,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
resume_callback=resume_callback,
amount_kopeks=missing_kopeks,
),
parse_mode="HTML",
)
await callback.answer()
return
try:
if added and total_cost > 0:
success = await subtract_user_balance(
db, db_user, total_cost,
f"Добавление стран: {', '.join(added_names)} на {charged_months} мес"
)
if not success:
await callback.answer(
texts.t("PAYMENT_CHARGE_ERROR", "⚠️ Ошибка списания средств"),
show_alert=True,
)
return
await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=total_cost,
description=f"Добавление стран к подписке: {', '.join(added_names)} на {charged_months} мес"
)
if added:
from app.database.crud.server_squad import get_server_ids_by_uuids, add_user_to_servers
from app.database.crud.subscription import add_subscription_servers
added_server_ids = await get_server_ids_by_uuids(db, added)
if added_server_ids:
await add_subscription_servers(db, subscription, added_server_ids, added_server_prices)
await add_user_to_servers(db, added_server_ids)
logger.info(
f"📊 Добавлены серверы с ценами за {charged_months} мес: {list(zip(added_server_ids, added_server_prices))}")
subscription.connected_squads = selected_countries
subscription.updated_at = datetime.utcnow()
await db.commit()
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
await db.refresh(subscription)
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_update_notification(
db, db_user, subscription, "servers", current_countries, selected_countries, total_cost
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления об изменении серверов: {e}")
success_text = texts.t(
"COUNTRY_CHANGES_SUCCESS_HEADER",
"✅ <b>Страны успешно обновлены!</b>\n\n",
)
if added_names:
success_text += texts.t(
"COUNTRY_CHANGES_ADDED_HEADER",
" <b>Добавлены страны:</b>\n",
)
success_text += "\n".join(f"{name}" for name in added_names)
if total_cost > 0:
success_text += "\n" + texts.t(
"COUNTRY_CHANGES_CHARGED",
"💰 Списано: {amount} (за {months} мес)",
).format(
amount=texts.format_price(total_cost),
months=charged_months,
)
if total_discount > 0:
success_text += texts.t(
"COUNTRY_CHANGES_DISCOUNT_INFO",
" (скидка {percent}%: -{amount})",
).format(
percent=servers_discount_percent,
amount=texts.format_price(total_discount),
)
success_text += "\n"
if removed_names:
success_text += "\n" + texts.t(
"COUNTRY_CHANGES_REMOVED_HEADER",
" <b>Отключены страны:</b>\n",
)
success_text += "\n".join(f"{name}" for name in removed_names)
success_text += "\n" + texts.t(
"COUNTRY_CHANGES_REMOVED_WARNING",
" Повторное подключение будет платным",
) + "\n"
success_text += "\n" + texts.t(
"COUNTRY_CHANGES_ACTIVE_COUNT",
"🌐 <b>Активных стран:</b> {count}",
).format(count=len(selected_countries))
await callback.message.edit_text(
success_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode="HTML"
)
await state.clear()
logger.info(
f"✅ Пользователь {db_user.telegram_id} обновил страны. Добавлено: {len(added)}, удалено: {len(removed)}, заплатил: {total_cost / 100}")
except Exception as e:
logger.error(f"⚠️ Ошибка применения изменений: {e}")
await callback.message.edit_text(
texts.ERROR,
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
async def select_country(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
country_uuid = callback.data.split('_')[1]
data = await state.get_data()
selected_countries = data.get('countries', [])
if country_uuid in selected_countries:
selected_countries.remove(country_uuid)
else:
selected_countries.append(country_uuid)
countries = await _get_available_countries(db_user.promo_group_id)
allowed_country_ids = {country['uuid'] for country in countries}
if country_uuid not in allowed_country_ids and country_uuid not in selected_countries:
await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True)
return
period_base_price = PERIOD_PRICES[data['period_days']]
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()
countries_price, _ = await subscription_service.get_countries_price_by_uuids(
selected_countries,
db,
promo_group_id=db_user.promo_group_id,
)
except AttributeError:
logger.warning("Используем fallback функцию для расчета цен стран")
countries_price, _ = await get_countries_price_by_uuids_fallback(
selected_countries,
db,
promo_group_id=db_user.promo_group_id,
)
data['countries'] = selected_countries
data['total_price'] = base_price + countries_price
await state.set_data(data)
await callback.message.edit_reply_markup(
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
)
await callback.answer()
async def countries_continue(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User
):
data = await state.get_data()
texts = get_texts(db_user.language)
if not data.get('countries'):
await callback.answer("⚠️ Выберите хотя бы одну страну!", show_alert=True)
return
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
await callback.message.edit_text(
texts.SELECT_DEVICES,
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_devices)
await callback.answer()
async def _get_available_countries(promo_group_id: Optional[int] = None):
from app.utils.cache import cache, cache_key
from app.database.database import AsyncSessionLocal
from app.database.crud.server_squad import get_available_server_squads
cache_key_value = cache_key("available_countries", promo_group_id or "all")
cached_countries = await cache.get(cache_key_value)
if cached_countries:
return cached_countries
try:
async with AsyncSessionLocal() as db:
available_servers = await get_available_server_squads(
db, promo_group_id=promo_group_id
)
if promo_group_id is not None and not available_servers:
logger.info(
"Промогруппа %s не имеет доступных серверов, возврат пустого списка",
promo_group_id,
)
await cache.set(cache_key_value, [], 60)
return []
countries = []
for server in available_servers:
countries.append({
"uuid": server.squad_uuid,
"name": server.display_name,
"price_kopeks": server.price_kopeks,
"country_code": server.country_code,
"is_available": server.is_available and not server.is_full
})
if not countries:
logger.info("🔄 Серверов в БД нет, получаем из RemnaWave...")
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
squads = await service.get_all_squads()
for squad in squads:
squad_name = squad["name"]
if not any(flag in squad_name for flag in
["🇳🇱", "🇩🇪", "🇺🇸", "🇫🇷", "🇬🇧", "🇮🇹", "🇪🇸", "🇨🇦", "🇯🇵", "🇸🇬", "🇦🇺"]):
name_lower = squad_name.lower()
if "netherlands" in name_lower or "нидерланды" in name_lower or "nl" in name_lower:
squad_name = f"🇳🇱 {squad_name}"
elif "germany" in name_lower or "германия" in name_lower or "de" in name_lower:
squad_name = f"🇩🇪 {squad_name}"
elif "usa" in name_lower or "сша" in name_lower or "america" in name_lower or "us" in name_lower:
squad_name = f"🇺🇸 {squad_name}"
else:
squad_name = f"🌐 {squad_name}"
countries.append({
"uuid": squad["uuid"],
"name": squad_name,
"price_kopeks": 0,
"is_available": True
})
await cache.set(cache_key_value, countries, 300)
return countries
except Exception as e:
logger.error(f"Ошибка получения списка стран: {e}")
fallback_countries = [
{"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True},
]
await cache.set(cache_key_value, fallback_countries, 60)
return fallback_countries
async def _get_countries_info(squad_uuids):
countries = await _get_available_countries()
return [c for c in countries if c['uuid'] in squad_uuids]
async def handle_add_country_to_subscription(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
logger.info(f"🔍 handle_add_country_to_subscription вызван для {db_user.telegram_id}")
logger.info(f"🔍 Callback data: {callback.data}")
current_state = await state.get_state()
logger.info(f"🔍 Текущее состояние: {current_state}")
country_uuid = callback.data.split('_')[1]
data = await state.get_data()
logger.info(f"🔍 Данные состояния: {data}")
selected_countries = data.get('countries', [])
countries = await _get_available_countries(db_user.promo_group_id)
allowed_country_ids = {country['uuid'] for country in countries}
if country_uuid not in allowed_country_ids and country_uuid not in selected_countries:
await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True)
return
if country_uuid in selected_countries:
selected_countries.remove(country_uuid)
logger.info(f"🔍 Удалена страна: {country_uuid}")
else:
selected_countries.append(country_uuid)
logger.info(f"🔍 Добавлена страна: {country_uuid}")
total_price = 0
subscription = db_user.subscription
period_hint_days = _get_period_hint_from_subscription(subscription)
servers_discount_percent = _get_addon_discount_percent_for_user(
db_user,
"servers",
period_hint_days,
)
for country in countries:
if not country.get('is_available', True):
continue
if (
country['uuid'] in selected_countries
and country['uuid'] not in subscription.connected_squads
):
server_price = country['price_kopeks']
if servers_discount_percent > 0 and server_price > 0:
discounted_price, _ = apply_percentage_discount(
server_price,
servers_discount_percent,
)
else:
discounted_price = server_price
total_price += discounted_price
data['countries'] = selected_countries
data['total_price'] = total_price
await state.set_data(data)
logger.info(f"🔍 Новые выбранные страны: {selected_countries}")
logger.info(f"🔍 Общая стоимость: {total_price}")
try:
from app.keyboards.inline import get_manage_countries_keyboard
await callback.message.edit_reply_markup(
reply_markup=get_manage_countries_keyboard(
countries,
selected_countries,
subscription.connected_squads,
db_user.language,
subscription.end_date,
servers_discount_percent,
)
)
logger.info(f"✅ Клавиатура обновлена")
except Exception as e:
logger.error(f"❌ Ошибка обновления клавиатуры: {e}")
await callback.answer()
async def _should_show_countries_management(user: Optional[User] = None) -> bool:
try:
promo_group_id = user.promo_group_id if user else None
promo_group = getattr(user, "promo_group", None) if user else None
if promo_group and getattr(promo_group, "server_squads", None):
allowed_servers = [
server
for server in promo_group.server_squads
if server.is_available and not server.is_full
]
if allowed_servers:
if len(allowed_servers) > 1:
logger.debug(
"Промогруппа %s имеет %s доступных серверов, показываем управление странами",
promo_group.id,
len(allowed_servers),
)
return True
logger.debug(
"Промогруппа %s имеет всего %s доступный сервер, пропускаем шаг выбора стран",
promo_group.id,
len(allowed_servers),
)
return False
countries = await _get_available_countries(promo_group_id)
available_countries = [c for c in countries if c.get('is_available', True)]
return len(available_countries) > 1
except Exception as e:
logger.error(f"Ошибка проверки доступных серверов: {e}")
return True
async def confirm_add_countries_to_subscription(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
data = await state.get_data()
texts = get_texts(db_user.language)
subscription = db_user.subscription
selected_countries = data.get('countries', [])
current_countries = subscription.connected_squads
countries = await _get_available_countries(db_user.promo_group_id)
allowed_country_ids = {country['uuid'] for country in countries}
selected_countries = [
country_uuid
for country_uuid in selected_countries
if country_uuid in allowed_country_ids or country_uuid in current_countries
]
new_countries = [c for c in selected_countries if c not in current_countries]
removed_countries = [c for c in current_countries if c not in selected_countries]
if not new_countries and not removed_countries:
await callback.answer("⚠️ Изменения не обнаружены", show_alert=True)
return
total_price = 0
new_countries_names = []
removed_countries_names = []
period_hint_days = _get_period_hint_from_subscription(subscription)
servers_discount_percent = _get_addon_discount_percent_for_user(
db_user,
"servers",
period_hint_days,
)
total_discount_value = 0
for country in countries:
if not country.get('is_available', True):
continue
if country['uuid'] in new_countries:
server_price = country['price_kopeks']
if servers_discount_percent > 0 and server_price > 0:
discounted_per_month, discount_per_month = apply_percentage_discount(
server_price,
servers_discount_percent,
)
else:
discounted_per_month = server_price
discount_per_month = 0
charged_price, charged_months = calculate_prorated_price(
discounted_per_month,
subscription.end_date,
)
total_price += charged_price
total_discount_value += discount_per_month * charged_months
new_countries_names.append(country['name'])
if country['uuid'] in removed_countries:
removed_countries_names.append(country['name'])
if new_countries and db_user.balance_kopeks < total_price:
missing_kopeks = total_price - db_user.balance_kopeks
message_text = texts.t(
"ADDON_INSUFFICIENT_FUNDS_MESSAGE",
(
"⚠️ <b>Недостаточно средств</b>\n\n"
"Стоимость услуги: {required}\n"
"На балансе: {balance}\n"
"Не хватает: {missing}\n\n"
"Выберите способ пополнения. Сумма подставится автоматически."
),
).format(
required=texts.format_price(total_price),
balance=texts.format_price(db_user.balance_kopeks),
missing=texts.format_price(missing_kopeks),
)
await callback.message.edit_text(
message_text,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
amount_kopeks=missing_kopeks,
),
parse_mode="HTML",
)
await state.clear()
await callback.answer()
return
try:
if new_countries and total_price > 0:
success = await subtract_user_balance(
db, db_user, total_price,
f"Добавление стран к подписке: {', '.join(new_countries_names)}"
)
if not success:
await callback.answer("❌ Ошибка списания средств", show_alert=True)
return
await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=total_price,
description=f"Добавление стран к подписке: {', '.join(new_countries_names)}"
)
subscription.connected_squads = selected_countries
subscription.updated_at = datetime.utcnow()
await db.commit()
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
await db.refresh(db_user)
await db.refresh(subscription)
success_text = "✅ Страны успешно обновлены!\n\n"
if new_countries_names:
success_text += f" Добавлены страны:\n{chr(10).join(f'{name}' for name in new_countries_names)}\n"
if total_price > 0:
success_text += f"💰 Списано: {texts.format_price(total_price)}"
if total_discount_value > 0:
success_text += (
f" (скидка {servers_discount_percent}%:"
f" -{texts.format_price(total_discount_value)})"
)
success_text += "\n"
if removed_countries_names:
success_text += f"\n Отключены страны:\n{chr(10).join(f'{name}' for name in removed_countries_names)}\n"
success_text += " Повторное подключение будет платным\n"
success_text += f"\n🌍 Активных стран: {len(selected_countries)}"
await callback.message.edit_text(
success_text,
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(
f"✅ Пользователь {db_user.telegram_id} обновил страны подписки. Добавлено: {len(new_countries)}, убрано: {len(removed_countries)}")
except Exception as e:
logger.error(f"Ошибка обновления стран подписки: {e}")
await callback.message.edit_text(
texts.ERROR,
reply_markup=get_back_keyboard(db_user.language)
)
await state.clear()
await callback.answer()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,158 @@
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, get_traffic_prices
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.subscription import (
create_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
update_subscription_autopay
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import (
User, TransactionType, SubscriptionStatus,
Subscription
)
from app.keyboards.inline import (
get_subscription_keyboard, get_trial_keyboard,
get_subscription_period_keyboard, get_traffic_packages_keyboard,
get_countries_keyboard, get_devices_keyboard,
get_subscription_confirm_keyboard, get_autopay_keyboard,
get_autopay_days_keyboard, get_back_keyboard,
get_add_traffic_keyboard,
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
get_app_selection_keyboard, get_specific_app_keyboard,
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
get_devices_management_keyboard, get_device_management_help_keyboard,
get_happ_cryptolink_keyboard,
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
get_happ_download_button_row,
get_payment_methods_keyboard_with_cart,
get_subscription_confirm_keyboard_with_cart,
get_insufficient_balance_keyboard_with_cart
)
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
save_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.promo_offer_service import promo_offer_service
from app.states import SubscriptionStates
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation,
format_period_description,
apply_percentage_discount,
)
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
convert_subscription_link_to_happ_scheme,
)
from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
async def handle_happ_download_request(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
prompt_text = texts.t(
"HAPP_DOWNLOAD_PROMPT",
"📥 <b>Скачать Happ</b>\nВыберите ваше устройство:",
)
keyboard = get_happ_download_platform_keyboard(db_user.language)
await callback.message.answer(prompt_text, reply_markup=keyboard, parse_mode="HTML")
await callback.answer()
async def handle_happ_download_platform_choice(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
platform = callback.data.split('_')[-1]
if platform == "pc":
platform = "windows"
texts = get_texts(db_user.language)
link = settings.get_happ_download_link(platform)
if not link:
await callback.answer(
texts.t("HAPP_DOWNLOAD_LINK_NOT_SET", "❌ Ссылка для этого устройства не настроена"),
show_alert=True,
)
return
platform_names = {
"ios": texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"),
"android": texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"),
"macos": texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"),
"windows": texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"),
}
link_text = texts.t(
"HAPP_DOWNLOAD_LINK_MESSAGE",
"⬇️ Скачайте Happ для {platform}:",
).format(platform=platform_names.get(platform, platform.upper()))
keyboard = get_happ_download_link_keyboard(db_user.language, link)
await callback.message.edit_text(link_text, reply_markup=keyboard, parse_mode="HTML")
await callback.answer()
async def handle_happ_download_close(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
try:
await callback.message.delete()
except Exception:
pass
await callback.answer()
async def handle_happ_download_back(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
prompt_text = texts.t(
"HAPP_DOWNLOAD_PROMPT",
"📥 <b>Скачать Happ</b>\nВыберите ваше устройство:",
)
keyboard = get_happ_download_platform_keyboard(db_user.language)
await callback.message.edit_text(prompt_text, reply_markup=keyboard, parse_mode="HTML")
await callback.answer()

View File

@@ -0,0 +1,354 @@
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, get_traffic_prices
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.subscription import (
create_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
update_subscription_autopay
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import (
User, TransactionType, SubscriptionStatus,
Subscription
)
from app.keyboards.inline import (
get_subscription_keyboard, get_trial_keyboard,
get_subscription_period_keyboard, get_traffic_packages_keyboard,
get_countries_keyboard, get_devices_keyboard,
get_subscription_confirm_keyboard, get_autopay_keyboard,
get_autopay_days_keyboard, get_back_keyboard,
get_add_traffic_keyboard,
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
get_app_selection_keyboard, get_specific_app_keyboard,
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
get_devices_management_keyboard, get_device_management_help_keyboard,
get_happ_cryptolink_keyboard,
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
get_happ_download_button_row,
get_payment_methods_keyboard_with_cart,
get_subscription_confirm_keyboard_with_cart,
get_insufficient_balance_keyboard_with_cart
)
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
save_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.promo_offer_service import promo_offer_service
from app.states import SubscriptionStates
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation,
format_period_description,
apply_percentage_discount,
)
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
convert_subscription_link_to_happ_scheme,
)
from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
async def handle_connect_subscription(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
subscription = db_user.subscription
subscription_link = get_display_subscription_link(subscription)
hide_subscription_link = settings.should_hide_subscription_link()
if not subscription_link:
await callback.answer(
texts.t(
"SUBSCRIPTION_NO_ACTIVE_LINK",
"У вас нет активной подписки или ссылка еще генерируется",
),
show_alert=True,
)
return
connect_mode = settings.CONNECT_BUTTON_MODE
if connect_mode == "miniapp_subscription":
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
web_app=types.WebAppInfo(url=subscription_link)
)
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
]
])
await callback.message.edit_text(
texts.t(
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE",
"""📱 <b>Подключить подписку</b>
🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:""",
),
reply_markup=keyboard,
parse_mode="HTML"
)
elif connect_mode == "miniapp_custom":
if not settings.MINIAPP_CUSTOM_URL:
await callback.answer(
texts.t(
"CUSTOM_MINIAPP_URL_NOT_SET",
"⚠ Кастомная ссылка для мини-приложения не настроена",
),
show_alert=True,
)
return
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL)
)
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
]
])
await callback.message.edit_text(
texts.t(
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE",
"""🚀 <b>Подключить подписку</b>
📱 Нажмите кнопку ниже, чтобы открыть приложение:""",
),
reply_markup=keyboard,
parse_mode="HTML"
)
elif connect_mode == "link":
rows = [
[
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
url=subscription_link
)
]
]
happ_row = get_happ_download_button_row(texts)
if happ_row:
rows.append(happ_row)
rows.append([
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
])
keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
await callback.message.edit_text(
texts.t(
"SUBSCRIPTION_CONNECT_LINK_MESSAGE",
"""🚀 <b>Подключить подписку</b>",
🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:""",
),
reply_markup=keyboard,
parse_mode="HTML"
)
elif connect_mode == "happ_cryptolink":
rows = [
[
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
callback_data="open_subscription_link",
)
]
]
happ_row = get_happ_download_button_row(texts)
if happ_row:
rows.append(happ_row)
rows.append([
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
])
keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
await callback.message.edit_text(
texts.t(
"SUBSCRIPTION_CONNECT_LINK_MESSAGE",
"""🚀 <b>Подключить подписку</b>",
🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:""",
),
reply_markup=keyboard,
parse_mode="HTML"
)
else:
if hide_subscription_link:
device_text = texts.t(
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN",
"""📱 <b>Подключить подписку</b>
Ссылка подписки доступна по кнопкам ниже или в разделе "Моя подписка".
💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:""",
)
else:
device_text = texts.t(
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE",
"""📱 <b>Подключить подписку</b>
🔗 <b>Ссылка подписки:</b>
<code>{subscription_url}</code>
💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:""",
).format(subscription_url=subscription_link)
await callback.message.edit_text(
device_text,
reply_markup=get_device_selection_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
async def handle_open_subscription_link(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
subscription = db_user.subscription
subscription_link = get_display_subscription_link(subscription)
if not subscription_link:
await callback.answer(
texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"),
show_alert=True,
)
return
if settings.is_happ_cryptolink_mode():
redirect_link = get_happ_cryptolink_redirect_link(subscription_link)
happ_scheme_link = convert_subscription_link_to_happ_scheme(subscription_link)
happ_message = (
texts.t(
"SUBSCRIPTION_HAPP_OPEN_TITLE",
"🔗 <b>Подключение через Happ</b>",
)
+ "\n\n"
+ texts.t(
"SUBSCRIPTION_HAPP_OPEN_LINK",
"<a href=\"{subscription_link}\">🔓 Открыть ссылку в Happ</a>",
).format(subscription_link=happ_scheme_link)
+ "\n\n"
+ texts.t(
"SUBSCRIPTION_HAPP_OPEN_HINT",
"💡 Если ссылка не открывается автоматически, скопируйте её вручную:",
)
)
if redirect_link:
happ_message += "\n\n" + texts.t(
"SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT",
"▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.",
)
happ_message += "\n\n" + texts.t(
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK",
"<blockquote expandable><code>{crypto_link}</code></blockquote>",
).format(crypto_link=subscription_link)
keyboard = get_happ_cryptolink_keyboard(
subscription_link,
db_user.language,
redirect_link=redirect_link,
)
await callback.message.answer(
happ_message,
parse_mode="HTML",
disable_web_page_preview=True,
reply_markup=keyboard,
)
await callback.answer()
return
link_text = (
texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 <b>Ссылка подписки:</b>")
+ "\n\n"
+ f"<code>{subscription_link}</code>\n\n"
+ texts.t("SUBSCRIPTION_LINK_USAGE_TITLE", "📱 <b>Как использовать:</b>")
+ "\n"
+ "\n".join(
[
texts.t(
"SUBSCRIPTION_LINK_STEP1",
"1. Нажмите на ссылку выше чтобы её скопировать",
),
texts.t(
"SUBSCRIPTION_LINK_STEP2",
"2. Откройте ваше VPN приложение",
),
texts.t(
"SUBSCRIPTION_LINK_STEP3",
"3. Найдите функцию \"Добавить подписку\" или \"Import\"",
),
texts.t(
"SUBSCRIPTION_LINK_STEP4",
"4. Вставьте скопированную ссылку",
),
]
)
+ "\n\n"
+ texts.t(
"SUBSCRIPTION_LINK_HINT",
"💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.",
)
)
await callback.message.edit_text(
link_text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
callback_data="subscription_connect")
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
]
]),
parse_mode="HTML"
)
await callback.answer()

View File

@@ -0,0 +1,131 @@
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, get_traffic_prices
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.subscription import (
create_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
update_subscription_autopay
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import (
User, TransactionType, SubscriptionStatus,
Subscription
)
from app.keyboards.inline import (
get_subscription_keyboard, get_trial_keyboard,
get_subscription_period_keyboard, get_traffic_packages_keyboard,
get_countries_keyboard, get_devices_keyboard,
get_subscription_confirm_keyboard, get_autopay_keyboard,
get_autopay_days_keyboard, get_back_keyboard,
get_add_traffic_keyboard,
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
get_app_selection_keyboard, get_specific_app_keyboard,
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
get_devices_management_keyboard, get_device_management_help_keyboard,
get_happ_cryptolink_keyboard,
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
get_happ_download_button_row,
get_payment_methods_keyboard_with_cart,
get_subscription_confirm_keyboard_with_cart,
get_insufficient_balance_keyboard_with_cart
)
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
save_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.promo_offer_service import promo_offer_service
from app.states import SubscriptionStates
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation,
format_period_description,
apply_percentage_discount,
)
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
convert_subscription_link_to_happ_scheme,
)
from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
from .common import logger
async def send_trial_notification(callback: types.CallbackQuery, db: AsyncSession, db_user: User,
subscription: Subscription):
try:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_trial_activation_notification(db, db_user, subscription)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о триале: {e}")
async def send_purchase_notification(
callback: types.CallbackQuery,
db: AsyncSession,
db_user: User,
subscription: Subscription,
transaction_id: int,
period_days: int,
was_trial_conversion: bool = False
):
try:
from app.database.crud.transaction import get_transaction_by_id
transaction = await get_transaction_by_id(db, transaction_id)
if transaction:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_purchase_notification(
db, db_user, subscription, transaction, period_days, was_trial_conversion
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о покупке: {e}")
async def send_extension_notification(
callback: types.CallbackQuery,
db: AsyncSession,
db_user: User,
subscription: Subscription,
transaction_id: int,
extended_days: int,
old_end_date: datetime
):
try:
from app.database.crud.transaction import get_transaction_by_id
transaction = await get_transaction_by_id(db, transaction_id)
if transaction:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_extension_notification(
db, db_user, subscription, transaction, extended_days, old_end_date
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о продлении: {e}")

View File

@@ -0,0 +1,465 @@
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, get_traffic_prices
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.subscription import (
create_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
update_subscription_autopay
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import (
User, TransactionType, SubscriptionStatus,
Subscription
)
from app.keyboards.inline import (
get_subscription_keyboard, get_trial_keyboard,
get_subscription_period_keyboard, get_traffic_packages_keyboard,
get_countries_keyboard, get_devices_keyboard,
get_subscription_confirm_keyboard, get_autopay_keyboard,
get_autopay_days_keyboard, get_back_keyboard,
get_add_traffic_keyboard,
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
get_app_selection_keyboard, get_specific_app_keyboard,
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
get_devices_management_keyboard, get_device_management_help_keyboard,
get_happ_cryptolink_keyboard,
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
get_happ_download_button_row,
get_payment_methods_keyboard_with_cart,
get_subscription_confirm_keyboard_with_cart,
get_insufficient_balance_keyboard_with_cart
)
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
save_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.promo_offer_service import promo_offer_service
from app.states import SubscriptionStates
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation,
format_period_description,
apply_percentage_discount,
)
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
convert_subscription_link_to_happ_scheme,
)
from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
from .common import _apply_discount_to_monthly_component, _apply_promo_offer_discount, logger
from .countries import _get_available_countries, _get_countries_info, get_countries_price_by_uuids_fallback
from .devices import get_current_devices_count
from .promo import _build_promo_group_discount_text, _get_promo_offer_hint
async def _prepare_subscription_summary(
db_user: User,
data: Dict[str, Any],
texts,
) -> Tuple[str, Dict[str, Any]]:
summary_data = dict(data)
countries = await _get_available_countries(db_user.promo_group_id)
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_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()
traffic_price_per_month = settings.get_traffic_price(traffic_limit)
final_traffic_gb = traffic_limit
else:
traffic_gb = summary_data.get('traffic_gb', 0)
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
final_traffic_gb = traffic_gb
traffic_discount_percent = db_user.get_promo_discount(
"traffic",
summary_data['period_days'],
)
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:
if country['uuid'] in selected_country_ids:
server_price_per_month = country['price_kopeks']
countries_price_per_month += server_price_per_month
selected_countries_names.append(country['name'])
server_monthly_prices.append(server_price_per_month)
servers_discount_percent = db_user.get_promo_discount(
"servers",
summary_data['period_days'],
)
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
devices_discount_percent = db_user.get_promo_discount(
"devices",
summary_data['period_days'],
)
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
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")
original_total_price = total_price
promo_offer_component = _apply_promo_offer_discount(db_user, total_price)
if promo_offer_component["discount"] > 0:
total_price = promo_offer_component["discounted"]
summary_data['total_price'] = total_price
if promo_offer_component["discount"] > 0:
summary_data['promo_offer_discount_percent'] = promo_offer_component["percent"]
summary_data['promo_offer_discount_value'] = promo_offer_component["discount"]
summary_data['total_price_before_promo_offer'] = original_total_price
else:
summary_data.pop('promo_offer_discount_percent', None)
summary_data.pop('promo_offer_discount_value', None)
summary_data.pop('total_price_before_promo_offer', None)
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"]
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:
traffic_display = "Безлимитный"
else:
traffic_display = f"{final_traffic_gb} ГБ"
else:
if summary_data.get('traffic_gb', 0) == 0:
traffic_display = "Безлимитный"
else:
traffic_display = f"{summary_data.get('traffic_gb', 0)} ГБ"
if base_discount_total > 0:
base_line = (
f"- Базовый период: <s>{texts.format_price(base_price_original)}</s> "
f"{texts.format_price(base_price)}"
f" (скидка {period_discount_percent}%:"
f" -{texts.format_price(base_discount_total)})"
)
else:
base_line = f"- Базовый период: {texts.format_price(base_price_original)}"
details_lines = [base_line]
if total_traffic_price > 0:
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:
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:
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)
if promo_offer_component["discount"] > 0:
details_lines.append(
texts.t(
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT",
"- Промо-предложение: -{amount} ({percent}% дополнительно)",
).format(
amount=texts.format_price(promo_offer_component["discount"]),
percent=promo_offer_component["percent"],
)
)
details_text = "\n".join(details_lines)
summary_text = (
"📋 <b>Сводка заказа</b>\n\n"
f"📅 <b>Период:</b> {period_display}\n"
f"📊 <b>Трафик:</b> {traffic_display}\n"
f"🌍 <b>Страны:</b> {', '.join(selected_countries_names)}\n"
f"📱 <b>Устройства:</b> {devices_selected}\n\n"
"💰 <b>Детализация стоимости:</b>\n"
f"{details_text}\n\n"
f"💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}\n\n"
"Подтверждаете покупку?"
)
return summary_text, summary_data
async def _build_subscription_period_prompt(
db_user: User,
texts,
db: AsyncSession,
) -> str:
base_text = texts.BUY_SUBSCRIPTION_START.rstrip()
lines: List[str] = [base_text]
promo_offer_hint = await _get_promo_offer_hint(db, db_user, texts)
if promo_offer_hint:
lines.extend(["", promo_offer_hint])
promo_text = _build_promo_group_discount_text(
db_user,
settings.get_available_subscription_periods(),
texts=texts,
)
if promo_text:
lines.extend(["", promo_text])
return "\n".join(lines) + "\n"
async def get_subscription_cost(subscription, db: AsyncSession) -> int:
try:
if subscription.is_trial:
return 0
from app.config import settings
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
base_cost_original = PERIOD_PRICES.get(30, 0)
try:
owner = subscription.user
except AttributeError:
owner = None
promo_group_id = getattr(owner, "promo_group_id", None) if owner else None
period_discount_percent = 0
if owner:
try:
period_discount_percent = owner.get_promo_discount("period", 30)
except AttributeError:
period_discount_percent = 0
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,
promo_group_id=promo_group_id,
)
except AttributeError:
servers_cost, _ = await get_countries_price_by_uuids_fallback(
subscription.connected_squads,
db,
promo_group_id=promo_group_id,
)
traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb)
devices_cost = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
total_cost = base_cost + servers_cost + traffic_cost + devices_cost
logger.info(f"📊 Месячная стоимость конфигурации подписки {subscription.id}:")
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:
logger.info(f" 📊 Трафик: {traffic_cost / 100}")
if devices_cost > 0:
logger.info(f" 📱 Устройства: {devices_cost / 100}")
logger.info(f" 💎 ИТОГО: {total_cost / 100}")
return total_cost
except Exception as e:
logger.error(f"⚠️ Ошибка расчета стоимости подписки: {e}")
return 0
async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession):
devices_used = await get_current_devices_count(db_user)
countries_info = await _get_countries_info(subscription.connected_squads)
countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет"
subscription_url = getattr(subscription, 'subscription_url', None) or "Генерируется..."
if subscription.is_trial:
status_text = "🎁 Тестовая"
type_text = "Триал"
else:
if subscription.is_active:
status_text = "✅ Оплачена"
else:
status_text = "⌛ Истекла"
type_text = "Платная подписка"
if subscription.traffic_limit_gb == 0:
if settings.is_traffic_fixed():
traffic_text = "∞ Безлимитный"
else:
traffic_text = "∞ Безлимитный"
else:
if settings.is_traffic_fixed():
traffic_text = f"{subscription.traffic_limit_gb} ГБ"
else:
traffic_text = f"{subscription.traffic_limit_gb} ГБ"
subscription_cost = await get_subscription_cost(subscription, db)
info_text = texts.SUBSCRIPTION_INFO.format(
status=status_text,
type=type_text,
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
days_left=max(0, subscription.days_left),
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
traffic_limit=traffic_text,
countries_count=len(subscription.connected_squads),
devices_used=devices_used,
devices_limit=subscription.device_limit,
autopay_status="✅ Включен" if subscription.autopay_enabled else "⌛ Выключен"
)
if subscription_cost > 0:
info_text += f"\n💰 <b>Стоимость подписки в месяц:</b> {texts.format_price(subscription_cost)}"
if (
subscription_url
and subscription_url != "Генерируется..."
and not settings.should_hide_subscription_link()
):
info_text += f"\n\n🔗 <b>Ваша ссылка для импорта в VPN приложениe:</b>\n<code>{subscription_url}</code>"
return info_text

View File

@@ -0,0 +1,459 @@
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, get_traffic_prices
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.subscription import (
create_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
update_subscription_autopay
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import (
User, TransactionType, SubscriptionStatus,
Subscription
)
from app.keyboards.inline import (
get_subscription_keyboard, get_trial_keyboard,
get_subscription_period_keyboard, get_traffic_packages_keyboard,
get_countries_keyboard, get_devices_keyboard,
get_subscription_confirm_keyboard, get_autopay_keyboard,
get_autopay_days_keyboard, get_back_keyboard,
get_add_traffic_keyboard,
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
get_app_selection_keyboard, get_specific_app_keyboard,
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
get_devices_management_keyboard, get_device_management_help_keyboard,
get_happ_cryptolink_keyboard,
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
get_happ_download_button_row,
get_payment_methods_keyboard_with_cart,
get_subscription_confirm_keyboard_with_cart,
get_insufficient_balance_keyboard_with_cart
)
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
save_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.promo_offer_service import promo_offer_service
from app.states import SubscriptionStates
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation,
format_period_description,
apply_percentage_discount,
)
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
convert_subscription_link_to_happ_scheme,
)
from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
from .common import _format_text_with_placeholders
async def _get_promo_offer_hint(
db: AsyncSession,
db_user: User,
texts,
percent: Optional[int] = None,
) -> Optional[str]:
return await build_promo_offer_hint(db, db_user, texts, percent)
def _build_promo_group_discount_text(
db_user: User,
periods: Optional[List[int]] = None,
texts=None,
) -> str:
promo_group = getattr(db_user, "promo_group", None)
if not promo_group:
return ""
if texts is None:
texts = get_texts(db_user.language)
service_lines: List[str] = []
if promo_group.server_discount_percent > 0:
service_lines.append(
texts.PROMO_GROUP_DISCOUNT_SERVERS.format(
percent=promo_group.server_discount_percent
)
)
if promo_group.traffic_discount_percent > 0:
service_lines.append(
texts.PROMO_GROUP_DISCOUNT_TRAFFIC.format(
percent=promo_group.traffic_discount_percent
)
)
if promo_group.device_discount_percent > 0:
service_lines.append(
texts.PROMO_GROUP_DISCOUNT_DEVICES.format(
percent=promo_group.device_discount_percent
)
)
period_lines: List[str] = []
period_candidates: set[int] = set(periods or [])
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
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 ""
lines: List[str] = [texts.PROMO_GROUP_DISCOUNTS_HEADER]
if service_lines:
lines.extend(service_lines)
if period_lines:
if service_lines:
lines.append("")
lines.append(texts.PROMO_GROUP_PERIOD_DISCOUNTS_HEADER)
lines.extend(period_lines)
return "\n".join(lines)
async def claim_discount_offer(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
try:
offer_id = int(callback.data.split("_")[-1])
except (ValueError, AttributeError):
await callback.answer(
texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"),
show_alert=True,
)
return
offer = await get_offer_by_id(db, offer_id)
if not offer or offer.user_id != db_user.id:
await callback.answer(
texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"),
show_alert=True,
)
return
now = datetime.utcnow()
if offer.claimed_at is not None:
await callback.answer(
texts.get("DISCOUNT_CLAIM_ALREADY", " Скидка уже была активирована"),
show_alert=True,
)
return
if not offer.is_active or offer.expires_at <= now:
offer.is_active = False
await db.commit()
await callback.answer(
texts.get("DISCOUNT_CLAIM_EXPIRED", "⚠️ Время действия предложения истекло"),
show_alert=True,
)
return
effect_type = (offer.effect_type or "percent_discount").lower()
if effect_type == "balance_bonus":
effect_type = "percent_discount"
if effect_type == "test_access":
success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access(
db,
db_user,
offer,
)
if not success:
if error_code == "subscription_missing":
error_message = texts.get(
"TEST_ACCESS_NO_SUBSCRIPTION",
"❌ Для активации предложения необходима действующая подписка.",
)
elif error_code == "squads_missing":
error_message = texts.get(
"TEST_ACCESS_NO_SQUADS",
"Не удалось определить список серверов для теста. Обратитесь к администратору.",
)
elif error_code == "already_connected":
error_message = texts.get(
"TEST_ACCESS_ALREADY_CONNECTED",
" Этот сервер уже подключен к вашей подписке.",
)
elif error_code == "remnawave_sync_failed":
error_message = texts.get(
"TEST_ACCESS_REMNAWAVE_ERROR",
"Не удалось подключить серверы. Попробуйте позже или обратитесь в поддержку.",
)
else:
error_message = texts.get(
"TEST_ACCESS_UNKNOWN_ERROR",
"Не удалось активировать предложение. Попробуйте позже.",
)
await callback.answer(error_message, show_alert=True)
return
await mark_offer_claimed(
db,
offer,
details={
"context": "test_access_claim",
"new_squads": newly_added,
"expires_at": expires_at.isoformat() if expires_at else None,
},
)
expires_text = expires_at.strftime("%d.%m.%Y %H:%M") if expires_at else ""
success_message = texts.get(
"TEST_ACCESS_ACTIVATED_MESSAGE",
"🎉 Тестовые сервера подключены! Доступ активен до {expires_at}.",
).format(expires_at=expires_text)
popup_text = texts.get("TEST_ACCESS_ACTIVATED_POPUP", "✅ Доступ выдан!")
await callback.answer(popup_text, show_alert=True)
back_keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.get("BACK_TO_MENU", "🏠 В главное меню"),
callback_data="back_to_menu",
)
]
]
)
await callback.message.answer(success_message, reply_markup=back_keyboard)
return
discount_percent = int(offer.discount_percent or 0)
if discount_percent <= 0:
await callback.answer(
texts.get("DISCOUNT_CLAIM_ERROR", "Не удалось активировать скидку. Попробуйте позже."),
show_alert=True,
)
return
db_user.promo_offer_discount_percent = discount_percent
db_user.promo_offer_discount_source = offer.notification_type
db_user.updated_at = now
extra_data = offer.extra_data or {}
raw_duration = extra_data.get("active_discount_hours")
template_id = extra_data.get("template_id")
if raw_duration in (None, "") and template_id:
try:
template = await get_promo_offer_template_by_id(db, int(template_id))
except (ValueError, TypeError):
template = None
if template and template.active_discount_hours:
raw_duration = template.active_discount_hours
try:
duration_hours = int(raw_duration) if raw_duration is not None else None
except (TypeError, ValueError):
duration_hours = None
if duration_hours and duration_hours > 0:
discount_expires_at = now + timedelta(hours=duration_hours)
else:
discount_expires_at = None
db_user.promo_offer_discount_expires_at = discount_expires_at
await mark_offer_claimed(
db,
offer,
details={
"context": "discount_claim",
"discount_percent": discount_percent,
"discount_expires_at": discount_expires_at.isoformat() if discount_expires_at else None,
},
)
await db.refresh(db_user)
success_template = texts.get(
"DISCOUNT_CLAIM_SUCCESS",
"🎉 Скидка {percent}% активирована! Она автоматически применится при следующей оплате.",
)
expires_text = (
discount_expires_at.strftime("%d.%m.%Y %H:%M") if discount_expires_at else ""
)
format_values: Dict[str, Any] = {"percent": discount_percent}
if duration_hours and duration_hours > 0:
format_values.setdefault("hours", duration_hours)
format_values.setdefault("duration_hours", duration_hours)
if discount_expires_at:
format_values.setdefault("expires_at", expires_text)
format_values.setdefault("expires_at_iso", discount_expires_at.isoformat())
try:
expires_timestamp = int(discount_expires_at.timestamp())
except (OverflowError, OSError, ValueError):
expires_timestamp = None
if expires_timestamp:
format_values.setdefault("expires_at_ts", expires_timestamp)
remaining_hours = int((discount_expires_at - now).total_seconds() // 3600)
if remaining_hours > 0:
format_values.setdefault("expires_in_hours", remaining_hours)
amount_text = ""
if isinstance(extra_data, dict):
raw_amount_text = (
extra_data.get("amount_text")
or extra_data.get("discount_amount_text")
or extra_data.get("formatted_amount")
)
if isinstance(raw_amount_text, str) and raw_amount_text.strip():
amount_text = raw_amount_text.strip()
else:
raw_amount = extra_data.get("amount") or extra_data.get("discount_amount")
if isinstance(raw_amount, (int, float)):
amount_text = settings.format_price(int(raw_amount))
elif isinstance(raw_amount, str) and raw_amount.strip():
amount_text = raw_amount.strip()
if not amount_text:
for key in ("discount_amount_kopeks", "amount_kopeks", "bonus_amount_kopeks"):
maybe_amount = extra_data.get(key)
try:
amount_value = int(maybe_amount)
except (TypeError, ValueError):
continue
if amount_value > 0:
amount_text = settings.format_price(amount_value)
break
for key, value in extra_data.items():
if (
isinstance(key, str)
and key.isidentifier()
and key not in format_values
and isinstance(value, (str, int, float))
):
format_values[key] = value
if not amount_text:
try:
bonus_amount = int(getattr(offer, "bonus_amount_kopeks", 0))
except (TypeError, ValueError):
bonus_amount = 0
if bonus_amount > 0:
amount_text = settings.format_price(bonus_amount)
if amount_text:
format_values.setdefault("amount", amount_text)
success_message = _format_text_with_placeholders(success_template, format_values)
await callback.answer("✅ Скидка активирована!", show_alert=True)
offer_type = None
if isinstance(extra_data, dict):
offer_type = extra_data.get("offer_type")
subscription = getattr(db_user, "subscription", None)
if offer_type == "purchase_discount":
button_text = texts.get("MENU_BUY_SUBSCRIPTION", "💎 Купить подписку")
button_callback = "subscription_upgrade"
elif offer_type == "extend_discount":
button_text = texts.get("SUBSCRIPTION_EXTEND", "💎 Продлить подписку")
button_callback = "subscription_extend"
else:
has_active_paid_subscription = bool(
subscription
and getattr(subscription, "is_active", False)
and not getattr(subscription, "is_trial", False)
)
if has_active_paid_subscription:
button_text = texts.get("SUBSCRIPTION_EXTEND", "💎 Продлить подписку")
button_callback = "subscription_extend"
else:
button_text = texts.get("MENU_BUY_SUBSCRIPTION", "💎 Купить подписку")
button_callback = "subscription_upgrade"
buy_keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
build_miniapp_or_callback_button(
text=button_text,
callback_data=button_callback,
)
]
]
)
await callback.message.answer(success_message, reply_markup=buy_keyboard)
async def handle_promo_offer_close(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
try:
await callback.message.delete()
except Exception:
try:
await callback.message.edit_reply_markup()
except Exception:
pass
await callback.answer()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,734 @@
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, get_traffic_prices
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.subscription import (
create_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
update_subscription_autopay
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import (
User, TransactionType, SubscriptionStatus,
Subscription
)
from app.keyboards.inline import (
get_subscription_keyboard, get_trial_keyboard,
get_subscription_period_keyboard, get_traffic_packages_keyboard,
get_countries_keyboard, get_devices_keyboard,
get_subscription_confirm_keyboard, get_autopay_keyboard,
get_autopay_days_keyboard, get_back_keyboard,
get_add_traffic_keyboard,
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
get_app_selection_keyboard, get_specific_app_keyboard,
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
get_devices_management_keyboard, get_device_management_help_keyboard,
get_happ_cryptolink_keyboard,
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
get_happ_download_button_row,
get_payment_methods_keyboard_with_cart,
get_subscription_confirm_keyboard_with_cart,
get_insufficient_balance_keyboard_with_cart
)
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
save_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.promo_offer_service import promo_offer_service
from app.states import SubscriptionStates
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation,
format_period_description,
apply_percentage_discount,
)
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
convert_subscription_link_to_happ_scheme,
)
from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
from .common import _apply_addon_discount, _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, get_confirm_switch_traffic_keyboard, get_traffic_switch_keyboard, logger
from .countries import _get_available_countries, _should_show_countries_management
async def handle_add_traffic(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
from app.config import settings
texts = get_texts(db_user.language)
if settings.is_traffic_fixed():
await callback.answer(
texts.t(
"TRAFFIC_FIXED_MODE",
"⚠️ В текущем режиме трафик фиксированный и не может быть изменен",
),
show_alert=True,
)
return
subscription = db_user.subscription
if not subscription or subscription.is_trial:
await callback.answer(
texts.t("PAID_FEATURE_ONLY", "⚠ Эта функция доступна только для платных подписок"),
show_alert=True,
)
return
if subscription.traffic_limit_gb == 0:
await callback.answer(
texts.t("TRAFFIC_ALREADY_UNLIMITED", "У вас уже безлимитный трафик"),
show_alert=True,
)
return
current_traffic = subscription.traffic_limit_gb
period_hint_days = _get_period_hint_from_subscription(subscription)
traffic_discount_percent = _get_addon_discount_percent_for_user(
db_user,
"traffic",
period_hint_days,
)
prompt_text = texts.t(
"ADD_TRAFFIC_PROMPT",
(
"📈 <b>Добавить трафик к подписке</b>\n\n"
"Текущий лимит: {current_traffic}\n"
"Выберите дополнительный трафик:"
),
).format(current_traffic=texts.format_traffic(current_traffic))
await callback.message.edit_text(
prompt_text,
reply_markup=get_add_traffic_keyboard(
db_user.language,
subscription.end_date,
traffic_discount_percent,
),
parse_mode="HTML"
)
await callback.answer()
async def handle_reset_traffic(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
from app.config import settings
if settings.is_traffic_fixed():
await callback.answer("⚠️ В текущем режиме трафик фиксированный и не может быть сброшен", show_alert=True)
return
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription or subscription.is_trial:
await callback.answer("⌛ Эта функция доступна только для платных подписок", show_alert=True)
return
if subscription.traffic_limit_gb == 0:
await callback.answer("У вас безлимитный трафик", show_alert=True)
return
reset_price = PERIOD_PRICES[30]
if db_user.balance_kopeks < reset_price:
await callback.answer("⌛ Недостаточно средств на балансе", show_alert=True)
return
await callback.message.edit_text(
f"🔄 <b>Сброс трафика</b>\n\n"
f"Использовано: {texts.format_traffic(subscription.traffic_used_gb)}\n"
f"Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}\n\n"
f"Стоимость сброса: {texts.format_price(reset_price)}\n\n"
"После сброса счетчик использованного трафика станет равным 0.",
reply_markup=get_reset_traffic_confirm_keyboard(reset_price, db_user.language)
)
await callback.answer()
async def confirm_reset_traffic(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
from app.config import settings
if settings.is_traffic_fixed():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
texts = get_texts(db_user.language)
subscription = db_user.subscription
reset_price = PERIOD_PRICES[30]
if db_user.balance_kopeks < reset_price:
missing_kopeks = reset_price - db_user.balance_kopeks
message_text = texts.t(
"ADDON_INSUFFICIENT_FUNDS_MESSAGE",
(
"⚠️ <b>Недостаточно средств</b>\n\n"
"Стоимость услуги: {required}\n"
"На балансе: {balance}\n"
"Не хватает: {missing}\n\n"
"Выберите способ пополнения. Сумма подставится автоматически."
),
).format(
required=texts.format_price(reset_price),
balance=texts.format_price(db_user.balance_kopeks),
missing=texts.format_price(missing_kopeks),
)
await callback.message.edit_text(
message_text,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
amount_kopeks=missing_kopeks,
),
parse_mode="HTML",
)
await callback.answer()
return
try:
success = await subtract_user_balance(
db, db_user, reset_price,
"Сброс трафика"
)
if not success:
await callback.answer("⌛ Ошибка списания средств", show_alert=True)
return
subscription.traffic_used_gb = 0.0
subscription.updated_at = datetime.utcnow()
await db.commit()
subscription_service = SubscriptionService()
remnawave_service = RemnaWaveService()
user = db_user
if user.remnawave_uuid:
async with remnawave_service.get_api_client() as api:
await api.reset_user_traffic(user.remnawave_uuid)
await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=reset_price,
description="Сброс трафика"
)
await db.refresh(db_user)
await db.refresh(subscription)
await callback.message.edit_text(
f"✅ Трафик успешно сброшен!\n\n"
f"🔄 Использованный трафик обнулен\n"
f"📊 Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}",
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(f"✅ Пользователь {db_user.telegram_id} сбросил трафик")
except Exception as e:
logger.error(f"Ошибка сброса трафика: {e}")
await callback.message.edit_text(
texts.ERROR,
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
async def refresh_traffic_config():
try:
from app.config import refresh_traffic_prices
refresh_traffic_prices()
packages = settings.get_traffic_packages()
enabled_count = sum(1 for pkg in packages if pkg['enabled'])
logger.info(f"🔄 Конфигурация трафика обновлена: {enabled_count} активных пакетов")
for pkg in packages:
if pkg['enabled']:
gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ"
logger.info(f" 📦 {gb_text}: {pkg['price'] / 100}")
return True
except Exception as e:
logger.error(f"⚠️ Ошибка обновления конфигурации трафика: {e}")
return False
async def get_traffic_packages_info() -> str:
try:
packages = settings.get_traffic_packages()
info_lines = ["📦 Настроенные пакеты трафика:"]
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
disabled_packages = [pkg for pkg in packages if not pkg['enabled']]
if enabled_packages:
info_lines.append("\n✅ Активные:")
for pkg in enabled_packages:
gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ"
info_lines.append(f"{gb_text}: {pkg['price'] // 100}")
if disabled_packages:
info_lines.append("\n❌ Отключенные:")
for pkg in disabled_packages:
gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ"
info_lines.append(f"{gb_text}: {pkg['price'] // 100}")
info_lines.append(f"\n📊 Всего пакетов: {len(packages)}")
info_lines.append(f"🟢 Активных: {len(enabled_packages)}")
info_lines.append(f"🔴 Отключенных: {len(disabled_packages)}")
return "\n".join(info_lines)
except Exception as e:
return f"⚠️ Ошибка получения информации: {e}"
async def select_traffic(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User
):
traffic_gb = int(callback.data.split('_')[1])
texts = get_texts(db_user.language)
data = await state.get_data()
data['traffic_gb'] = traffic_gb
traffic_price = settings.get_traffic_price(traffic_gb)
data['total_price'] += traffic_price
await state.set_data(data)
if await _should_show_countries_management(db_user):
countries = await _get_available_countries(db_user.promo_group_id)
await callback.message.edit_text(
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, [], db_user.language)
)
await state.set_state(SubscriptionStates.selecting_countries)
else:
countries = await _get_available_countries(db_user.promo_group_id)
available_countries = [c for c in countries if c.get('is_available', True)]
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
await state.set_data(data)
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
await callback.message.edit_text(
texts.SELECT_DEVICES,
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_devices)
await callback.answer()
async def add_traffic(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
if settings.is_traffic_fixed():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
traffic_gb = int(callback.data.split('_')[2])
texts = get_texts(db_user.language)
subscription = db_user.subscription
base_price = settings.get_traffic_price(traffic_gb)
if base_price == 0 and traffic_gb != 0:
await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True)
return
period_hint_days = _get_period_hint_from_subscription(subscription)
discount_result = _apply_addon_discount(
db_user,
"traffic",
base_price,
period_hint_days,
)
discounted_per_month = discount_result["discounted"]
discount_per_month = discount_result["discount"]
charged_months = 1
if subscription:
price, charged_months = calculate_prorated_price(
discounted_per_month,
subscription.end_date,
)
else:
price = discounted_per_month
total_discount_value = discount_per_month * charged_months
if db_user.balance_kopeks < price:
missing_kopeks = price - db_user.balance_kopeks
message_text = texts.t(
"ADDON_INSUFFICIENT_FUNDS_MESSAGE",
(
"⚠️ <b>Недостаточно средств</b>\n\n"
"Стоимость услуги: {required}\n"
"На балансе: {balance}\n"
"Не хватает: {missing}\n\n"
"Выберите способ пополнения. Сумма подставится автоматически."
),
).format(
required=texts.format_price(price),
balance=texts.format_price(db_user.balance_kopeks),
missing=texts.format_price(missing_kopeks),
)
await callback.message.edit_text(
message_text,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
amount_kopeks=missing_kopeks,
),
parse_mode="HTML",
)
await callback.answer()
return
try:
success = await subtract_user_balance(
db,
db_user,
price,
f"Добавление {traffic_gb} ГБ трафика",
)
if not success:
await callback.answer("⚠️ Ошибка списания средств", show_alert=True)
return
if traffic_gb == 0:
subscription.traffic_limit_gb = 0
else:
await add_subscription_traffic(db, subscription, traffic_gb)
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price,
description=f"Добавление {traffic_gb} ГБ трафика",
)
await db.refresh(db_user)
await db.refresh(subscription)
success_text = f"✅ Трафик успешно добавлен!\n\n"
if traffic_gb == 0:
success_text += "🎉 Теперь у вас безлимитный трафик!"
else:
success_text += f"📈 Добавлено: {traffic_gb} ГБ\n"
success_text += f"Новый лимит: {texts.format_traffic(subscription.traffic_limit_gb)}"
if price > 0:
success_text += f"\n💰 Списано: {texts.format_price(price)}"
if total_discount_value > 0:
success_text += (
f" (скидка {discount_result['percent']}%:"
f" -{texts.format_price(total_discount_value)})"
)
await callback.message.edit_text(
success_text,
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {traffic_gb} ГБ трафика")
except Exception as e:
logger.error(f"Ошибка добавления трафика: {e}")
await callback.message.edit_text(
texts.ERROR,
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
async def handle_no_traffic_packages(
callback: types.CallbackQuery,
db_user: User
):
await callback.answer(
"⚠️ В данный момент нет доступных пакетов трафика. "
"Обратитесь в техподдержку для получения информации.",
show_alert=True
)
async def handle_switch_traffic(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
from app.config import settings
if settings.is_traffic_fixed():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription or subscription.is_trial:
await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True)
return
current_traffic = subscription.traffic_limit_gb
period_hint_days = _get_period_hint_from_subscription(subscription)
traffic_discount_percent = _get_addon_discount_percent_for_user(
db_user,
"traffic",
period_hint_days,
)
await callback.message.edit_text(
f"🔄 <b>Переключение лимита трафика</b>\n\n"
f"Текущий лимит: {texts.format_traffic(current_traffic)}\n"
f"Выберите новый лимит трафика:\n\n"
f"💡 <b>Важно:</b>\n"
f"• При увеличении - доплата за разницу\n"
f"• При уменьшении - возврат средств не производится",
reply_markup=get_traffic_switch_keyboard(
current_traffic,
db_user.language,
subscription.end_date,
traffic_discount_percent,
),
parse_mode="HTML"
)
await callback.answer()
async def confirm_switch_traffic(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
new_traffic_gb = int(callback.data.split('_')[2])
texts = get_texts(db_user.language)
subscription = db_user.subscription
current_traffic = subscription.traffic_limit_gb
if new_traffic_gb == current_traffic:
await callback.answer(" Лимит трафика не изменился", show_alert=True)
return
old_price_per_month = settings.get_traffic_price(current_traffic)
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
months_remaining = get_remaining_months(subscription.end_date)
period_hint_days = months_remaining * 30 if months_remaining > 0 else None
traffic_discount_percent = _get_addon_discount_percent_for_user(
db_user,
"traffic",
period_hint_days,
)
discounted_old_per_month, _ = apply_percentage_discount(
old_price_per_month,
traffic_discount_percent,
)
discounted_new_per_month, _ = apply_percentage_discount(
new_price_per_month,
traffic_discount_percent,
)
price_difference_per_month = discounted_new_per_month - discounted_old_per_month
discount_savings_per_month = (
(new_price_per_month - old_price_per_month) - price_difference_per_month
)
if price_difference_per_month > 0:
total_price_difference = price_difference_per_month * months_remaining
if db_user.balance_kopeks < total_price_difference:
missing_kopeks = total_price_difference - db_user.balance_kopeks
message_text = texts.t(
"ADDON_INSUFFICIENT_FUNDS_MESSAGE",
(
"⚠️ <b>Недостаточно средств</b>\n\n"
"Стоимость услуги: {required}\n"
"На балансе: {balance}\n"
"Не хватает: {missing}\n\n"
"Выберите способ пополнения. Сумма подставится автоматически."
),
).format(
required=f"{texts.format_price(total_price_difference)} (за {months_remaining} мес)",
balance=texts.format_price(db_user.balance_kopeks),
missing=texts.format_price(missing_kopeks),
)
await callback.message.edit_text(
message_text,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
amount_kopeks=missing_kopeks,
),
parse_mode="HTML",
)
await callback.answer()
return
action_text = f"увеличить до {texts.format_traffic(new_traffic_gb)}"
cost_text = f"Доплата: {texts.format_price(total_price_difference)} (за {months_remaining} мес)"
if discount_savings_per_month > 0:
total_discount_savings = discount_savings_per_month * months_remaining
cost_text += (
f" (скидка {traffic_discount_percent}%:"
f" -{texts.format_price(total_discount_savings)})"
)
else:
total_price_difference = 0
action_text = f"уменьшить до {texts.format_traffic(new_traffic_gb)}"
cost_text = "Возврат средств не производится"
confirm_text = f"🔄 <b>Подтверждение переключения трафика</b>\n\n"
confirm_text += f"Текущий лимит: {texts.format_traffic(current_traffic)}\n"
confirm_text += f"Новый лимит: {texts.format_traffic(new_traffic_gb)}\n\n"
confirm_text += f"Действие: {action_text}\n"
confirm_text += f"💰 {cost_text}\n\n"
confirm_text += "Подтвердить переключение?"
await callback.message.edit_text(
confirm_text,
reply_markup=get_confirm_switch_traffic_keyboard(new_traffic_gb, total_price_difference, db_user.language),
parse_mode="HTML"
)
await callback.answer()
async def execute_switch_traffic(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
callback_parts = callback.data.split('_')
new_traffic_gb = int(callback_parts[3])
price_difference = int(callback_parts[4])
texts = get_texts(db_user.language)
subscription = db_user.subscription
current_traffic = subscription.traffic_limit_gb
try:
if price_difference > 0:
success = await subtract_user_balance(
db, db_user, price_difference,
f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB"
)
if not success:
await callback.answer("⚠️ Ошибка списания средств", show_alert=True)
return
months_remaining = get_remaining_months(subscription.end_date)
await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price_difference,
description=f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB на {months_remaining} мес"
)
subscription.traffic_limit_gb = new_traffic_gb
subscription.updated_at = datetime.utcnow()
await db.commit()
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
await db.refresh(db_user)
await db.refresh(subscription)
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_update_notification(
db, db_user, subscription, "traffic", current_traffic, new_traffic_gb, price_difference
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления об изменении трафика: {e}")
if new_traffic_gb > current_traffic:
success_text = f"✅ Лимит трафика увеличен!\n\n"
success_text += f"📊 Было: {texts.format_traffic(current_traffic)}"
success_text += f"Стало: {texts.format_traffic(new_traffic_gb)}\n"
if price_difference > 0:
success_text += f"💰 Списано: {texts.format_price(price_difference)}"
elif new_traffic_gb < current_traffic:
success_text = f"✅ Лимит трафика уменьшен!\n\n"
success_text += f"📊 Было: {texts.format_traffic(current_traffic)}"
success_text += f"Стало: {texts.format_traffic(new_traffic_gb)}\n"
success_text += f" Возврат средств не производится"
await callback.message.edit_text(
success_text,
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(
f"✅ Пользователь {db_user.telegram_id} переключил трафик с {current_traffic}GB на {new_traffic_gb}GB, доплата: {price_difference / 100}")
except Exception as e:
logger.error(f"Ошибка переключения трафика: {e}")
await callback.message.edit_text(
texts.ERROR,
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()

View File

@@ -210,9 +210,12 @@
- `app/handlers/start.py` — Python-модуль
Классы: нет
Функции: `_get_language_prompt_text`, `_get_subscription_status`, `_get_subscription_status_simple`, `_insert_random_message`, `get_referral_code_keyboard`, `register_handlers`
- `app/handlers/subscription.py`Python-модуль
Классы: `_SafeFormatDict` (1 методов)
Функции: `_format_text_with_placeholders`, `_get_addon_discount_percent_for_user`, `_apply_addon_discount`, `_get_promo_offer_discount_percent`, `_apply_promo_offer_discount`, `_get_period_hint_from_subscription`, `_apply_discount_to_monthly_component`, `_build_promo_group_discount_text`, `update_traffic_prices`, `format_traffic_display`, `validate_traffic_price`, `load_app_config`, `get_localized_value`, `get_step_description`, `format_additional_section`, `build_redirect_link`, `get_apps_for_device`, `get_device_name`, `create_deep_link`, `get_reset_devices_confirm_keyboard`, `get_traffic_switch_keyboard`, `get_confirm_switch_traffic_keyboard`, `register_handlers`
- `app/handlers/subscription/`пакет обработчиков подписки
Ключевые модули:
- `common.py` — вспомогательные функции форматирования, расчётов и построения клавиатур.
- `purchase.py` — пользовательские сценарии, регистрация обработчиков (`register_handlers`).
- `countries.py`, `devices.py`, `traffic.py`, `autopay.py`, `promo.py`, `happ.py`, `links.py`, `notifications.py`, `pricing.py` — тематические обработчики и сервисные утилиты.
Публичные функции доступны через `app.handlers.subscription` (например, `create_deep_link`, `get_servers_display_names`, `register_handlers`).
- `app/handlers/support.py` — Python-модуль
Классы: нет
Функции: `register_handlers`