Merge pull request #2264 from BEDOLAGA-DEV/main

main
This commit is contained in:
PEDZEO
2026-01-12 07:38:37 +03:00
committed by GitHub
32 changed files with 4502 additions and 396 deletions
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.9.5-$(git rev-parse --short HEAD)"
VERSION="v2.9.6-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.9.5-dev-$(git rev-parse --short HEAD)"
VERSION="v2.9.6-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.9.5-pr-$(git rev-parse --short HEAD)"
VERSION="v2.9.6-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.9.5-$(git rev-parse --short HEAD)"
VERSION="v2.9.6-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.9.5-dev-$(git rev-parse --short HEAD)"
VERSION="v2.9.6-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.9.5-pr-$(git rev-parse --short HEAD)"
VERSION="v2.9.6-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v2.9.5"
ARG VERSION="v2.9.6"
ARG BUILD_DATE
ARG VCS_REF
+2
View File
@@ -447,6 +447,8 @@ class Settings(BaseSettings):
FREEKASSA_PAYMENT_SYSTEM_ID: Optional[int] = None
# Использовать API для создания заказов (нужно для NSPK СБП)
FREEKASSA_USE_API: bool = False
# Публичный IP сервера для Freekassa API (если не задан - определяется автоматически)
SERVER_PUBLIC_IP: Optional[str] = None
MAIN_MENU_MODE: str = "default"
CONNECT_BUTTON_MODE: str = "guide"
+29 -4
View File
@@ -316,11 +316,22 @@ async def extend_subscription(
current_time = datetime.utcnow()
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}")
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}, тариф={subscription.tariff_id}")
# Определяем, происходит ли СМЕНА тарифа (а не продление того же)
is_tariff_change = (
tariff_id is not None
and subscription.tariff_id is not None
and tariff_id != subscription.tariff_id
)
if is_tariff_change:
logger.info(f"🔄 Обнаружена СМЕНА тарифа: {subscription.tariff_id}{tariff_id}")
# НОВОЕ: Вычисляем бонусные дни от триала ДО изменения end_date
# Бонусные дни НЕ начисляются при смене тарифа
bonus_days = 0
if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
if not is_tariff_change and subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
# Вычисляем остаток триала
if subscription.end_date and subscription.end_date > current_time:
remaining = subscription.end_date - current_time
@@ -342,6 +353,11 @@ async def extend_subscription(
abs(days),
subscription.end_date,
)
elif is_tariff_change:
# При СМЕНЕ тарифа срок начинается с текущей даты
subscription.end_date = current_time + timedelta(days=total_days)
subscription.start_date = current_time
logger.info(f"📅 СМЕНА тарифа: срок начинается с текущей даты + {total_days} дней")
elif subscription.end_date > current_time:
subscription.end_date = subscription.end_date + timedelta(days=total_days)
logger.info(f"📅 Подписка активна, добавляем {total_days} дней ({days} + {bonus_days} бонус) к текущей дате окончания")
@@ -380,6 +396,11 @@ async def extend_subscription(
subscription.tariff_id = tariff_id
logger.info(f"📦 Обновлен тариф подписки: {old_tariff_id}{tariff_id}")
# При покупке тарифа сбрасываем триальный статус
if subscription.is_trial:
subscription.is_trial = False
logger.info(f"🎓 Подписка {subscription.id} конвертирована из триала в платную")
if traffic_limit_gb is not None:
old_traffic = subscription.traffic_limit_gb
subscription.traffic_limit_gb = traffic_limit_gb
@@ -388,8 +409,12 @@ async def extend_subscription(
logger.info(f"📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ")
elif settings.RESET_TRAFFIC_ON_PAYMENT:
subscription.traffic_used_gb = 0.0
subscription.purchased_traffic_gb = 0
logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
# В режиме тарифов сохраняем докупленный трафик при продлении
if subscription.tariff_id is None:
subscription.purchased_traffic_gb = 0
logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
else:
logger.info("🔄 Сбрасываем использованный трафик, докупленный сохранен (режим тарифов)")
if device_limit is not None:
old_devices = subscription.device_limit
+15
View File
@@ -167,6 +167,9 @@ async def create_tariff(
tier_level: int = 1,
is_trial_available: bool = False,
promo_group_ids: Optional[List[int]] = None,
traffic_topup_enabled: bool = False,
traffic_topup_packages: Optional[Dict[str, int]] = None,
max_topup_traffic_gb: int = 0,
) -> Tariff:
"""Создает новый тариф."""
normalized_prices = _normalize_period_prices(period_prices)
@@ -182,6 +185,9 @@ async def create_tariff(
period_prices=normalized_prices,
tier_level=max(1, tier_level),
is_trial_available=is_trial_available,
traffic_topup_enabled=traffic_topup_enabled,
traffic_topup_packages=traffic_topup_packages or {},
max_topup_traffic_gb=max(0, max_topup_traffic_gb),
)
db.add(tariff)
@@ -227,6 +233,9 @@ async def update_tariff(
tier_level: Optional[int] = None,
is_trial_available: Optional[bool] = None,
promo_group_ids: Optional[List[int]] = None,
traffic_topup_enabled: Optional[bool] = None,
traffic_topup_packages: Optional[Dict[str, int]] = None,
max_topup_traffic_gb: Optional[int] = None,
) -> Tariff:
"""Обновляет существующий тариф."""
if name is not None:
@@ -252,6 +261,12 @@ async def update_tariff(
tariff.tier_level = max(1, tier_level)
if is_trial_available is not None:
tariff.is_trial_available = is_trial_available
if traffic_topup_enabled is not None:
tariff.traffic_topup_enabled = traffic_topup_enabled
if traffic_topup_packages is not None:
tariff.traffic_topup_packages = traffic_topup_packages
if max_topup_traffic_gb is not None:
tariff.max_topup_traffic_gb = max(0, max_topup_traffic_gb)
# Обновляем промогруппы если указаны
if promo_group_ids is not None:
+31 -1
View File
@@ -762,6 +762,13 @@ class Tariff(Base):
# Дополнительные настройки
is_trial_available = Column(Boolean, default=False, nullable=False) # Можно ли взять триал на этом тарифе
# Докупка трафика
traffic_topup_enabled = Column(Boolean, default=False, nullable=False) # Разрешена ли докупка трафика
# Пакеты трафика: JSON {"5": 5000, "10": 9000, "20": 15000} (ГБ: цена в копейках)
traffic_topup_packages = Column(JSON, default=dict)
# Максимальный лимит трафика после докупки (0 = без ограничений)
max_topup_traffic_gb = Column(Integer, default=0, nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
@@ -805,6 +812,29 @@ class Tariff(Base):
return True # Если у пользователя нет группы - доступен
return any(pg.id == promo_group_id for pg in self.allowed_promo_groups)
def get_traffic_topup_packages(self) -> Dict[int, int]:
"""Возвращает пакеты трафика для докупки: {ГБ: цена в копейках}."""
packages = self.traffic_topup_packages or {}
return {int(gb): int(price) for gb, price in packages.items()}
def get_traffic_topup_price(self, gb: int) -> Optional[int]:
"""Возвращает цену в копейках для указанного пакета трафика."""
packages = self.get_traffic_topup_packages()
return packages.get(gb)
def get_available_traffic_packages(self) -> List[int]:
"""Возвращает список доступных пакетов трафика в ГБ."""
packages = self.get_traffic_topup_packages()
return sorted(packages.keys())
def can_topup_traffic(self) -> bool:
"""Проверяет, можно ли докупить трафик на этом тарифе."""
return (
self.traffic_topup_enabled
and bool(self.traffic_topup_packages)
and not self.is_unlimited_traffic
)
def __repr__(self):
return f"<Tariff(id={self.id}, name='{self.name}', tier={self.tier_level}, active={self.is_active})>"
@@ -2249,4 +2279,4 @@ class CabinetRefreshToken(Base):
def __repr__(self) -> str:
status = "valid" if self.is_valid else ("revoked" if self.is_revoked else "expired")
return f"<CabinetRefreshToken id={self.id} user_id={self.user_id} status={status}>"
return f"<CabinetRefreshToken id={self.id} user_id={self.user_id} status={status}>"
+87
View File
@@ -5332,6 +5332,87 @@ async def add_tariff_device_price_column() -> bool:
return False
async def add_tariff_traffic_topup_columns() -> bool:
"""Добавляет колонки для докупки трафика в тарифах."""
try:
columns_added = 0
# Колонка traffic_topup_enabled
if not await check_column_exists('tariffs', 'traffic_topup_enabled'):
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN traffic_topup_enabled INTEGER DEFAULT 0 NOT NULL"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN traffic_topup_enabled BOOLEAN DEFAULT FALSE NOT NULL"
))
else: # MySQL
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN traffic_topup_enabled TINYINT(1) DEFAULT 0 NOT NULL"
))
logger.info("✅ Колонка traffic_topup_enabled добавлена в tariffs")
columns_added += 1
else:
logger.info("️ Колонка traffic_topup_enabled уже существует в tariffs")
# Колонка traffic_topup_packages (JSON)
if not await check_column_exists('tariffs', 'traffic_topup_packages'):
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN traffic_topup_packages TEXT DEFAULT '{}'"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN traffic_topup_packages JSONB DEFAULT '{}'"
))
else: # MySQL
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN traffic_topup_packages JSON DEFAULT NULL"
))
logger.info("✅ Колонка traffic_topup_packages добавлена в tariffs")
columns_added += 1
else:
logger.info("️ Колонка traffic_topup_packages уже существует в tariffs")
# Колонка max_topup_traffic_gb (максимальный лимит трафика после докупок)
if not await check_column_exists('tariffs', 'max_topup_traffic_gb'):
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN max_topup_traffic_gb INTEGER DEFAULT 0 NOT NULL"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN max_topup_traffic_gb INTEGER DEFAULT 0 NOT NULL"
))
else: # MySQL
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN max_topup_traffic_gb INT DEFAULT 0 NOT NULL"
))
logger.info("✅ Колонка max_topup_traffic_gb добавлена в tariffs")
columns_added += 1
else:
logger.info("️ Колонка max_topup_traffic_gb уже существует в tariffs")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления колонок докупки трафика: {error}")
return False
async def run_universal_migration():
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
@@ -5834,6 +5915,12 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с колонкой device_price_kopeks в tariffs")
traffic_topup_columns_ready = await add_tariff_traffic_topup_columns()
if traffic_topup_columns_ready:
logger.info("✅ Колонки докупки трафика в tariffs готовы")
else:
logger.warning("⚠️ Проблемы с колонками докупки трафика в tariffs")
logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===")
fk_updated = await fix_foreign_keys_for_user_deletion()
if fk_updated:
+17
View File
@@ -39,6 +39,23 @@ class WebhookServer:
if settings.is_freekassa_enabled():
self.app.router.add_post(settings.FREEKASSA_WEBHOOK_PATH, self._freekassa_webhook_handler)
else:
# Диагностика почему Freekassa не включена
if settings.FREEKASSA_ENABLED:
missing = []
if settings.FREEKASSA_SHOP_ID is None:
missing.append("FREEKASSA_SHOP_ID")
if settings.FREEKASSA_API_KEY is None:
missing.append("FREEKASSA_API_KEY")
if settings.FREEKASSA_SECRET_WORD_1 is None:
missing.append("FREEKASSA_SECRET_WORD_1")
if settings.FREEKASSA_SECRET_WORD_2 is None:
missing.append("FREEKASSA_SECRET_WORD_2")
if missing:
logger.warning(
f"Freekassa ENABLED=true, но webhook не зарегистрирован. "
f"Отсутствуют параметры: {', '.join(missing)}"
)
self.app.router.add_get('/health', self._health_check)
+6 -2
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import asyncio
import logging
import json
@@ -9,13 +11,15 @@ from ipaddress import (
ip_address,
ip_network,
)
from typing import Iterable, Optional, Dict, Any, List, Union, Tuple
from typing import Iterable, Optional, Dict, Any, List, Union, Tuple, TYPE_CHECKING
from aiohttp import web
from app.config import settings
from app.services.payment_service import PaymentService
from app.database.database import get_db
if TYPE_CHECKING:
from app.services.payment_service import PaymentService
logger = logging.getLogger(__name__)
+12
View File
@@ -135,6 +135,16 @@ def _status_info(
return "", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
return "", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
if record.method == PaymentMethod.FREEKASSA:
mapping = {
"pending": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"success": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"paid": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"error": ("", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
return "", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")
@@ -158,6 +168,8 @@ def _is_checkable(record: PendingPayment) -> bool:
return status in {"pending", "waiting_for_capture"}
if record.method == PaymentMethod.CRYPTOBOT:
return status in {"active"}
if record.method == PaymentMethod.FREEKASSA:
return status in {"pending", ""}
return False
+449
View File
@@ -191,6 +191,9 @@ def get_tariff_view_keyboard(
InlineKeyboardButton(text="📱💰 Цена за устройство", callback_data=f"admin_tariff_edit_device_price:{tariff.id}"),
InlineKeyboardButton(text="⏰ Дни триала", callback_data=f"admin_tariff_edit_trial_days:{tariff.id}"),
])
buttons.append([
InlineKeyboardButton(text="📈 Докупка трафика", callback_data=f"admin_tariff_edit_traffic_topup:{tariff.id}"),
])
buttons.append([
InlineKeyboardButton(text="🌐 Серверы", callback_data=f"admin_tariff_edit_squads:{tariff.id}"),
InlineKeyboardButton(text="👥 Промогруппы", callback_data=f"admin_tariff_edit_promo:{tariff.id}"),
@@ -229,6 +232,23 @@ def get_tariff_view_keyboard(
return InlineKeyboardMarkup(inline_keyboard=buttons)
def _format_traffic_topup_packages(tariff: Tariff) -> str:
"""Форматирует пакеты докупки трафика для отображения."""
if not getattr(tariff, 'traffic_topup_enabled', False):
return "❌ Отключено"
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
if not packages:
return "✅ Включено, но пакеты не настроены"
lines = ["✅ Включено"]
for gb in sorted(packages.keys()):
price = packages[gb]
lines.append(f"{gb} ГБ: {_format_price_kopeks(price)}")
return "\n".join(lines)
def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> str:
"""Форматирует информацию о тарифе."""
texts = get_texts(language)
@@ -264,6 +284,9 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
else:
device_price_display = "Недоступно"
# Форматируем докупку трафика
traffic_topup_display = _format_traffic_topup_packages(tariff)
return f"""📦 <b>Тариф: {tariff.name}</b>
{status}
@@ -277,6 +300,9 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
Триал: {trial_status}
Дней триала: {trial_days_display}
<b>Докупка трафика:</b>
{traffic_topup_display}
<b>Цены:</b>
{prices_display}
@@ -1310,6 +1336,419 @@ async def process_edit_tariff_trial_days(
)
# ============ РЕДАКТИРОВАНИЕ ДОКУПКИ ТРАФИКА ============
def _parse_traffic_topup_packages(text: str) -> Dict[int, int]:
"""
Парсит строку с пакетами докупки трафика.
Формат: "5:5000, 10:9000, 20:15000" (ГБ:цена_в_копейках)
"""
packages = {}
text = text.replace(";", ",").replace("=", ":")
for part in text.split(","):
part = part.strip()
if not part:
continue
if ":" not in part:
continue
gb_str, price_str = part.split(":", 1)
try:
gb = int(gb_str.strip())
price = int(price_str.strip())
if gb > 0 and price > 0:
packages[gb] = price
except ValueError:
continue
return packages
def _format_traffic_topup_packages_for_edit(packages: Dict[int, int]) -> str:
"""Форматирует пакеты докупки для редактирования."""
if not packages:
return "5:5000, 10:9000, 20:15000"
parts = []
for gb in sorted(packages.keys()):
parts.append(f"{gb}:{packages[gb]}")
return ", ".join(parts)
@admin_required
@error_handler
async def start_edit_tariff_traffic_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Показывает меню настройки докупки трафика."""
texts = get_texts(db_user.language)
tariff_id = int(callback.data.split(":")[1])
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
await callback.answer("Тариф не найден", show_alert=True)
return
# Проверяем, безлимитный ли тариф
if tariff.is_unlimited_traffic:
await callback.answer("Докупка недоступна для безлимитного тарифа", show_alert=True)
return
is_enabled = getattr(tariff, 'traffic_topup_enabled', False)
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
max_topup_traffic = getattr(tariff, 'max_topup_traffic_gb', 0) or 0
# Форматируем текущие настройки
if is_enabled:
status = "✅ Включено"
if packages:
packages_display = "\n".join(f"{gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items()))
else:
packages_display = " Пакеты не настроены"
else:
status = "❌ Отключено"
packages_display = " -"
# Форматируем лимит
if max_topup_traffic > 0:
max_limit_display = f"{max_topup_traffic} ГБ"
else:
max_limit_display = "Без ограничений"
buttons = []
# Переключение вкл/выкл
if is_enabled:
buttons.append([
InlineKeyboardButton(text="❌ Отключить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}")
])
else:
buttons.append([
InlineKeyboardButton(text="✅ Включить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}")
])
# Редактирование пакетов и лимита (только если включено)
if is_enabled:
buttons.append([
InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}")
])
buttons.append([
InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}")
])
buttons.append([
InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}")
])
await callback.message.edit_text(
f"📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n"
f"Статус: {status}\n\n"
f"<b>Пакеты:</b>\n{packages_display}\n\n"
f"<b>Макс. лимит:</b> {max_limit_display}\n\n"
"Пользователи смогут докупать трафик по заданным ценам.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def toggle_tariff_traffic_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
"""Переключает включение/выключение докупки трафика."""
tariff_id = int(callback.data.split(":")[1])
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
await callback.answer("Тариф не найден", show_alert=True)
return
is_enabled = getattr(tariff, 'traffic_topup_enabled', False)
new_value = not is_enabled
tariff = await update_tariff(db, tariff, traffic_topup_enabled=new_value)
status_text = "включена" if new_value else "отключена"
await callback.answer(f"Докупка трафика {status_text}")
# Перерисовываем меню
texts = get_texts(db_user.language)
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
max_topup_traffic = getattr(tariff, 'max_topup_traffic_gb', 0) or 0
if new_value:
status = "✅ Включено"
if packages:
packages_display = "\n".join(f"{gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items()))
else:
packages_display = " Пакеты не настроены"
else:
status = "❌ Отключено"
packages_display = " -"
# Форматируем лимит
if max_topup_traffic > 0:
max_limit_display = f"{max_topup_traffic} ГБ"
else:
max_limit_display = "Без ограничений"
buttons = []
if new_value:
buttons.append([
InlineKeyboardButton(text="❌ Отключить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}")
])
buttons.append([
InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}")
])
buttons.append([
InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}")
])
else:
buttons.append([
InlineKeyboardButton(text="✅ Включить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}")
])
buttons.append([
InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}")
])
try:
await callback.message.edit_text(
f"📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n"
f"Статус: {status}\n\n"
f"<b>Пакеты:</b>\n{packages_display}\n\n"
f"<b>Макс. лимит:</b> {max_limit_display}\n\n"
"Пользователи смогут докупать трафик по заданным ценам.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="HTML"
)
except TelegramBadRequest:
pass
@admin_required
@error_handler
async def start_edit_traffic_topup_packages(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Начинает редактирование пакетов докупки трафика."""
texts = get_texts(db_user.language)
tariff_id = int(callback.data.split(":")[1])
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
await callback.answer("Тариф не найден", show_alert=True)
return
await state.set_state(AdminStates.editing_tariff_traffic_topup_packages)
await state.update_data(tariff_id=tariff_id, language=db_user.language)
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
current_packages = _format_traffic_topup_packages_for_edit(packages)
if packages:
packages_display = "\n".join(f"{gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items()))
else:
packages_display = " Не настроены"
await callback.message.edit_text(
f"📦 <b>Настройка пакетов докупки трафика</b>\n\n"
f"Тариф: <b>{tariff.name}</b>\n\n"
f"<b>Текущие пакеты:</b>\n{packages_display}\n\n"
"Введите пакеты в формате:\n"
f"<code>{current_packages}</code>\n\n"
"(ГБ:цена_в_копейках, через запятую)\n"
"Например: <code>5:5000, 10:9000</code> = 5ГБ за 50₽, 10ГБ за 90₽",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_edit_traffic_topup:{tariff_id}")]
]),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def process_edit_traffic_topup_packages(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Обрабатывает новые пакеты докупки трафика."""
data = await state.get_data()
tariff_id = data.get("tariff_id")
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
await message.answer("Тариф не найден")
await state.clear()
return
packages = _parse_traffic_topup_packages(message.text.strip())
if not packages:
await message.answer(
"Не удалось распознать пакеты.\n\n"
"Формат: <code>ГБ:цена_в_копейках</code>\n"
"Пример: <code>5:5000, 10:9000, 20:15000</code>",
parse_mode="HTML"
)
return
# Преобразуем в формат для JSON (строковые ключи)
packages_json = {str(gb): price for gb, price in packages.items()}
tariff = await update_tariff(db, tariff, traffic_topup_packages=packages_json)
await state.clear()
# Показываем обновленное меню
texts = get_texts(db_user.language)
packages_display = "\n".join(f"{gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items()))
max_topup_traffic = getattr(tariff, 'max_topup_traffic_gb', 0) or 0
max_limit_display = f"{max_topup_traffic} ГБ" if max_topup_traffic > 0 else "Без ограничений"
buttons = [
[InlineKeyboardButton(text="❌ Отключить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}")],
[InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}")],
[InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}")],
[InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}")]
]
await message.answer(
f"✅ <b>Пакеты обновлены!</b>\n\n"
f"📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n"
f"Статус: ✅ Включено\n\n"
f"<b>Пакеты:</b>\n{packages_display}\n\n"
f"<b>Макс. лимит:</b> {max_limit_display}\n\n"
"Пользователи смогут докупать трафик по заданным ценам.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="HTML"
)
# ============ МАКСИМАЛЬНЫЙ ЛИМИТ ДОКУПКИ ТРАФИКА ============
@admin_required
@error_handler
async def start_edit_max_topup_traffic(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Начинает редактирование максимального лимита докупки трафика."""
texts = get_texts(db_user.language)
tariff_id = int(callback.data.split(":")[1])
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
await callback.answer("Тариф не найден", show_alert=True)
return
await state.set_state(AdminStates.editing_tariff_max_topup_traffic)
await state.update_data(tariff_id=tariff_id)
current_limit = getattr(tariff, 'max_topup_traffic_gb', 0) or 0
if current_limit > 0:
current_display = f"{current_limit} ГБ"
else:
current_display = "Без ограничений"
await callback.message.edit_text(
f"📊 <b>Максимальный лимит трафика</b>\n\n"
f"Тариф: <b>{tariff.name}</b>\n"
f"Текущий лимит: <b>{current_display}</b>\n\n"
f"Введите максимальный общий объем трафика (в ГБ), который может быть на подписке после всех докупок.\n\n"
f"• Например, если тариф дает 100 ГБ и лимит 200 ГБ — пользователь сможет докупить еще 100 ГБ\n"
f"• Введите <code>0</code> для снятия ограничения",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_edit_traffic_topup:{tariff_id}")]
]),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def process_edit_max_topup_traffic(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Обрабатывает новое значение максимального лимита докупки трафика."""
texts = get_texts(db_user.language)
state_data = await state.get_data()
tariff_id = state_data.get("tariff_id")
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
await message.answer("Тариф не найден")
await state.clear()
return
# Парсим значение
text = message.text.strip()
try:
new_limit = int(text)
if new_limit < 0:
raise ValueError("Negative value")
except ValueError:
await message.answer(
"Введите целое число (0 или больше).\n\n"
"• <code>0</code> — без ограничений\n"
"• <code>200</code> — максимум 200 ГБ на подписке",
parse_mode="HTML"
)
return
tariff = await update_tariff(db, tariff, max_topup_traffic_gb=new_limit)
await state.clear()
# Показываем обновленное меню
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
if packages:
packages_display = "\n".join(f"{gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items()))
else:
packages_display = " Пакеты не настроены"
max_limit_display = f"{new_limit} ГБ" if new_limit > 0 else "Без ограничений"
buttons = [
[InlineKeyboardButton(text="❌ Отключить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}")],
[InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}")],
[InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}")],
[InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}")]
]
await message.answer(
f"✅ <b>Лимит обновлен!</b>\n\n"
f"📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n"
f"Статус: ✅ Включено\n\n"
f"<b>Пакеты:</b>\n{packages_display}\n\n"
f"<b>Макс. лимит:</b> {max_limit_display}\n\n"
"Пользователи смогут докупать трафик по заданным ценам.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
parse_mode="HTML"
)
# ============ УДАЛЕНИЕ ТАРИФА ============
@admin_required
@@ -1855,6 +2294,16 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(start_edit_tariff_trial_days, F.data.startswith("admin_tariff_edit_trial_days:"))
dp.message.register(process_edit_tariff_trial_days, AdminStates.editing_tariff_trial_days)
# Редактирование докупки трафика
dp.callback_query.register(start_edit_tariff_traffic_topup, F.data.startswith("admin_tariff_edit_traffic_topup:"))
dp.callback_query.register(toggle_tariff_traffic_topup, F.data.startswith("admin_tariff_toggle_traffic_topup:"))
dp.callback_query.register(start_edit_traffic_topup_packages, F.data.startswith("admin_tariff_edit_topup_packages:"))
dp.message.register(process_edit_traffic_topup_packages, AdminStates.editing_tariff_traffic_topup_packages)
# Редактирование макс. лимита докупки трафика
dp.callback_query.register(start_edit_max_topup_traffic, F.data.startswith("admin_tariff_edit_max_topup:"))
dp.message.register(process_edit_max_topup_traffic, AdminStates.editing_tariff_max_topup_traffic)
# Удаление
dp.callback_query.register(confirm_delete_tariff, F.data.startswith("admin_tariff_delete:"))
dp.callback_query.register(delete_tariff_confirmed, F.data.startswith("admin_tariff_delete_confirm:"))
+387 -2
View File
@@ -1063,12 +1063,16 @@ async def _render_user_subscription_overview(
)
])
# Кнопка смены тарифа в режиме тарифов
# Кнопки тарифов в режиме тарифов
if settings.is_tariffs_mode():
keyboard.append([
types.InlineKeyboardButton(
text="📦 Сменить тариф",
callback_data=f"admin_sub_change_tariff_{user_id}"
),
types.InlineKeyboardButton(
text="💳 Купить тариф",
callback_data=f"admin_tariff_buy_{user_id}"
)
])
@@ -4983,6 +4987,366 @@ async def admin_buy_subscription_execute(
await db.rollback()
# ==================== Покупка тарифа администратором ====================
@admin_required
@error_handler
async def admin_buy_tariff(
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
target_user = profile["user"]
# Получаем доступные тарифы
from app.database.crud.tariff import get_tariffs_for_user
tariffs = await get_tariffs_for_user(db, target_user)
if not tariffs:
await callback.message.edit_text(
"❌ <b>Нет доступных тарифов</b>\n\n"
"Создайте тарифы в разделе управления тарифами.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")]
])
)
await callback.answer()
return
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
text = f"💳 <b>Покупка тарифа для пользователя</b>\n\n"
text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n"
text += f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n"
text += "📦 <b>Выберите тариф:</b>\n\n"
for tariff in tariffs:
traffic = "♾️" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ"
prices = tariff.period_prices or {}
min_price = min(prices.values()) if prices else 0
text += f"<b>{tariff.name}</b> — {traffic}/{tariff.device_limit}📱 от {settings.format_price(min_price)}\n"
keyboard = []
for tariff in tariffs:
keyboard.append([
types.InlineKeyboardButton(
text=f"📦 {tariff.name}",
callback_data=f"admin_tariff_buy_select_{user_id}_{tariff.id}"
)
])
keyboard.append([
types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def admin_buy_tariff_period(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Показывает выбор периода для тарифа."""
parts = callback.data.split('_')
user_id = int(parts[4])
tariff_id = int(parts[5])
user_service = UserService()
profile = await user_service.get_user_profile(db, user_id)
if not profile:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
target_user = profile["user"]
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff or not tariff.is_active:
await callback.answer("❌ Тариф недоступен", show_alert=True)
return
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
traffic = "♾️ Безлимит" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ"
text = f"💳 <b>Покупка тарифа для пользователя</b>\n\n"
text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n"
text += f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n"
text += f"📦 <b>Тариф: {tariff.name}</b>\n"
text += f"📊 Трафик: {traffic}\n"
text += f"📱 Устройств: {tariff.device_limit}\n"
text += f"🌐 Серверов: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}\n\n"
text += "Выберите период:"
prices = tariff.period_prices or {}
keyboard = []
for period_str, price in sorted(prices.items(), key=lambda x: int(x[0])):
period = int(period_str)
keyboard.append([
types.InlineKeyboardButton(
text=f"{period} дней — {settings.format_price(price)}",
callback_data=f"admin_tariff_buy_confirm_{user_id}_{tariff_id}_{period}_{price}"
)
])
keyboard.append([
types.InlineKeyboardButton(text="⬅️ К тарифам", callback_data=f"admin_tariff_buy_{user_id}")
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def admin_buy_tariff_confirm(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Подтверждение покупки тарифа."""
parts = callback.data.split('_')
user_id = int(parts[4])
tariff_id = int(parts[5])
period = int(parts[6])
price_kopeks = int(parts[7])
user_service = UserService()
profile = await user_service.get_user_profile(db, user_id)
if not profile:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
target_user = profile["user"]
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff or not tariff.is_active:
await callback.answer("❌ Тариф недоступен", show_alert=True)
return
# Проверяем баланс
if target_user.balance_kopeks < price_kopeks:
missing = price_kopeks - target_user.balance_kopeks
await callback.message.edit_text(
f"❌ <b>Недостаточно средств</b>\n\n"
f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n"
f"💳 Стоимость: {settings.format_price(price_kopeks)}\n"
f"📉 Не хватает: {settings.format_price(missing)}\n\n"
f"Пополните баланс пользователя перед покупкой.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_tariff_buy_select_{user_id}_{tariff_id}")]
]),
parse_mode="HTML"
)
await callback.answer()
return
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
traffic = "♾️ Безлимит" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ"
text = f"💳 <b>Подтверждение покупки тарифа</b>\n\n"
text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n"
text += f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n"
text += f"📦 <b>Тариф: {tariff.name}</b>\n"
text += f"📊 Трафик: {traffic}\n"
text += f"📱 Устройств: {tariff.device_limit}\n"
text += f"📅 Период: {period} дней\n"
text += f"💰 Стоимость: {settings.format_price(price_kopeks)}\n\n"
text += "Подтвердить покупку?"
keyboard = [
[
types.InlineKeyboardButton(
text="✅ Подтвердить",
callback_data=f"admin_tariff_buy_exec_{user_id}_{tariff_id}_{period}_{price_kopeks}"
)
],
[
types.InlineKeyboardButton(
text="❌ Отмена",
callback_data=f"admin_tariff_buy_select_{user_id}_{tariff_id}"
)
]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def admin_buy_tariff_execute(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Выполняет покупку тарифа для пользователя."""
parts = callback.data.split('_')
user_id = int(parts[4])
tariff_id = int(parts[5])
period = int(parts[6])
price_kopeks = int(parts[7])
user_service = UserService()
profile = await user_service.get_user_profile(db, user_id)
if not profile:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
target_user = profile["user"]
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff or not tariff.is_active:
await callback.answer("❌ Тариф недоступен", show_alert=True)
return
# Проверяем баланс ещё раз
if target_user.balance_kopeks < price_kopeks:
await callback.answer("❌ Недостаточно средств на балансе", show_alert=True)
return
try:
from app.database.crud.user import subtract_user_balance
from app.database.crud.subscription import extend_subscription, create_paid_subscription, get_subscription_by_user_id
from app.database.crud.transaction import create_transaction
from app.services.subscription_service import SubscriptionService
# Списываем баланс
success = await subtract_user_balance(
db, target_user, price_kopeks,
f"Покупка тарифа {tariff.name} на {period} дней (администратор)"
)
if not success:
await callback.answer("❌ Ошибка списания средств", show_alert=True)
return
# Получаем серверы из тарифа
squads = tariff.allowed_squads or []
# Проверяем есть ли подписка
existing_subscription = await get_subscription_by_user_id(db, target_user.id)
if existing_subscription:
# Продлеваем существующую подписку
subscription = await extend_subscription(
db,
existing_subscription,
days=period,
tariff_id=tariff.id,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
connected_squads=squads,
)
else:
# Создаем новую подписку
subscription = await create_paid_subscription(
db=db,
user_id=target_user.id,
duration_days=period,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
connected_squads=squads,
tariff_id=tariff.id,
)
# Обновляем в Remnawave
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason="покупка тарифа (администратор)",
)
except Exception as e:
logger.error(f"Ошибка обновления Remnawave: {e}")
# Создаем транзакцию
await create_transaction(
db,
user_id=target_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=-price_kopeks,
description=f"Покупка тарифа {tariff.name} на {period} дней (администратор)",
)
target_user_link = f'<a href="tg://user?id={target_user.telegram_id}">{target_user.full_name}</a>'
traffic = "♾️ Безлимит" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ"
await callback.message.edit_text(
f"✅ <b>Тариф успешно куплен!</b>\n\n"
f"👤 {target_user_link} (ID: {target_user.telegram_id})\n"
f"📦 Тариф: {tariff.name}\n"
f"📊 Трафик: {traffic}\n"
f"📱 Устройств: {tariff.device_limit}\n"
f"📅 Период: {period} дней\n"
f"💰 Списано: {settings.format_price(price_kopeks)}\n"
f"📅 Действует до: {format_datetime(subscription.end_date)}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
]),
parse_mode="HTML"
)
# Уведомляем пользователя
try:
if callback.bot:
await callback.bot.send_message(
chat_id=target_user.telegram_id,
text=f"💳 <b>Администратор оформил вам тариф</b>\n\n"
f"📦 Тариф: {tariff.name}\n"
f"📊 Трафик: {traffic}\n"
f"📱 Устройств: {tariff.device_limit}\n"
f"📅 Период: {period} дней\n"
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}\n"
f"📅 Действует до: {format_datetime(subscription.end_date)}",
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления пользователю: {e}")
await callback.answer("✅ Тариф куплен!", show_alert=True)
except Exception as e:
logger.error(f"Ошибка покупки тарифа администратором: {e}", exc_info=True)
await callback.answer("❌ Ошибка при покупке тарифа", show_alert=True)
await db.rollback()
@admin_required
@error_handler
async def change_subscription_type_confirm(
@@ -5673,7 +6037,28 @@ def register_handlers(dp: Dispatcher):
admin_buy_subscription_execute,
F.data.startswith("admin_buy_sub_execute_")
)
# Регистрация обработчиков для покупки тарифа администратором
dp.callback_query.register(
admin_buy_tariff,
F.data.startswith("admin_tariff_buy_") & ~F.data.startswith("admin_tariff_buy_select_") & ~F.data.startswith("admin_tariff_buy_confirm_") & ~F.data.startswith("admin_tariff_buy_exec_")
)
dp.callback_query.register(
admin_buy_tariff_period,
F.data.startswith("admin_tariff_buy_select_")
)
dp.callback_query.register(
admin_buy_tariff_confirm,
F.data.startswith("admin_tariff_buy_confirm_")
)
dp.callback_query.register(
admin_buy_tariff_execute,
F.data.startswith("admin_tariff_buy_exec_")
)
# Регистрация обработчиков для фильтрации пользователей
dp.callback_query.register(
show_users_filters,
+519 -25
View File
@@ -69,13 +69,12 @@ def _apply_promo_discount(price: int, discount_percent: int) -> int:
def _get_user_period_discount(db_user: User, period_days: int) -> int:
"""Получает скидку пользователя на период из промогруппы."""
promo_group = getattr(db_user, 'promo_group', None)
if promo_group:
# Используем метод get_discount_percent с категорией "period"
discount = promo_group.get_discount_percent("period", period_days)
if discount > 0:
return discount
# Проверяем персональную скидку
personal_discount = get_user_active_promo_discount_percent(db_user)
return personal_discount
@@ -1176,12 +1175,8 @@ async def select_tariff_switch_period(
if subscription and subscription.end_date:
remaining_days = max(0, (subscription.end_date - datetime.utcnow()).days)
# Определяем что произойдёт с временем
if remaining_days >= period:
time_info = f"⏰ Осталось дней: {remaining_days} (будет сохранено)"
else:
days_to_add = period - remaining_days
time_info = f"⏰ Осталось дней: {remaining_days} → будет {period} (+{days_to_add})"
# При смене тарифа устанавливается ровно оплаченный период
time_info = f"⏰ Будет установлено: {period} дней"
if user_balance >= final_price:
discount_text = ""
@@ -1275,21 +1270,15 @@ async def confirm_tariff_switch(
# Получаем список серверов из тарифа
squads = tariff.allowed_squads or []
# Рассчитываем сколько дней осталось у текущей подписки
from datetime import datetime
remaining_days = (subscription.end_date - datetime.utcnow()).days
if remaining_days < 0:
remaining_days = 0
# Если выбранный период больше оставшегося - добавляем разницу
# Пользователь должен получить минимум то, за что заплатил
days_to_add = max(0, period - remaining_days)
# При смене тарифа пользователь получает ровно тот период, за который заплатил
# Старые дни не сохраняются - это смена тарифа, а не продление
days_for_new_tariff = period
# Обновляем подписку с новыми параметрами тарифа
subscription = await extend_subscription(
db,
subscription,
days=days_to_add, # Добавляем только разницу, если период > остатка
days=days_for_new_tariff, # Даем ровно оплаченный период
tariff_id=tariff.id,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
@@ -1325,7 +1314,7 @@ async def confirm_tariff_switch(
db_user,
subscription,
None, # Транзакция отсутствует, оплата с баланса
days_to_add, # Добавленные дни (0 если остаток >= периода)
days_for_new_tariff, # Итоговый срок подписки
was_trial_conversion=False,
amount_kopeks=final_price,
)
@@ -1343,11 +1332,8 @@ async def confirm_tariff_switch(
traffic = _format_traffic(tariff.traffic_limit_gb)
# Формируем текст о времени подписки
if days_to_add > 0:
time_info = f"📅 Добавлено дней: {days_to_add}"
else:
time_info = "📅 Остаток времени подписки сохранён"
# При смене тарифа устанавливается оплаченный период
time_info = f"📅 Период: {days_for_new_tariff} дней"
await callback.message.edit_text(
f"🎉 <b>Тариф успешно изменён!</b>\n\n"
@@ -1370,6 +1356,509 @@ async def confirm_tariff_switch(
await callback.answer("Произошла ошибка при переключении тарифа", show_alert=True)
# ==================== Мгновенное переключение тарифов (без выбора периода) ====================
def _get_tariff_monthly_price(tariff: Tariff) -> int:
"""Получает месячную цену тарифа (30 дней) с fallback на пропорциональный расчёт."""
price = tariff.get_price_for_period(30)
if price is not None:
return price
# Fallback: пропорционально пересчитываем из первого доступного периода
periods = tariff.get_available_periods()
if periods:
first_period = periods[0]
first_price = tariff.get_price_for_period(first_period)
if first_price:
return int(first_price * 30 / first_period)
return 0
def _calculate_instant_switch_cost(
current_tariff: Tariff,
new_tariff: Tariff,
remaining_days: int,
db_user: Optional[User] = None,
) -> tuple[int, bool]:
"""
Рассчитывает стоимость мгновенного переключения тарифа.
Если новый тариф дороже - доплата пропорционально оставшимся дням.
Если дешевле или равен - бесплатно.
Формула: (new_monthly - current_monthly) * remaining_days / 30
Скидка применяется к обоим тарифам одинаково.
Returns:
(upgrade_cost_kopeks, is_upgrade)
"""
current_monthly = _get_tariff_monthly_price(current_tariff)
new_monthly = _get_tariff_monthly_price(new_tariff)
discount_percent = 0
if db_user:
discount_percent = _get_user_period_discount(db_user, 30)
if discount_percent > 0:
current_monthly = _apply_promo_discount(current_monthly, discount_percent)
new_monthly = _apply_promo_discount(new_monthly, discount_percent)
price_diff = new_monthly - current_monthly
if price_diff <= 0:
return 0, False
upgrade_cost = int(price_diff * remaining_days / 30)
return upgrade_cost, True
def format_instant_switch_list_text(
tariffs: List[Tariff],
current_tariff: Tariff,
remaining_days: int,
db_user: Optional[User] = None,
) -> str:
"""Форматирует текст со списком тарифов для мгновенного переключения."""
lines = [
"📦 <b>Мгновенная смена тарифа</b>",
f"📌 Текущий: <b>{current_tariff.name}</b>",
f"⏰ Осталось: <b>{remaining_days} дн.</b>",
"",
"💡 При переключении остаток дней сохраняется.",
"⬆️ Повышение тарифа = доплата за разницу",
"⬇️ Понижение = бесплатно",
"",
]
for tariff in tariffs:
if tariff.id == current_tariff.id:
continue
traffic_gb = tariff.traffic_limit_gb
traffic = "" if traffic_gb == 0 else f"{traffic_gb}ГБ"
# Рассчитываем стоимость переключения
cost, is_upgrade = _calculate_instant_switch_cost(
current_tariff, tariff, remaining_days, db_user
)
if is_upgrade:
cost_text = f"⬆️ +{_format_price_kopeks(cost, compact=True)}"
else:
cost_text = "⬇️ Бесплатно"
lines.append(f"<b>{tariff.name}</b> — {traffic}/{tariff.device_limit}📱 {cost_text}")
if tariff.description:
lines.append(f"<i>{tariff.description}</i>")
lines.append("")
return "\n".join(lines)
def get_instant_switch_keyboard(
tariffs: List[Tariff],
current_tariff: Tariff,
remaining_days: int,
language: str,
db_user: Optional[User] = None,
) -> InlineKeyboardMarkup:
"""Создает клавиатуру для мгновенного переключения тарифа."""
texts = get_texts(language)
buttons = []
for tariff in tariffs:
if tariff.id == current_tariff.id:
continue
# Рассчитываем стоимость
cost, is_upgrade = _calculate_instant_switch_cost(
current_tariff, tariff, remaining_days, db_user
)
if is_upgrade:
btn_text = f"📦 {tariff.name} (+{_format_price_kopeks(cost, compact=True)})"
else:
btn_text = f"📦 {tariff.name} (бесплатно)"
buttons.append([
InlineKeyboardButton(
text=btn_text,
callback_data=f"instant_sw_preview:{tariff.id}"
)
])
buttons.append([
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_instant_switch_confirm_keyboard(
tariff_id: int,
language: str,
) -> InlineKeyboardMarkup:
"""Создает клавиатуру подтверждения мгновенного переключения."""
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="✅ Подтвердить переключение",
callback_data=f"instant_sw_confirm:{tariff_id}"
)
],
[
InlineKeyboardButton(
text=texts.BACK,
callback_data="instant_switch"
)
]
])
def get_instant_switch_insufficient_balance_keyboard(
tariff_id: int,
language: str,
) -> InlineKeyboardMarkup:
"""Создает клавиатуру при недостаточном балансе для мгновенного переключения."""
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="💳 Пополнить баланс",
callback_data="balance_topup"
)
],
[
InlineKeyboardButton(
text=texts.BACK,
callback_data="instant_switch"
)
]
])
@error_handler
async def show_instant_switch_list(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Показывает список тарифов для мгновенного переключения."""
from datetime import datetime
texts = get_texts(db_user.language)
await state.clear()
# Проверяем наличие активной подписки
subscription = await get_subscription_by_user_id(db, db_user.id)
if not subscription:
await callback.answer("У вас нет активной подписки", show_alert=True)
return
if not subscription.tariff_id:
await callback.answer("У вашей подписки нет тарифа", show_alert=True)
return
# Получаем текущий тариф
current_tariff = await get_tariff_by_id(db, subscription.tariff_id)
if not current_tariff:
await callback.answer("Текущий тариф не найден", show_alert=True)
return
# Рассчитываем оставшиеся дни
remaining_days = 0
if subscription.end_date:
remaining_days = max(0, (subscription.end_date - datetime.utcnow()).days)
if remaining_days == 0:
await callback.message.edit_text(
"❌ <b>Переключение недоступно</b>\n\n"
"У вашей подписки не осталось активных дней.\n"
"Используйте продление или покупку нового тарифа.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")]
]),
parse_mode="HTML"
)
await callback.answer()
return
# Получаем доступные тарифы
promo_group_id = getattr(db_user, 'promo_group_id', None)
tariffs = await get_tariffs_for_user(db, promo_group_id)
# Фильтруем текущий тариф
available_tariffs = [t for t in tariffs if t.id != current_tariff.id]
if not available_tariffs:
await callback.message.edit_text(
"😔 <b>Нет доступных тарифов для переключения</b>\n\n"
"Вы уже используете единственный доступный тариф.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")]
]),
parse_mode="HTML"
)
await callback.answer()
return
# Формируем текст со списком тарифов
switch_text = format_instant_switch_list_text(
tariffs, current_tariff, remaining_days, db_user
)
await callback.message.edit_text(
switch_text,
reply_markup=get_instant_switch_keyboard(
tariffs, current_tariff, remaining_days, db_user.language, db_user
),
parse_mode="HTML"
)
await state.update_data(
current_tariff_id=current_tariff.id,
remaining_days=remaining_days,
)
await callback.answer()
@error_handler
async def preview_instant_switch(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Показывает превью мгновенного переключения тарифа."""
from datetime import datetime
tariff_id = int(callback.data.split(":")[1])
new_tariff = await get_tariff_by_id(db, tariff_id)
if not new_tariff or not new_tariff.is_active:
await callback.answer("Тариф недоступен", show_alert=True)
return
# Получаем данные из состояния
data = await state.get_data()
current_tariff_id = data.get('current_tariff_id')
remaining_days = data.get('remaining_days', 0)
# Если данных нет в state, получаем заново
subscription = await get_subscription_by_user_id(db, db_user.id)
if not subscription or not subscription.tariff_id:
await callback.answer("Подписка не найдена", show_alert=True)
return
current_tariff_id = current_tariff_id or subscription.tariff_id
current_tariff = await get_tariff_by_id(db, current_tariff_id)
if not current_tariff:
await callback.answer("Текущий тариф не найден", show_alert=True)
return
if not remaining_days and subscription.end_date:
remaining_days = max(0, (subscription.end_date - datetime.utcnow()).days)
# Рассчитываем стоимость переключения
upgrade_cost, is_upgrade = _calculate_instant_switch_cost(
current_tariff, new_tariff, remaining_days, db_user
)
# Проверяем баланс
user_balance = db_user.balance_kopeks or 0
traffic = _format_traffic(new_tariff.traffic_limit_gb)
current_traffic = _format_traffic(current_tariff.traffic_limit_gb)
texts = get_texts(db_user.language)
if is_upgrade:
# Upgrade - нужна доплата
if user_balance >= upgrade_cost:
await callback.message.edit_text(
f"⬆️ <b>Повышение тарифа</b>\n\n"
f"📌 Текущий: <b>{current_tariff.name}</b>\n"
f" • Трафик: {current_traffic}\n"
f" • Устройств: {current_tariff.device_limit}\n\n"
f"📦 Новый: <b>{new_tariff.name}</b>\n"
f" • Трафик: {traffic}\n"
f" • Устройств: {new_tariff.device_limit}\n\n"
f"⏰ Осталось дней: <b>{remaining_days}</b>\n"
f"💰 <b>Доплата: {_format_price_kopeks(upgrade_cost)}</b>\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
f"После оплаты: {_format_price_kopeks(user_balance - upgrade_cost)}",
reply_markup=get_instant_switch_confirm_keyboard(tariff_id, db_user.language),
parse_mode="HTML"
)
else:
missing = upgrade_cost - user_balance
await callback.message.edit_text(
f"❌ <b>Недостаточно средств</b>\n\n"
f"📦 Новый тариф: <b>{new_tariff.name}</b>\n"
f"💰 Требуется доплата: {_format_price_kopeks(upgrade_cost)}\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>",
reply_markup=get_instant_switch_insufficient_balance_keyboard(tariff_id, db_user.language),
parse_mode="HTML"
)
else:
# Downgrade или тот же уровень - бесплатно
await callback.message.edit_text(
f"⬇️ <b>Переключение тарифа</b>\n\n"
f"📌 Текущий: <b>{current_tariff.name}</b>\n"
f" • Трафик: {current_traffic}\n"
f" • Устройств: {current_tariff.device_limit}\n\n"
f"📦 Новый: <b>{new_tariff.name}</b>\n"
f" • Трафик: {traffic}\n"
f" • Устройств: {new_tariff.device_limit}\n\n"
f"⏰ Осталось дней: <b>{remaining_days}</b>\n"
f"💰 <b>Бесплатно</b> (понижение/равный тариф)",
reply_markup=get_instant_switch_confirm_keyboard(tariff_id, db_user.language),
parse_mode="HTML"
)
await state.update_data(
switch_tariff_id=tariff_id,
upgrade_cost=upgrade_cost,
is_upgrade=is_upgrade,
current_tariff_id=current_tariff_id,
remaining_days=remaining_days,
)
await callback.answer()
@error_handler
async def confirm_instant_switch(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Подтверждает мгновенное переключение тарифа."""
from datetime import datetime, timedelta
tariff_id = int(callback.data.split(":")[1])
new_tariff = await get_tariff_by_id(db, tariff_id)
if not new_tariff or not new_tariff.is_active:
await callback.answer("Тариф недоступен", show_alert=True)
return
# Получаем данные из состояния
data = await state.get_data()
upgrade_cost = data.get('upgrade_cost', 0)
is_upgrade = data.get('is_upgrade', False)
remaining_days = data.get('remaining_days', 0)
# Проверяем подписку
subscription = await get_subscription_by_user_id(db, db_user.id)
if not subscription:
await callback.answer("Подписка не найдена", show_alert=True)
return
# Проверяем баланс если это upgrade
user_balance = db_user.balance_kopeks or 0
if is_upgrade and user_balance < upgrade_cost:
await callback.answer("Недостаточно средств на балансе", show_alert=True)
return
texts = get_texts(db_user.language)
try:
# Списываем баланс если это upgrade
if is_upgrade and upgrade_cost > 0:
success = await subtract_user_balance(
db, db_user, upgrade_cost,
f"Переключение на тариф {new_tariff.name}"
)
if not success:
await callback.answer("Ошибка списания баланса", show_alert=True)
return
# Получаем список серверов из нового тарифа
squads = new_tariff.allowed_squads or []
# Обновляем подписку с новыми параметрами тарифа
# НЕ меняем end_date - только параметры тарифа
subscription.tariff_id = new_tariff.id
subscription.traffic_limit_gb = new_tariff.traffic_limit_gb
subscription.device_limit = new_tariff.device_limit
subscription.connected_squads = squads
await db.commit()
await db.refresh(subscription)
# Обновляем пользователя в Remnawave
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=False, # Не сбрасываем трафик при переключении
reset_reason="мгновенное переключение тарифа",
)
except Exception as e:
logger.error(f"Ошибка обновления Remnawave при мгновенном переключении: {e}")
# Создаем транзакцию если была оплата
if is_upgrade and upgrade_cost > 0:
await create_transaction(
db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=-upgrade_cost,
description=f"Переключение на тариф {new_tariff.name}",
)
# Отправляем уведомление админу
try:
admin_notification_service = AdminNotificationService(callback.bot)
await admin_notification_service.send_subscription_purchase_notification(
db,
db_user,
subscription,
None,
remaining_days,
was_trial_conversion=False,
amount_kopeks=upgrade_cost,
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления админу: {e}")
await state.clear()
traffic = _format_traffic(new_tariff.traffic_limit_gb)
if is_upgrade:
cost_text = f"💰 Списано: {_format_price_kopeks(upgrade_cost)}"
else:
cost_text = "💰 Бесплатно"
await callback.message.edit_text(
f"🎉 <b>Тариф успешно изменён!</b>\n\n"
f"📦 Новый тариф: <b>{new_tariff.name}</b>\n"
f"📊 Трафик: {traffic}\n"
f"📱 Устройств: {new_tariff.device_limit}\n"
f"⏰ Осталось дней: {remaining_days}\n"
f"{cost_text}",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
]),
parse_mode="HTML"
)
await callback.answer("Тариф изменён!", show_alert=True)
except Exception as e:
logger.error(f"Ошибка при мгновенном переключении тарифа: {e}", exc_info=True)
await callback.answer("Произошла ошибка при переключении тарифа", show_alert=True)
def register_tariff_purchase_handlers(dp: Dispatcher):
"""Регистрирует обработчики покупки по тарифам."""
# Список тарифов (для режима tariffs)
@@ -1389,8 +1878,13 @@ def register_tariff_purchase_handlers(dp: Dispatcher):
dp.callback_query.register(select_tariff_extend_period, F.data.startswith("tariff_extend:"))
dp.callback_query.register(confirm_tariff_extend, F.data.startswith("tariff_ext_confirm:"))
# Переключение тарифов
# Переключение тарифов (с выбором периода)
dp.callback_query.register(show_tariff_switch_list, F.data == "tariff_switch")
dp.callback_query.register(select_tariff_switch, F.data.startswith("tariff_sw_select:"))
dp.callback_query.register(select_tariff_switch_period, F.data.startswith("tariff_sw_period:"))
dp.callback_query.register(confirm_tariff_switch, F.data.startswith("tariff_sw_confirm:"))
# Мгновенное переключение тарифов (без выбора периода)
dp.callback_query.register(show_instant_switch_list, F.data == "instant_switch")
dp.callback_query.register(preview_instant_switch, F.data.startswith("instant_sw_preview:"))
dp.callback_query.register(confirm_instant_switch, F.data.startswith("instant_sw_confirm:"))
+89 -38
View File
@@ -31,7 +31,7 @@ from app.keyboards.inline import (
get_countries_keyboard, get_devices_keyboard,
get_subscription_confirm_keyboard, get_autopay_keyboard,
get_autopay_days_keyboard, get_back_keyboard,
get_add_traffic_keyboard,
get_add_traffic_keyboard, get_add_traffic_keyboard_from_tariff,
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
@@ -93,41 +93,9 @@ async def handle_add_traffic(
db: AsyncSession
):
from app.config import settings
from app.database.crud.tariff import get_tariff_by_id
texts = get_texts(db_user.language)
# Проверяем, включена ли функция докупки трафика
if not settings.is_traffic_topup_enabled():
await callback.answer(
texts.t(
"TRAFFIC_TOPUP_DISABLED",
"⚠️ Функция докупки трафика отключена",
),
show_alert=True,
)
return
# В режиме тарифов докупка трафика недоступна
if settings.is_tariffs_mode():
await callback.answer(
texts.t(
"TARIFF_TRAFFIC_TOPUP_DISABLED",
"⚠️ В режиме тарифов докупка трафика недоступна",
),
show_alert=True,
)
return
if settings.is_traffic_topup_blocked():
await callback.answer(
texts.t(
"TRAFFIC_FIXED_MODE",
"⚠️ В текущем режиме трафик фиксированный и не может быть изменен",
),
show_alert=True,
)
return
subscription = db_user.subscription
if not subscription or subscription.is_trial:
@@ -144,6 +112,74 @@ async def handle_add_traffic(
)
return
# Режим тарифов - проверяем настройки тарифа
if settings.is_tariffs_mode() and subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if not tariff or not tariff.can_topup_traffic():
await callback.answer(
texts.t(
"TARIFF_TRAFFIC_TOPUP_DISABLED",
"⚠️ На вашем тарифе докупка трафика недоступна",
),
show_alert=True,
)
return
# Показываем пакеты из тарифа
current_traffic = subscription.traffic_limit_gb
packages = tariff.get_traffic_topup_packages()
period_hint_days = _get_period_hint_from_subscription(subscription)
traffic_discount_percent = _get_addon_discount_percent_for_user(
db_user,
"traffic",
period_hint_days,
)
prompt_text = texts.t(
"ADD_TRAFFIC_PROMPT",
(
"📈 <b>Добавить трафик к подписке</b>\n\n"
"Текущий лимит: {current_traffic}\n"
"Выберите дополнительный трафик:"
),
).format(current_traffic=texts.format_traffic(current_traffic))
await callback.message.edit_text(
prompt_text,
reply_markup=get_add_traffic_keyboard_from_tariff(
db_user.language,
packages,
subscription.end_date,
traffic_discount_percent,
),
parse_mode="HTML"
)
await callback.answer()
return
# Стандартный режим - проверяем глобальные настройки
if not settings.is_traffic_topup_enabled():
await callback.answer(
texts.t(
"TRAFFIC_TOPUP_DISABLED",
"⚠️ Функция докупки трафика отключена",
),
show_alert=True,
)
return
if settings.is_traffic_topup_blocked():
await callback.answer(
texts.t(
"TRAFFIC_FIXED_MODE",
"⚠️ В текущем режиме трафик фиксированный и не может быть изменен",
),
show_alert=True,
)
return
current_traffic = subscription.traffic_limit_gb
period_hint_days = _get_period_hint_from_subscription(subscription)
traffic_discount_percent = _get_addon_discount_percent_for_user(
@@ -472,15 +508,30 @@ async def add_traffic(
db_user: User,
db: AsyncSession
):
if settings.is_traffic_topup_blocked():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
from app.database.crud.tariff import get_tariff_by_id
traffic_gb = int(callback.data.split('_')[2])
texts = get_texts(db_user.language)
subscription = db_user.subscription
base_price = settings.get_traffic_topup_price(traffic_gb)
# Получаем цену: из тарифа или из глобальных настроек
base_price = 0
tariff = None
if settings.is_tariffs_mode() and subscription and subscription.tariff_id:
# Режим тарифов - берем цену из тарифа
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and tariff.can_topup_traffic():
base_price = tariff.get_traffic_topup_price(traffic_gb) or 0
else:
await callback.answer("⚠️ На вашем тарифе докупка трафика недоступна", show_alert=True)
return
else:
# Стандартный режим
if settings.is_traffic_topup_blocked():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
base_price = settings.get_traffic_topup_price(traffic_gb)
if base_price == 0 and traffic_gb != 0:
await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True)
+111 -30
View File
@@ -508,14 +508,18 @@ def get_main_menu_keyboard(
)
# Добавляем кнопку докупки трафика для лимитированных подписок
if (
settings.BUY_TRAFFIC_BUTTON_VISIBLE
and settings.is_traffic_topup_enabled()
and not settings.is_traffic_topup_blocked()
and subscription
and not subscription.is_trial
and (subscription.traffic_limit_gb or 0) > 0
):
# В режиме тарифов проверяем tariff_id (детальная проверка в хендлере)
# В классическом режиме проверяем глобальные настройки
show_traffic_topup = False
if subscription and not subscription.is_trial and (subscription.traffic_limit_gb or 0) > 0:
if settings.is_tariffs_mode() and getattr(subscription, 'tariff_id', None):
# Режим тарифов - показываем кнопку, проверка настроек тарифа в хендлере
show_traffic_topup = settings.BUY_TRAFFIC_BUTTON_VISIBLE
elif settings.is_traffic_topup_enabled() and not settings.is_traffic_topup_blocked():
# Классический режим - проверяем глобальные настройки
show_traffic_topup = settings.BUY_TRAFFIC_BUTTON_VISIBLE
if show_traffic_topup:
paired_buttons.append(
InlineKeyboardButton(text=texts.t("BUY_TRAFFIC_BUTTON", "📈 Докупить трафик"), callback_data="buy_traffic")
)
@@ -971,43 +975,46 @@ def get_subscription_keyboard(
if happ_row:
keyboard.append(happ_row)
if not is_trial:
if is_trial:
keyboard.append([
InlineKeyboardButton(text=texts.MENU_EXTEND_SUBSCRIPTION, callback_data="subscription_extend")
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="subscription_upgrade")
])
else:
# Ряд: [Продлить] [Автоплатеж]
keyboard.append([
InlineKeyboardButton(text=texts.MENU_EXTEND_SUBSCRIPTION, callback_data="subscription_extend"),
InlineKeyboardButton(
text=texts.t("AUTOPAY_BUTTON", "💳 Автоплатеж"),
callback_data="subscription_autopay",
)
])
if is_trial:
keyboard.append([
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="subscription_upgrade")
])
else:
keyboard.append([
# Ряд: [Настройки] [Тариф] (если режим тарифов)
settings_row = [
InlineKeyboardButton(
text=texts.t("SUBSCRIPTION_SETTINGS_BUTTON", "⚙️ Настройки подписки"),
text=texts.t("SUBSCRIPTION_SETTINGS_BUTTON", "⚙️ Настройки"),
callback_data="subscription_settings",
)
])
# Кнопка смены тарифа для режима тарифов
]
if settings.is_tariffs_mode() and subscription:
keyboard.append([
settings_row.append(
InlineKeyboardButton(
text=texts.t("CHANGE_TARIFF_BUTTON", "📦 Сменить тариф"),
callback_data="tariff_switch"
text=texts.t("CHANGE_TARIFF_BUTTON", "📦 Тариф"),
callback_data="instant_switch"
)
])
)
keyboard.append(settings_row)
# Кнопка докупки трафика для платных подписок
if (
settings.is_traffic_topup_enabled()
and not settings.is_traffic_topup_blocked()
and subscription
and (subscription.traffic_limit_gb or 0) > 0
):
# В режиме тарифов проверяем tariff_id, в классическом - глобальные настройки
show_traffic_topup = False
if subscription and (subscription.traffic_limit_gb or 0) > 0:
if settings.is_tariffs_mode() and getattr(subscription, 'tariff_id', None):
show_traffic_topup = True
elif settings.is_traffic_topup_enabled() and not settings.is_traffic_topup_blocked():
show_traffic_topup = True
if show_traffic_topup:
keyboard.append([
InlineKeyboardButton(
text=texts.t("BUY_TRAFFIC_BUTTON", "📈 Докупить трафик"),
@@ -1795,7 +1802,81 @@ def get_add_traffic_keyboard(
])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_add_traffic_keyboard_from_tariff(
language: str,
packages: dict, # {gb: price_kopeks}
subscription_end_date: datetime = None,
discount_percent: int = 0,
) -> InlineKeyboardMarkup:
"""
Клавиатура для докупки трафика из настроек тарифа.
Args:
language: Язык интерфейса
packages: Словарь {ГБ: цена_в_копейках} из тарифа
subscription_end_date: Дата окончания подписки для расчета цены
discount_percent: Процент скидки
"""
from app.utils.pricing_utils import get_remaining_months
texts = get_texts(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} мес)"
if not packages:
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text=texts.t("NO_TRAFFIC_PACKAGES", "❌ Нет доступных пакетов"),
callback_data="no_traffic_packages"
)],
[InlineKeyboardButton(
text=texts.BACK,
callback_data="menu_subscription"
)]
])
buttons = []
# Сортируем пакеты по размеру
sorted_packages = sorted(packages.items(), key=lambda x: x[0])
for gb, price_per_month in sorted_packages:
discounted_per_month, discount_per_month = apply_percentage_discount(
price_per_month,
discount_percent,
)
total_price = discounted_per_month * months_multiplier
total_discount = discount_per_month * months_multiplier
if language == "ru":
text = f"📊 +{gb} ГБ трафика - {total_price // 100}{period_text}"
else:
text = f"📊 +{gb} GB traffic - {total_price // 100}{period_text}"
if discount_percent > 0 and total_discount > 0:
text += f" (скидка {discount_percent}%: -{total_discount // 100}₽)"
buttons.append([
InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}")
])
buttons.append([
InlineKeyboardButton(
text=texts.BACK,
callback_data="menu_subscription"
)
])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_change_devices_keyboard(
current_devices: int,
language: str = DEFAULT_LANGUAGE,
+6 -2
View File
@@ -201,6 +201,7 @@
"ADMIN_MAIN_SETTINGS": "⚙️ Settings",
"ADMIN_MAIN_SUPPORT": "🛟 Support",
"ADMIN_MAIN_SYSTEM": "🛠️ System",
"ADMIN_MAIN_TARIFFS": "📦 Tariffs",
"ADMIN_MAIN_TRIALS": "🧪 Trials",
"ADMIN_MAIN_PAYMENTS": "💳 Top-ups",
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions",
@@ -889,6 +890,7 @@
"AUTOPAY_SUCCESS": "\n✅ <b>Autopay completed</b>\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n",
"AUTOPAY_TOGGLE_SUCCESS": "✅ Autopay {status}!",
"BACK": "⬅️ Back",
"BACK_BUTTON": "◀️ Back",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu",
"BACK_TO_MENU": "🏠 Back to menu",
"BACK_TO_OPEN_TICKETS": "🔴 Open tickets",
@@ -1031,6 +1033,7 @@
"FAQ_PAGE_NOT_AVAILABLE": "This FAQ page is not available.",
"FAQ_PAGE_TITLE": "<b>{title}</b>",
"FAQ_PAGE_UNTITLED": "Untitled",
"FREEKASSA_ENTER_AMOUNT": "💳 <b>Top up via {name}</b>\n\nEnter the amount in rubles.\n\nMinimum: {min_amount}\nMaximum: {max_amount}",
"GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up",
"HAPP_DOWNLOAD_BUTTON": "⬇️ Download Happ",
"HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:",
@@ -1140,6 +1143,7 @@
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
"PAYMENT_PLATEGA": "💳 Platega",
"PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment",
"PAYMENT_CREATE_ERROR": "❌ Failed to create payment. Please try again later.",
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
"PAYMENT_HELEKET": "🪙 Cryptocurrency (Heleket)",
"HELEKET_PAYMENT_ALREADY_PAID": "✅ Payment has already been credited",
@@ -1397,7 +1401,7 @@
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Personal {percent}% discount</b>\n\nIt's been {trigger_days} days without a subscription. Come back and activate the extra discount — it stacks with your promo group and is valid until {expires_at}.",
"SUBSCRIPTION_EXPIRING": "\n⚠️ <b>Subscription expiring!</b>\n\nYour subscription expires in {days} days.\n\nRenew it now so you don't lose access.\n",
"SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ <b>Subscription expires in {days_text}!</b>\n\nYour paid subscription ends on {end_date}.\n\n💳 <b>Autopay:</b> {autopay_status}\n\n{action_text}\n",
"SUBSCRIPTION_EXTEND": "💎 Extend subscription",
"SUBSCRIPTION_EXTEND": "💎 Extend",
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Subscription link is ready. Tap the \"Connect\" button below to open it in Happ.",
"SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Tap the \"Connect\" button below to open Happ and add the subscription automatically.",
@@ -1425,7 +1429,7 @@
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Extra discount {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Discount active for {time_left}\n<code>{bar}</code>",
"SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Subscription settings",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Settings",
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Subscription settings</b>\n\n📊 <b>Current parameters:</b>\n🌐 Countries: {countries_count}\n📈 Traffic: {traffic_used} / {traffic_limit}\n📱 Devices: {devices_used} / {devices_limit}\n\nChoose what you want to change:",
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Settings are available only for paid subscriptions",
"SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 <b>{app_name} - {device_name}</b>",
+7 -3
View File
@@ -204,6 +204,7 @@
"ADMIN_MAIN_SETTINGS": "⚙️ Настройки",
"ADMIN_MAIN_SUPPORT": "🛟 Поддержка",
"ADMIN_MAIN_SYSTEM": "🛠️ Система",
"ADMIN_MAIN_TARIFFS": "📦 Тарифы",
"ADMIN_MAIN_TRIALS": "🧪 Триалы",
"ADMIN_MAIN_PAYMENTS": "💳 Пополнения",
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки",
@@ -905,6 +906,7 @@
"AUTOPAY_SUCCESS": "\n✅ <b>Автоплатеж выполнен</b>\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n",
"AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатеж {status}!",
"BACK": "⬅️ Назад",
"BACK_BUTTON": "◀️ Назад",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню",
"BACK_TO_MENU": "🏠 В главное меню",
"BACK_TO_OPEN_TICKETS": "🔴 Открытые тикеты",
@@ -941,7 +943,7 @@
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ",
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Количество устройств увеличено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ",
"CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств",
"CHANGE_TARIFF_BUTTON": "📦 Сменить тариф",
"CHANGE_TARIFF_BUTTON": "📦 Тариф",
"CHANNEL_CHECK_BUTTON": "✅ Я подписался",
"CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.",
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",
@@ -1048,6 +1050,7 @@
"FAQ_PAGE_NOT_AVAILABLE": "Эта страница FAQ недоступна.",
"FAQ_PAGE_TITLE": "<b>{title}</b>",
"FAQ_PAGE_UNTITLED": "Без названия",
"FREEKASSA_ENTER_AMOUNT": "💳 <b>Пополнение через {name}</b>\n\nВведите сумму пополнения в рублях.\n\nМинимум: {min_amount}\nМаксимум: {max_amount}",
"GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса",
"HAPP_DOWNLOAD_BUTTON": "⬇️ Скачать Happ",
"HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:",
@@ -1157,6 +1160,7 @@
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
"PAYMENT_PLATEGA": "💳 Platega",
"PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств",
"PAYMENT_CREATE_ERROR": "❌ Не удалось создать платёж. Попробуйте позже.",
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
"PAYMENT_HELEKET": "🪙 Криптовалюта (Heleket)",
"HELEKET_PAYMENT_ALREADY_PAID": "✅ Платёж уже зачислен",
@@ -1414,7 +1418,7 @@
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Индивидуальная скидка {percent}%</b>\n\nПрошло {trigger_days} дней без подписки. Вернитесь и активируйте дополнительную скидку — она суммируется с промогруппой и действует до {expires_at}.",
"SUBSCRIPTION_EXPIRING": "\n⚠️ <b>Подписка истекает!</b>\n\nВаша подписка истекает через {days} дней.\n\nНе забудьте продлить подписку, чтобы не потерять доступ к серверам.\n",
"SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ <b>Подписка истекает через {days_text}!</b>\n\nВаша платная подписка истекает {end_date}.\n\n💳 <b>Автоплатеж:</b> {autopay_status}\n\n{action_text}\n",
"SUBSCRIPTION_EXTEND": "💎 Продлить подписку",
"SUBSCRIPTION_EXTEND": "💎 Продлить",
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
"SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.",
@@ -1442,7 +1446,7 @@
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Скидка действует ещё: {time_left}\n<code>{bar}</code>",
"SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки",
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Настройки подписки</b>\n\n📊 <b>Текущие параметры:</b>\n🌐 Стран: {countries_count}\n📈 Трафик: {traffic_used} / {traffic_limit}\n📱 Устройства: {devices_used} / {devices_limit}\n\nВыберите что хотите изменить:",
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Настройки доступны только для платных подписок",
"SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 <b>{app_name} - {device_name}</b>",
+1
View File
@@ -131,6 +131,7 @@
"ADMIN_MAIN_SETTINGS": "⚙️ Налаштування",
"ADMIN_MAIN_SUPPORT": "🛟 Підтримка",
"ADMIN_MAIN_SYSTEM": "🛠️ Система",
"ADMIN_MAIN_TARIFFS": "📦 Тарифи",
"ADMIN_MAIN_TRIALS": "🧪 Тріали",
"ADMIN_MAIN_PAYMENTS": "💳 Поповнення",
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзери/Підписки",
+1
View File
@@ -130,6 +130,7 @@
"ADMIN_MAIN_SETTINGS":"⚙️设置",
"ADMIN_MAIN_SUPPORT":"🛟支持",
"ADMIN_MAIN_SYSTEM":"🛠️系统",
"ADMIN_MAIN_TARIFFS":"📦套餐",
"ADMIN_MAIN_TRIALS":"🧪试用",
"ADMIN_MAIN_PAYMENTS":"💳充值",
"ADMIN_MAIN_USERS_SUBSCRIPTIONS":"👥用户/订阅",
+131 -8
View File
@@ -4,6 +4,9 @@ import hashlib
import hmac
import time
import logging
import asyncio
import json
import urllib.request
from typing import Optional, Dict, Any, Set
import aiohttp
@@ -12,6 +15,10 @@ from app.config import settings
logger = logging.getLogger(__name__)
# Кэш для публичного IP
_cached_public_ip: Optional[str] = None
_ip_fetch_lock = asyncio.Lock()
# IP-адреса Freekassa для проверки webhook
FREEKASSA_IPS: Set[str] = {
"168.119.157.136",
@@ -22,6 +29,62 @@ FREEKASSA_IPS: Set[str] = {
API_BASE_URL = "https://api.fk.life/v1"
# Сервисы для определения публичного IP (в порядке приоритета)
IP_SERVICES = [
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
"https://ipinfo.io/ip",
]
async def get_public_ip() -> str:
"""
Получает публичный IP сервера.
1. Сначала проверяет переменную окружения SERVER_PUBLIC_IP
2. Если нет - запрашивает через внешние сервисы и кэширует
"""
global _cached_public_ip
# Проверяем переменную окружения
env_ip = getattr(settings, 'SERVER_PUBLIC_IP', None)
if env_ip:
return env_ip
# Возвращаем кэшированный IP если есть
if _cached_public_ip:
return _cached_public_ip
async with _ip_fetch_lock:
# Повторная проверка после получения блокировки
if _cached_public_ip:
return _cached_public_ip
# Пробуем получить IP от внешних сервисов
async with aiohttp.ClientSession() as session:
for service_url in IP_SERVICES:
try:
async with session.get(
service_url,
timeout=aiohttp.ClientTimeout(total=5)
) as response:
if response.status == 200:
ip = (await response.text()).strip()
# Простая валидация IPv4
if ip and len(ip.split('.')) == 4:
_cached_public_ip = ip
logger.info(f"Определён публичный IP сервера: {ip}")
return ip
except Exception as e:
logger.debug(f"Не удалось получить IP от {service_url}: {e}")
continue
# Fallback на известный рабочий IP если ничего не получилось
fallback_ip = "185.92.183.173"
logger.warning(f"Не удалось определить публичный IP, используем fallback: {fallback_ip}")
_cached_public_ip = fallback_ip
return fallback_ip
class FreekassaService:
"""Сервис для работы с API Freekassa."""
@@ -121,6 +184,7 @@ class FreekassaService:
phone: Optional[str] = None,
payment_system_id: Optional[int] = None,
lang: str = "ru",
ip: Optional[str] = None,
) -> str:
"""
Формирует URL для перенаправления на оплату (форма выбора).
@@ -128,6 +192,60 @@ class FreekassaService:
"""
# Приводим amount к int, если это целое число
final_amount = int(amount) if float(amount).is_integer() else amount
# Используем payment_system_id из настроек, если не передан явно
ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID
# Специальная обработка для метода оплаты 44 (NSPK), чтобы работало как в старой версии
if ps_id == 44:
try:
# Определяем IP (важно для API запроса) - здесь синхронно, поэтому лучше иметь передачу IP
# Если IP не передан, используем fallback
target_ip = ip or "185.92.183.173"
target_email = email or "test@example.com"
params = {
"shopId": self.shop_id,
"nonce": int(time.time_ns()),
"paymentId": str(order_id),
"i": 44,
"email": target_email,
"ip": target_ip,
"amount": final_amount,
"currency": "RUB"
}
# Генерация подписи
params["signature"] = self._generate_api_signature(params)
logger.info(f"Freekassa synchronous build_payment_url for 44: {params}")
data_json = json.dumps(params).encode('utf-8')
req = urllib.request.Request(
f"{API_BASE_URL}/orders/create",
data=data_json,
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=30) as response:
resp_body = response.read().decode('utf-8')
data = json.loads(resp_body)
if data.get("type") == "error":
logger.error(f"Freekassa build_payment_url error: {data}")
# Fallback to standard flow if error? Or raise?
# User wants it to work. Raise to see error is safer.
# raise Exception(f"Freekassa API Error: {data.get('message')}")
# Но чтобы не ломать полностью, можно попробовать вернуть обычную ссылку,
# если API не сработал? Нет, вернем ошибку или ссылку из data.
if data.get("location"):
return data.get("location")
except Exception as e:
logger.error(f"Failed to create order 44 via sync API: {e}")
# Если не получилось, попробуем сгенерировать обычную ссылку как fallback
pass
signature = self.generate_form_signature(final_amount, currency, order_id)
params = {
@@ -144,8 +262,6 @@ class FreekassaService:
if phone:
params["phone"] = phone
# Используем payment_system_id из настроек, если не передан явно
ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID
if ps_id:
params["i"] = ps_id
@@ -177,13 +293,18 @@ class FreekassaService:
# Используем payment_system_id из настроек, если не передан явно
ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID or 1
target_email = email or "test@example.com"
# Определяем публичный IP сервера
server_ip = ip or await get_public_ip()
params = {
"shopId": self.shop_id,
"nonce": int(time.time_ns()), # Наносекунды для уникальности
"paymentId": str(order_id),
"i": ps_id,
"email": email or "user@example.com",
"ip": ip or "127.0.0.1",
"email": target_email,
"ip": server_ip,
"amount": final_amount,
"currency": currency,
}
@@ -206,10 +327,12 @@ class FreekassaService:
data = await response.json()
if response.status != 200 or data.get("type") == "error":
# Проверяем на ошибку - API может вернуть error или type=error
error_msg = data.get("error") or data.get("message")
if response.status != 200 or data.get("type") == "error" or error_msg:
logger.error(f"Freekassa create_order error: {data}")
raise Exception(
f"Freekassa API error: {data.get('message', 'Unknown error')}"
f"Freekassa API error: {error_msg or 'Unknown error'}"
)
return data
@@ -255,7 +378,7 @@ class FreekassaService:
}
params["signature"] = self._generate_api_signature(params)
logger.info(f"Freekassa get_order_status params: {params}")
logger.debug(f"Freekassa get_order_status params: {params}")
try:
async with aiohttp.ClientSession() as session:
@@ -266,7 +389,7 @@ class FreekassaService:
timeout=aiohttp.ClientTimeout(total=30),
) as response:
text = await response.text()
logger.info(f"Freekassa get_order_status response: {text}")
logger.debug(f"Freekassa get_order_status response: {text}")
return await response.json()
except aiohttp.ClientError as e:
logger.exception(f"Freekassa API connection error: {e}")
+68 -13
View File
@@ -505,17 +505,7 @@ class FreekassaPaymentMixin:
local_payment_id: int,
) -> Optional[Dict[str, Any]]:
"""
Проверяет статус платежа Freekassa по локальному ID.
Freekassa не предоставляет API для проверки статуса платежа,
поэтому возвращаем текущее состояние из БД.
Args:
db: Сессия БД
local_payment_id: Внутренний ID платежа
Returns:
Dict с информацией о платеже или None если не найден
Проверяет статус платежа Freekassa по локальному ID через API.
"""
freekassa_crud = import_module("app.database.crud.freekassa")
@@ -524,8 +514,73 @@ class FreekassaPaymentMixin:
logger.warning("Freekassa payment not found: id=%s", local_payment_id)
return None
# Freekassa не имеет API для проверки статуса,
# информация приходит только через webhook
if payment.is_paid:
return {
"payment": payment,
"status": "success",
"is_paid": True,
}
if not settings.FREEKASSA_API_KEY:
return {
"payment": payment,
"status": payment.status or "pending",
"is_paid": payment.is_paid,
}
try:
# Запрашиваем статус заказа в Freekassa
response = await freekassa_service.get_order_status(payment.order_id)
# Freekassa возвращает список заказов
orders = response.get("orders", [])
target_order = None
# Ищем наш заказ в списке
for order in orders:
# В ответе API поле называется merchant_order_id, а не paymentId
# Поддерживаем оба варианта на всякий случай
order_key = str(order.get("merchant_order_id") or order.get("paymentId"))
if order_key == str(payment.order_id):
target_order = order
break
if target_order:
# Статус 1 = Оплачен
fk_status = int(target_order.get("status", 0))
if fk_status == 1:
logger.info("Freekassa payment %s confirmed via API", payment.order_id)
callback_payload = {
"check_source": "api",
"fk_order_data": target_order,
}
# ID заказа на стороне FK (fk_order_id или id)
fk_intid = str(target_order.get("fk_order_id") or target_order.get("id"))
# Обновляем статус
payment = await freekassa_crud.update_freekassa_payment_status(
db=db,
payment=payment,
status="success",
is_paid=True,
freekassa_order_id=fk_intid,
payment_system_id=int(target_order.get("curID")) if target_order.get("curID") else None,
callback_payload=callback_payload,
)
# Финализируем
await self._finalize_freekassa_payment(
db,
payment,
intid=fk_intid,
trigger="api_check",
)
except Exception as e:
logger.error("Error checking Freekassa payment status: %s", e)
return {
"payment": payment,
"status": payment.status or "pending",
+23 -1
View File
@@ -23,7 +23,7 @@ from app.database.models import (
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
CryptoBotPayment, PlategaPayment, SubscriptionConversion, UserMessage, WelcomeText,
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, HeleketPayment,
AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod,
FreekassaPayment, AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod,
TransactionType
)
from app.config import settings
@@ -960,6 +960,28 @@ class UserService:
except Exception as e:
logger.error(f"❌ Ошибка удаления Heleket платежей: {e}")
# Удаляем Freekassa платежи
try:
freekassa_payments_result = await db.execute(
select(FreekassaPayment).where(FreekassaPayment.user_id == user_id)
)
freekassa_payments = freekassa_payments_result.scalars().all()
if freekassa_payments:
logger.info(f"🔄 Удаляем {len(freekassa_payments)} Freekassa платежей")
await db.execute(
update(FreekassaPayment)
.where(FreekassaPayment.user_id == user_id)
.values(transaction_id=None)
)
await db.flush()
await db.execute(
delete(FreekassaPayment).where(FreekassaPayment.user_id == user_id)
)
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка удаления Freekassa платежей: {e}")
try:
transactions_result = await db.execute(
select(Transaction).where(Transaction.user_id == user_id)
+2
View File
@@ -180,6 +180,8 @@ class AdminStates(StatesGroup):
editing_tariff_trial_days = State()
editing_tariff_squads = State()
editing_tariff_promo_groups = State()
editing_tariff_traffic_topup_packages = State()
editing_tariff_max_topup_traffic = State()
class SupportStates(StatesGroup):
File diff suppressed because it is too large Load Diff
+83 -2
View File
@@ -55,7 +55,7 @@ class MiniAppPromoGroup(BaseModel):
server_discount_percent: int = 0
traffic_discount_percent: int = 0
device_discount_percent: int = 0
period_discounts: Dict[int, int] = Field(default_factory=dict)
period_discounts: Dict[str, int] = Field(default_factory=dict)
apply_discounts_to_addons: bool = True
@@ -70,7 +70,7 @@ class MiniAppAutoPromoGroupLevel(BaseModel):
server_discount_percent: int = 0
traffic_discount_percent: int = 0
device_discount_percent: int = 0
period_discounts: Dict[int, int] = Field(default_factory=dict)
period_discounts: Dict[str, int] = Field(default_factory=dict)
apply_discounts_to_addons: bool = True
@@ -503,6 +503,10 @@ class MiniAppTariffPeriod(BaseModel):
price_label: str
price_per_month_kopeks: Optional[int] = None
price_per_month_label: Optional[str] = None
# Скидка промогруппы
original_price_kopeks: Optional[int] = None # Цена без скидки
original_price_label: Optional[str] = None
discount_percent: int = 0 # Процент скидки
class MiniAppTariff(BaseModel):
@@ -520,6 +524,22 @@ class MiniAppTariff(BaseModel):
periods: List[MiniAppTariffPeriod] = Field(default_factory=list)
is_current: bool = False
is_available: bool = True
# Для режима мгновенного переключения тарифа
switch_cost_kopeks: Optional[int] = None # Стоимость переключения (None если не в режиме switch)
switch_cost_label: Optional[str] = None # Форматированная стоимость
is_upgrade: Optional[bool] = None # True = повышение, False = понижение
is_switch_free: Optional[bool] = None # True = бесплатное переключение
class MiniAppTrafficTopupPackage(BaseModel):
"""Пакет докупки трафика."""
gb: int
price_kopeks: int
price_label: str
# Скидка промогруппы на трафик
original_price_kopeks: Optional[int] = None
original_price_label: Optional[str] = None
discount_percent: int = 0
class MiniAppCurrentTariff(BaseModel):
@@ -533,6 +553,30 @@ class MiniAppCurrentTariff(BaseModel):
is_unlimited_traffic: bool = False
device_limit: int
servers_count: int
# Месячная цена для расчёта стоимости переключения тарифа
monthly_price_kopeks: int = 0
# Докупка трафика
traffic_topup_enabled: bool = False
traffic_topup_packages: List[MiniAppTrafficTopupPackage] = Field(default_factory=list)
# Лимит докупки трафика (0 = без лимита)
max_topup_traffic_gb: int = 0
available_topup_gb: Optional[int] = None # Сколько еще можно докупить (None = без лимита)
class MiniAppTrafficTopupRequest(BaseModel):
"""Запрос на докупку трафика."""
init_data: str = Field(..., alias="initData")
subscription_id: Optional[int] = Field(None, alias="subscriptionId")
gb: int
class MiniAppTrafficTopupResponse(BaseModel):
"""Ответ на докупку трафика."""
success: bool = True
message: str = ""
new_traffic_limit_gb: int = 0
new_balance_kopeks: int = 0
charged_kopeks: int = 0
class MiniAppTariffsRequest(BaseModel):
@@ -548,6 +592,7 @@ class MiniAppTariffsResponse(BaseModel):
current_tariff: Optional[MiniAppCurrentTariff] = None
balance_kopeks: int = 0
balance_label: Optional[str] = None
promo_group: Optional[MiniAppPromoGroup] = None # Промогруппа пользователя для отображения скидок
class MiniAppTariffPurchaseRequest(BaseModel):
@@ -569,6 +614,42 @@ class MiniAppTariffPurchaseResponse(BaseModel):
balance_label: Optional[str] = None
class MiniAppTariffSwitchRequest(BaseModel):
"""Запрос на переключение тарифа (без выбора периода)."""
init_data: str = Field(...)
tariff_id: int = Field(...)
class MiniAppTariffSwitchPreviewResponse(BaseModel):
"""Предпросмотр переключения тарифа."""
can_switch: bool = True
current_tariff_id: Optional[int] = None
current_tariff_name: Optional[str] = None
new_tariff_id: int
new_tariff_name: str
remaining_days: int = 0
upgrade_cost_kopeks: int = 0 # 0 если даунгрейд или равная цена
upgrade_cost_label: str = ""
balance_kopeks: int = 0
balance_label: str = ""
has_enough_balance: bool = True
missing_amount_kopeks: int = 0
missing_amount_label: str = ""
is_upgrade: bool = False # True если новый тариф дороже
message: Optional[str] = None
class MiniAppTariffSwitchResponse(BaseModel):
"""Ответ на переключение тарифа."""
success: bool = True
message: Optional[str] = None
tariff_id: int
tariff_name: str
charged_kopeks: int = 0
balance_kopeks: int = 0
balance_label: str = ""
class MiniAppSubscriptionResponse(BaseModel):
success: bool = True
subscription_id: Optional[int] = None
+95
View File
@@ -798,6 +798,100 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
routes_registered = True
if settings.is_freekassa_enabled():
@router.options(settings.FREEKASSA_WEBHOOK_PATH)
async def freekassa_options() -> Response:
return Response(
status_code=status.HTTP_200_OK,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
)
@router.get(settings.FREEKASSA_WEBHOOK_PATH)
async def freekassa_health() -> JSONResponse:
return JSONResponse(
{
"status": "ok",
"service": "freekassa_webhook",
"enabled": settings.is_freekassa_enabled(),
}
)
@router.post(settings.FREEKASSA_WEBHOOK_PATH)
async def freekassa_webhook(request: Request) -> Response:
# Получаем IP клиента с учетом прокси
x_forwarded_for = request.headers.get("X-Forwarded-For")
if x_forwarded_for:
client_ip = x_forwarded_for.split(",")[0].strip()
else:
real_ip = request.headers.get("X-Real-IP")
if real_ip:
client_ip = real_ip.strip()
else:
client_ip = request.client.host if request.client else "127.0.0.1"
# Получаем данные формы
try:
form_data = await request.form()
except Exception:
logger.error("Freekassa webhook: не удалось прочитать данные формы")
return Response("Error reading form data", status_code=status.HTTP_400_BAD_REQUEST)
# Извлекаем параметры
merchant_id = form_data.get("MERCHANT_ID")
amount = form_data.get("AMOUNT")
order_id = form_data.get("MERCHANT_ORDER_ID")
sign = form_data.get("SIGN")
intid = form_data.get("intid")
cur_id = form_data.get("CUR_ID")
if not all([merchant_id, amount, order_id, sign, intid]):
logger.warning("Freekassa webhook: отсутствуют обязательные параметры")
return Response("Missing parameters", status_code=status.HTTP_400_BAD_REQUEST)
# Преобразуем типы
try:
merchant_id_int = int(merchant_id)
amount_float = float(amount)
cur_id_int = int(cur_id) if cur_id else None
except ValueError:
logger.warning("Freekassa webhook: неверный формат параметров")
return Response("Invalid parameters format", status_code=status.HTTP_400_BAD_REQUEST)
# Обрабатываем callback
db_generator = get_db()
try:
db = await db_generator.__anext__()
except StopAsyncIteration:
return Response("DB Error", status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
try:
success = await payment_service.process_freekassa_webhook(
db,
merchant_id=merchant_id_int,
amount=amount_float,
order_id=order_id,
sign=sign,
intid=intid,
cur_id=cur_id_int,
client_ip=client_ip,
)
finally:
try:
await db_generator.__anext__()
except StopAsyncIteration:
pass
if success:
return Response("YES", status_code=status.HTTP_200_OK)
return Response("Error", status_code=status.HTTP_400_BAD_REQUEST)
routes_registered = True
if routes_registered:
@router.get("/health/payment-webhooks")
async def payment_webhooks_health() -> JSONResponse:
@@ -813,6 +907,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
"pal24_enabled": settings.is_pal24_enabled(),
"platega_enabled": settings.is_platega_enabled(),
"cloudpayments_enabled": settings.is_cloudpayments_enabled(),
"freekassa_enabled": settings.is_freekassa_enabled(),
}
)
+1
View File
@@ -120,6 +120,7 @@ def create_unified_app(
"pal24": settings.is_pal24_enabled(),
"wata": settings.is_wata_enabled(),
"heleket": settings.is_heleket_enabled(),
"freekassa": settings.is_freekassa_enabled(),
}
if enable_telegram_webhook:
+30 -20
View File
@@ -7,14 +7,18 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot}
POSTGRES_USER: ${POSTGRES_USER:-remnawave_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123}
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=C'
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- bot_network
- remnawave-network # Подключаем к сети панели
- remnawave-network # Подключаем к сети панели
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"]
test:
[
'CMD-SHELL',
'pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}',
]
interval: 30s
timeout: 5s
retries: 5
@@ -29,9 +33,9 @@ services:
- redis_data:/data
networks:
- bot_network
- remnawave-network # Подключаем к сети панели
- remnawave-network # Подключаем к сети панели
healthcheck:
test: ["CMD", "redis-cli", "ping"]
test: ['CMD', 'redis-cli', 'ping']
interval: 30s
timeout: 10s
retries: 3
@@ -48,16 +52,16 @@ services:
env_file:
- .env
environment:
DOCKER_ENV: "true"
DATABASE_MODE: "auto"
POSTGRES_HOST: "postgres"
POSTGRES_PORT: "5432"
POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}"
POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}"
REDIS_URL: "redis://redis:6379/0"
TZ: "Europe/Moscow"
LOCALES_PATH: "${LOCALES_PATH:-/app/locales}"
DOCKER_ENV: 'true'
DATABASE_MODE: 'auto'
POSTGRES_HOST: 'postgres'
POSTGRES_PORT: '5432'
POSTGRES_DB: '${POSTGRES_DB:-remnawave_bot}'
POSTGRES_USER: '${POSTGRES_USER:-remnawave_user}'
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-secure_password_123}'
REDIS_URL: 'redis://redis:6379/0'
TZ: 'Europe/Moscow'
LOCALES_PATH: '${LOCALES_PATH:-/app/locales}'
volumes:
- ./logs:/app/logs:rw
- ./data:/app/data:rw
@@ -66,12 +70,16 @@ services:
- /etc/localtime:/etc/localtime:ro
- ./vpn_logo.png:/app/vpn_logo.png:ro
ports:
- "${WEB_API_PORT:-8080}:8080"
- '${WEB_API_PORT:-8080}:8080'
networks:
- bot_network
- remnawave-network # Подключаем к сети панели
- remnawave-network # Подключаем к сети панели
healthcheck:
test: ["CMD-SHELL", "python -c \"import requests, os; requests.get('http://localhost:8080/health', headers={'X-API-Key': os.environ.get('WEB_API_DEFAULT_TOKEN')}, timeout=5) or exit(1)\""]
test:
[
'CMD-SHELL',
'python -c "import requests, os; requests.get(''http://localhost:8080/health'', headers={''X-API-Key'': os.environ.get(''WEB_API_DEFAULT_TOKEN'')}, timeout=5) or exit(1)"',
]
interval: 60s
timeout: 10s
retries: 3
@@ -90,7 +98,9 @@ networks:
config:
- subnet: 172.20.0.0/16
gateway: 172.20.0.1
driver_opts:
com.docker.network.driver.mtu: 1350
remnawave-network:
name: remnawave-network
external: true # Используем существующую сеть панели
external: true # Используем существующую сеть панели
+27 -17
View File
@@ -7,13 +7,17 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot}
POSTGRES_USER: ${POSTGRES_USER:-remnawave_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123}
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=C'
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- bot_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"]
test:
[
'CMD-SHELL',
'pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}',
]
interval: 30s
timeout: 5s
retries: 5
@@ -29,7 +33,7 @@ services:
networks:
- bot_network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
test: ['CMD', 'redis-cli', 'ping']
interval: 30s
timeout: 10s
retries: 3
@@ -46,18 +50,18 @@ services:
env_file:
- .env
environment:
DOCKER_ENV: "true"
DATABASE_MODE: "auto"
POSTGRES_HOST: "postgres"
POSTGRES_PORT: "5432"
POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}"
POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}"
REDIS_URL: "redis://redis:6379/0"
TZ: "Europe/Moscow"
LOCALES_PATH: "${LOCALES_PATH:-/app/locales}"
DOCKER_ENV: 'true'
DATABASE_MODE: 'auto'
POSTGRES_HOST: 'postgres'
POSTGRES_PORT: '5432'
POSTGRES_DB: '${POSTGRES_DB:-remnawave_bot}'
POSTGRES_USER: '${POSTGRES_USER:-remnawave_user}'
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-secure_password_123}'
REDIS_URL: 'redis://redis:6379/0'
TZ: 'Europe/Moscow'
LOCALES_PATH: '${LOCALES_PATH:-/app/locales}'
volumes:
# Логи
- ./logs:/app/logs:rw
@@ -72,11 +76,15 @@ services:
# Логотип для сообщений
- ./vpn_logo.png:/app/vpn_logo.png:ro
ports:
- "${WEB_API_PORT:-8080}:8080"
- '${WEB_API_PORT:-8080}:8080'
networks:
- bot_network
healthcheck:
test: ["CMD-SHELL", "python -c \"import requests, os; requests.get('http://localhost:8080/health', headers={'X-API-Key': os.environ.get('WEB_API_DEFAULT_TOKEN')}, timeout=5) or exit(1)\""]
test:
[
'CMD-SHELL',
'python -c "import requests, os; requests.get(''http://localhost:8080/health'', headers={''X-API-Key'': os.environ.get(''WEB_API_DEFAULT_TOKEN'')}, timeout=5) or exit(1)"',
]
interval: 60s
timeout: 10s
retries: 3
@@ -95,3 +103,5 @@ networks:
config:
- subnet: 172.20.0.0/16
gateway: 172.20.0.1
driver_opts:
com.docker.network.driver.mtu: 1350
+2
View File
@@ -648,6 +648,8 @@ async def main():
webhook_lines.append(f"WATA: {_fmt(settings.WATA_WEBHOOK_PATH)}")
if settings.is_heleket_enabled():
webhook_lines.append(f"Heleket: {_fmt(settings.HELEKET_WEBHOOK_PATH)}")
if settings.is_freekassa_enabled():
webhook_lines.append(f"Freekassa: {_fmt(settings.FREEKASSA_WEBHOOK_PATH)}")
timeline.log_section(
"Активные webhook endpoints",
+1306 -103
View File
File diff suppressed because it is too large Load Diff