feat(tariffs): добавлена поддержка докупки трафика и улучшения тарифов
- Реализована возможность докупки трафика для тарифов с новыми параметрами: traffic_topup_enabled, traffic_topup_packages и max_topup_traffic_gb. - Обновлены схемы и маршруты для управления тарифами и трафиком. - Добавлены новые эндпоинты для работы с докупкой трафика в мини-приложении. - Обновлены настройки и логика для проверки доступности докупки трафика в зависимости от тарифа. - Внедрены улучшения в обработку платежей через Freekassa. Обновлён .env.example с новыми параметрами для режима тарифов.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -188,6 +188,9 @@ async def get_tariff(
|
||||
is_active=tariff.is_active,
|
||||
is_trial_available=tariff.is_trial_available,
|
||||
allow_traffic_topup=tariff.allow_traffic_topup,
|
||||
traffic_topup_enabled=tariff.traffic_topup_enabled,
|
||||
traffic_topup_packages=tariff.traffic_topup_packages or {},
|
||||
max_topup_traffic_gb=tariff.max_topup_traffic_gb,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
device_price_kopeks=tariff.device_price_kopeks,
|
||||
@@ -224,6 +227,9 @@ async def create_new_tariff(
|
||||
description=request.description,
|
||||
is_active=request.is_active,
|
||||
allow_traffic_topup=request.allow_traffic_topup,
|
||||
traffic_topup_enabled=request.traffic_topup_enabled,
|
||||
traffic_topup_packages=request.traffic_topup_packages,
|
||||
max_topup_traffic_gb=request.max_topup_traffic_gb,
|
||||
traffic_limit_gb=request.traffic_limit_gb,
|
||||
device_limit=request.device_limit,
|
||||
device_price_kopeks=request.device_price_kopeks,
|
||||
@@ -268,6 +274,12 @@ async def update_existing_tariff(
|
||||
updates["is_active"] = request.is_active
|
||||
if request.allow_traffic_topup is not None:
|
||||
updates["allow_traffic_topup"] = request.allow_traffic_topup
|
||||
if request.traffic_topup_enabled is not None:
|
||||
updates["traffic_topup_enabled"] = request.traffic_topup_enabled
|
||||
if request.traffic_topup_packages is not None:
|
||||
updates["traffic_topup_packages"] = request.traffic_topup_packages
|
||||
if request.max_topup_traffic_gb is not None:
|
||||
updates["max_topup_traffic_gb"] = request.max_topup_traffic_gb
|
||||
if request.traffic_limit_gb is not None:
|
||||
updates["traffic_limit_gb"] = request.traffic_limit_gb
|
||||
if request.device_limit is not None:
|
||||
|
||||
@@ -73,6 +73,9 @@ class TariffDetailResponse(BaseModel):
|
||||
is_active: bool
|
||||
is_trial_available: bool
|
||||
allow_traffic_topup: bool = True
|
||||
traffic_topup_enabled: bool = False
|
||||
traffic_topup_packages: Dict[str, int] = Field(default_factory=dict)
|
||||
max_topup_traffic_gb: int = 0
|
||||
traffic_limit_gb: int
|
||||
device_limit: int
|
||||
device_price_kopeks: Optional[int] = None
|
||||
@@ -97,6 +100,9 @@ class TariffCreateRequest(BaseModel):
|
||||
description: Optional[str] = None
|
||||
is_active: bool = True
|
||||
allow_traffic_topup: bool = True
|
||||
traffic_topup_enabled: bool = False
|
||||
traffic_topup_packages: Dict[str, int] = Field(default_factory=dict)
|
||||
max_topup_traffic_gb: int = Field(0, ge=0)
|
||||
traffic_limit_gb: int = Field(0, ge=0, description="0 = unlimited")
|
||||
device_limit: int = Field(1, ge=1)
|
||||
device_price_kopeks: Optional[int] = Field(None, ge=0)
|
||||
@@ -113,6 +119,9 @@ class TariffUpdateRequest(BaseModel):
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
allow_traffic_topup: Optional[bool] = None
|
||||
traffic_topup_enabled: Optional[bool] = None
|
||||
traffic_topup_packages: Optional[Dict[str, int]] = None
|
||||
max_topup_traffic_gb: Optional[int] = Field(None, ge=0)
|
||||
traffic_limit_gb: Optional[int] = Field(None, ge=0)
|
||||
device_limit: Optional[int] = Field(None, ge=1)
|
||||
device_price_kopeks: Optional[int] = Field(None, ge=0)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
# В режиме тарифов сохраняем докупленный трафик при продлении
|
||||
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
|
||||
|
||||
@@ -170,6 +170,9 @@ async def create_tariff(
|
||||
is_trial_available: bool = False,
|
||||
allow_traffic_topup: bool = True,
|
||||
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)
|
||||
@@ -188,6 +191,9 @@ async def create_tariff(
|
||||
tier_level=max(1, tier_level),
|
||||
is_trial_available=is_trial_available,
|
||||
allow_traffic_topup=allow_traffic_topup,
|
||||
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)
|
||||
@@ -235,6 +241,9 @@ async def update_tariff(
|
||||
is_trial_available: Optional[bool] = None,
|
||||
allow_traffic_topup: 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:
|
||||
@@ -264,6 +273,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:
|
||||
|
||||
@@ -782,6 +782,13 @@ class Tariff(Base):
|
||||
is_trial_available = Column(Boolean, default=False, nullable=False) # Можно ли взять триал на этом тарифе
|
||||
allow_traffic_topup = Column(Boolean, default=True, 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())
|
||||
|
||||
@@ -840,6 +847,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})>"
|
||||
|
||||
|
||||
@@ -5627,7 +5627,88 @@ async def create_wheel_tables() -> bool:
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания таблиц колеса удачи: {error}")
|
||||
logger.error(f"❌ Ошибка создания таблиц для колеса удачи: {error}")
|
||||
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
|
||||
|
||||
|
||||
@@ -6145,6 +6226,12 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с колонкой allow_traffic_topup в 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:
|
||||
|
||||
Vendored
+17
@@ -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)
|
||||
|
||||
|
||||
Vendored
+6
-2
@@ -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__)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:"))
|
||||
|
||||
+386
-1
@@ -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(
|
||||
@@ -5674,6 +6038,27 @@ def register_handlers(dp: Dispatcher):
|
||||
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,
|
||||
|
||||
@@ -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:"))
|
||||
|
||||
@@ -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,46 +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
|
||||
|
||||
# Проверяем настройку на уровне тарифа (если тариф задан)
|
||||
subscription = db_user.subscription
|
||||
if subscription and subscription.tariff_id:
|
||||
# Загружаем тариф с проверкой allow_traffic_topup
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff and not tariff.allow_traffic_topup:
|
||||
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:
|
||||
@@ -149,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(
|
||||
@@ -477,23 +508,29 @@ async def add_traffic(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
if settings.is_traffic_topup_blocked():
|
||||
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем настройку тарифа
|
||||
subscription = db_user.subscription
|
||||
if subscription and subscription.tariff_id:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff and not tariff.allow_traffic_topup:
|
||||
await callback.answer("⚠️ Для вашего тарифа докупка трафика недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
traffic_gb = int(callback.data.split('_')[2])
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
# Получаем цену: из тарифа или из глобальных настроек
|
||||
base_price = 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:
|
||||
|
||||
+110
-29
@@ -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", "📈 Докупить трафик"),
|
||||
@@ -1796,6 +1803,80 @@ 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,
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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": "👥 Юзери/Підписки",
|
||||
|
||||
@@ -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":"👥用户/订阅",
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
+857
-17
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+25
-15
@@ -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 # Подключаем к сети панели
|
||||
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
|
||||
@@ -31,7 +35,7 @@ services:
|
||||
- bot_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 # Подключаем к сети панели
|
||||
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,6 +98,8 @@ 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
|
||||
|
||||
+25
-15
@@ -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}"
|
||||
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"
|
||||
REDIS_URL: 'redis://redis:6379/0'
|
||||
|
||||
TZ: "Europe/Moscow"
|
||||
LOCALES_PATH: "${LOCALES_PATH:-/app/locales}"
|
||||
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
|
||||
|
||||
@@ -678,6 +678,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",
|
||||
|
||||
+1251
-48
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user