Compare commits

...

36 Commits

Author SHA1 Message Date
Egor 519978f634 Merge pull request #24 from Fr1ngg/dev
Изменение логики расчета цен за доп. услуги (трафик, сервера, устройства). Теперь рассчитывается цена за месяц
2025-09-05 02:45:20 +03:00
Egor 7095d8f015 Update inline.py 2025-09-05 02:25:19 +03:00
Egor e8dcfa0687 Update inline.py 2025-09-05 02:22:22 +03:00
Egor a0e12fa89a Update subscription.py 2025-09-05 02:21:40 +03:00
Egor 097be9aa0f Update inline.py 2025-09-05 02:13:47 +03:00
Egor f50b5296f4 Update subscription.py 2025-09-05 02:08:59 +03:00
Egor 14e94053ee Update inline.py 2025-09-05 02:07:40 +03:00
Egor 3c27fa4d90 Update subscription.py 2025-09-05 01:56:33 +03:00
Egor 2a78ace675 Update subscription.py 2025-09-05 01:40:31 +03:00
Egor 81774f87d8 Update subscription.py 2025-09-05 01:36:49 +03:00
Egor 6735c2b38f Update subscription_service.py 2025-09-05 01:30:49 +03:00
Egor 8bdc5a2332 Update subscription.py 2025-09-05 01:29:13 +03:00
Egor d26262bfbf Update subscription_service.py 2025-09-05 01:28:55 +03:00
Egor 48f2f01bdd Update __init__.py 2025-09-05 01:28:00 +03:00
Egor ef68a43589 Create pricing_utils.py 2025-09-05 01:27:26 +03:00
Egor 733c1b72ec Update subscription.py 2025-09-05 01:25:59 +03:00
Egor c9ca360286 Update subscription_service.py 2025-09-05 01:18:14 +03:00
Egor 5aad1e025b Update users.py 2025-09-04 21:56:27 +03:00
Egor e811e7c877 Merge pull request #22 from Fr1ngg/dev
Dev
2025-09-04 21:45:36 +03:00
Egor 932e9f292b Update admin.py 2025-09-04 21:42:20 +03:00
Egor 03749574d6 Update users.py 2025-09-04 21:33:09 +03:00
Egor 1059a51688 Update users.py 2025-09-04 21:30:31 +03:00
Egor a1963d5b17 Update users.py 2025-09-04 21:26:07 +03:00
Egor 426e736cd5 Update users.py 2025-09-04 21:18:18 +03:00
Egor fb03cff403 Update users.py 2025-09-04 21:11:28 +03:00
Egor 40780f1184 Update users.py 2025-09-04 21:00:58 +03:00
Egor c76290b51e Update users.py 2025-09-04 20:55:01 +03:00
Egor 5b6dd3058e Update users.py 2025-09-04 20:46:52 +03:00
Egor c8d91a1ec1 Update admin.py 2025-09-04 20:43:55 +03:00
Egor 21952f84eb Update states.py 2025-09-04 20:42:54 +03:00
Egor 7bdfaa312b Update users.py 2025-09-04 20:42:19 +03:00
Egor 1829a469e7 Merge pull request #21 from Fr1ngg/dev
Dev
2025-09-04 18:40:02 +03:00
Egor ddd37a4e98 Update monitoring_service.py 2025-09-04 18:38:44 +03:00
Egor 2804c701c0 Update monitoring_service.py 2025-09-04 18:35:59 +03:00
Egor b3c85b2db5 Delete app/handlers/subscription_notifications.py 2025-09-04 18:28:42 +03:00
Egor 987b8170c6 Update monitoring_service.py 2025-09-04 18:26:38 +03:00
11 changed files with 1558 additions and 455 deletions
+153 -16
View File
@@ -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
+693
View File
@@ -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
View File
@@ -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}")
-171
View File
@@ -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
+4 -2
View File
@@ -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
View File
@@ -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
+82 -46
View File
@@ -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
+139
View File
@@ -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:
+2
View File
@@ -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
View File
@@ -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'
]
+94
View File
@@ -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)