Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 519978f634 | |||
| 7095d8f015 | |||
| e8dcfa0687 | |||
| a0e12fa89a | |||
| 097be9aa0f | |||
| f50b5296f4 | |||
| 14e94053ee | |||
| 3c27fa4d90 | |||
| 2a78ace675 | |||
| 81774f87d8 | |||
| 6735c2b38f | |||
| 8bdc5a2332 | |||
| d26262bfbf | |||
| 48f2f01bdd | |||
| ef68a43589 | |||
| 733c1b72ec | |||
| c9ca360286 | |||
| 5aad1e025b | |||
| e811e7c877 | |||
| 932e9f292b | |||
| 03749574d6 | |||
| 1059a51688 | |||
| a1963d5b17 | |||
| 426e736cd5 | |||
| fb03cff403 | |||
| 40780f1184 | |||
| c76290b51e | |||
| 5b6dd3058e | |||
| c8d91a1ec1 | |||
| 21952f84eb | |||
| 7bdfaa312b | |||
| 1829a469e7 | |||
| ddd37a4e98 | |||
| 2804c701c0 | |||
| b3c85b2db5 | |||
| 987b8170c6 |
@@ -9,6 +9,7 @@ from app.database.models import (
|
||||
Subscription, SubscriptionStatus, User,
|
||||
SubscriptionServer
|
||||
)
|
||||
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -407,9 +408,19 @@ async def add_subscription_servers(
|
||||
server_squad_ids: List[int],
|
||||
paid_prices: List[int] = None
|
||||
) -> Subscription:
|
||||
|
||||
if paid_prices is None:
|
||||
paid_prices = [0] * len(server_squad_ids)
|
||||
months_remaining = get_remaining_months(subscription.end_date)
|
||||
paid_prices = []
|
||||
|
||||
from app.database.models import ServerSquad
|
||||
for server_id in server_squad_ids:
|
||||
result = await db.execute(
|
||||
select(ServerSquad.price_kopeks)
|
||||
.where(ServerSquad.id == server_id)
|
||||
)
|
||||
server_price_per_month = result.scalar() or 0
|
||||
total_price_for_period = server_price_per_month * months_remaining
|
||||
paid_prices.append(total_price_for_period)
|
||||
|
||||
for i, server_id in enumerate(server_squad_ids):
|
||||
subscription_server = SubscriptionServer(
|
||||
@@ -422,9 +433,79 @@ async def add_subscription_servers(
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"🌍 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов")
|
||||
logger.info(f"🌍 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов с ценами: {paid_prices}")
|
||||
return subscription
|
||||
|
||||
async def get_server_monthly_price(
|
||||
db: AsyncSession,
|
||||
server_squad_id: int
|
||||
) -> int:
|
||||
from app.database.models import ServerSquad
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad.price_kopeks)
|
||||
.where(ServerSquad.id == server_squad_id)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def get_servers_monthly_prices(
|
||||
db: AsyncSession,
|
||||
server_squad_ids: List[int]
|
||||
) -> List[int]:
|
||||
prices = []
|
||||
for server_id in server_squad_ids:
|
||||
price = await get_server_monthly_price(db, server_id)
|
||||
prices.append(price)
|
||||
return prices
|
||||
|
||||
async def calculate_subscription_total_cost(
|
||||
db: AsyncSession,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: List[int],
|
||||
devices: int
|
||||
) -> Tuple[int, dict]:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
servers_prices = await get_servers_monthly_prices(db, server_squad_ids)
|
||||
servers_price_per_month = sum(servers_prices)
|
||||
total_servers_price = servers_price_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
total_cost = base_price + total_traffic_price + total_servers_price + total_devices_price
|
||||
|
||||
details = {
|
||||
'base_price': base_price,
|
||||
'traffic_price_per_month': traffic_price_per_month,
|
||||
'total_traffic_price': total_traffic_price,
|
||||
'servers_price_per_month': servers_price_per_month,
|
||||
'total_servers_price': total_servers_price,
|
||||
'devices_price_per_month': devices_price_per_month,
|
||||
'total_devices_price': total_devices_price,
|
||||
'months_in_period': months_in_period,
|
||||
'servers_individual_prices': [price * months_in_period for price in servers_prices]
|
||||
}
|
||||
|
||||
logger.info(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" Базовый период: {base_price/100}₽")
|
||||
logger.info(f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽")
|
||||
logger.info(f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽")
|
||||
logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽")
|
||||
logger.info(f" ИТОГО: {total_cost/100}₽")
|
||||
|
||||
return total_cost, details
|
||||
|
||||
async def get_subscription_server_ids(
|
||||
db: AsyncSession,
|
||||
subscription_id: int
|
||||
@@ -497,30 +578,43 @@ async def get_subscription_renewal_cost(
|
||||
period_days: int
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES, TRAFFIC_PRICES, settings
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
servers_info = await get_subscription_servers(db, subscription_id)
|
||||
servers_cost = sum(server_info['paid_price_kopeks'] for server_info in servers_info)
|
||||
|
||||
subscription = await db.get(Subscription, subscription_id)
|
||||
if not subscription:
|
||||
return base_price
|
||||
|
||||
traffic_cost = 0
|
||||
if subscription.traffic_limit_gb > 0:
|
||||
traffic_cost = TRAFFIC_PRICES.get(subscription.traffic_limit_gb, 0)
|
||||
servers_info = await get_subscription_servers(db, subscription_id)
|
||||
servers_price_per_month = 0
|
||||
for server_info in servers_info:
|
||||
from app.database.models import ServerSquad
|
||||
result = await db.execute(
|
||||
select(ServerSquad.price_kopeks)
|
||||
.where(ServerSquad.id == server_info['server_id'])
|
||||
)
|
||||
current_server_price = result.scalar() or 0
|
||||
servers_price_per_month += current_server_price
|
||||
|
||||
devices_cost = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
total_servers_cost = servers_price_per_month * months_in_period
|
||||
|
||||
total_cost = base_price + servers_cost + traffic_cost + devices_cost
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
total_traffic_cost = traffic_price_per_month * months_in_period
|
||||
|
||||
logger.info(f"💰 Расчет продления подписки {subscription_id} на {period_days} дней:")
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_cost = devices_price_per_month * months_in_period
|
||||
|
||||
total_cost = base_price + total_servers_cost + total_traffic_cost + total_devices_cost
|
||||
|
||||
logger.info(f"💰 Расчет продления подписки {subscription_id} на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" 📅 Период: {base_price/100}₽")
|
||||
logger.info(f" 🌍 Серверы: {servers_cost/100}₽")
|
||||
logger.info(f" 📊 Трафик: {traffic_cost/100}₽")
|
||||
logger.info(f" 📱 Устройства: {devices_cost/100}₽")
|
||||
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}₽")
|
||||
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}₽")
|
||||
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}₽")
|
||||
logger.info(f" 💎 ИТОГО: {total_cost/100}₽")
|
||||
|
||||
return total_cost
|
||||
@@ -530,6 +624,49 @@ async def get_subscription_renewal_cost(
|
||||
from app.config import PERIOD_PRICES
|
||||
return PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
async def calculate_addon_cost_for_remaining_period(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
additional_traffic_gb: int = 0,
|
||||
additional_devices: int = 0,
|
||||
additional_server_ids: List[int] = None
|
||||
) -> int:
|
||||
if additional_server_ids is None:
|
||||
additional_server_ids = []
|
||||
|
||||
months_to_pay = get_remaining_months(subscription.end_date)
|
||||
|
||||
total_cost = 0
|
||||
|
||||
if additional_traffic_gb > 0:
|
||||
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
|
||||
traffic_total_cost = traffic_price_per_month * months_to_pay
|
||||
total_cost += traffic_total_cost
|
||||
logger.info(f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {months_to_pay} = {traffic_total_cost/100}₽")
|
||||
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_total_cost = devices_price_per_month * months_to_pay
|
||||
total_cost += devices_total_cost
|
||||
logger.info(f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес × {months_to_pay} = {devices_total_cost/100}₽")
|
||||
|
||||
if additional_server_ids:
|
||||
from app.database.models import ServerSquad
|
||||
for server_id in additional_server_ids:
|
||||
result = await db.execute(
|
||||
select(ServerSquad.price_kopeks, ServerSquad.display_name)
|
||||
.where(ServerSquad.id == server_id)
|
||||
)
|
||||
server_data = result.first()
|
||||
if server_data:
|
||||
server_price_per_month, server_name = server_data
|
||||
server_total_cost = server_price_per_month * months_to_pay
|
||||
total_cost += server_total_cost
|
||||
logger.info(f"Сервер {server_name}: {server_price_per_month/100}₽/мес × {months_to_pay} = {server_total_cost/100}₽")
|
||||
|
||||
logger.info(f"💰 Итого доплата за {months_to_pay} мес: {total_cost/100}₽")
|
||||
return total_cost
|
||||
|
||||
async def expire_subscription(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
|
||||
@@ -16,6 +16,8 @@ from app.localization.texts import get_texts
|
||||
from app.services.user_service import UserService
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime, format_time_ago
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.database.crud.server_squad import get_all_server_squads, get_server_squad_by_uuid, get_server_squad_by_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1300,6 +1302,642 @@ async def process_subscription_grant_text(
|
||||
|
||||
await state.clear()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_user_servers_management(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user_service = UserService()
|
||||
profile = await user_service.get_user_profile(db, user_id)
|
||||
|
||||
if not profile:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
user = profile["user"]
|
||||
subscription = profile["subscription"]
|
||||
|
||||
text = f"🌍 <b>Управление серверами пользователя</b>\n\n"
|
||||
text += f"👤 {user.full_name} (ID: <code>{user.telegram_id}</code>)\n\n"
|
||||
|
||||
if subscription:
|
||||
current_squads = subscription.connected_squads or []
|
||||
|
||||
if current_squads:
|
||||
text += f"<b>Текущие серверы ({len(current_squads)}):</b>\n"
|
||||
|
||||
for squad_uuid in current_squads:
|
||||
try:
|
||||
server = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
if server:
|
||||
text += f"• {server.display_name}\n"
|
||||
else:
|
||||
text += f"• {squad_uuid[:8]}... (неизвестный)\n"
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения сервера {squad_uuid}: {e}")
|
||||
text += f"• {squad_uuid[:8]}... (ошибка загрузки)\n"
|
||||
else:
|
||||
text += "<b>Серверы:</b> Не подключены\n"
|
||||
|
||||
text += f"\n<b>Устройства:</b> {subscription.device_limit}\n"
|
||||
traffic_display = f"{subscription.traffic_used_gb:.1f}/"
|
||||
if subscription.traffic_limit_gb == 0:
|
||||
traffic_display += "∞ ГБ"
|
||||
else:
|
||||
traffic_display += f"{subscription.traffic_limit_gb} ГБ"
|
||||
text += f"<b>Трафик:</b> {traffic_display}\n"
|
||||
else:
|
||||
text += "❌ <b>Подписка отсутствует</b>"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌍 Сменить сервер", callback_data=f"admin_user_change_server_{user_id}"),
|
||||
types.InlineKeyboardButton(text="📱 Устройства", callback_data=f"admin_user_devices_{user_id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📊 Трафик", callback_data=f"admin_user_traffic_{user_id}"),
|
||||
types.InlineKeyboardButton(text="🔄 Сбросить устройства", callback_data=f"admin_user_reset_devices_{user_id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ К пользователю", callback_data=f"admin_user_manage_{user_id}")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_server_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
await _show_servers_for_user(callback, user_id, db)
|
||||
await callback.answer()
|
||||
|
||||
async def _show_servers_for_user(
|
||||
callback: types.CallbackQuery,
|
||||
user_id: int,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
current_squads = []
|
||||
if user and user.subscription:
|
||||
current_squads = user.subscription.connected_squads or []
|
||||
|
||||
all_servers, _ = await get_all_server_squads(db, available_only=False)
|
||||
|
||||
servers_to_show = []
|
||||
for server in all_servers:
|
||||
if server.is_available or server.squad_uuid in current_squads:
|
||||
servers_to_show.append(server)
|
||||
|
||||
if not servers_to_show:
|
||||
await callback.message.edit_text(
|
||||
"❌ Доступные серверы не найдены",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
text = f"🌍 <b>Управление серверами</b>\n\n"
|
||||
text += f"Нажмите на сервер чтобы добавить/убрать:\n"
|
||||
text += f"✅ - выбранный сервер\n"
|
||||
text += f"⚪ - доступный сервер\n"
|
||||
text += f"🔒 - неактивный (только для уже назначенных)\n\n"
|
||||
|
||||
keyboard = []
|
||||
selected_servers = [s for s in servers_to_show if s.squad_uuid in current_squads]
|
||||
available_servers = [s for s in servers_to_show if s.squad_uuid not in current_squads and s.is_available]
|
||||
inactive_servers = [s for s in servers_to_show if s.squad_uuid not in current_squads and not s.is_available]
|
||||
|
||||
sorted_servers = selected_servers + available_servers + inactive_servers
|
||||
|
||||
for server in sorted_servers[:20]:
|
||||
is_selected = server.squad_uuid in current_squads
|
||||
|
||||
if is_selected:
|
||||
emoji = "✅"
|
||||
elif server.is_available:
|
||||
emoji = "⚪"
|
||||
else:
|
||||
emoji = "🔒"
|
||||
|
||||
display_name = server.display_name
|
||||
if not server.is_available and not is_selected:
|
||||
display_name += " (неактивный)"
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{emoji} {display_name}",
|
||||
callback_data=f"admin_user_toggle_server_{user_id}_{server.id}"
|
||||
)
|
||||
])
|
||||
|
||||
if len(servers_to_show) > 20:
|
||||
text += f"\n📝 Показано первых 20 из {len(servers_to_show)} серверов"
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_servers_{user_id}"),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка показа серверов: {e}")
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_user_server(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
parts = callback.data.split('_')
|
||||
user_id = int(parts[4])
|
||||
server_id = int(parts[5])
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user or not user.subscription:
|
||||
await callback.answer("❌ Пользователь или подписка не найдены", show_alert=True)
|
||||
return
|
||||
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден", show_alert=True)
|
||||
return
|
||||
|
||||
subscription = user.subscription
|
||||
current_squads = list(subscription.connected_squads or [])
|
||||
|
||||
if server.squad_uuid in current_squads:
|
||||
current_squads.remove(server.squad_uuid)
|
||||
action_text = "удален"
|
||||
else:
|
||||
current_squads.append(server.squad_uuid)
|
||||
action_text = "добавлен"
|
||||
|
||||
subscription.connected_squads = current_squads
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
remnawave_service = RemnaWaveService()
|
||||
async with remnawave_service.api as api:
|
||||
await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
active_internal_squads=current_squads
|
||||
)
|
||||
logger.info(f"✅ Обновлены серверы в RemnaWave для пользователя {user.telegram_id}")
|
||||
except Exception as rw_error:
|
||||
logger.error(f"❌ Ошибка обновления RemnaWave: {rw_error}")
|
||||
|
||||
logger.info(f"Админ {db_user.id}: сервер {server.display_name} {action_text} для пользователя {user_id}")
|
||||
|
||||
await refresh_server_selection_screen(callback, user_id, db_user, db)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка переключения сервера: {e}")
|
||||
await callback.answer("❌ Ошибка изменения сервера", show_alert=True)
|
||||
|
||||
async def refresh_server_selection_screen(
|
||||
callback: types.CallbackQuery,
|
||||
user_id: int,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
current_squads = []
|
||||
if user and user.subscription:
|
||||
current_squads = user.subscription.connected_squads or []
|
||||
|
||||
servers, _ = await get_all_server_squads(db, available_only=True)
|
||||
|
||||
if not servers:
|
||||
await callback.message.edit_text(
|
||||
"❌ Доступные серверы не найдены",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
text = f"🌍 <b>Управление серверами</b>\n\n"
|
||||
text += f"Нажмите на сервер чтобы добавить/убрать:\n\n"
|
||||
|
||||
keyboard = []
|
||||
for server in servers[:15]:
|
||||
is_selected = server.squad_uuid in current_squads
|
||||
emoji = "✅" if is_selected else "⚪"
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{emoji} {server.display_name}",
|
||||
callback_data=f"admin_user_toggle_server_{user_id}_{server.id}"
|
||||
)
|
||||
])
|
||||
|
||||
if len(servers) > 15:
|
||||
text += f"\n📝 Показано первых 15 из {len(servers)} серверов"
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_servers_{user_id}"),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_servers_{user_id}")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления экрана серверов: {e}")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_devices_edit(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
await state.update_data(editing_devices_user_id=user_id)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"📱 <b>Изменение количества устройств</b>\n\n"
|
||||
"Введите новое количество устройств (от 1 до 10):\n"
|
||||
"• Текущее значение будет заменено\n"
|
||||
"• Примеры: 1, 2, 5, 10\n\n"
|
||||
"Или нажмите /cancel для отмены",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="1", callback_data=f"admin_user_devices_set_{user_id}_1"),
|
||||
types.InlineKeyboardButton(text="2", callback_data=f"admin_user_devices_set_{user_id}_2"),
|
||||
types.InlineKeyboardButton(text="3", callback_data=f"admin_user_devices_set_{user_id}_3")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="5", callback_data=f"admin_user_devices_set_{user_id}_5"),
|
||||
types.InlineKeyboardButton(text="10", callback_data=f"admin_user_devices_set_{user_id}_10")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_servers_{user_id}")
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_user_devices)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def set_user_devices_button(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
parts = callback.data.split('_')
|
||||
user_id = int(parts[-2])
|
||||
devices = int(parts[-1])
|
||||
|
||||
success = await _update_user_devices(db, user_id, devices, db_user.id)
|
||||
|
||||
if success:
|
||||
await callback.message.edit_text(
|
||||
f"✅ Количество устройств изменено на: {devices}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
])
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка изменения количества устройств",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_devices_edit_text(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
user_id = data.get("editing_devices_user_id")
|
||||
|
||||
if not user_id:
|
||||
await message.answer("❌ Ошибка: пользователь не найден")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
devices = int(message.text.strip())
|
||||
|
||||
if devices <= 0 or devices > 10:
|
||||
await message.answer("❌ Количество устройств должно быть от 1 до 10")
|
||||
return
|
||||
|
||||
success = await _update_user_devices(db, user_id, devices, db_user.id)
|
||||
|
||||
if success:
|
||||
await message.answer(
|
||||
f"✅ Количество устройств изменено на: {devices}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
])
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Ошибка изменения количества устройств")
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное число устройств")
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_traffic_edit(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
await state.update_data(editing_traffic_user_id=user_id)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"📊 <b>Изменение лимита трафика</b>\n\n"
|
||||
"Введите новый лимит трафика в ГБ:\n"
|
||||
"• 0 - безлимитный трафик\n"
|
||||
"• Примеры: 50, 100, 500, 1000\n"
|
||||
"• Максимум: 10000 ГБ\n\n"
|
||||
"Или нажмите /cancel для отмены",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="50 ГБ", callback_data=f"admin_user_traffic_set_{user_id}_50"),
|
||||
types.InlineKeyboardButton(text="100 ГБ", callback_data=f"admin_user_traffic_set_{user_id}_100")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="500 ГБ", callback_data=f"admin_user_traffic_set_{user_id}_500"),
|
||||
types.InlineKeyboardButton(text="1000 ГБ", callback_data=f"admin_user_traffic_set_{user_id}_1000")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="♾️ Безлимит", callback_data=f"admin_user_traffic_set_{user_id}_0")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_servers_{user_id}")
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_user_traffic)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def set_user_traffic_button(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
parts = callback.data.split('_')
|
||||
user_id = int(parts[-2])
|
||||
traffic_gb = int(parts[-1])
|
||||
|
||||
success = await _update_user_traffic(db, user_id, traffic_gb, db_user.id)
|
||||
|
||||
if success:
|
||||
traffic_text = "♾️ безлимитный" if traffic_gb == 0 else f"{traffic_gb} ГБ"
|
||||
await callback.message.edit_text(
|
||||
f"✅ Лимит трафика изменен на: {traffic_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
])
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка изменения лимита трафика",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_traffic_edit_text(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
user_id = data.get("editing_traffic_user_id")
|
||||
|
||||
if not user_id:
|
||||
await message.answer("❌ Ошибка: пользователь не найден")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
traffic_gb = int(message.text.strip())
|
||||
|
||||
if traffic_gb < 0 or traffic_gb > 10000:
|
||||
await message.answer("❌ Лимит трафика должен быть от 0 до 10000 ГБ (0 = безлимит)")
|
||||
return
|
||||
|
||||
success = await _update_user_traffic(db, user_id, traffic_gb, db_user.id)
|
||||
|
||||
if success:
|
||||
traffic_text = "♾️ безлимитный" if traffic_gb == 0 else f"{traffic_gb} ГБ"
|
||||
await message.answer(
|
||||
f"✅ Лимит трафика изменен на: {traffic_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
])
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Ошибка изменения лимита трафика")
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите корректное число ГБ")
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_reset_devices(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
await callback.message.edit_text(
|
||||
"🔄 <b>Сброс устройств пользователя</b>\n\n"
|
||||
"⚠️ <b>ВНИМАНИЕ!</b>\n"
|
||||
"Вы уверены, что хотите сбросить все HWID устройства этого пользователя?\n\n"
|
||||
"Это действие:\n"
|
||||
"• Удалит все привязанные устройства\n"
|
||||
"• Пользователь сможет заново подключить устройства\n"
|
||||
"• Действие необратимо!\n\n"
|
||||
"Продолжить?",
|
||||
reply_markup=get_confirmation_keyboard(
|
||||
f"admin_user_reset_devices_confirm_{user_id}",
|
||||
f"admin_user_servers_{user_id}",
|
||||
db_user.language
|
||||
)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def reset_user_devices(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user or not user.remnawave_uuid:
|
||||
await callback.answer("❌ Пользователь не найден или не связан с RemnaWave", show_alert=True)
|
||||
return
|
||||
|
||||
remnawave_service = RemnaWaveService()
|
||||
async with remnawave_service.api as api:
|
||||
success = await api.reset_user_devices(user.remnawave_uuid)
|
||||
|
||||
if success:
|
||||
await callback.message.edit_text(
|
||||
"✅ Устройства пользователя успешно сброшены",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
])
|
||||
)
|
||||
logger.info(f"Админ {db_user.id} сбросил устройства пользователя {user_id}")
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка сброса устройств",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🌍 Управление серверами", callback_data=f"admin_user_servers_{user_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка сброса устройств: {e}")
|
||||
await callback.answer("❌ Ошибка сброса устройств", show_alert=True)
|
||||
|
||||
async def _update_user_devices(db: AsyncSession, user_id: int, devices: int, admin_id: int) -> bool:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user or not user.subscription:
|
||||
logger.error(f"Пользователь {user_id} или подписка не найдены")
|
||||
return False
|
||||
|
||||
subscription = user.subscription
|
||||
old_devices = subscription.device_limit
|
||||
subscription.device_limit = devices
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
remnawave_service = RemnaWaveService()
|
||||
async with remnawave_service.api as api:
|
||||
await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
hwid_device_limit=devices
|
||||
)
|
||||
logger.info(f"✅ Обновлен лимит устройств в RemnaWave для пользователя {user.telegram_id}")
|
||||
except Exception as rw_error:
|
||||
logger.error(f"❌ Ошибка обновления лимита устройств в RemnaWave: {rw_error}")
|
||||
|
||||
logger.info(f"Админ {admin_id} изменил лимит устройств пользователя {user_id}: {old_devices} -> {devices}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления лимита устройств: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def _update_user_traffic(db: AsyncSession, user_id: int, traffic_gb: int, admin_id: int) -> bool:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user or not user.subscription:
|
||||
logger.error(f"Пользователь {user_id} или подписка не найдены")
|
||||
return False
|
||||
|
||||
subscription = user.subscription
|
||||
old_traffic = subscription.traffic_limit_gb
|
||||
subscription.traffic_limit_gb = traffic_gb
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
from app.external.remnawave_api import TrafficLimitStrategy
|
||||
|
||||
remnawave_service = RemnaWaveService()
|
||||
async with remnawave_service.api as api:
|
||||
await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
traffic_limit_bytes=traffic_gb * (1024**3) if traffic_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH
|
||||
)
|
||||
logger.info(f"✅ Обновлен лимит трафика в RemnaWave для пользователя {user.telegram_id}")
|
||||
except Exception as rw_error:
|
||||
logger.error(f"❌ Ошибка обновления лимита трафика в RemnaWave: {rw_error}")
|
||||
|
||||
traffic_text_old = "безлимитный" if old_traffic == 0 else f"{old_traffic} ГБ"
|
||||
traffic_text_new = "безлимитный" if traffic_gb == 0 else f"{traffic_gb} ГБ"
|
||||
logger.info(f"Админ {admin_id} изменил лимит трафика пользователя {user_id}: {traffic_text_old} -> {traffic_text_new}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления лимита трафика: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def _extend_subscription_by_days(db: AsyncSession, user_id: int, days: int, admin_id: int) -> bool:
|
||||
try:
|
||||
@@ -1648,3 +2286,58 @@ def register_handlers(dp: Dispatcher):
|
||||
process_subscription_grant_text,
|
||||
AdminStates.granting_subscription
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_user_servers_management,
|
||||
F.data.startswith("admin_user_servers_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_server_selection,
|
||||
F.data.startswith("admin_user_change_server_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
toggle_user_server,
|
||||
F.data.startswith("admin_user_toggle_server_") & ~F.data.endswith("_add") & ~F.data.endswith("_remove")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_devices_edit,
|
||||
F.data.startswith("admin_user_devices_") & ~F.data.contains("set")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
set_user_devices_button,
|
||||
F.data.startswith("admin_user_devices_set_")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_devices_edit_text,
|
||||
AdminStates.editing_user_devices
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_traffic_edit,
|
||||
F.data.startswith("admin_user_traffic_") & ~F.data.contains("set")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
set_user_traffic_button,
|
||||
F.data.startswith("admin_user_traffic_set_")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_traffic_edit_text,
|
||||
AdminStates.editing_user_traffic
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
confirm_reset_devices,
|
||||
F.data.startswith("admin_user_reset_devices_") & ~F.data.contains("confirm")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
reset_user_devices,
|
||||
F.data.startswith("admin_user_reset_devices_confirm_")
|
||||
)
|
||||
|
||||
+250
-121
@@ -40,6 +40,12 @@ from app.localization.texts import get_texts
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.referral_service import process_referral_purchase
|
||||
from app.utils.pricing_utils import (
|
||||
calculate_months_from_days,
|
||||
get_remaining_months,
|
||||
calculate_prorated_price,
|
||||
validate_pricing_calculation
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -451,7 +457,7 @@ async def handle_add_countries(
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not subscription or subscription.is_trial:
|
||||
await callback.answer("❌ Эта функция доступна только для платных подписок", show_alert=True)
|
||||
await callback.answer("⚠ Эта функция доступна только для платных подписок", show_alert=True)
|
||||
return
|
||||
|
||||
countries = await _get_available_countries()
|
||||
@@ -484,7 +490,8 @@ async def handle_add_countries(
|
||||
countries,
|
||||
current_countries.copy(),
|
||||
current_countries,
|
||||
db_user.language
|
||||
db_user.language,
|
||||
subscription.end_date
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -533,7 +540,7 @@ async def handle_manage_country(
|
||||
|
||||
subscription = db_user.subscription
|
||||
if not subscription or subscription.is_trial:
|
||||
await callback.answer("❌ Только для платных подписок", show_alert=True)
|
||||
await callback.answer("⚠ Только для платных подписок", show_alert=True)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
@@ -558,13 +565,14 @@ async def handle_manage_country(
|
||||
countries,
|
||||
current_selected,
|
||||
subscription.connected_squads,
|
||||
db_user.language
|
||||
db_user.language,
|
||||
subscription.end_date
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Клавиатура обновлена")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обновления клавиатуры: {e}")
|
||||
logger.error(f"⚠ Ошибка обновления клавиатуры: {e}")
|
||||
|
||||
await callback.answer()
|
||||
|
||||
@@ -574,6 +582,8 @@ async def apply_countries_changes(
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price
|
||||
|
||||
logger.info(f"🔍 Применение изменений стран")
|
||||
|
||||
data = await state.get_data()
|
||||
@@ -593,44 +603,58 @@ async def apply_countries_changes(
|
||||
logger.info(f"🔍 Добавлено: {added}, Удалено: {removed}")
|
||||
|
||||
countries = await _get_available_countries()
|
||||
cost = 0
|
||||
|
||||
# Рассчитываем оставшиеся месяцы подписки для новых серверов
|
||||
months_to_pay = get_remaining_months(subscription.end_date)
|
||||
|
||||
cost_per_month = 0
|
||||
added_names = []
|
||||
removed_names = []
|
||||
|
||||
added_server_prices = []
|
||||
added_server_ids = []
|
||||
|
||||
for country in countries:
|
||||
if country['uuid'] in added:
|
||||
cost += country['price_kopeks']
|
||||
server_price_per_month = country['price_kopeks']
|
||||
cost_per_month += server_price_per_month
|
||||
added_names.append(country['name'])
|
||||
added_server_prices.append(country['price_kopeks'])
|
||||
if country['uuid'] in removed:
|
||||
removed_names.append(country['name'])
|
||||
|
||||
if cost > 0 and db_user.balance_kopeks < cost:
|
||||
total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date)
|
||||
|
||||
# Рассчитываем цены для каждого сервера за весь период
|
||||
for country in countries:
|
||||
if country['uuid'] in added:
|
||||
server_price_per_month = country['price_kopeks']
|
||||
server_total_price = server_price_per_month * charged_months
|
||||
added_server_prices.append(server_total_price)
|
||||
|
||||
logger.info(f"Стоимость новых серверов: {cost_per_month/100}₽/мес × {charged_months} мес = {total_cost/100}₽")
|
||||
|
||||
if total_cost > 0 and db_user.balance_kopeks < total_cost:
|
||||
await callback.answer(
|
||||
f"❌ Недостаточно средств!\nТребуется: {texts.format_price(cost)}\nУ вас: {texts.format_price(db_user.balance_kopeks)}",
|
||||
f"⚠ Недостаточно средств!\nТребуется: {texts.format_price(total_cost)} (за {charged_months} мес)\nУ вас: {texts.format_price(db_user.balance_kopeks)}",
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
if added and cost > 0:
|
||||
if added and total_cost > 0:
|
||||
success = await subtract_user_balance(
|
||||
db, db_user, cost,
|
||||
f"Добавление стран: {', '.join(added_names)}"
|
||||
db, db_user, total_cost,
|
||||
f"Добавление стран: {', '.join(added_names)} на {charged_months} мес"
|
||||
)
|
||||
if not success:
|
||||
await callback.answer("❌ Ошибка списания средств", show_alert=True)
|
||||
await callback.answer("⚠ Ошибка списания средств", show_alert=True)
|
||||
return
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=cost,
|
||||
description=f"Добавление стран к подписке: {', '.join(added_names)}"
|
||||
amount_kopeks=total_cost,
|
||||
description=f"Добавление стран к подписке: {', '.join(added_names)} на {charged_months} мес"
|
||||
)
|
||||
|
||||
if added:
|
||||
@@ -643,7 +667,7 @@ async def apply_countries_changes(
|
||||
await add_subscription_servers(db, subscription, added_server_ids, added_server_prices)
|
||||
await add_user_to_servers(db, added_server_ids)
|
||||
|
||||
logger.info(f"📊 Добавлены серверы с ценами: {list(zip(added_server_ids, added_server_prices))}")
|
||||
logger.info(f"📊 Добавлены серверы с ценами за {charged_months} мес: {list(zip(added_server_ids, added_server_prices))}")
|
||||
|
||||
subscription.connected_squads = selected_countries
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
@@ -652,12 +676,12 @@ async def apply_countries_changes(
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
if cost > 0:
|
||||
if total_cost > 0:
|
||||
try:
|
||||
await process_referral_purchase(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
purchase_amount_kopeks=cost,
|
||||
purchase_amount_kopeks=total_cost,
|
||||
transaction_id=None
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -670,8 +694,8 @@ async def apply_countries_changes(
|
||||
if added_names:
|
||||
success_text += f"➕ <b>Добавлены страны:</b>\n"
|
||||
success_text += "\n".join(f"• {name}" for name in added_names)
|
||||
if cost > 0:
|
||||
success_text += f"\n💰 Списано: {texts.format_price(cost)}"
|
||||
if total_cost > 0:
|
||||
success_text += f"\n💰 Списано: {texts.format_price(total_cost)} (за {charged_months} мес)"
|
||||
success_text += "\n"
|
||||
|
||||
if removed_names:
|
||||
@@ -688,10 +712,10 @@ async def apply_countries_changes(
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} обновил страны. Добавлено: {len(added)}, удалено: {len(removed)}")
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} обновил страны. Добавлено: {len(added)}, удалено: {len(removed)}, заплатил: {total_cost/100}₽")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка применения изменений: {e}")
|
||||
logger.error(f"⚠ Ошибка применения изменений: {e}")
|
||||
await callback.message.edit_text(
|
||||
texts.ERROR,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
@@ -715,11 +739,11 @@ async def handle_add_traffic(
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not subscription or subscription.is_trial:
|
||||
await callback.answer("⌛ Эта функция доступна только для платных подписок", show_alert=True)
|
||||
await callback.answer("⚠ Эта функция доступна только для платных подписок", show_alert=True)
|
||||
return
|
||||
|
||||
if subscription.traffic_limit_gb == 0:
|
||||
await callback.answer("⌛ У вас уже безлимитный трафик", show_alert=True)
|
||||
await callback.answer("⚠ У вас уже безлимитный трафик", show_alert=True)
|
||||
return
|
||||
|
||||
current_traffic = subscription.traffic_limit_gb
|
||||
@@ -728,23 +752,23 @@ async def handle_add_traffic(
|
||||
f"📈 <b>Добавить трафик к подписке</b>\n\n"
|
||||
f"Текущий лимит: {texts.format_traffic(current_traffic)}\n"
|
||||
f"Выберите дополнительный трафик:",
|
||||
reply_markup=get_add_traffic_keyboard(db_user.language)
|
||||
reply_markup=get_add_traffic_keyboard(db_user.language, subscription.end_date),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
|
||||
async def handle_add_devices(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not subscription or subscription.is_trial:
|
||||
await callback.answer("❌ Эта функция доступна только для платных подписок", show_alert=True)
|
||||
await callback.answer("⚠ Эта функция доступна только для платных подписок", show_alert=True)
|
||||
return
|
||||
|
||||
current_devices = subscription.device_limit
|
||||
@@ -753,7 +777,8 @@ async def handle_add_devices(
|
||||
f"📱 <b>Добавить устройства к подписке</b>\n\n"
|
||||
f"Текущий лимит: {current_devices} устройств\n"
|
||||
f"Выберите количество дополнительных устройств:",
|
||||
reply_markup=get_add_devices_keyboard(current_devices, db_user.language)
|
||||
reply_markup=get_add_devices_keyboard(current_devices, db_user.language, subscription.end_date),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
@@ -764,15 +789,17 @@ async def handle_extend_subscription(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.utils.pricing_utils import calculate_months_from_days, format_period_description
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not subscription or subscription.is_trial:
|
||||
await callback.answer("⌛ Продление доступно только для платных подписок", show_alert=True)
|
||||
await callback.answer("⚠ Продление доступно только для платных подписок", show_alert=True)
|
||||
return
|
||||
|
||||
if subscription.days_left > 3:
|
||||
await callback.answer("⌛ Продление доступно за 3 дня до окончания подписки", show_alert=True)
|
||||
await callback.answer("⚠ Продление доступно за 3 дня до окончания подписки", show_alert=True)
|
||||
return
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
@@ -782,29 +809,40 @@ async def handle_extend_subscription(
|
||||
|
||||
for days in available_periods:
|
||||
try:
|
||||
price = await subscription_service.calculate_renewal_price(subscription, days, db)
|
||||
months_in_period = calculate_months_from_days(days)
|
||||
|
||||
from app.config import PERIOD_PRICES
|
||||
base_price = PERIOD_PRICES.get(days, 0)
|
||||
|
||||
servers_price_per_month, _ = await subscription_service.get_countries_price_by_uuids(
|
||||
subscription.connected_squads, db
|
||||
)
|
||||
total_servers_price = servers_price_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
price = base_price + total_servers_price + total_devices_price + total_traffic_price
|
||||
renewal_prices[days] = price
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка расчета цены для периода {days}: {e}")
|
||||
continue
|
||||
|
||||
if not renewal_prices:
|
||||
await callback.answer("⌛ Нет доступных периодов для продления", show_alert=True)
|
||||
await callback.answer("⚠ Нет доступных периодов для продления", show_alert=True)
|
||||
return
|
||||
|
||||
prices_text = ""
|
||||
period_display = {
|
||||
14: "14 дней",
|
||||
30: "30 дней",
|
||||
60: "60 дней",
|
||||
90: "90 дней",
|
||||
180: "180 дней",
|
||||
360: "360 дней"
|
||||
}
|
||||
|
||||
for days in available_periods:
|
||||
if days in renewal_prices and days in period_display:
|
||||
prices_text += f"📅 {period_display[days]} - {texts.format_price(renewal_prices[days])}\n"
|
||||
if days in renewal_prices:
|
||||
period_display = format_period_description(days, db_user.language)
|
||||
prices_text += f"📅 {period_display} - {texts.format_price(renewal_prices[days])}\n"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"⏰ Продление подписки\n\n"
|
||||
@@ -869,17 +907,18 @@ async def confirm_add_traffic(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
|
||||
return
|
||||
from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price
|
||||
|
||||
traffic_gb = int(callback.data.split('_')[2])
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
price = settings.get_traffic_price(traffic_gb)
|
||||
months_to_pay = get_remaining_months(subscription.end_date)
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
price, charged_months = calculate_prorated_price(traffic_price_per_month, subscription.end_date)
|
||||
|
||||
logger.info(f"Добавление трафика {traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {charged_months} мес = {price/100}₽")
|
||||
|
||||
if price == 0 and traffic_gb != 0:
|
||||
await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True)
|
||||
@@ -892,7 +931,7 @@ async def confirm_add_traffic(
|
||||
try:
|
||||
success = await subtract_user_balance(
|
||||
db, db_user, price,
|
||||
f"Добавление {traffic_gb} ГБ трафика"
|
||||
f"Добавление {traffic_gb} ГБ трафика на {charged_months} мес"
|
||||
)
|
||||
|
||||
if not success:
|
||||
@@ -912,7 +951,7 @@ async def confirm_add_traffic(
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price,
|
||||
description=f"Добавление {traffic_gb} ГБ трафика"
|
||||
description=f"Добавление {traffic_gb} ГБ трафика на {charged_months} мес"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -935,12 +974,14 @@ async def confirm_add_traffic(
|
||||
success_text += f"📈 Добавлено: {traffic_gb} ГБ\n"
|
||||
success_text += f"Новый лимит: {texts.format_traffic(subscription.traffic_limit_gb)}"
|
||||
|
||||
success_text += f"\n💰 Списано: {texts.format_price(price)} (за {charged_months} мес)"
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_text,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {traffic_gb} ГБ трафика")
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {traffic_gb} ГБ трафика за {price/100}₽")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления трафика: {e}")
|
||||
@@ -962,6 +1003,7 @@ async def confirm_add_devices(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price
|
||||
|
||||
devices_count = int(callback.data.split('_')[2])
|
||||
texts = get_texts(db_user.language)
|
||||
@@ -977,7 +1019,10 @@ async def confirm_add_devices(
|
||||
)
|
||||
return
|
||||
|
||||
price = devices_count * settings.PRICE_PER_DEVICE
|
||||
devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE
|
||||
price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date)
|
||||
|
||||
logger.info(f"Добавление {devices_count} устройств: {devices_price_per_month/100}₽/мес × {charged_months} мес = {price/100}₽")
|
||||
|
||||
if db_user.balance_kopeks < price:
|
||||
await callback.answer("⚠️ Недостаточно средств на балансе", show_alert=True)
|
||||
@@ -986,7 +1031,7 @@ async def confirm_add_devices(
|
||||
try:
|
||||
success = await subtract_user_balance(
|
||||
db, db_user, price,
|
||||
f"Добавление {devices_count} устройств"
|
||||
f"Добавление {devices_count} устройств на {charged_months} мес"
|
||||
)
|
||||
|
||||
if not success:
|
||||
@@ -1003,7 +1048,7 @@ async def confirm_add_devices(
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price,
|
||||
description=f"Добавление {devices_count} устройств"
|
||||
description=f"Добавление {devices_count} устройств на {charged_months} мес"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -1022,11 +1067,12 @@ async def confirm_add_devices(
|
||||
await callback.message.edit_text(
|
||||
f"✅ Устройства успешно добавлены!\n\n"
|
||||
f"📱 Добавлено: {devices_count} устройств\n"
|
||||
f"Новый лимит: {subscription.device_limit} устройств",
|
||||
f"Новый лимит: {subscription.device_limit} устройств\n"
|
||||
f"💰 Списано: {texts.format_price(price)} (за {charged_months} мес)",
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {devices_count} устройств")
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {devices_count} устройств за {price/100}₽")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления устройств: {e}")
|
||||
@@ -1043,37 +1089,70 @@ async def confirm_extend_subscription(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.utils.pricing_utils import calculate_months_from_days, validate_pricing_calculation
|
||||
|
||||
days = int(callback.data.split('_')[2])
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not subscription:
|
||||
await callback.answer("❌ У вас нет активной подписки", show_alert=True)
|
||||
await callback.answer("⚠ У вас нет активной подписки", show_alert=True)
|
||||
return
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
months_in_period = calculate_months_from_days(days)
|
||||
|
||||
try:
|
||||
price = await subscription_service.calculate_renewal_price(subscription, days, db)
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
base_price = PERIOD_PRICES.get(days, 0)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
servers_price_per_month, _ = await subscription_service.get_countries_price_by_uuids(
|
||||
subscription.connected_squads, db
|
||||
)
|
||||
total_servers_price = servers_price_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
price = base_price + total_servers_price + total_devices_price + total_traffic_price
|
||||
|
||||
monthly_additions = servers_price_per_month + devices_price_per_month + traffic_price_per_month
|
||||
is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, price)
|
||||
|
||||
if not is_valid:
|
||||
logger.error(f"Ошибка в расчете цены продления для пользователя {db_user.telegram_id}")
|
||||
await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True)
|
||||
return
|
||||
|
||||
logger.info(f"💰 Расчет продления подписки {subscription.id} на {days} дней ({months_in_period} мес):")
|
||||
logger.info(f" 📅 Период {days} дней: {base_price/100}₽")
|
||||
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽")
|
||||
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽")
|
||||
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽")
|
||||
logger.info(f" 💎 ИТОГО: {price/100}₽")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ ОШИБКА РАСЧЕТА ЦЕНЫ: {e}")
|
||||
await callback.answer("❌ Ошибка расчета стоимости", show_alert=True)
|
||||
logger.error(f"⚠ ОШИБКА РАСЧЕТА ЦЕНЫ: {e}")
|
||||
await callback.answer("⚠ Ошибка расчета стоимости", show_alert=True)
|
||||
return
|
||||
|
||||
if db_user.balance_kopeks < price:
|
||||
await callback.answer("❌ Недостаточно средств на балансе", show_alert=True)
|
||||
await callback.answer("⚠ Недостаточно средств на балансе", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
logger.info(f"🔄 Начинаем продление подписки {subscription.id} на {days} дней за {price/100}₽")
|
||||
|
||||
success = await subtract_user_balance(
|
||||
db, db_user, price,
|
||||
f"Продление подписки на {days} дней"
|
||||
)
|
||||
|
||||
if not success:
|
||||
await callback.answer("❌ Ошибка списания средств", show_alert=True)
|
||||
await callback.answer("⚠ Ошибка списания средств", show_alert=True)
|
||||
return
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
@@ -1090,26 +1169,30 @@ async def confirm_extend_subscription(
|
||||
await db.refresh(subscription)
|
||||
await db.refresh(db_user)
|
||||
|
||||
from app.database.crud.server_squad import get_server_ids_by_uuids
|
||||
from app.database.crud.subscription import add_subscription_servers
|
||||
|
||||
server_ids = await get_server_ids_by_uuids(db, subscription.connected_squads)
|
||||
if server_ids:
|
||||
server_prices_for_period = [total_servers_price // len(server_ids)] * len(server_ids)
|
||||
await add_subscription_servers(db, subscription, server_ids, server_prices_for_period)
|
||||
|
||||
try:
|
||||
remnawave_result = await subscription_service.update_remnawave_user(db, subscription)
|
||||
if remnawave_result:
|
||||
logger.info(f"✅ RemnaWave обновлен успешно")
|
||||
else:
|
||||
logger.error(f"❌ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE")
|
||||
logger.error(f"⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE: {e}")
|
||||
logger.error(f"⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE: {e}")
|
||||
|
||||
try:
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price,
|
||||
description=f"Продление подписки на {days} дней"
|
||||
)
|
||||
logger.info(f"✅ Транзакция создана: ID {transaction.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ ОШИБКА СОЗДАНИЯ ТРАНЗАКЦИИ: {e}")
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price,
|
||||
description=f"Продление подписки на {days} дней ({months_in_period} мес)"
|
||||
)
|
||||
|
||||
try:
|
||||
await process_referral_purchase(
|
||||
@@ -1118,9 +1201,8 @@ async def confirm_extend_subscription(
|
||||
purchase_amount_kopeks=price,
|
||||
transaction_id=None
|
||||
)
|
||||
logger.info(f"✅ Рефералы обработаны")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ ОШИБКА ОБРАБОТКИ РЕФЕРАЛОВ: {e}")
|
||||
logger.error(f"⚠ ОШИБКА ОБРАБОТКИ РЕФЕРАЛОВ: {e}")
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Подписка успешно продлена!\n\n"
|
||||
@@ -1133,12 +1215,12 @@ async def confirm_extend_subscription(
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {days} дней за {price/100}₽")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ КРИТИЧЕСКАЯ ОШИБКА ПРОДЛЕНИЯ: {e}")
|
||||
logger.error(f"⚠ КРИТИЧЕСКАЯ ОШИБКА ПРОДЛЕНИЯ: {e}")
|
||||
import traceback
|
||||
logger.error(f"TRACEBACK: {traceback.format_exc()}")
|
||||
|
||||
await callback.message.edit_text(
|
||||
"❌ Произошла ошибка при продлении подписки. Обратитесь в поддержку.",
|
||||
"⚠ Произошла ошибка при продлении подписки. Обратитесь в поддержку.",
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
@@ -1558,6 +1640,8 @@ async def devices_continue(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.utils.pricing_utils import calculate_months_from_days, format_period_description, validate_pricing_calculation
|
||||
|
||||
if not callback.data == "devices_continue":
|
||||
await callback.answer("⚠️ Некорректный запрос", show_alert=True)
|
||||
return
|
||||
@@ -1568,30 +1652,48 @@ async def devices_continue(
|
||||
countries = await _get_available_countries()
|
||||
selected_countries_names = []
|
||||
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
countries_price, _ = await subscription_service.get_countries_price_by_uuids(data['countries'], db)
|
||||
except AttributeError:
|
||||
logger.warning("Используем fallback функцию для расчета цен стран")
|
||||
countries_price, _ = await get_countries_price_by_uuids_fallback(data['countries'], db)
|
||||
|
||||
for country in countries:
|
||||
if country['uuid'] in data['countries']:
|
||||
selected_countries_names.append(country['name'])
|
||||
months_in_period = calculate_months_from_days(data['period_days'])
|
||||
period_display = format_period_description(data['period_days'], db_user.language)
|
||||
|
||||
base_price = PERIOD_PRICES[data['period_days']]
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit())
|
||||
traffic_price_per_month = settings.get_traffic_price(settings.get_fixed_traffic_limit())
|
||||
final_traffic_gb = settings.get_fixed_traffic_limit()
|
||||
else:
|
||||
traffic_price = settings.get_traffic_price(data['traffic_gb'])
|
||||
traffic_price_per_month = settings.get_traffic_price(data['traffic_gb'])
|
||||
final_traffic_gb = data['traffic_gb']
|
||||
|
||||
devices_price = max(0, data['devices'] - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
total_price = base_price + traffic_price + countries_price + devices_price
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
countries_price_per_month = 0
|
||||
selected_server_prices = []
|
||||
|
||||
for country in countries:
|
||||
if country['uuid'] in data['countries']:
|
||||
server_price_per_month = country['price_kopeks']
|
||||
countries_price_per_month += server_price_per_month
|
||||
selected_countries_names.append(country['name'])
|
||||
selected_server_prices.append(server_price_per_month * months_in_period)
|
||||
|
||||
total_countries_price = countries_price_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, data['devices'] - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
total_price = base_price + total_traffic_price + total_countries_price + total_devices_price
|
||||
|
||||
monthly_additions = countries_price_per_month + devices_price_per_month + traffic_price_per_month
|
||||
is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, total_price)
|
||||
|
||||
if not is_valid:
|
||||
logger.error(f"Ошибка в расчете цены подписки для пользователя {db_user.telegram_id}")
|
||||
await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True)
|
||||
return
|
||||
|
||||
data['total_price'] = total_price
|
||||
data['server_prices_for_period'] = selected_server_prices
|
||||
await state.set_data(data)
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
@@ -1608,12 +1710,18 @@ async def devices_continue(
|
||||
summary_text = f"""
|
||||
📋 <b>Сводка заказа</b>
|
||||
|
||||
📅 <b>Период:</b> {data['period_days']} дней
|
||||
📅 <b>Период:</b> {period_display}
|
||||
📊 <b>Трафик:</b> {traffic_display}
|
||||
🌍 <b>Страны:</b> {", ".join(selected_countries_names)}
|
||||
📱 <b>Устройства:</b> {data['devices']}
|
||||
|
||||
💰 <b>Общая стоимость:</b> {texts.format_price(total_price)}
|
||||
💰 <b>Детализация стоимости:</b>
|
||||
- Базовый период: {texts.format_price(base_price)}
|
||||
- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_traffic_price)}
|
||||
- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_countries_price)}
|
||||
- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_devices_price)}
|
||||
|
||||
💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}
|
||||
|
||||
Подтверждаете покупку?
|
||||
"""
|
||||
@@ -1634,30 +1742,57 @@ async def confirm_purchase(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.utils.pricing_utils import calculate_months_from_days, validate_pricing_calculation
|
||||
|
||||
data = await state.get_data()
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
countries = await _get_available_countries()
|
||||
|
||||
months_in_period = calculate_months_from_days(data['period_days'])
|
||||
|
||||
base_price = PERIOD_PRICES[data['period_days']]
|
||||
|
||||
countries_price = 0
|
||||
countries_price_per_month = 0
|
||||
server_prices = []
|
||||
for country in countries:
|
||||
if country['uuid'] in data['countries']:
|
||||
countries_price += country['price_kopeks']
|
||||
server_prices.append(country['price_kopeks'])
|
||||
server_price_per_month = country['price_kopeks']
|
||||
server_price_total = server_price_per_month * months_in_period
|
||||
countries_price_per_month += server_price_per_month
|
||||
server_prices.append(server_price_total)
|
||||
|
||||
devices_price = max(0, data['devices'] - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
total_countries_price = countries_price_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, data['devices'] - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit())
|
||||
traffic_price_per_month = settings.get_traffic_price(settings.get_fixed_traffic_limit())
|
||||
final_traffic_gb = settings.get_fixed_traffic_limit()
|
||||
else:
|
||||
traffic_price = settings.get_traffic_price(data['traffic_gb'])
|
||||
traffic_price_per_month = settings.get_traffic_price(data['traffic_gb'])
|
||||
final_traffic_gb = data['traffic_gb']
|
||||
|
||||
final_price = base_price + traffic_price + countries_price + devices_price
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
final_price = base_price + total_traffic_price + total_countries_price + total_devices_price
|
||||
|
||||
monthly_additions = countries_price_per_month + devices_price_per_month + traffic_price_per_month
|
||||
is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, final_price)
|
||||
|
||||
if not is_valid:
|
||||
logger.error(f"Ошибка в расчете цены подписки для пользователя {db_user.telegram_id}")
|
||||
await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True)
|
||||
return
|
||||
|
||||
logger.info(f"Расчет покупки подписки на {data['period_days']} дней ({months_in_period} мес):")
|
||||
logger.info(f" Период: {base_price/100}₽")
|
||||
logger.info(f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽")
|
||||
logger.info(f" Серверы: {countries_price_per_month/100}₽/мес × {months_in_period} = {total_countries_price/100}₽")
|
||||
logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽")
|
||||
logger.info(f" ИТОГО: {final_price/100}₽")
|
||||
|
||||
if db_user.balance_kopeks < final_price:
|
||||
await callback.message.edit_text(
|
||||
@@ -1684,7 +1819,7 @@ async def confirm_purchase(
|
||||
existing_subscription = db_user.subscription
|
||||
|
||||
if existing_subscription:
|
||||
logger.info(f"🔄 Обновляем существующую подписку пользователя {db_user.telegram_id}")
|
||||
logger.info(f"Обновляем существующую подписку пользователя {db_user.telegram_id}")
|
||||
|
||||
existing_subscription.is_trial = False
|
||||
existing_subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
@@ -1702,10 +1837,8 @@ async def confirm_purchase(
|
||||
await db.refresh(existing_subscription)
|
||||
subscription = existing_subscription
|
||||
|
||||
logger.info(f"✅ Подписка обновлена. Новая дата окончания: {subscription.end_date}")
|
||||
|
||||
else:
|
||||
logger.info(f"🆕 Создаем новую подписку для пользователя {db_user.telegram_id}")
|
||||
logger.info(f"Создаем новую подписку для пользователя {db_user.telegram_id}")
|
||||
subscription = await create_paid_subscription_with_traffic_mode(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
@@ -1727,23 +1860,19 @@ async def confirm_purchase(
|
||||
await add_subscription_servers(db, subscription, server_ids, server_prices)
|
||||
await add_user_to_servers(db, server_ids)
|
||||
|
||||
logger.info(f"📊 Сохранены цены серверов: {server_prices}")
|
||||
logger.info(f"📊 Обновлены счетчики пользователей для серверов: {server_ids}")
|
||||
logger.info(f"Сохранены цены серверов за весь период: {server_prices}")
|
||||
|
||||
await db.refresh(db_user)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
if db_user.remnawave_uuid:
|
||||
logger.info(f"🔄 Обновляем существующего RemnaWave пользователя {db_user.remnawave_uuid}")
|
||||
remnawave_user = await subscription_service.update_remnawave_user(db, subscription)
|
||||
else:
|
||||
logger.info(f"🆕 Создаем нового RemnaWave пользователя для {db_user.telegram_id}")
|
||||
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
|
||||
|
||||
if not remnawave_user:
|
||||
logger.error(f"⚠️ Не удалось создать/обновить RemnaWave пользователя для {db_user.telegram_id}")
|
||||
logger.info(f"🔄 Fallback: принудительное создание нового RemnaWave пользователя")
|
||||
logger.error(f"Не удалось создать/обновить RemnaWave пользователя для {db_user.telegram_id}")
|
||||
remnawave_user = await subscription_service.create_remnawave_user(db, subscription)
|
||||
|
||||
await create_transaction(
|
||||
@@ -1751,7 +1880,7 @@ async def confirm_purchase(
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=final_price,
|
||||
description=f"Подписка на {data['period_days']} дней"
|
||||
description=f"Подписка на {data['period_days']} дней ({months_in_period} мес)"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -1769,7 +1898,7 @@ async def confirm_purchase(
|
||||
|
||||
if remnawave_user and hasattr(subscription, 'subscription_url') and subscription.subscription_url:
|
||||
success_text = f"{texts.SUBSCRIPTION_PURCHASED}\n\n"
|
||||
success_text += f"🔗 <b>Ваша ссылка для подключения:</b>\n"
|
||||
success_text += f"📗 <b>Ваша ссылка для подключения:</b>\n"
|
||||
success_text += f"<code>{subscription.subscription_url}</code>\n\n"
|
||||
success_text += f"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве"
|
||||
|
||||
@@ -1796,7 +1925,7 @@ async def confirm_purchase(
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} купил подписку на {data['period_days']} дней")
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} купил подписку на {data['period_days']} дней за {final_price/100}₽")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка покупки подписки: {e}")
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
from app.database.models import User, Subscription
|
||||
from app.database.crud.user import get_user_by_id, subtract_user_balance
|
||||
from app.database.crud.subscription import get_expiring_subscriptions, extend_subscription
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.models import TransactionType
|
||||
from app.keyboards.inline import get_autopay_notification_keyboard, get_subscription_expiring_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def send_subscription_expiring_notification(
|
||||
bot: Bot,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
days_left: int
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
if subscription.is_trial:
|
||||
text = texts.TRIAL_ENDING_SOON.format(
|
||||
price=texts.format_price(30000)
|
||||
)
|
||||
else:
|
||||
autopay_status = texts.AUTOPAY_ENABLED_TEXT if subscription.autopay_enabled else texts.AUTOPAY_DISABLED_TEXT
|
||||
|
||||
if subscription.autopay_enabled:
|
||||
action_text = f"💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}"
|
||||
else:
|
||||
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
|
||||
|
||||
text = texts.SUBSCRIPTION_EXPIRING_PAID.format(
|
||||
days=days_left,
|
||||
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
|
||||
autopay_status=autopay_status,
|
||||
action_text=action_text
|
||||
)
|
||||
|
||||
keyboard = get_subscription_expiring_keyboard(subscription.id, user.language)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"✅ Отправлено уведомление об истечении подписки пользователю {user.telegram_id}")
|
||||
return True
|
||||
|
||||
except TelegramBadRequest as e:
|
||||
logger.warning(f"⚠️ Не удалось отправить уведомление пользователю {user.telegram_id}: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка отправки уведомления об истечении подписки: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def send_autopay_failed_notification(
|
||||
bot: Bot,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
required_amount: int
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
text = texts.AUTOPAY_FAILED.format(
|
||||
balance=texts.format_price(user.balance_kopeks),
|
||||
required=texts.format_price(required_amount)
|
||||
)
|
||||
|
||||
keyboard = get_autopay_notification_keyboard(subscription.id, user.language)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"✅ Отправлено уведомление о неудачном автоплатеже пользователю {user.telegram_id}")
|
||||
return True
|
||||
|
||||
except TelegramBadRequest as e:
|
||||
logger.warning(f"⚠️ Не удалось отправить уведомление пользователю {user.telegram_id}: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка отправки уведомления о неудачном автоплатеже: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def process_autopayment(
|
||||
bot: Bot,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user:
|
||||
logger.error(f"Пользователь {subscription.user_id} не найден для автоплатежа")
|
||||
return False
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
renewal_cost = await subscription_service.calculate_renewal_price(
|
||||
subscription, 30, db
|
||||
)
|
||||
|
||||
if user.balance_kopeks < renewal_cost:
|
||||
logger.warning(f"Недостаточно средств для автоплатежа у пользователя {user.telegram_id}")
|
||||
await send_autopay_failed_notification(bot, db, subscription, renewal_cost)
|
||||
return False
|
||||
|
||||
success = await subtract_user_balance(
|
||||
db, user, renewal_cost,
|
||||
f"Автопродление подписки на 30 дней"
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.error(f"Ошибка списания средств для автоплатежа у пользователя {user.telegram_id}")
|
||||
await send_autopay_failed_notification(bot, db, subscription, renewal_cost)
|
||||
return False
|
||||
|
||||
await extend_subscription(db, subscription, 30)
|
||||
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=renewal_cost,
|
||||
description="Автопродление подписки на 30 дней"
|
||||
)
|
||||
|
||||
texts = get_texts(user.language)
|
||||
success_text = texts.AUTOPAY_SUCCESS.format(
|
||||
days=30,
|
||||
amount=texts.format_price(renewal_cost),
|
||||
new_end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M")
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=success_text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"✅ Автоплатеж успешно выполнен для пользователя {user.telegram_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обработки автоплатежа: {e}")
|
||||
return False
|
||||
@@ -180,7 +180,10 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
|
||||
InlineKeyboardButton(text="📱 Подписка", callback_data=f"admin_user_subscription_{user_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_user_statistics_{user_id}"),
|
||||
InlineKeyboardButton(text="⚙️ Настройка", callback_data=f"admin_user_servers_{user_id}"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_user_statistics_{user_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📋 Транзакции", callback_data=f"admin_user_transactions_{user_id}")
|
||||
]
|
||||
]
|
||||
@@ -578,7 +581,6 @@ def get_admin_pagination_keyboard(
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_maintenance_keyboard(language: str = "ru", is_active: bool = False, monitoring_active: bool = False) -> InlineKeyboardMarkup:
|
||||
"""Клавиатура для управления техработами"""
|
||||
|
||||
if language == "en":
|
||||
toggle_text = "🔴 Disable maintenance" if is_active else "🔧 Enable maintenance"
|
||||
|
||||
+128
-96
@@ -1,10 +1,13 @@
|
||||
from typing import List, Optional
|
||||
from aiogram import types
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from datetime import datetime
|
||||
|
||||
from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
|
||||
from app.localization.texts import get_texts
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_rules_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
@@ -656,82 +659,103 @@ def get_extend_subscription_keyboard(language: str = "ru") -> InlineKeyboardMark
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_add_traffic_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
def get_add_traffic_keyboard(language: str = "ru", subscription_end_date: datetime = None) -> InlineKeyboardMarkup:
|
||||
from app.utils.pricing_utils import get_remaining_months
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
return get_back_keyboard(language)
|
||||
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} мес)"
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
packages = settings.get_traffic_packages()
|
||||
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
|
||||
|
||||
traffic_packages = settings.get_traffic_packages()
|
||||
if not enabled_packages:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text="❌ Нет доступных пакетов" if language == "ru" else "❌ No packages available",
|
||||
callback_data="no_traffic_packages"
|
||||
)],
|
||||
[InlineKeyboardButton(
|
||||
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
|
||||
callback_data="menu_subscription"
|
||||
)]
|
||||
])
|
||||
|
||||
for package in traffic_packages:
|
||||
gb = package["gb"]
|
||||
price = package["price"]
|
||||
enabled = package["enabled"]
|
||||
|
||||
if not enabled:
|
||||
continue
|
||||
buttons = []
|
||||
|
||||
for package in enabled_packages:
|
||||
gb = package['gb']
|
||||
price_per_month = package['price']
|
||||
total_price = price_per_month * months_multiplier
|
||||
|
||||
if gb == 0:
|
||||
text = f"📊 Безлимит - {settings.format_price(package['price'])}"
|
||||
if language == "ru":
|
||||
text = f"♾️ Безлимитный трафик - {total_price/100:.2f} ₽{period_text}"
|
||||
else:
|
||||
text = f"♾️ Unlimited traffic - {total_price/100:.2f} ₽{period_text}"
|
||||
else:
|
||||
text = f"📊 +{gb} ГБ - {settings.format_price(package['price'])}"
|
||||
if language == "ru":
|
||||
text = f"📊 +{gb} ГБ трафика - {total_price/100:.2f} ₽{period_text}"
|
||||
else:
|
||||
text = f"📊 +{gb} GB traffic - {total_price/100:.2f} ₽{period_text}"
|
||||
|
||||
keyboard.append([
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}")
|
||||
])
|
||||
|
||||
if not keyboard:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="⚠️ Пакеты трафика не настроены",
|
||||
callback_data="no_traffic_packages"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
|
||||
callback_data="menu_subscription"
|
||||
)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_add_devices_keyboard(current_devices: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 100
|
||||
def get_add_devices_keyboard(current_devices: int, language: str = "ru", subscription_end_date: datetime = None) -> InlineKeyboardMarkup:
|
||||
from app.utils.pricing_utils import get_remaining_months
|
||||
from app.config import settings
|
||||
|
||||
max_add = min(5, max_devices - current_devices)
|
||||
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} мес)"
|
||||
|
||||
for add_count in range(1, max_add + 1):
|
||||
price = add_count * settings.PRICE_PER_DEVICE
|
||||
total_devices = current_devices + add_count
|
||||
device_price_per_month = settings.PRICE_PER_DEVICE
|
||||
|
||||
buttons = []
|
||||
|
||||
for count in [1, 2, 3, 4, 5]:
|
||||
new_total = current_devices + count
|
||||
if settings.MAX_DEVICES_LIMIT > 0 and new_total > settings.MAX_DEVICES_LIMIT:
|
||||
continue
|
||||
|
||||
add_device_word = _get_device_declension(add_count)
|
||||
price_per_month = count * device_price_per_month
|
||||
total_price = price_per_month * months_multiplier
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"📱 +{add_count} {add_device_word} (итого: {total_devices}) - {settings.format_price(price)}",
|
||||
callback_data=f"add_devices_{add_count}"
|
||||
)
|
||||
if language == "ru":
|
||||
text = f"📱 +{count} устройство(а) (итого: {new_total}) - {total_price/100:.2f} ₽{period_text}"
|
||||
else:
|
||||
text = f"📱 +{count} device(s) (total: {new_total}) - {total_price/100:.2f} ₽{period_text}"
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text=text, callback_data=f"add_devices_{count}")
|
||||
])
|
||||
|
||||
if max_add == 0:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="⚠️ Достигнут максимум устройств",
|
||||
callback_data="max_devices_reached"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
|
||||
callback_data="menu_subscription"
|
||||
)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_reset_traffic_confirm_keyboard(price_kopeks: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
@@ -757,64 +781,72 @@ def get_manage_countries_keyboard(
|
||||
countries: List[dict],
|
||||
selected: List[str],
|
||||
current_subscription_countries: List[str],
|
||||
language: str = "ru"
|
||||
language: str = "ru",
|
||||
subscription_end_date: datetime = None
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
from app.utils.pricing_utils import get_remaining_months
|
||||
|
||||
months_multiplier = 1
|
||||
if subscription_end_date:
|
||||
months_multiplier = get_remaining_months(subscription_end_date)
|
||||
logger.info(f"🔍 Расчет для управления странами: осталось {months_multiplier} месяцев до {subscription_end_date}")
|
||||
|
||||
buttons = []
|
||||
total_cost = 0
|
||||
|
||||
for country in countries:
|
||||
if not country.get('is_available', True):
|
||||
continue
|
||||
uuid = country['uuid']
|
||||
name = country['name']
|
||||
price_per_month = country['price_kopeks']
|
||||
|
||||
is_currently_connected = country['uuid'] in current_subscription_countries
|
||||
is_selected = country['uuid'] in selected
|
||||
|
||||
if is_currently_connected:
|
||||
if is_selected:
|
||||
emoji = "✅"
|
||||
status = ""
|
||||
if uuid in current_subscription_countries:
|
||||
if uuid in selected:
|
||||
icon = "✅"
|
||||
else:
|
||||
emoji = "➖"
|
||||
status = " (отключить БЕСПЛАТНО)"
|
||||
icon = "➖"
|
||||
else:
|
||||
if is_selected:
|
||||
emoji = "➕"
|
||||
price_text = f" (+{texts.format_price(country['price_kopeks'])})" if country['price_kopeks'] > 0 else " (Бесплатно)"
|
||||
status = price_text
|
||||
if uuid in selected:
|
||||
icon = "➕"
|
||||
total_cost += price_per_month * months_multiplier
|
||||
else:
|
||||
emoji = "⚪"
|
||||
price_text = f" (+{texts.format_price(country['price_kopeks'])})" if country['price_kopeks'] > 0 else " (Бесплатно)"
|
||||
status = price_text
|
||||
icon = "⚪"
|
||||
|
||||
keyboard.append([
|
||||
if uuid not in current_subscription_countries and uuid in selected:
|
||||
total_price = price_per_month * months_multiplier
|
||||
if months_multiplier > 1:
|
||||
price_text = f" ({price_per_month/100:.2f}₽/мес × {months_multiplier} = {total_price/100:.2f}₽)"
|
||||
logger.info(f"🔍 Сервер {name}: {price_per_month/100}₽/мес × {months_multiplier} мес = {total_price/100}₽")
|
||||
else:
|
||||
price_text = f" ({total_price/100:.2f}₽)"
|
||||
display_name = f"{icon} {name}{price_text}"
|
||||
else:
|
||||
display_name = f"{icon} {name}"
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{emoji} {country['name']}{status}",
|
||||
callback_data=f"country_manage_{country['uuid']}"
|
||||
text=display_name,
|
||||
callback_data=f"country_manage_{uuid}"
|
||||
)
|
||||
])
|
||||
|
||||
if not keyboard:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="❌ Нет доступных серверов",
|
||||
callback_data="no_servers"
|
||||
)
|
||||
])
|
||||
if total_cost > 0:
|
||||
apply_text = f"✅ Применить изменения ({total_cost/100:.2f} ₽)"
|
||||
logger.info(f"🔍 Общая стоимость новых серверов: {total_cost/100}₽")
|
||||
else:
|
||||
apply_text = "✅ Применить изменения"
|
||||
|
||||
added = [c for c in selected if c not in current_subscription_countries]
|
||||
removed = [c for c in current_subscription_countries if c not in selected]
|
||||
|
||||
apply_text = "✅ Применить изменения"
|
||||
if added or removed:
|
||||
changes_count = len(added) + len(removed)
|
||||
apply_text += f" ({changes_count})"
|
||||
|
||||
keyboard.extend([
|
||||
[InlineKeyboardButton(text=apply_text, callback_data="countries_apply")],
|
||||
[InlineKeyboardButton(text="❌ Отмена", callback_data="menu_subscription")]
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text=apply_text, callback_data="countries_apply")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
|
||||
callback_data="menu_subscription"
|
||||
)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def get_device_selection_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
|
||||
@@ -37,6 +37,7 @@ class MonitoringService:
|
||||
self.payment_service = PaymentService()
|
||||
self.bot = bot
|
||||
self._notified_users: Set[str] = set()
|
||||
self._last_cleanup = datetime.utcnow()
|
||||
|
||||
async def start_monitoring(self):
|
||||
if self.is_running:
|
||||
@@ -62,6 +63,8 @@ class MonitoringService:
|
||||
async def _monitoring_cycle(self):
|
||||
async for db in get_db():
|
||||
try:
|
||||
await self._cleanup_notification_cache()
|
||||
|
||||
await self._check_expired_subscriptions(db)
|
||||
await self._check_expiring_subscriptions(db)
|
||||
await self._check_trial_expiring_soon(db)
|
||||
@@ -69,10 +72,6 @@ class MonitoringService:
|
||||
await self._cleanup_inactive_users(db)
|
||||
await self._sync_with_remnawave(db)
|
||||
|
||||
current_hour = datetime.utcnow().hour
|
||||
if current_hour == 0:
|
||||
self._notified_users.clear()
|
||||
|
||||
await self._log_monitoring_event(
|
||||
db, "monitoring_cycle_completed",
|
||||
"Цикл мониторинга успешно завершен",
|
||||
@@ -90,6 +89,15 @@ class MonitoringService:
|
||||
finally:
|
||||
break
|
||||
|
||||
async def _cleanup_notification_cache(self):
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if (current_time - self._last_cleanup).total_seconds() >= 3600:
|
||||
old_count = len(self._notified_users)
|
||||
self._notified_users.clear()
|
||||
self._last_cleanup = current_time
|
||||
logger.info(f"🧹 Очищен кеш уведомлений ({old_count} записей)")
|
||||
|
||||
async def _check_expired_subscriptions(self, db: AsyncSession):
|
||||
try:
|
||||
expired_subscriptions = await get_expired_subscriptions(db)
|
||||
@@ -168,30 +176,52 @@ class MonitoringService:
|
||||
async def _check_expiring_subscriptions(self, db: AsyncSession):
|
||||
try:
|
||||
warning_days = settings.get_autopay_warning_days()
|
||||
all_processed_users = set()
|
||||
|
||||
for days in warning_days:
|
||||
expiring_subscriptions = await self._get_expiring_paid_subscriptions(db, days)
|
||||
sent_count = 0
|
||||
|
||||
for subscription in expiring_subscriptions:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
notification_key = f"expiring_{user.telegram_id}_{days}d"
|
||||
if notification_key in self._notified_users:
|
||||
continue
|
||||
notification_key = f"expiring_{user.telegram_id}_{days}d_{subscription.id}"
|
||||
user_key = f"user_{user.telegram_id}_today"
|
||||
|
||||
if (notification_key in self._notified_users or
|
||||
user_key in all_processed_users):
|
||||
logger.debug(f"🔄 Пропускаем дублирование для пользователя {user.telegram_id} на {days} дней")
|
||||
continue
|
||||
|
||||
should_send = True
|
||||
for other_days in warning_days:
|
||||
if other_days < days:
|
||||
other_subs = await self._get_expiring_paid_subscriptions(db, other_days)
|
||||
if any(s.user_id == user.id for s in other_subs):
|
||||
should_send = False
|
||||
logger.debug(f"🎯 Пропускаем уведомление на {days} дней для пользователя {user.telegram_id}, есть более срочное на {other_days} дней")
|
||||
break
|
||||
|
||||
if not should_send:
|
||||
continue
|
||||
|
||||
if self.bot:
|
||||
await self._send_subscription_expiring_notification(user, subscription, days)
|
||||
self._notified_users.add(notification_key)
|
||||
|
||||
logger.info(f"⚠️ Пользователю {user.telegram_id} отправлено уведомление об истечении подписки через {days} дней")
|
||||
success = await self._send_subscription_expiring_notification(user, subscription, days)
|
||||
if success:
|
||||
self._notified_users.add(notification_key)
|
||||
all_processed_users.add(user_key)
|
||||
sent_count += 1
|
||||
logger.info(f"✅ Пользователю {user.telegram_id} отправлено уведомление об истечении подписки через {days} дней")
|
||||
else:
|
||||
logger.warning(f"❌ Не удалось отправить уведомление пользователю {user.telegram_id}")
|
||||
|
||||
if expiring_subscriptions:
|
||||
if sent_count > 0:
|
||||
await self._log_monitoring_event(
|
||||
db, "expiring_notifications_sent",
|
||||
f"Отправлено {len(expiring_subscriptions)} уведомлений об истечении через {days} дней",
|
||||
{"days": days, "count": len(expiring_subscriptions)}
|
||||
f"Отправлено {sent_count} уведомлений об истечении через {days} дней",
|
||||
{"days": days, "count": sent_count}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -220,15 +250,15 @@ class MonitoringService:
|
||||
if not user:
|
||||
continue
|
||||
|
||||
notification_key = f"trial_2h_{user.telegram_id}"
|
||||
notification_key = f"trial_2h_{user.telegram_id}_{subscription.id}"
|
||||
if notification_key in self._notified_users:
|
||||
continue
|
||||
|
||||
if self.bot:
|
||||
await self._send_trial_ending_notification(user, subscription)
|
||||
self._notified_users.add(notification_key)
|
||||
|
||||
logger.info(f"🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа")
|
||||
success = await self._send_trial_ending_notification(user, subscription)
|
||||
if success:
|
||||
self._notified_users.add(notification_key)
|
||||
logger.info(f"🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа")
|
||||
|
||||
if trial_expiring:
|
||||
await self._log_monitoring_event(
|
||||
@@ -257,9 +287,9 @@ class MonitoringService:
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(f"🔍 Поиск платных подписок, истекающих в ближайшие {days_before} дней")
|
||||
logger.info(f"📅 Текущее время: {current_time}")
|
||||
logger.info(f"📅 Пороговая дата: {threshold_date}")
|
||||
logger.debug(f"🔍 Поиск платных подписок, истекающих в ближайшие {days_before} дней")
|
||||
logger.debug(f"📅 Текущее время: {current_time}")
|
||||
logger.debug(f"📅 Пороговая дата: {threshold_date}")
|
||||
|
||||
subscriptions = result.scalars().all()
|
||||
logger.info(f"📊 Найдено {len(subscriptions)} платных подписок для уведомлений")
|
||||
@@ -340,15 +370,15 @@ class MonitoringService:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки автоплатежей: {e}")
|
||||
|
||||
async def _send_subscription_expired_notification(self, user: User):
|
||||
async def _send_subscription_expired_notification(self, user: User) -> bool:
|
||||
try:
|
||||
message = """
|
||||
❌ <b>Подписка истекла</b>
|
||||
⛔ <b>Подписка истекла</b>
|
||||
|
||||
Ваша подписка истекла. Для восстановления доступа продлите подписку.
|
||||
Ваша подписка истекла. Для восстановления доступа продлите подписку.
|
||||
|
||||
🔧 Доступ к серверам заблокирован до продления.
|
||||
"""
|
||||
🔧 Доступ к серверам заблокирован до продления.
|
||||
"""
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
@@ -363,11 +393,13 @@ class MonitoringService:
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}")
|
||||
return False
|
||||
|
||||
async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int):
|
||||
async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int) -> bool:
|
||||
try:
|
||||
from app.utils.formatters import format_days_declension
|
||||
|
||||
@@ -382,14 +414,14 @@ class MonitoringService:
|
||||
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
|
||||
|
||||
message = f"""
|
||||
⚠️ <b>Подписка истекает через {days_text}!</b>
|
||||
⚠️ <b>Подписка истекает через {days_text}!</b>
|
||||
|
||||
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
|
||||
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
|
||||
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
|
||||
{action_text}
|
||||
"""
|
||||
{action_text}
|
||||
"""
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
@@ -405,30 +437,32 @@ class MonitoringService:
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}")
|
||||
return False
|
||||
|
||||
async def _send_trial_ending_notification(self, user: User, subscription: Subscription):
|
||||
async def _send_trial_ending_notification(self, user: User, subscription: Subscription) -> bool:
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
|
||||
message = f"""
|
||||
🎁 <b>Тестовая подписка скоро закончится!</b>
|
||||
🎁 <b>Тестовая подписка скоро закончится!</b>
|
||||
|
||||
Ваша тестовая подписка истекает через 2 часа.
|
||||
Ваша тестовая подписка истекает через 2 часа.
|
||||
|
||||
💎 <b>Не хотите остаться без VPN?</b>
|
||||
Переходите на полную подписку со скидкой!
|
||||
💎 <b>Не хотите остаться без VPN?</b>
|
||||
Переходите на полную подписку со скидкой!
|
||||
|
||||
🔥 <b>Специальное предложение:</b>
|
||||
• 30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
|
||||
• Безлимитный трафик
|
||||
• Все серверы доступны
|
||||
• Поддержка до 3 устройств
|
||||
🔥 <b>Специальное предложение:</b>
|
||||
• 30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
|
||||
• Безлимитный трафик
|
||||
• Все серверы доступны
|
||||
• Поддержка до 3 устройств
|
||||
|
||||
⚡️ Успейте оформить до окончания тестового периода!
|
||||
"""
|
||||
⚡️ Успейте оформить до окончания тестового периода!
|
||||
"""
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
@@ -443,9 +477,11 @@ class MonitoringService:
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}")
|
||||
return False
|
||||
|
||||
async def _send_autopay_success_notification(self, user: User, amount: int, days: int):
|
||||
try:
|
||||
@@ -554,7 +590,7 @@ class MonitoringService:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка логирования события мониторинга: {e}")
|
||||
|
||||
|
||||
async def get_monitoring_status(self, db: AsyncSession) -> Dict[str, Any]:
|
||||
try:
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
@@ -10,6 +10,12 @@ from app.external.remnawave_api import (
|
||||
TrafficLimitStrategy, RemnaWaveAPIError
|
||||
)
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.utils.pricing_utils import (
|
||||
calculate_months_from_days,
|
||||
get_remaining_months,
|
||||
calculate_prorated_price,
|
||||
validate_pricing_calculation
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -388,6 +394,139 @@ class SubscriptionService:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения цен стран: {e}")
|
||||
return len(country_uuids) * 1000
|
||||
|
||||
async def calculate_subscription_price_with_months(
|
||||
self,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: List[int],
|
||||
devices: int,
|
||||
db: AsyncSession
|
||||
) -> Tuple[int, List[int]]:
|
||||
|
||||
from app.config import PERIOD_PRICES
|
||||
from app.database.crud.server_squad import get_server_squad_by_id
|
||||
|
||||
if settings.MAX_DEVICES_LIMIT > 0 and devices > settings.MAX_DEVICES_LIMIT:
|
||||
raise ValueError(f"Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}")
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
server_prices = []
|
||||
total_servers_price = 0
|
||||
|
||||
for server_id in server_squad_ids:
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if server and server.is_available and not server.is_full:
|
||||
server_price_per_month = server.price_kopeks
|
||||
server_price_total = server_price_per_month * months_in_period
|
||||
server_prices.append(server_price_total)
|
||||
total_servers_price += server_price_total
|
||||
logger.debug(f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_in_period} мес = {server_price_total/100}₽")
|
||||
else:
|
||||
server_prices.append(0)
|
||||
logger.warning(f"Сервер ID {server_id} недоступен")
|
||||
|
||||
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
total_price = base_price + total_traffic_price + total_servers_price + total_devices_price
|
||||
|
||||
logger.info(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" Период {period_days} дней: {base_price/100}₽")
|
||||
logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽")
|
||||
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽")
|
||||
logger.info(f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽")
|
||||
logger.info(f" ИТОГО: {total_price/100}₽")
|
||||
|
||||
return total_price, server_prices
|
||||
|
||||
async def calculate_renewal_price_with_months(
|
||||
self,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
db: AsyncSession
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
servers_price_per_month, _ = await self.get_countries_price_by_uuids(
|
||||
subscription.connected_squads, db
|
||||
)
|
||||
total_servers_price = servers_price_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
total_price = base_price + total_servers_price + total_devices_price + total_traffic_price
|
||||
|
||||
logger.info(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽")
|
||||
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}₽")
|
||||
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽")
|
||||
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽")
|
||||
logger.info(f" 💎 ИТОГО: {total_price/100}₽")
|
||||
|
||||
return total_price
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка расчета стоимости продления: {e}")
|
||||
from app.config import PERIOD_PRICES
|
||||
return PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
async def calculate_addon_price_with_remaining_period(
|
||||
self,
|
||||
subscription: Subscription,
|
||||
additional_traffic_gb: int = 0,
|
||||
additional_devices: int = 0,
|
||||
additional_server_ids: List[int] = None,
|
||||
db: AsyncSession = None
|
||||
) -> int:
|
||||
|
||||
if additional_server_ids is None:
|
||||
additional_server_ids = []
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
months_to_pay = get_remaining_months(subscription.end_date)
|
||||
|
||||
total_price = 0
|
||||
|
||||
if additional_traffic_gb > 0:
|
||||
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
|
||||
total_price += traffic_price_per_month * months_to_pay
|
||||
logger.info(f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес x {months_to_pay} = {traffic_price_per_month * months_to_pay/100}₽")
|
||||
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_price += devices_price_per_month * months_to_pay
|
||||
logger.info(f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес x {months_to_pay} = {devices_price_per_month * months_to_pay/100}₽")
|
||||
|
||||
if additional_server_ids and db:
|
||||
for server_id in additional_server_ids:
|
||||
from app.database.crud.server_squad import get_server_squad_by_id
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if server and server.is_available:
|
||||
server_price_per_month = server.price_kopeks
|
||||
server_total_price = server_price_per_month * months_to_pay
|
||||
total_price += server_total_price
|
||||
logger.info(f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_to_pay} = {server_total_price/100}₽")
|
||||
|
||||
logger.info(f"Итого доплата за {months_to_pay} мес: {total_price/100}₽")
|
||||
return total_price
|
||||
|
||||
def _gb_to_bytes(self, gb: int) -> int:
|
||||
if gb == 0:
|
||||
|
||||
@@ -52,6 +52,8 @@ class AdminStates(StatesGroup):
|
||||
editing_squad_price = State()
|
||||
editing_traffic_price = State()
|
||||
editing_device_price = State()
|
||||
editing_user_devices = State()
|
||||
editing_user_traffic = State()
|
||||
|
||||
editing_rules_page = State()
|
||||
|
||||
|
||||
+13
-3
@@ -1,3 +1,13 @@
|
||||
"""
|
||||
Утилиты
|
||||
"""
|
||||
from .pricing_utils import (
|
||||
calculate_months_from_days,
|
||||
get_remaining_months,
|
||||
calculate_prorated_price,
|
||||
format_period_description
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'calculate_months_from_days',
|
||||
'get_remaining_months',
|
||||
'calculate_prorated_price',
|
||||
'format_period_description'
|
||||
]
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Tuple
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def calculate_months_from_days(days: int) -> int:
|
||||
return max(1, round(days / 30))
|
||||
|
||||
|
||||
def get_remaining_months(end_date: datetime) -> int:
|
||||
current_time = datetime.utcnow()
|
||||
if end_date <= current_time:
|
||||
return 1
|
||||
|
||||
remaining_days = (end_date - current_time).days
|
||||
return max(1, round(remaining_days / 30))
|
||||
|
||||
|
||||
def calculate_period_multiplier(period_days: int) -> Tuple[int, float]:
|
||||
exact_months = period_days / 30
|
||||
months_count = max(1, round(exact_months))
|
||||
|
||||
logger.debug(f"Период {period_days} дней = {exact_months:.2f} точных месяцев ≈ {months_count} месяцев для расчета")
|
||||
|
||||
return months_count, exact_months
|
||||
|
||||
|
||||
def calculate_prorated_price(
|
||||
monthly_price: int,
|
||||
end_date: datetime,
|
||||
min_charge_months: int = 1
|
||||
) -> Tuple[int, int]:
|
||||
months_remaining = get_remaining_months(end_date)
|
||||
months_to_charge = max(min_charge_months, months_remaining)
|
||||
|
||||
total_price = monthly_price * months_to_charge
|
||||
|
||||
logger.debug(f"Расчет пропорциональной цены: {monthly_price/100}₽/мес × {months_to_charge} мес = {total_price/100}₽")
|
||||
|
||||
return total_price, months_to_charge
|
||||
|
||||
|
||||
def format_period_description(days: int, language: str = "ru") -> str:
|
||||
months = calculate_months_from_days(days)
|
||||
|
||||
if language == "ru":
|
||||
if days == 30:
|
||||
return "1 месяц"
|
||||
elif days == 60:
|
||||
return "2 месяца"
|
||||
elif days == 90:
|
||||
return "3 месяца"
|
||||
elif days == 180:
|
||||
return "6 месяцев"
|
||||
elif days == 360:
|
||||
return "12 месяцев"
|
||||
else:
|
||||
month_word = "месяц" if months == 1 else ("месяца" if 2 <= months <= 4 else "месяцев")
|
||||
return f"{days} дней ({months} {month_word})"
|
||||
else:
|
||||
month_word = "month" if months == 1 else "months"
|
||||
return f"{days} days ({months} {month_word})"
|
||||
|
||||
|
||||
def validate_pricing_calculation(
|
||||
base_price: int,
|
||||
monthly_additions: int,
|
||||
months: int,
|
||||
total_calculated: int
|
||||
) -> bool:
|
||||
expected_total = base_price + (monthly_additions * months)
|
||||
is_valid = expected_total == total_calculated
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(f"Несоответствие в расчете цены: ожидалось {expected_total/100}₽, получено {total_calculated/100}₽")
|
||||
logger.warning(f"Детали: базовая цена {base_price/100}₽ + месячные дополнения {monthly_additions/100}₽ × {months} мес")
|
||||
|
||||
return is_valid
|
||||
|
||||
|
||||
STANDARD_PERIODS = {
|
||||
14: {"months": 0.5, "display_ru": "2 недели", "display_en": "2 weeks"},
|
||||
30: {"months": 1, "display_ru": "1 месяц", "display_en": "1 month"},
|
||||
60: {"months": 2, "display_ru": "2 месяца", "display_en": "2 months"},
|
||||
90: {"months": 3, "display_ru": "3 месяца", "display_en": "3 months"},
|
||||
180: {"months": 6, "display_ru": "6 месяцев", "display_en": "6 months"},
|
||||
360: {"months": 12, "display_ru": "1 год", "display_en": "1 year"},
|
||||
}
|
||||
|
||||
|
||||
def get_period_info(days: int) -> dict:
|
||||
return STANDARD_PERIODS.get(days)
|
||||
Reference in New Issue
Block a user