Add admin promo groups and integrate discounts
This commit is contained in:
@@ -31,6 +31,7 @@ from app.handlers.admin import (
|
||||
statistics as admin_statistics,
|
||||
servers as admin_servers,
|
||||
maintenance as admin_maintenance,
|
||||
promo_groups as admin_promo_groups,
|
||||
campaigns as admin_campaigns,
|
||||
user_messages as admin_user_messages,
|
||||
updates as admin_updates,
|
||||
@@ -127,6 +128,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_rules.register_handlers(dp)
|
||||
admin_remnawave.register_handlers(dp)
|
||||
admin_statistics.register_handlers(dp)
|
||||
admin_promo_groups.register_handlers(dp)
|
||||
admin_campaigns.register_handlers(dp)
|
||||
admin_maintenance.register_handlers(dp)
|
||||
admin_user_messages.register_handlers(dp)
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoGroup, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_promo_groups_with_counts(
|
||||
db: AsyncSession,
|
||||
) -> List[Tuple[PromoGroup, int]]:
|
||||
result = await db.execute(
|
||||
select(PromoGroup, func.count(User.id))
|
||||
.outerjoin(User, User.promo_group_id == PromoGroup.id)
|
||||
.group_by(PromoGroup.id)
|
||||
.order_by(PromoGroup.is_default.desc(), PromoGroup.name)
|
||||
)
|
||||
return result.all()
|
||||
|
||||
|
||||
async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[PromoGroup]:
|
||||
return await db.get(PromoGroup, group_id)
|
||||
|
||||
|
||||
async def get_default_promo_group(db: AsyncSession) -> Optional[PromoGroup]:
|
||||
result = await db.execute(
|
||||
select(PromoGroup).where(PromoGroup.is_default.is_(True))
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def create_promo_group(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
*,
|
||||
server_discount_percent: int,
|
||||
traffic_discount_percent: int,
|
||||
device_discount_percent: int,
|
||||
) -> PromoGroup:
|
||||
promo_group = PromoGroup(
|
||||
name=name.strip(),
|
||||
server_discount_percent=max(0, min(100, server_discount_percent)),
|
||||
traffic_discount_percent=max(0, min(100, traffic_discount_percent)),
|
||||
device_discount_percent=max(0, min(100, device_discount_percent)),
|
||||
is_default=False,
|
||||
)
|
||||
|
||||
db.add(promo_group)
|
||||
await db.commit()
|
||||
await db.refresh(promo_group)
|
||||
|
||||
logger.info(
|
||||
"Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%)",
|
||||
promo_group.name,
|
||||
promo_group.server_discount_percent,
|
||||
promo_group.traffic_discount_percent,
|
||||
promo_group.device_discount_percent,
|
||||
)
|
||||
|
||||
return promo_group
|
||||
|
||||
|
||||
async def update_promo_group(
|
||||
db: AsyncSession,
|
||||
group: PromoGroup,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
server_discount_percent: Optional[int] = None,
|
||||
traffic_discount_percent: Optional[int] = None,
|
||||
device_discount_percent: Optional[int] = None,
|
||||
) -> PromoGroup:
|
||||
if name is not None:
|
||||
group.name = name.strip()
|
||||
if server_discount_percent is not None:
|
||||
group.server_discount_percent = max(0, min(100, server_discount_percent))
|
||||
if traffic_discount_percent is not None:
|
||||
group.traffic_discount_percent = max(0, min(100, traffic_discount_percent))
|
||||
if device_discount_percent is not None:
|
||||
group.device_discount_percent = max(0, min(100, device_discount_percent))
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(group)
|
||||
|
||||
logger.info(
|
||||
"Обновлена промогруппа '%s' (id=%s)",
|
||||
group.name,
|
||||
group.id,
|
||||
)
|
||||
return group
|
||||
|
||||
|
||||
async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
|
||||
if group.is_default:
|
||||
logger.warning("Попытка удалить базовую промогруппу запрещена")
|
||||
return False
|
||||
|
||||
default_group = await get_default_promo_group(db)
|
||||
if not default_group:
|
||||
logger.error("Не найдена базовая промогруппа для reassignment")
|
||||
return False
|
||||
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.promo_group_id == group.id)
|
||||
.values(promo_group_id=default_group.id)
|
||||
)
|
||||
await db.delete(group)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Промогруппа '%s' (id=%s) удалена, пользователи переведены в '%s'",
|
||||
group.name,
|
||||
group.id,
|
||||
default_group.name,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def get_promo_group_members(
|
||||
db: AsyncSession,
|
||||
group_id: int,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
) -> List[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.where(User.promo_group_id == group_id)
|
||||
.order_by(User.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_promo_group_members(db: AsyncSession, group_id: int) -> int:
|
||||
result = await db.execute(
|
||||
select(func.count(User.id)).where(User.promo_group_id == group_id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
@@ -6,8 +6,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
Subscription, SubscriptionStatus, User,
|
||||
SubscriptionServer
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
User,
|
||||
SubscriptionServer,
|
||||
PromoGroup,
|
||||
)
|
||||
from app.database.crud.notification import clear_notifications
|
||||
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
|
||||
@@ -495,12 +498,32 @@ async def get_servers_monthly_prices(
|
||||
prices.append(price)
|
||||
return prices
|
||||
|
||||
def _get_discount_percent(
|
||||
user: Optional[User],
|
||||
promo_group: Optional[PromoGroup],
|
||||
category: str,
|
||||
) -> int:
|
||||
if user is not None:
|
||||
try:
|
||||
return user.get_promo_discount(category)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
if promo_group is not None:
|
||||
return promo_group.get_discount_percent(category)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
async def calculate_subscription_total_cost(
|
||||
db: AsyncSession,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: List[int],
|
||||
devices: int
|
||||
devices: int,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> Tuple[int, dict]:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
@@ -508,39 +531,83 @@ async def calculate_subscription_total_cost(
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
traffic_discount_percent = _get_discount_percent(user, promo_group, "traffic")
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_price = discounted_traffic_per_month * months_in_period
|
||||
total_traffic_discount = traffic_discount_per_month * months_in_period
|
||||
|
||||
servers_prices = await get_servers_monthly_prices(db, server_squad_ids)
|
||||
servers_price_per_month = sum(servers_prices)
|
||||
total_servers_price = servers_price_per_month * months_in_period
|
||||
|
||||
servers_discount_percent = _get_discount_percent(user, promo_group, "servers")
|
||||
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_price = discounted_servers_per_month * months_in_period
|
||||
total_servers_discount = servers_discount_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
devices_discount_percent = _get_discount_percent(user, promo_group, "devices")
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_price = discounted_devices_per_month * months_in_period
|
||||
total_devices_discount = devices_discount_per_month * months_in_period
|
||||
|
||||
total_cost = base_price + total_traffic_price + total_servers_price + total_devices_price
|
||||
|
||||
|
||||
details = {
|
||||
'base_price': base_price,
|
||||
'traffic_price_per_month': traffic_price_per_month,
|
||||
'traffic_discount_percent': traffic_discount_percent,
|
||||
'traffic_discount_total': total_traffic_discount,
|
||||
'total_traffic_price': total_traffic_price,
|
||||
'servers_price_per_month': servers_price_per_month,
|
||||
'servers_discount_percent': servers_discount_percent,
|
||||
'servers_discount_total': total_servers_discount,
|
||||
'total_servers_price': total_servers_price,
|
||||
'devices_price_per_month': devices_price_per_month,
|
||||
'devices_discount_percent': devices_discount_percent,
|
||||
'devices_discount_total': total_devices_discount,
|
||||
'total_devices_price': total_devices_price,
|
||||
'months_in_period': months_in_period,
|
||||
'servers_individual_prices': [price * months_in_period for price in servers_prices]
|
||||
'servers_individual_prices': [
|
||||
(price - (price * servers_discount_percent // 100)) * months_in_period
|
||||
for price in servers_prices
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
logger.info(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" Базовый период: {base_price/100}₽")
|
||||
if total_traffic_price > 0:
|
||||
logger.info(f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽")
|
||||
message = (
|
||||
f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽"
|
||||
)
|
||||
if total_traffic_discount > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_servers_price > 0:
|
||||
logger.info(f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽")
|
||||
message = (
|
||||
f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽"
|
||||
)
|
||||
if total_servers_discount > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_devices_price > 0:
|
||||
logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽")
|
||||
message = (
|
||||
f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽"
|
||||
)
|
||||
if total_devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" ИТОГО: {total_cost/100}₽")
|
||||
|
||||
return total_cost, details
|
||||
@@ -614,19 +681,33 @@ async def remove_subscription_servers(
|
||||
async def get_subscription_renewal_cost(
|
||||
db: AsyncSession,
|
||||
subscription_id: int,
|
||||
period_days: int
|
||||
period_days: int,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
subscription = await db.get(Subscription, subscription_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user).selectinload(User.promo_group),
|
||||
)
|
||||
.where(Subscription.id == subscription_id)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
if not subscription:
|
||||
return base_price
|
||||
|
||||
|
||||
if user is None:
|
||||
user = subscription.user
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
servers_info = await get_subscription_servers(db, subscription_id)
|
||||
servers_price_per_month = 0
|
||||
for server_info in servers_info:
|
||||
@@ -637,26 +718,59 @@ async def get_subscription_renewal_cost(
|
||||
)
|
||||
current_server_price = result.scalar() or 0
|
||||
servers_price_per_month += current_server_price
|
||||
|
||||
total_servers_cost = servers_price_per_month * months_in_period
|
||||
|
||||
|
||||
servers_discount_percent = _get_discount_percent(user, promo_group, "servers")
|
||||
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_cost = discounted_servers_per_month * months_in_period
|
||||
total_servers_discount = servers_discount_per_month * months_in_period
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
total_traffic_cost = traffic_price_per_month * months_in_period
|
||||
|
||||
traffic_discount_percent = _get_discount_percent(user, promo_group, "traffic")
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_cost = discounted_traffic_per_month * months_in_period
|
||||
total_traffic_discount = traffic_discount_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_cost = devices_price_per_month * months_in_period
|
||||
|
||||
devices_discount_percent = _get_discount_percent(user, promo_group, "devices")
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_cost = discounted_devices_per_month * months_in_period
|
||||
total_devices_discount = devices_discount_per_month * months_in_period
|
||||
|
||||
total_cost = base_price + total_servers_cost + total_traffic_cost + total_devices_cost
|
||||
|
||||
|
||||
logger.info(f"💰 Расчет продления подписки {subscription_id} на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" 📅 Период: {base_price/100}₽")
|
||||
if total_servers_cost > 0:
|
||||
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}₽")
|
||||
message = (
|
||||
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}₽"
|
||||
)
|
||||
if total_servers_discount > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_traffic_cost > 0:
|
||||
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}₽")
|
||||
message = (
|
||||
f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}₽"
|
||||
)
|
||||
if total_traffic_discount > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_devices_cost > 0:
|
||||
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}₽")
|
||||
message = (
|
||||
f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}₽"
|
||||
)
|
||||
if total_devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" 💎 ИТОГО: {total_cost/100}₽")
|
||||
|
||||
return total_cost
|
||||
@@ -671,27 +785,54 @@ async def calculate_addon_cost_for_remaining_period(
|
||||
subscription: Subscription,
|
||||
additional_traffic_gb: int = 0,
|
||||
additional_devices: int = 0,
|
||||
additional_server_ids: List[int] = None
|
||||
additional_server_ids: List[int] = None,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> int:
|
||||
if additional_server_ids is None:
|
||||
additional_server_ids = []
|
||||
|
||||
|
||||
months_to_pay = get_remaining_months(subscription.end_date)
|
||||
|
||||
|
||||
total_cost = 0
|
||||
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
if additional_traffic_gb > 0:
|
||||
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
|
||||
traffic_total_cost = traffic_price_per_month * months_to_pay
|
||||
traffic_discount_percent = _get_discount_percent(user, promo_group, "traffic")
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
traffic_total_cost = discounted_traffic_per_month * months_to_pay
|
||||
total_cost += traffic_total_cost
|
||||
logger.info(f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {months_to_pay} = {traffic_total_cost/100}₽")
|
||||
|
||||
message = (
|
||||
f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {months_to_pay} = {traffic_total_cost/100}₽"
|
||||
)
|
||||
if traffic_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_total_cost = devices_price_per_month * months_to_pay
|
||||
devices_discount_percent = _get_discount_percent(user, promo_group, "devices")
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
devices_total_cost = discounted_devices_per_month * months_to_pay
|
||||
total_cost += devices_total_cost
|
||||
logger.info(f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес × {months_to_pay} = {devices_total_cost/100}₽")
|
||||
|
||||
message = (
|
||||
f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес × {months_to_pay} = {devices_total_cost/100}₽"
|
||||
)
|
||||
if devices_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
if additional_server_ids:
|
||||
from app.database.models import ServerSquad
|
||||
for server_id in additional_server_ids:
|
||||
@@ -702,9 +843,19 @@ async def calculate_addon_cost_for_remaining_period(
|
||||
server_data = result.first()
|
||||
if server_data:
|
||||
server_price_per_month, server_name = server_data
|
||||
server_total_cost = server_price_per_month * months_to_pay
|
||||
servers_discount_percent = _get_discount_percent(user, promo_group, "servers")
|
||||
server_discount_per_month = server_price_per_month * servers_discount_percent // 100
|
||||
discounted_server_per_month = server_price_per_month - server_discount_per_month
|
||||
server_total_cost = discounted_server_per_month * months_to_pay
|
||||
total_cost += server_total_cost
|
||||
logger.info(f"Сервер {server_name}: {server_price_per_month/100}₽/мес × {months_to_pay} = {server_total_cost/100}₽")
|
||||
message = (
|
||||
f"Сервер {server_name}: {server_price_per_month/100}₽/мес × {months_to_pay} = {server_total_cost/100}₽"
|
||||
)
|
||||
if server_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{server_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
logger.info(f"💰 Итого доплата за {months_to_pay} мес: {total_cost/100}₽")
|
||||
return total_cost
|
||||
|
||||
@@ -7,8 +7,9 @@ from sqlalchemy import select, and_, or_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import User, UserStatus, Subscription, Transaction
|
||||
from app.database.models import User, UserStatus, Subscription, Transaction, PromoGroup
|
||||
from app.config import settings
|
||||
from app.database.crud.promo_group import get_default_promo_group
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,7 +23,10 @@ def generate_referral_code() -> str:
|
||||
async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
@@ -36,7 +40,10 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
@@ -49,7 +56,9 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
|
||||
|
||||
async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User).where(User.referral_code == referral_code)
|
||||
select(User)
|
||||
.options(selectinload(User.promo_group))
|
||||
.where(User.referral_code == referral_code)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@@ -82,6 +91,20 @@ async def create_user(
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
referral_code = await generate_unique_referral_code(db, telegram_id)
|
||||
|
||||
default_group = await get_default_promo_group(db)
|
||||
if not default_group:
|
||||
default_group = PromoGroup(
|
||||
name="Базовый юзер",
|
||||
server_discount_percent=0,
|
||||
traffic_discount_percent=0,
|
||||
device_discount_percent=0,
|
||||
is_default=True,
|
||||
)
|
||||
db.add(default_group)
|
||||
await db.flush()
|
||||
|
||||
promo_group_id = default_group.id
|
||||
|
||||
user = User(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
@@ -92,15 +115,19 @@ async def create_user(
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False,
|
||||
has_made_first_topup=False
|
||||
has_made_first_topup=False,
|
||||
promo_group_id=promo_group_id,
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if default_group:
|
||||
user.promo_group = default_group
|
||||
|
||||
logger.info(f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}")
|
||||
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@@ -281,7 +308,10 @@ async def get_users_count(
|
||||
async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.order_by(User.created_at.desc())
|
||||
)
|
||||
@@ -293,7 +323,10 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
User.last_activity < threshold_date,
|
||||
|
||||
@@ -155,6 +155,29 @@ class CryptoBotPayment(Base):
|
||||
return f"<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>"
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = "promo_groups"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), unique=True, nullable=False)
|
||||
server_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
traffic_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
device_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
is_default = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
users = relationship("User", back_populates="promo_group")
|
||||
|
||||
def get_discount_percent(self, category: str) -> int:
|
||||
mapping = {
|
||||
"servers": self.server_discount_percent,
|
||||
"traffic": self.traffic_discount_percent,
|
||||
"devices": self.device_discount_percent,
|
||||
}
|
||||
return max(0, min(100, mapping.get(category, 0)))
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
@@ -185,6 +208,8 @@ class User(Base):
|
||||
vless_uuid = Column(String(255), nullable=True)
|
||||
ss_password = Column(String(255), nullable=True)
|
||||
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
promo_group = relationship("PromoGroup", back_populates="users")
|
||||
|
||||
@property
|
||||
def balance_rubles(self) -> float:
|
||||
@@ -194,6 +219,11 @@ class User(Base):
|
||||
def full_name(self) -> str:
|
||||
parts = [self.first_name, self.last_name]
|
||||
return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}"
|
||||
|
||||
def get_promo_discount(self, category: str) -> int:
|
||||
if not self.promo_group:
|
||||
return 0
|
||||
return self.promo_group.get_discount_percent(category)
|
||||
|
||||
def add_balance(self, kopeks: int) -> None:
|
||||
self.balance_kopeks += kopeks
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.promo_group import (
|
||||
get_promo_groups_with_counts,
|
||||
get_promo_group_by_id,
|
||||
create_promo_group,
|
||||
update_promo_group,
|
||||
delete_promo_group,
|
||||
get_promo_group_members,
|
||||
count_promo_group_members,
|
||||
)
|
||||
from app.database.models import PromoGroup
|
||||
from app.localization.texts import get_texts
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.keyboards.admin import (
|
||||
get_admin_pagination_keyboard,
|
||||
get_confirmation_keyboard,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_discount_line(texts, group) -> str:
|
||||
return texts.t(
|
||||
"ADMIN_PROMO_GROUPS_DISCOUNTS",
|
||||
"Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
|
||||
).format(
|
||||
servers=group.server_discount_percent,
|
||||
traffic=group.traffic_discount_percent,
|
||||
devices=group.device_discount_percent,
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promo_groups_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
groups = await get_promo_groups_with_counts(db)
|
||||
|
||||
total_members = sum(count for _, count in groups)
|
||||
header = texts.t("ADMIN_PROMO_GROUPS_TITLE", "💳 <b>Промогруппы</b>")
|
||||
|
||||
if groups:
|
||||
summary = texts.t(
|
||||
"ADMIN_PROMO_GROUPS_SUMMARY",
|
||||
"Всего групп: {count}\nВсего участников: {members}",
|
||||
).format(count=len(groups), members=total_members)
|
||||
lines = [header, "", summary, ""]
|
||||
|
||||
keyboard_rows = []
|
||||
for group, member_count in groups:
|
||||
default_suffix = (
|
||||
texts.t("ADMIN_PROMO_GROUPS_DEFAULT_LABEL", " (базовая)")
|
||||
if group.is_default
|
||||
else ""
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
f"{'⭐' if group.is_default else '🎯'} <b>{group.name}</b>{default_suffix}",
|
||||
_format_discount_line(texts, group),
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT",
|
||||
"Участников: {count}",
|
||||
).format(count=member_count),
|
||||
"",
|
||||
]
|
||||
)
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{'⭐' if group.is_default else '🎯'} {group.name}",
|
||||
callback_data=f"promo_group_manage_{group.id}",
|
||||
)
|
||||
])
|
||||
else:
|
||||
lines = [header, "", texts.t("ADMIN_PROMO_GROUPS_EMPTY", "Промогруппы не найдены.")]
|
||||
keyboard_rows = []
|
||||
|
||||
keyboard_rows.append(
|
||||
[types.InlineKeyboardButton(text="➕ Создать", callback_data="admin_promo_group_create")]
|
||||
)
|
||||
keyboard_rows.append(
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(line for line in lines if line is not None),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _get_group_or_alert(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
) -> Optional[PromoGroup]:
|
||||
group_id = int(callback.data.split("_")[-1])
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
if not group:
|
||||
await callback.answer("❌ Промогруппа не найдена", show_alert=True)
|
||||
return None
|
||||
return group
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promo_group_details(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
group = await _get_group_or_alert(callback, db)
|
||||
if not group:
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
member_count = await count_promo_group_members(db, group.id)
|
||||
|
||||
default_note = (
|
||||
"\n" + texts.t("ADMIN_PROMO_GROUP_DETAILS_DEFAULT", "Это базовая группа.")
|
||||
if group.is_default
|
||||
else ""
|
||||
)
|
||||
|
||||
text = "\n".join(
|
||||
[
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_DETAILS_TITLE",
|
||||
"💳 <b>Промогруппа:</b> {name}",
|
||||
).format(name=group.name),
|
||||
_format_discount_line(texts, group),
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS",
|
||||
"Участников: {count}",
|
||||
).format(count=member_count),
|
||||
default_note,
|
||||
]
|
||||
)
|
||||
|
||||
keyboard_rows = []
|
||||
if member_count > 0:
|
||||
keyboard_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PROMO_GROUP_MEMBERS_BUTTON", "👥 Участники"),
|
||||
callback_data=f"promo_group_members_{group.id}_page_1",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PROMO_GROUP_EDIT_BUTTON", "✏️ Изменить"),
|
||||
callback_data=f"promo_group_edit_{group.id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if not group.is_default:
|
||||
keyboard_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PROMO_GROUP_DELETE_BUTTON", "🗑️ Удалить"),
|
||||
callback_data=f"promo_group_delete_{group.id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard_rows.append(
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_groups")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text.strip(),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def _validate_percent(value: str) -> int:
|
||||
percent = int(value)
|
||||
if percent < 0 or percent > 100:
|
||||
raise ValueError
|
||||
return percent
|
||||
|
||||
|
||||
async def _prompt_for_discount(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
prompt_key: str,
|
||||
default_text: str,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", "ru"))
|
||||
await message.answer(texts.t(prompt_key, default_text))
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_create_promo_group(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
await state.set_state(AdminStates.creating_promo_group_name)
|
||||
await state.update_data(language=db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT", "Введите название новой промогруппы:"),
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_groups")]
|
||||
]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def process_create_group_name(message: types.Message, state: FSMContext):
|
||||
name = message.text.strip()
|
||||
if not name:
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_NAME", "Название не может быть пустым."))
|
||||
return
|
||||
|
||||
await state.update_data(new_group_name=name)
|
||||
await state.set_state(AdminStates.creating_promo_group_traffic_discount)
|
||||
await _prompt_for_discount(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT",
|
||||
"Введите скидку на трафик (0-100):",
|
||||
)
|
||||
|
||||
|
||||
async def process_create_group_traffic(message: types.Message, state: FSMContext):
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
try:
|
||||
value = _validate_percent(message.text)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
|
||||
return
|
||||
|
||||
await state.update_data(new_group_traffic=value)
|
||||
await state.set_state(AdminStates.creating_promo_group_server_discount)
|
||||
await _prompt_for_discount(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT",
|
||||
"Введите скидку на серверы (0-100):",
|
||||
)
|
||||
|
||||
|
||||
async def process_create_group_servers(message: types.Message, state: FSMContext):
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
try:
|
||||
value = _validate_percent(message.text)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
|
||||
return
|
||||
|
||||
await state.update_data(new_group_servers=value)
|
||||
await state.set_state(AdminStates.creating_promo_group_device_discount)
|
||||
await _prompt_for_discount(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT",
|
||||
"Введите скидку на устройства (0-100):",
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_create_group_devices(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", db_user.language))
|
||||
|
||||
try:
|
||||
devices_discount = _validate_percent(message.text)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
|
||||
return
|
||||
|
||||
try:
|
||||
group = await create_promo_group(
|
||||
db,
|
||||
data["new_group_name"],
|
||||
traffic_discount_percent=data["new_group_traffic"],
|
||||
server_discount_percent=data["new_group_servers"],
|
||||
device_discount_percent=devices_discount,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось создать промогруппу: {e}")
|
||||
await message.answer(texts.ERROR)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
texts.t("ADMIN_PROMO_GROUP_CREATED", "Промогруппа «{name}» создана.").format(name=group.name)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_promo_group(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
group = await _get_group_or_alert(callback, db)
|
||||
if not group:
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
await state.set_state(AdminStates.editing_promo_group_name)
|
||||
await state.update_data(edit_group_id=group.id, language=db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT",
|
||||
"Введите новое название промогруппы (текущее: {name}):",
|
||||
).format(name=group.name),
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_group_manage_{group.id}")]
|
||||
]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def process_edit_group_name(message: types.Message, state: FSMContext):
|
||||
name = message.text.strip()
|
||||
if not name:
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_NAME", "Название не может быть пустым."))
|
||||
return
|
||||
|
||||
await state.update_data(edit_group_name=name)
|
||||
await state.set_state(AdminStates.editing_promo_group_traffic_discount)
|
||||
await _prompt_for_discount(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT",
|
||||
"Введите новую скидку на трафик (0-100):",
|
||||
)
|
||||
|
||||
|
||||
async def process_edit_group_traffic(message: types.Message, state: FSMContext):
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
try:
|
||||
value = _validate_percent(message.text)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
|
||||
return
|
||||
|
||||
await state.update_data(edit_group_traffic=value)
|
||||
await state.set_state(AdminStates.editing_promo_group_server_discount)
|
||||
await _prompt_for_discount(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT",
|
||||
"Введите новую скидку на серверы (0-100):",
|
||||
)
|
||||
|
||||
|
||||
async def process_edit_group_servers(message: types.Message, state: FSMContext):
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
try:
|
||||
value = _validate_percent(message.text)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
|
||||
return
|
||||
|
||||
await state.update_data(edit_group_servers=value)
|
||||
await state.set_state(AdminStates.editing_promo_group_device_discount)
|
||||
await _prompt_for_discount(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT",
|
||||
"Введите новую скидку на устройства (0-100):",
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_group_devices(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", db_user.language))
|
||||
|
||||
try:
|
||||
devices_discount = _validate_percent(message.text)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
|
||||
return
|
||||
|
||||
group = await get_promo_group_by_id(db, data["edit_group_id"])
|
||||
if not group:
|
||||
await message.answer("❌ Промогруппа не найдена")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await update_promo_group(
|
||||
db,
|
||||
group,
|
||||
name=data["edit_group_name"],
|
||||
traffic_discount_percent=data["edit_group_traffic"],
|
||||
server_discount_percent=data["edit_group_servers"],
|
||||
device_discount_percent=devices_discount,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promo_group_members(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
parts = callback.data.split("_")
|
||||
group_id = int(parts[3])
|
||||
page = int(parts[-1])
|
||||
limit = 10
|
||||
offset = (page - 1) * limit
|
||||
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
if not group:
|
||||
await callback.answer("❌ Промогруппа не найдена", show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
members = await get_promo_group_members(db, group_id, offset=offset, limit=limit)
|
||||
total_members = await count_promo_group_members(db, group_id)
|
||||
total_pages = max(1, (total_members + limit - 1) // limit)
|
||||
|
||||
title = texts.t(
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_TITLE",
|
||||
"👥 Участники группы {name}",
|
||||
).format(name=group.name)
|
||||
|
||||
if not members:
|
||||
body = texts.t("ADMIN_PROMO_GROUP_MEMBERS_EMPTY", "В этой группе пока нет участников.")
|
||||
else:
|
||||
lines = []
|
||||
for index, user in enumerate(members, start=offset + 1):
|
||||
username = f"@{user.username}" if user.username else "—"
|
||||
lines.append(
|
||||
f"{index}. {user.full_name} (ID {user.id}, {username}, TG {user.telegram_id})"
|
||||
)
|
||||
body = "\n".join(lines)
|
||||
|
||||
keyboard = []
|
||||
if total_pages > 1:
|
||||
pagination = get_admin_pagination_keyboard(
|
||||
page,
|
||||
total_pages,
|
||||
f"promo_group_members_{group_id}",
|
||||
f"promo_group_manage_{group_id}",
|
||||
db_user.language,
|
||||
)
|
||||
keyboard.extend(pagination.inline_keyboard)
|
||||
|
||||
keyboard.append(
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_group_manage_{group_id}")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"{title}\n\n{body}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def request_delete_promo_group(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
group = await _get_group_or_alert(callback, db)
|
||||
if not group:
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if group.is_default:
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_PROMO_GROUP_DELETE_FORBIDDEN", "Базовую промогруппу нельзя удалить."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
confirm_text = texts.t(
|
||||
"ADMIN_PROMO_GROUP_DELETE_CONFIRM",
|
||||
"Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.",
|
||||
).format(name=group.name)
|
||||
|
||||
await callback.message.edit_text(
|
||||
confirm_text,
|
||||
reply_markup=get_confirmation_keyboard(
|
||||
confirm_action=f"promo_group_delete_confirm_{group.id}",
|
||||
cancel_action=f"promo_group_manage_{group.id}",
|
||||
language=db_user.language,
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_promo_group_confirmed(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
group = await _get_group_or_alert(callback, db)
|
||||
if not group:
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
success = await delete_promo_group(db, group)
|
||||
if not success:
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_PROMO_GROUP_DELETE_FORBIDDEN", "Базовую промогруппу нельзя удалить."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_PROMO_GROUP_DELETED", "Промогруппа «{name}» удалена.").format(name=group.name),
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_groups")]
|
||||
]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_promo_groups_menu, F.data == "admin_promo_groups")
|
||||
dp.callback_query.register(show_promo_group_details, F.data.startswith("promo_group_manage_"))
|
||||
dp.callback_query.register(start_create_promo_group, F.data == "admin_promo_group_create")
|
||||
dp.callback_query.register(start_edit_promo_group, F.data.startswith("promo_group_edit_"))
|
||||
dp.callback_query.register(request_delete_promo_group, F.data.startswith("promo_group_delete_"))
|
||||
dp.callback_query.register(
|
||||
delete_promo_group_confirmed,
|
||||
F.data.startswith("promo_group_delete_confirm_"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_promo_group_members,
|
||||
F.data.regexp(r"^promo_group_members_\d+_page_\d+$"),
|
||||
)
|
||||
|
||||
dp.message.register(process_create_group_name, AdminStates.creating_promo_group_name)
|
||||
dp.message.register(
|
||||
process_create_group_traffic,
|
||||
AdminStates.creating_promo_group_traffic_discount,
|
||||
)
|
||||
dp.message.register(
|
||||
process_create_group_servers,
|
||||
AdminStates.creating_promo_group_server_discount,
|
||||
)
|
||||
dp.message.register(
|
||||
process_create_group_devices,
|
||||
AdminStates.creating_promo_group_device_discount,
|
||||
)
|
||||
|
||||
dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name)
|
||||
dp.message.register(
|
||||
process_edit_group_traffic,
|
||||
AdminStates.editing_promo_group_traffic_discount,
|
||||
)
|
||||
dp.message.register(
|
||||
process_edit_group_servers,
|
||||
AdminStates.editing_promo_group_server_discount,
|
||||
)
|
||||
dp.message.register(
|
||||
process_edit_group_devices,
|
||||
AdminStates.editing_promo_group_device_discount,
|
||||
)
|
||||
+378
-98
@@ -66,6 +66,25 @@ logger = logging.getLogger(__name__)
|
||||
TRAFFIC_PRICES = get_traffic_prices()
|
||||
|
||||
|
||||
def _apply_discount_to_monthly_component(
|
||||
amount_per_month: int,
|
||||
percent: int,
|
||||
months: int,
|
||||
) -> Dict[str, int]:
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(amount_per_month, percent)
|
||||
|
||||
return {
|
||||
"original_per_month": amount_per_month,
|
||||
"discounted_per_month": discounted_per_month,
|
||||
"discount_percent": max(0, min(100, percent)),
|
||||
"discount_per_month": discount_per_month,
|
||||
"total": discounted_per_month * months,
|
||||
"discount_total": discount_per_month * months,
|
||||
}
|
||||
|
||||
|
||||
async def _prepare_subscription_summary(
|
||||
db_user: User,
|
||||
data: Dict[str, Any],
|
||||
@@ -75,6 +94,7 @@ async def _prepare_subscription_summary(
|
||||
calculate_months_from_days,
|
||||
format_period_description,
|
||||
validate_pricing_calculation,
|
||||
apply_percentage_discount,
|
||||
)
|
||||
|
||||
summary_data = dict(data)
|
||||
@@ -94,11 +114,18 @@ async def _prepare_subscription_summary(
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
final_traffic_gb = traffic_gb
|
||||
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
traffic_discount_percent = db_user.get_promo_discount("traffic")
|
||||
traffic_component = _apply_discount_to_monthly_component(
|
||||
traffic_price_per_month,
|
||||
traffic_discount_percent,
|
||||
months_in_period,
|
||||
)
|
||||
total_traffic_price = traffic_component["total"]
|
||||
|
||||
countries_price_per_month = 0
|
||||
selected_countries_names: List[str] = []
|
||||
selected_server_prices: List[int] = []
|
||||
server_monthly_prices: List[int] = []
|
||||
|
||||
selected_country_ids = set(summary_data.get('countries', []))
|
||||
for country in countries:
|
||||
@@ -106,25 +133,78 @@ async def _prepare_subscription_summary(
|
||||
server_price_per_month = country['price_kopeks']
|
||||
countries_price_per_month += server_price_per_month
|
||||
selected_countries_names.append(country['name'])
|
||||
selected_server_prices.append(server_price_per_month * months_in_period)
|
||||
server_monthly_prices.append(server_price_per_month)
|
||||
|
||||
total_countries_price = countries_price_per_month * months_in_period
|
||||
servers_discount_percent = db_user.get_promo_discount("servers")
|
||||
total_countries_price = 0
|
||||
total_servers_discount = 0
|
||||
discounted_servers_price_per_month = 0
|
||||
|
||||
for server_price_per_month in server_monthly_prices:
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
server_price_per_month,
|
||||
servers_discount_percent,
|
||||
)
|
||||
total_price_for_server = discounted_per_month * months_in_period
|
||||
total_discount_for_server = discount_per_month * months_in_period
|
||||
|
||||
discounted_servers_price_per_month += discounted_per_month
|
||||
total_countries_price += total_price_for_server
|
||||
total_servers_discount += total_discount_for_server
|
||||
selected_server_prices.append(total_price_for_server)
|
||||
|
||||
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
devices_discount_percent = db_user.get_promo_discount("devices")
|
||||
devices_component = _apply_discount_to_monthly_component(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
months_in_period,
|
||||
)
|
||||
total_devices_price = devices_component["total"]
|
||||
|
||||
total_price = base_price + total_traffic_price + total_countries_price + total_devices_price
|
||||
|
||||
monthly_additions = countries_price_per_month + devices_price_per_month + traffic_price_per_month
|
||||
is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, total_price)
|
||||
discounted_monthly_additions = (
|
||||
traffic_component["discounted_per_month"]
|
||||
+ discounted_servers_price_per_month
|
||||
+ devices_component["discounted_per_month"]
|
||||
)
|
||||
|
||||
is_valid = validate_pricing_calculation(
|
||||
base_price,
|
||||
discounted_monthly_additions,
|
||||
months_in_period,
|
||||
total_price,
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
raise ValueError("Subscription price calculation validation failed")
|
||||
|
||||
summary_data['total_price'] = total_price
|
||||
summary_data['server_prices_for_period'] = selected_server_prices
|
||||
summary_data['months_in_period'] = months_in_period
|
||||
summary_data['base_price'] = base_price
|
||||
summary_data['final_traffic_gb'] = final_traffic_gb
|
||||
summary_data['traffic_price_per_month'] = traffic_price_per_month
|
||||
summary_data['traffic_discount_percent'] = traffic_component["discount_percent"]
|
||||
summary_data['traffic_discount_total'] = traffic_component["discount_total"]
|
||||
summary_data['traffic_discounted_price_per_month'] = traffic_component["discounted_per_month"]
|
||||
summary_data['total_traffic_price'] = total_traffic_price
|
||||
summary_data['servers_price_per_month'] = countries_price_per_month
|
||||
summary_data['countries_price_per_month'] = countries_price_per_month
|
||||
summary_data['servers_discount_percent'] = servers_discount_percent
|
||||
summary_data['servers_discount_total'] = total_servers_discount
|
||||
summary_data['servers_discounted_price_per_month'] = discounted_servers_price_per_month
|
||||
summary_data['total_servers_price'] = total_countries_price
|
||||
summary_data['total_countries_price'] = total_countries_price
|
||||
summary_data['devices_price_per_month'] = devices_price_per_month
|
||||
summary_data['devices_discount_percent'] = devices_component["discount_percent"]
|
||||
summary_data['devices_discount_total'] = devices_component["discount_total"]
|
||||
summary_data['devices_discounted_price_per_month'] = devices_component["discounted_per_month"]
|
||||
summary_data['total_devices_price'] = total_devices_price
|
||||
summary_data['discounted_monthly_additions'] = discounted_monthly_additions
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
if final_traffic_gb == 0:
|
||||
@@ -140,17 +220,38 @@ async def _prepare_subscription_summary(
|
||||
details_lines = [f"- Базовый период: {texts.format_price(base_price)}"]
|
||||
|
||||
if total_traffic_price > 0:
|
||||
details_lines.append(
|
||||
f"- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_traffic_price)}"
|
||||
traffic_line = (
|
||||
f"- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period}"
|
||||
f" = {texts.format_price(total_traffic_price)}"
|
||||
)
|
||||
if traffic_component["discount_total"] > 0:
|
||||
traffic_line += (
|
||||
f" (скидка {traffic_component['discount_percent']}%:"
|
||||
f" -{texts.format_price(traffic_component['discount_total'])})"
|
||||
)
|
||||
details_lines.append(traffic_line)
|
||||
if total_countries_price > 0:
|
||||
details_lines.append(
|
||||
f"- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_countries_price)}"
|
||||
servers_line = (
|
||||
f"- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period}"
|
||||
f" = {texts.format_price(total_countries_price)}"
|
||||
)
|
||||
if total_servers_discount > 0:
|
||||
servers_line += (
|
||||
f" (скидка {servers_discount_percent}%:"
|
||||
f" -{texts.format_price(total_servers_discount)})"
|
||||
)
|
||||
details_lines.append(servers_line)
|
||||
if total_devices_price > 0:
|
||||
details_lines.append(
|
||||
f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_devices_price)}"
|
||||
devices_line = (
|
||||
f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period}"
|
||||
f" = {texts.format_price(total_devices_price)}"
|
||||
)
|
||||
if devices_component["discount_total"] > 0:
|
||||
devices_line += (
|
||||
f" (скидка {devices_component['discount_percent']}%:"
|
||||
f" -{texts.format_price(devices_component['discount_total'])})"
|
||||
)
|
||||
details_lines.append(devices_line)
|
||||
|
||||
details_text = "\n".join(details_lines)
|
||||
|
||||
@@ -1601,15 +1702,21 @@ async def handle_extend_subscription(
|
||||
servers_price_per_month, _ = await subscription_service.get_countries_price_by_uuids(
|
||||
subscription.connected_squads, db
|
||||
)
|
||||
total_servers_price = servers_price_per_month * months_in_period
|
||||
|
||||
servers_discount_percent = db_user.get_promo_discount("servers")
|
||||
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
|
||||
total_servers_price = (servers_price_per_month - servers_discount_per_month) * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
devices_discount_percent = db_user.get_promo_discount("devices")
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
total_devices_price = (devices_price_per_month - devices_discount_per_month) * months_in_period
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
traffic_discount_percent = db_user.get_promo_discount("traffic")
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
total_traffic_price = (traffic_price_per_month - traffic_discount_per_month) * months_in_period
|
||||
|
||||
price = base_price + total_servers_price + total_devices_price + total_traffic_price
|
||||
renewal_prices[days] = price
|
||||
|
||||
@@ -1783,62 +1890,112 @@ async def confirm_extend_subscription(
|
||||
):
|
||||
from app.utils.pricing_utils import calculate_months_from_days, validate_pricing_calculation
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
|
||||
days = int(callback.data.split('_')[2])
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
|
||||
if not subscription:
|
||||
await callback.answer("⚠ У вас нет активной подписки", show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
months_in_period = calculate_months_from_days(days)
|
||||
|
||||
old_end_date = subscription.end_date
|
||||
|
||||
server_uuid_prices: Dict[str, int] = {}
|
||||
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
|
||||
base_price = PERIOD_PRICES.get(days, 0)
|
||||
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
servers_price_per_month, _ = await subscription_service.get_countries_price_by_uuids(
|
||||
servers_price_per_month, per_server_monthly_prices = await subscription_service.get_countries_price_by_uuids(
|
||||
subscription.connected_squads, db
|
||||
)
|
||||
total_servers_price = servers_price_per_month * months_in_period
|
||||
|
||||
servers_discount_percent = db_user.get_promo_discount("servers")
|
||||
total_servers_price = 0
|
||||
total_servers_discount = 0
|
||||
|
||||
for squad_uuid, server_monthly_price in zip(subscription.connected_squads, per_server_monthly_prices):
|
||||
discount_per_month = server_monthly_price * servers_discount_percent // 100
|
||||
discounted_per_month = server_monthly_price - discount_per_month
|
||||
total_servers_price += discounted_per_month * months_in_period
|
||||
total_servers_discount += discount_per_month * months_in_period
|
||||
server_uuid_prices[squad_uuid] = discounted_per_month * months_in_period
|
||||
|
||||
discounted_servers_price_per_month = servers_price_per_month - (
|
||||
servers_price_per_month * servers_discount_percent // 100
|
||||
)
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
devices_discount_percent = db_user.get_promo_discount("devices")
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_price_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_price = discounted_devices_price_per_month * months_in_period
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
traffic_discount_percent = db_user.get_promo_discount("traffic")
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_price_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_price = discounted_traffic_price_per_month * months_in_period
|
||||
|
||||
price = base_price + total_servers_price + total_devices_price + total_traffic_price
|
||||
|
||||
monthly_additions = servers_price_per_month + devices_price_per_month + traffic_price_per_month
|
||||
|
||||
monthly_additions = (
|
||||
discounted_servers_price_per_month
|
||||
+ discounted_devices_price_per_month
|
||||
+ discounted_traffic_price_per_month
|
||||
)
|
||||
is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, price)
|
||||
|
||||
|
||||
if not is_valid:
|
||||
logger.error(f"Ошибка в расчете цены продления для пользователя {db_user.telegram_id}")
|
||||
await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
logger.info(f"💰 Расчет продления подписки {subscription.id} на {days} дней ({months_in_period} мес):")
|
||||
logger.info(f" 📅 Период {days} дней: {base_price/100}₽")
|
||||
if total_servers_price > 0:
|
||||
logger.info(f" 🌐 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽")
|
||||
logger.info(
|
||||
f" 🌐 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period}"
|
||||
f" = {total_servers_price/100}₽"
|
||||
+ (
|
||||
f" (скидка {servers_discount_percent}%:"
|
||||
f" -{total_servers_discount/100}₽)"
|
||||
if total_servers_discount > 0
|
||||
else ""
|
||||
)
|
||||
)
|
||||
if total_devices_price > 0:
|
||||
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽")
|
||||
logger.info(
|
||||
f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period}"
|
||||
f" = {total_devices_price/100}₽"
|
||||
+ (
|
||||
f" (скидка {devices_discount_percent}%:"
|
||||
f" -{devices_discount_per_month * months_in_period/100}₽)"
|
||||
if devices_discount_percent > 0 and devices_discount_per_month > 0
|
||||
else ""
|
||||
)
|
||||
)
|
||||
if total_traffic_price > 0:
|
||||
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽")
|
||||
logger.info(
|
||||
f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period}"
|
||||
f" = {total_traffic_price/100}₽"
|
||||
+ (
|
||||
f" (скидка {traffic_discount_percent}%:"
|
||||
f" -{traffic_discount_per_month * months_in_period/100}₽)"
|
||||
if traffic_discount_percent > 0 and traffic_discount_per_month > 0
|
||||
else ""
|
||||
)
|
||||
)
|
||||
logger.info(f" 💎 ИТОГО: {price/100}₽")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ ОШИБКА РАСЧЕТА ЦЕНЫ: {e}")
|
||||
await callback.answer("⚠ Ошибка расчета стоимости", show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
if db_user.balance_kopeks < price:
|
||||
missing_kopeks = price - db_user.balance_kopeks
|
||||
await callback.message.edit_text(
|
||||
@@ -1847,48 +2004,59 @@ async def confirm_extend_subscription(
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
success = await subtract_user_balance(
|
||||
db, db_user, price,
|
||||
f"Продление подписки на {days} дней"
|
||||
)
|
||||
|
||||
|
||||
if not success:
|
||||
await callback.answer("⚠ Ошибка списания средств", show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
|
||||
if subscription.end_date > current_time:
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
else:
|
||||
subscription.end_date = current_time + timedelta(days=days)
|
||||
|
||||
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = current_time
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
await db.refresh(db_user)
|
||||
|
||||
|
||||
from app.database.crud.server_squad import get_server_ids_by_uuids
|
||||
from app.database.crud.subscription import add_subscription_servers
|
||||
|
||||
|
||||
server_ids = await get_server_ids_by_uuids(db, subscription.connected_squads)
|
||||
if server_ids:
|
||||
server_prices_for_period = [total_servers_price // len(server_ids)] * len(server_ids)
|
||||
from sqlalchemy import select
|
||||
from app.database.models import ServerSquad
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad.id, ServerSquad.squad_uuid).where(ServerSquad.id.in_(server_ids))
|
||||
)
|
||||
id_to_uuid = {row.id: row.squad_uuid for row in result}
|
||||
default_price = total_servers_price // len(server_ids) if server_ids else 0
|
||||
server_prices_for_period = [
|
||||
server_uuid_prices.get(id_to_uuid.get(server_id, ""), default_price)
|
||||
for server_id in server_ids
|
||||
]
|
||||
await add_subscription_servers(db, subscription, server_ids, server_prices_for_period)
|
||||
|
||||
|
||||
try:
|
||||
remnawave_result = await subscription_service.update_remnawave_user(db, subscription)
|
||||
if remnawave_result:
|
||||
logger.info(f"✅ RemnaWave обновлен успешно")
|
||||
logger.info("✅ RemnaWave обновлен успешно")
|
||||
else:
|
||||
logger.error(f"⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE")
|
||||
logger.error("⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE")
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE: {e}")
|
||||
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
@@ -1896,7 +2064,7 @@ async def confirm_extend_subscription(
|
||||
amount_kopeks=price,
|
||||
description=f"Продление подписки на {days} дней ({months_in_period} мес)"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_extension_notification(
|
||||
@@ -1904,27 +2072,27 @@ async def confirm_extend_subscription(
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о продлении: {e}")
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Подписка успешно продлена!\n\n"
|
||||
f"⏰ Добавлено: {days} дней\n"
|
||||
f"Действует до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"✅ Подписка успешно продлена!\n\n",
|
||||
f"⏰ Добавлено: {days} дней\n",
|
||||
f"Действует до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')}\n\n",
|
||||
f"💰 Списано: {texts.format_price(price)}",
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {days} дней за {price/100}₽")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ КРИТИЧЕСКАЯ ОШИБКА ПРОДЛЕНИЯ: {e}")
|
||||
import traceback
|
||||
logger.error(f"TRACEBACK: {traceback.format_exc()}")
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
"⚠ Произошла ошибка при продлении подписки. Обратитесь в поддержку.",
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -2365,38 +2533,123 @@ async def confirm_purchase(
|
||||
|
||||
countries = await _get_available_countries()
|
||||
|
||||
months_in_period = calculate_months_from_days(data['period_days'])
|
||||
|
||||
base_price = PERIOD_PRICES[data['period_days']]
|
||||
|
||||
countries_price_per_month = 0
|
||||
server_prices = []
|
||||
for country in countries:
|
||||
if country['uuid'] in data['countries']:
|
||||
server_price_per_month = country['price_kopeks']
|
||||
server_price_total = server_price_per_month * months_in_period
|
||||
countries_price_per_month += server_price_per_month
|
||||
server_prices.append(server_price_total)
|
||||
|
||||
total_countries_price = countries_price_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, data['devices'] - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_price_per_month = settings.get_traffic_price(settings.get_fixed_traffic_limit())
|
||||
final_traffic_gb = settings.get_fixed_traffic_limit()
|
||||
months_in_period = data.get(
|
||||
'months_in_period', calculate_months_from_days(data['period_days'])
|
||||
)
|
||||
|
||||
base_price = data.get('base_price', PERIOD_PRICES[data['period_days']])
|
||||
server_prices = data.get('server_prices_for_period', [])
|
||||
|
||||
if not server_prices:
|
||||
countries_price_per_month = 0
|
||||
per_month_prices: List[int] = []
|
||||
for country in countries:
|
||||
if country['uuid'] in data['countries']:
|
||||
server_price_per_month = country['price_kopeks']
|
||||
countries_price_per_month += server_price_per_month
|
||||
per_month_prices.append(server_price_per_month)
|
||||
|
||||
servers_discount_percent = db_user.get_promo_discount("servers")
|
||||
total_servers_price = 0
|
||||
total_servers_discount = 0
|
||||
discounted_servers_price_per_month = 0
|
||||
server_prices = []
|
||||
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
|
||||
for server_price_per_month in per_month_prices:
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
server_price_per_month,
|
||||
servers_discount_percent,
|
||||
)
|
||||
total_price_for_server = discounted_per_month * months_in_period
|
||||
total_discount_for_server = discount_per_month * months_in_period
|
||||
|
||||
discounted_servers_price_per_month += discounted_per_month
|
||||
total_servers_price += total_price_for_server
|
||||
total_servers_discount += total_discount_for_server
|
||||
server_prices.append(total_price_for_server)
|
||||
|
||||
total_countries_price = total_servers_price
|
||||
else:
|
||||
traffic_price_per_month = settings.get_traffic_price(data['traffic_gb'])
|
||||
final_traffic_gb = data['traffic_gb']
|
||||
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
final_price = base_price + total_traffic_price + total_countries_price + total_devices_price
|
||||
|
||||
monthly_additions = countries_price_per_month + devices_price_per_month + traffic_price_per_month
|
||||
is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, final_price)
|
||||
total_countries_price = data.get('total_servers_price', sum(server_prices))
|
||||
countries_price_per_month = data.get('servers_price_per_month', 0)
|
||||
discounted_servers_price_per_month = data.get('servers_discounted_price_per_month', countries_price_per_month)
|
||||
total_servers_discount = data.get('servers_discount_total', 0)
|
||||
servers_discount_percent = data.get('servers_discount_percent', 0)
|
||||
|
||||
additional_devices = max(0, data['devices'] - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = data.get(
|
||||
'devices_price_per_month', additional_devices * settings.PRICE_PER_DEVICE
|
||||
)
|
||||
if 'devices_discount_percent' in data:
|
||||
devices_discount_percent = data.get('devices_discount_percent', 0)
|
||||
discounted_devices_price_per_month = data.get(
|
||||
'devices_discounted_price_per_month', devices_price_per_month
|
||||
)
|
||||
devices_discount_total = data.get('devices_discount_total', 0)
|
||||
total_devices_price = data.get(
|
||||
'total_devices_price', discounted_devices_price_per_month * months_in_period
|
||||
)
|
||||
else:
|
||||
devices_discount_percent = db_user.get_promo_discount("devices")
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
|
||||
discounted_devices_price_per_month, discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
devices_discount_total = discount_per_month * months_in_period
|
||||
total_devices_price = discounted_devices_price_per_month * months_in_period
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
final_traffic_gb = settings.get_fixed_traffic_limit()
|
||||
traffic_price_per_month = data.get(
|
||||
'traffic_price_per_month', settings.get_traffic_price(final_traffic_gb)
|
||||
)
|
||||
else:
|
||||
final_traffic_gb = data.get('final_traffic_gb', data.get('traffic_gb'))
|
||||
traffic_price_per_month = data.get(
|
||||
'traffic_price_per_month', settings.get_traffic_price(data['traffic_gb'])
|
||||
)
|
||||
|
||||
if 'traffic_discount_percent' in data:
|
||||
traffic_discount_percent = data.get('traffic_discount_percent', 0)
|
||||
discounted_traffic_price_per_month = data.get(
|
||||
'traffic_discounted_price_per_month', traffic_price_per_month
|
||||
)
|
||||
traffic_discount_total = data.get('traffic_discount_total', 0)
|
||||
total_traffic_price = data.get(
|
||||
'total_traffic_price', discounted_traffic_price_per_month * months_in_period
|
||||
)
|
||||
else:
|
||||
traffic_discount_percent = db_user.get_promo_discount("traffic")
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
|
||||
discounted_traffic_price_per_month, discount_per_month = apply_percentage_discount(
|
||||
traffic_price_per_month,
|
||||
traffic_discount_percent,
|
||||
)
|
||||
traffic_discount_total = discount_per_month * months_in_period
|
||||
total_traffic_price = discounted_traffic_price_per_month * months_in_period
|
||||
|
||||
total_servers_price = data.get('total_servers_price', total_countries_price)
|
||||
|
||||
final_price = data['total_price']
|
||||
|
||||
discounted_monthly_additions = data.get(
|
||||
'discounted_monthly_additions',
|
||||
discounted_traffic_price_per_month
|
||||
+ discounted_servers_price_per_month
|
||||
+ discounted_devices_price_per_month,
|
||||
)
|
||||
|
||||
is_valid = validate_pricing_calculation(
|
||||
base_price,
|
||||
discounted_monthly_additions,
|
||||
months_in_period,
|
||||
final_price,
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
logger.error(f"Ошибка в расчете цены подписки для пользователя {db_user.telegram_id}")
|
||||
@@ -2406,11 +2659,38 @@ async def confirm_purchase(
|
||||
logger.info(f"Расчет покупки подписки на {data['period_days']} дней ({months_in_period} мес):")
|
||||
logger.info(f" Период: {base_price/100}₽")
|
||||
if total_traffic_price > 0:
|
||||
logger.info(f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽")
|
||||
if total_countries_price > 0:
|
||||
logger.info(f" Серверы: {countries_price_per_month/100}₽/мес × {months_in_period} = {total_countries_price/100}₽")
|
||||
message = (
|
||||
f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period}"
|
||||
f" = {total_traffic_price/100}₽"
|
||||
)
|
||||
if traffic_discount_total > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%:"
|
||||
f" -{traffic_discount_total/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_servers_price > 0:
|
||||
message = (
|
||||
f" Серверы: {countries_price_per_month/100}₽/мес × {months_in_period}"
|
||||
f" = {total_servers_price/100}₽"
|
||||
)
|
||||
if total_servers_discount > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%:"
|
||||
f" -{total_servers_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_devices_price > 0:
|
||||
logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽")
|
||||
message = (
|
||||
f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period}"
|
||||
f" = {total_devices_price/100}₽"
|
||||
)
|
||||
if devices_discount_total > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%:"
|
||||
f" -{devices_discount_total/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" ИТОГО: {final_price/100}₽")
|
||||
|
||||
if db_user.balance_kopeks < final_price:
|
||||
|
||||
@@ -45,6 +45,9 @@ def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_CAMPAIGNS, callback_data="admin_campaigns")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_PROMO_GROUPS, callback_data="admin_promo_groups")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
|
||||
@@ -123,6 +123,36 @@
|
||||
"ADMIN_REMNAWAVE": "🖥️ Remnawave",
|
||||
"ADMIN_RULES": "📋 Rules",
|
||||
"ADMIN_STATISTICS": "📊 Statistics",
|
||||
"ADMIN_PROMO_GROUPS": "💳 Promo groups",
|
||||
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Promo groups</b>",
|
||||
"ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}",
|
||||
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 <b>Promo group:</b> {name}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members",
|
||||
"ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit",
|
||||
"ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Delete",
|
||||
"ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Enter a name for the new promo group:",
|
||||
"ADMIN_PROMO_GROUP_INVALID_NAME": "Name cannot be empty.",
|
||||
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.",
|
||||
"ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.",
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Delete promo group “{name}”? All users will be moved to the default group.",
|
||||
"ADMIN_PROMO_GROUP_DELETED": "Promo group “{name}” deleted.",
|
||||
"ADMIN_SUBSCRIPTIONS": "📱 Subscriptions",
|
||||
"ADMIN_USERS": "👥 Users",
|
||||
"AUTOPAY_DISABLED_TEXT": "Disabled — don't forget to renew manually!",
|
||||
|
||||
@@ -11,6 +11,36 @@
|
||||
"ADMIN_REMNAWAVE": "🖥️ Remnawave",
|
||||
"ADMIN_RULES": "📋 Правила",
|
||||
"ADMIN_STATISTICS": "📊 Статистика",
|
||||
"ADMIN_PROMO_GROUPS": "💳 Промогруппы",
|
||||
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Промогруппы</b>",
|
||||
"ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}",
|
||||
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 <b>Промогруппа:</b> {name}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники",
|
||||
"ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить",
|
||||
"ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Удалить",
|
||||
"ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Введите название новой промогруппы:",
|
||||
"ADMIN_PROMO_GROUP_INVALID_NAME": "Название не может быть пустым.",
|
||||
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.",
|
||||
"ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.",
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):",
|
||||
"ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.",
|
||||
"ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.",
|
||||
"ADMIN_SUBSCRIPTIONS": "📱 Подписки",
|
||||
"ADMIN_USERS": "👥 Пользователи",
|
||||
"AUTOPAY_BUTTON": "💳 Автоплатёж",
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Optional, List, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import Subscription, User, SubscriptionStatus
|
||||
from app.database.models import Subscription, User, SubscriptionStatus, PromoGroup
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveAPI, RemnaWaveUser, UserStatus,
|
||||
TrafficLimitStrategy, RemnaWaveAPIError
|
||||
@@ -19,6 +19,23 @@ from app.utils.pricing_utils import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_discount_percent(
|
||||
user: Optional[User],
|
||||
promo_group: Optional[PromoGroup],
|
||||
category: str,
|
||||
) -> int:
|
||||
if user is not None:
|
||||
try:
|
||||
return user.get_promo_discount(category)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
if promo_group is not None:
|
||||
return promo_group.get_discount_percent(category)
|
||||
|
||||
return 0
|
||||
|
||||
def get_traffic_reset_strategy():
|
||||
from app.config import settings
|
||||
strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper()
|
||||
@@ -266,9 +283,12 @@ class SubscriptionService:
|
||||
self,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: List[int],
|
||||
server_squad_ids: List[int],
|
||||
devices: int,
|
||||
db: AsyncSession
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> Tuple[int, List[int]]:
|
||||
|
||||
from app.config import PERIOD_PRICES
|
||||
@@ -279,68 +299,137 @@ class SubscriptionService:
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
traffic_price = settings.get_traffic_price(traffic_gb)
|
||||
|
||||
traffic_discount_percent = _resolve_discount_percent(user, promo_group, "traffic")
|
||||
traffic_discount = traffic_price * traffic_discount_percent // 100
|
||||
discounted_traffic_price = traffic_price - traffic_discount
|
||||
|
||||
server_prices = []
|
||||
total_servers_price = 0
|
||||
|
||||
servers_discount_percent = _resolve_discount_percent(user, promo_group, "servers")
|
||||
|
||||
for server_id in server_squad_ids:
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if server and server.is_available and not server.is_full:
|
||||
server_prices.append(server.price_kopeks)
|
||||
total_servers_price += server.price_kopeks
|
||||
logger.debug(f"Сервер {server.display_name}: {server.price_kopeks/100}₽")
|
||||
server_price = server.price_kopeks
|
||||
server_discount = server_price * servers_discount_percent // 100
|
||||
discounted_server_price = server_price - server_discount
|
||||
server_prices.append(discounted_server_price)
|
||||
total_servers_price += discounted_server_price
|
||||
log_message = f"Сервер {server.display_name}: {server_price/100}₽"
|
||||
if server_discount > 0:
|
||||
log_message += (
|
||||
f" (скидка {servers_discount_percent}%: -{server_discount/100}₽ → {discounted_server_price/100}₽)"
|
||||
)
|
||||
logger.debug(log_message)
|
||||
else:
|
||||
server_prices.append(0)
|
||||
logger.warning(f"Сервер ID {server_id} недоступен")
|
||||
|
||||
|
||||
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
total_price = base_price + traffic_price + total_servers_price + devices_price
|
||||
|
||||
devices_discount_percent = _resolve_discount_percent(user, promo_group, "devices")
|
||||
devices_discount = devices_price * devices_discount_percent // 100
|
||||
discounted_devices_price = devices_price - devices_discount
|
||||
|
||||
total_price = base_price + discounted_traffic_price + total_servers_price + discounted_devices_price
|
||||
|
||||
logger.info(f"Расчет стоимости новой подписки:")
|
||||
logger.info(f" Период {period_days} дней: {base_price/100}₽")
|
||||
if traffic_price > 0:
|
||||
logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price/100}₽")
|
||||
if discounted_traffic_price > 0:
|
||||
message = f" Трафик {traffic_gb} ГБ: {traffic_price/100}₽"
|
||||
if traffic_discount > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ → {discounted_traffic_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_servers_price > 0:
|
||||
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽")
|
||||
if devices_price > 0:
|
||||
logger.info(f" Устройства ({devices}): {devices_price/100}₽")
|
||||
message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽"
|
||||
if servers_discount_percent > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}% применяется ко всем серверам)"
|
||||
)
|
||||
logger.info(message)
|
||||
if discounted_devices_price > 0:
|
||||
message = f" Устройства ({devices}): {devices_price/100}₽"
|
||||
if devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ → {discounted_devices_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" ИТОГО: {total_price/100}₽")
|
||||
|
||||
|
||||
return total_price, server_prices
|
||||
|
||||
async def calculate_renewal_price(
|
||||
self,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
db: AsyncSession
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
servers_price, _ = await self.get_countries_price_by_uuids(
|
||||
subscription.connected_squads, db
|
||||
)
|
||||
|
||||
|
||||
servers_discount_percent = _resolve_discount_percent(user, promo_group, "servers")
|
||||
servers_discount = servers_price * servers_discount_percent // 100
|
||||
discounted_servers_price = servers_price - servers_discount
|
||||
|
||||
devices_price = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
devices_discount_percent = _resolve_discount_percent(user, promo_group, "devices")
|
||||
devices_discount = devices_price * devices_discount_percent // 100
|
||||
discounted_devices_price = devices_price - devices_discount
|
||||
|
||||
traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
|
||||
total_price = base_price + servers_price + devices_price + traffic_price
|
||||
|
||||
traffic_discount_percent = _resolve_discount_percent(user, promo_group, "traffic")
|
||||
traffic_discount = traffic_price * traffic_discount_percent // 100
|
||||
discounted_traffic_price = traffic_price - traffic_discount
|
||||
|
||||
total_price = (
|
||||
base_price
|
||||
+ discounted_servers_price
|
||||
+ discounted_devices_price
|
||||
+ discounted_traffic_price
|
||||
)
|
||||
|
||||
logger.info(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
|
||||
logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽")
|
||||
if servers_price > 0:
|
||||
logger.info(f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {servers_price/100}₽")
|
||||
message = f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {discounted_servers_price/100}₽"
|
||||
if servers_discount > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{servers_discount/100}₽ от {servers_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if devices_price > 0:
|
||||
logger.info(f" 📱 Устройства ({subscription.device_limit}): {devices_price/100}₽")
|
||||
message = f" 📱 Устройства ({subscription.device_limit}): {discounted_devices_price/100}₽"
|
||||
if devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ от {devices_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if traffic_price > 0:
|
||||
logger.info(f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {traffic_price/100}₽")
|
||||
message = f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {discounted_traffic_price/100}₽"
|
||||
if traffic_discount > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ от {traffic_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" 💎 ИТОГО: {total_price/100}₽")
|
||||
|
||||
|
||||
return total_price
|
||||
|
||||
except Exception as e:
|
||||
@@ -440,9 +529,12 @@ class SubscriptionService:
|
||||
self,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: List[int],
|
||||
server_squad_ids: List[int],
|
||||
devices: int,
|
||||
db: AsyncSession
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> Tuple[int, List[int]]:
|
||||
|
||||
from app.config import PERIOD_PRICES
|
||||
@@ -455,89 +547,153 @@ class SubscriptionService:
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
traffic_discount_percent = _resolve_discount_percent(user, promo_group, "traffic")
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_price = discounted_traffic_per_month * months_in_period
|
||||
|
||||
server_prices = []
|
||||
total_servers_price = 0
|
||||
|
||||
servers_discount_percent = _resolve_discount_percent(user, promo_group, "servers")
|
||||
|
||||
for server_id in server_squad_ids:
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if server and server.is_available and not server.is_full:
|
||||
server_price_per_month = server.price_kopeks
|
||||
server_price_total = server_price_per_month * months_in_period
|
||||
server_discount_per_month = server_price_per_month * servers_discount_percent // 100
|
||||
discounted_server_per_month = server_price_per_month - server_discount_per_month
|
||||
server_price_total = discounted_server_per_month * months_in_period
|
||||
server_prices.append(server_price_total)
|
||||
total_servers_price += server_price_total
|
||||
logger.debug(f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_in_period} мес = {server_price_total/100}₽")
|
||||
log_message = (
|
||||
f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_in_period} мес = {server_price_total/100}₽"
|
||||
)
|
||||
if server_discount_per_month > 0:
|
||||
log_message += (
|
||||
f" (скидка {servers_discount_percent}%: -{server_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.debug(log_message)
|
||||
else:
|
||||
server_prices.append(0)
|
||||
logger.warning(f"Сервер ID {server_id} недоступен")
|
||||
|
||||
|
||||
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
devices_discount_percent = _resolve_discount_percent(user, promo_group, "devices")
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_price = discounted_devices_per_month * months_in_period
|
||||
|
||||
total_price = base_price + total_traffic_price + total_servers_price + total_devices_price
|
||||
|
||||
|
||||
logger.info(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" Период {period_days} дней: {base_price/100}₽")
|
||||
if total_traffic_price > 0:
|
||||
logger.info(
|
||||
message = (
|
||||
f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽"
|
||||
)
|
||||
if traffic_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_servers_price > 0:
|
||||
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽")
|
||||
message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽"
|
||||
if servers_discount_percent > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}% применяется ко всем серверам)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_devices_price > 0:
|
||||
logger.info(
|
||||
message = (
|
||||
f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽"
|
||||
)
|
||||
if devices_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" ИТОГО: {total_price/100}₽")
|
||||
|
||||
|
||||
return total_price, server_prices
|
||||
|
||||
async def calculate_renewal_price_with_months(
|
||||
self,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
db: AsyncSession
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
servers_price_per_month, _ = await self.get_countries_price_by_uuids(
|
||||
subscription.connected_squads, db
|
||||
)
|
||||
total_servers_price = servers_price_per_month * months_in_period
|
||||
|
||||
servers_discount_percent = _resolve_discount_percent(user, promo_group, "servers")
|
||||
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_price = discounted_servers_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
devices_discount_percent = _resolve_discount_percent(user, promo_group, "devices")
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_price = discounted_devices_per_month * months_in_period
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
traffic_discount_percent = _resolve_discount_percent(user, promo_group, "traffic")
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_price = discounted_traffic_per_month * months_in_period
|
||||
|
||||
total_price = base_price + total_servers_price + total_devices_price + total_traffic_price
|
||||
|
||||
|
||||
logger.info(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽")
|
||||
if total_servers_price > 0:
|
||||
logger.info(
|
||||
message = (
|
||||
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}₽"
|
||||
)
|
||||
if servers_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{servers_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_devices_price > 0:
|
||||
logger.info(
|
||||
message = (
|
||||
f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽"
|
||||
)
|
||||
if devices_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_traffic_price > 0:
|
||||
logger.info(
|
||||
message = (
|
||||
f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽"
|
||||
)
|
||||
if traffic_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" 💎 ИТОГО: {total_price/100}₽")
|
||||
|
||||
|
||||
return total_price
|
||||
|
||||
except Exception as e:
|
||||
|
||||
+11
-1
@@ -59,10 +59,20 @@ class AdminStates(StatesGroup):
|
||||
editing_campaign_subscription_traffic = State()
|
||||
editing_campaign_subscription_devices = State()
|
||||
editing_campaign_subscription_servers = State()
|
||||
|
||||
|
||||
waiting_for_broadcast_message = State()
|
||||
waiting_for_broadcast_media = State()
|
||||
confirming_broadcast = State()
|
||||
|
||||
creating_promo_group_name = State()
|
||||
creating_promo_group_traffic_discount = State()
|
||||
creating_promo_group_server_discount = State()
|
||||
creating_promo_group_device_discount = State()
|
||||
|
||||
editing_promo_group_name = State()
|
||||
editing_promo_group_traffic_discount = State()
|
||||
editing_promo_group_server_discount = State()
|
||||
editing_promo_group_device_discount = State()
|
||||
|
||||
editing_squad_price = State()
|
||||
editing_traffic_price = State()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from typing import Tuple
|
||||
import logging
|
||||
|
||||
@@ -28,8 +29,8 @@ def calculate_period_multiplier(period_days: int) -> Tuple[int, float]:
|
||||
|
||||
|
||||
def calculate_prorated_price(
|
||||
monthly_price: int,
|
||||
end_date: datetime,
|
||||
monthly_price: int,
|
||||
end_date: datetime,
|
||||
min_charge_months: int = 1
|
||||
) -> Tuple[int, int]:
|
||||
months_remaining = get_remaining_months(end_date)
|
||||
@@ -42,6 +43,25 @@ def calculate_prorated_price(
|
||||
return total_price, months_to_charge
|
||||
|
||||
|
||||
def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]:
|
||||
if amount <= 0 or percent <= 0:
|
||||
return amount, 0
|
||||
|
||||
clamped_percent = max(0, min(100, percent))
|
||||
discount_value = amount * clamped_percent // 100
|
||||
discounted_amount = amount - discount_value
|
||||
|
||||
logger.debug(
|
||||
"Применена скидка %s%%: %s → %s (скидка %s)",
|
||||
clamped_percent,
|
||||
amount,
|
||||
discounted_amount,
|
||||
discount_value,
|
||||
)
|
||||
|
||||
return discounted_amount, discount_value
|
||||
|
||||
|
||||
def format_period_description(days: int, language: str = "ru") -> str:
|
||||
months = calculate_months_from_days(days)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user