10e231e52e
- Broadcast tri-state return: 'sent'/'blocked'/'failed' with blocked_count tracking - Background cleanup: mark blocked users + disable their subscriptions + Remnawave - blocked_count in BroadcastHistory model, schemas, API responses, admin UI - Filter User.status==ACTIVE in subscription queries: get_expiring_subscriptions, get_expired_subscriptions, get_subscriptions_for_autopay, get_daily_subscriptions_for_charge, get_disabled_daily_subscriptions_for_resume - Guard in notification_delivery_service.send_notification for BLOCKED/DELETED users - Fix subscription tariff switch: preserve remaining days with total_seconds() - Fix redundant local UTC imports across 16 files - Fix test mocks: add **kwargs, correct assertion, remove dead expression
503 lines
20 KiB
Python
503 lines
20 KiB
Python
from datetime import UTC, datetime
|
||
from typing import Any
|
||
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.config import PERIOD_PRICES, settings
|
||
from app.database.models import User
|
||
from app.utils.pricing_utils import (
|
||
apply_percentage_discount,
|
||
calculate_months_from_days,
|
||
format_period_description,
|
||
validate_pricing_calculation,
|
||
)
|
||
from app.utils.timezone import format_local_datetime
|
||
|
||
from .common import _apply_discount_to_monthly_component, _apply_promo_offer_discount, logger
|
||
from .countries import _get_available_countries, _get_countries_info, get_countries_price_by_uuids_fallback
|
||
from .devices import get_current_devices_count
|
||
from .promo import _build_promo_group_discount_text, _get_promo_offer_hint
|
||
|
||
|
||
async def _prepare_subscription_summary(
|
||
db_user: User,
|
||
data: dict[str, Any],
|
||
texts,
|
||
) -> tuple[str, dict[str, Any]]:
|
||
summary_data = dict(data)
|
||
|
||
if 'period_days' not in summary_data:
|
||
raise KeyError('period_days missing from subscription data — FSM state likely expired')
|
||
|
||
countries = await _get_available_countries(db_user.promo_group_id)
|
||
|
||
months_in_period = calculate_months_from_days(summary_data['period_days'])
|
||
period_display = format_period_description(summary_data['period_days'], db_user.language)
|
||
|
||
base_price_original = PERIOD_PRICES.get(summary_data['period_days'], 0)
|
||
period_discount_percent = db_user.get_promo_discount(
|
||
'period',
|
||
summary_data['period_days'],
|
||
)
|
||
base_price, base_discount_total = apply_percentage_discount(
|
||
base_price_original,
|
||
period_discount_percent,
|
||
)
|
||
|
||
if settings.is_traffic_fixed():
|
||
traffic_limit = settings.get_fixed_traffic_limit()
|
||
traffic_price_per_month = settings.get_traffic_price(traffic_limit)
|
||
final_traffic_gb = traffic_limit
|
||
else:
|
||
traffic_gb = summary_data.get('traffic_gb', 0)
|
||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||
final_traffic_gb = traffic_gb
|
||
|
||
traffic_discount_percent = db_user.get_promo_discount(
|
||
'traffic',
|
||
summary_data['period_days'],
|
||
)
|
||
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:
|
||
if country['uuid'] in selected_country_ids:
|
||
server_price_per_month = country['price_kopeks']
|
||
countries_price_per_month += server_price_per_month
|
||
selected_countries_names.append(country['name'])
|
||
server_monthly_prices.append(server_price_per_month)
|
||
|
||
servers_discount_percent = db_user.get_promo_discount(
|
||
'servers',
|
||
summary_data['period_days'],
|
||
)
|
||
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_selection_enabled = settings.is_devices_selection_enabled()
|
||
forced_disabled_limit: int | None = None
|
||
if devices_selection_enabled:
|
||
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||
else:
|
||
forced_disabled_limit = settings.get_disabled_mode_device_limit()
|
||
if forced_disabled_limit is None:
|
||
devices_selected = settings.DEFAULT_DEVICE_LIMIT
|
||
else:
|
||
devices_selected = forced_disabled_limit
|
||
|
||
summary_data['devices'] = devices_selected
|
||
additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT)
|
||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||
devices_discount_percent = db_user.get_promo_discount(
|
||
'devices',
|
||
summary_data['period_days'],
|
||
)
|
||
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
|
||
|
||
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')
|
||
|
||
original_total_price = total_price
|
||
promo_offer_component = _apply_promo_offer_discount(db_user, total_price)
|
||
if promo_offer_component['discount'] > 0:
|
||
total_price = promo_offer_component['discounted']
|
||
|
||
summary_data['total_price'] = total_price
|
||
if promo_offer_component['discount'] > 0:
|
||
summary_data['promo_offer_discount_percent'] = promo_offer_component['percent']
|
||
summary_data['promo_offer_discount_value'] = promo_offer_component['discount']
|
||
summary_data['total_price_before_promo_offer'] = original_total_price
|
||
else:
|
||
summary_data.pop('promo_offer_discount_percent', None)
|
||
summary_data.pop('promo_offer_discount_value', None)
|
||
summary_data.pop('total_price_before_promo_offer', None)
|
||
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['base_price_original'] = base_price_original
|
||
summary_data['base_discount_percent'] = period_discount_percent
|
||
summary_data['base_discount_total'] = base_discount_total
|
||
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:
|
||
traffic_display = 'Безлимитный'
|
||
else:
|
||
traffic_display = f'{final_traffic_gb} ГБ'
|
||
elif summary_data.get('traffic_gb', 0) == 0:
|
||
traffic_display = 'Безлимитный'
|
||
else:
|
||
traffic_display = f'{summary_data.get("traffic_gb", 0)} ГБ'
|
||
|
||
details_lines = []
|
||
|
||
# Добавляем строку базового периода только если цена не равна 0
|
||
if base_discount_total > 0 and base_price > 0:
|
||
base_line = (
|
||
f'- Базовый период: <s>{texts.format_price(base_price_original)}</s> '
|
||
f'{texts.format_price(base_price)}'
|
||
f' (скидка {period_discount_percent}%:'
|
||
f' -{texts.format_price(base_discount_total)})'
|
||
)
|
||
details_lines.append(base_line)
|
||
elif base_price_original > 0:
|
||
base_line = f'- Базовый период: {texts.format_price(base_price_original)}'
|
||
details_lines.append(base_line)
|
||
|
||
if total_traffic_price > 0:
|
||
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:
|
||
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}%: -{texts.format_price(total_servers_discount)})'
|
||
details_lines.append(servers_line)
|
||
if devices_selection_enabled and total_devices_price > 0:
|
||
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)
|
||
|
||
if promo_offer_component['discount'] > 0:
|
||
details_lines.append(
|
||
texts.t(
|
||
'SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT',
|
||
'- Промо-предложение: -{amount} ({percent}% дополнительно)',
|
||
).format(
|
||
amount=texts.format_price(promo_offer_component['discount']),
|
||
percent=promo_offer_component['percent'],
|
||
)
|
||
)
|
||
|
||
details_text = '\n'.join(details_lines)
|
||
|
||
summary_lines = [
|
||
'📋 <b>Сводка заказа</b>',
|
||
'',
|
||
f'📅 <b>Период:</b> {period_display}',
|
||
f'📊 <b>Трафик:</b> {traffic_display}',
|
||
f'🌍 <b>Страны:</b> {", ".join(selected_countries_names)}',
|
||
]
|
||
|
||
if devices_selection_enabled:
|
||
summary_lines.append(f'📱 <b>Устройства:</b> {devices_selected}')
|
||
|
||
summary_lines.extend(
|
||
[
|
||
'',
|
||
'💰 <b>Детализация стоимости:</b>',
|
||
details_text,
|
||
'',
|
||
f'💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}',
|
||
'',
|
||
'Подтверждаете покупку?',
|
||
]
|
||
)
|
||
|
||
summary_text = '\n'.join(summary_lines)
|
||
|
||
return summary_text, summary_data
|
||
|
||
|
||
async def _build_subscription_period_prompt(
|
||
db_user: User,
|
||
texts,
|
||
db: AsyncSession,
|
||
) -> str:
|
||
base_text = texts.BUY_SUBSCRIPTION_START.rstrip()
|
||
|
||
lines: list[str] = [base_text]
|
||
|
||
promo_offer_hint = await _get_promo_offer_hint(db, db_user, texts)
|
||
if promo_offer_hint:
|
||
lines.extend(['', promo_offer_hint])
|
||
|
||
promo_text = await _build_promo_group_discount_text(
|
||
db_user,
|
||
settings.get_available_subscription_periods(),
|
||
texts=texts,
|
||
)
|
||
|
||
if promo_text:
|
||
lines.extend(['', promo_text])
|
||
|
||
return '\n'.join(lines) + '\n'
|
||
|
||
|
||
async def get_subscription_cost(subscription, db: AsyncSession) -> int:
|
||
try:
|
||
if subscription.is_trial:
|
||
return 0
|
||
|
||
from app.config import settings
|
||
from app.services.subscription_service import SubscriptionService
|
||
|
||
subscription_service = SubscriptionService()
|
||
|
||
base_cost_original = PERIOD_PRICES.get(30, 0)
|
||
try:
|
||
owner = subscription.user
|
||
except AttributeError:
|
||
owner = None
|
||
|
||
promo_group_id = getattr(owner, 'promo_group_id', None) if owner else None
|
||
|
||
period_discount_percent = 0
|
||
if owner:
|
||
try:
|
||
period_discount_percent = owner.get_promo_discount('period', 30)
|
||
except AttributeError:
|
||
period_discount_percent = 0
|
||
|
||
base_cost, _ = apply_percentage_discount(
|
||
base_cost_original,
|
||
period_discount_percent,
|
||
)
|
||
|
||
try:
|
||
servers_cost, _ = await subscription_service.get_countries_price_by_uuids(
|
||
subscription.connected_squads,
|
||
db,
|
||
promo_group_id=promo_group_id,
|
||
)
|
||
except AttributeError:
|
||
servers_cost, _ = await get_countries_price_by_uuids_fallback(
|
||
subscription.connected_squads,
|
||
db,
|
||
promo_group_id=promo_group_id,
|
||
)
|
||
|
||
traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||
device_limit = subscription.device_limit
|
||
if device_limit is None:
|
||
if settings.is_devices_selection_enabled():
|
||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||
else:
|
||
forced_limit = settings.get_disabled_mode_device_limit()
|
||
if forced_limit is None:
|
||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||
else:
|
||
device_limit = forced_limit
|
||
|
||
devices_cost = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||
|
||
total_cost = base_cost + servers_cost + traffic_cost + devices_cost
|
||
|
||
logger.info('📊 Месячная стоимость конфигурации подписки', subscription_id=subscription.id)
|
||
base_log = f' 📅 Базовый тариф (30 дней): {base_cost_original / 100}₽'
|
||
if period_discount_percent > 0:
|
||
discount_value = base_cost_original * period_discount_percent // 100
|
||
base_log += f' → {base_cost / 100}₽ (скидка {period_discount_percent}%: -{discount_value / 100}₽)'
|
||
logger.info(base_log)
|
||
if servers_cost > 0:
|
||
logger.info('🌍 Серверы: ₽', servers_cost=servers_cost / 100)
|
||
if traffic_cost > 0:
|
||
logger.info('📊 Трафик: ₽', traffic_cost=traffic_cost / 100)
|
||
if devices_cost > 0:
|
||
logger.info('📱 Устройства: ₽', devices_cost=devices_cost / 100)
|
||
logger.info('💎 ИТОГО: ₽', total_cost=total_cost / 100)
|
||
|
||
return total_cost
|
||
|
||
except Exception as e:
|
||
logger.error('⚠️ Ошибка расчета стоимости подписки', error=e)
|
||
return 0
|
||
|
||
|
||
async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession):
|
||
devices_selection_enabled = settings.is_devices_selection_enabled()
|
||
|
||
if devices_selection_enabled:
|
||
devices_used = await get_current_devices_count(db_user)
|
||
else:
|
||
devices_used = 0
|
||
countries_info = await _get_countries_info(subscription.connected_squads)
|
||
', '.join([c['name'] for c in countries_info]) if countries_info else 'Нет'
|
||
|
||
subscription_url = getattr(subscription, 'subscription_url', None) or 'Генерируется...'
|
||
|
||
if subscription.is_trial:
|
||
status_text = '🎁 Тестовая'
|
||
type_text = 'Триал'
|
||
else:
|
||
if subscription.is_active:
|
||
status_text = '✅ Оплачена'
|
||
else:
|
||
status_text = '⌛ Истекла'
|
||
type_text = 'Платная подписка'
|
||
|
||
traffic_limit = subscription.traffic_limit_gb or 0
|
||
if traffic_limit == 0:
|
||
if settings.is_traffic_fixed():
|
||
traffic_text = '∞ Безлимитный'
|
||
else:
|
||
traffic_text = '∞ Безлимитный'
|
||
elif settings.is_traffic_fixed():
|
||
traffic_text = f'{traffic_limit} ГБ'
|
||
else:
|
||
traffic_text = f'{traffic_limit} ГБ'
|
||
|
||
subscription_cost = await get_subscription_cost(subscription, db)
|
||
|
||
info_template = texts.SUBSCRIPTION_INFO
|
||
|
||
if not devices_selection_enabled:
|
||
info_template = info_template.replace(
|
||
'\n📱 <b>Устройства:</b> {devices_used} / {devices_limit}',
|
||
'',
|
||
).replace(
|
||
'\n📱 <b>Devices:</b> {devices_used} / {devices_limit}',
|
||
'',
|
||
)
|
||
|
||
info_text = info_template.format(
|
||
status=status_text,
|
||
type=type_text,
|
||
end_date=format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M'),
|
||
days_left=max(0, subscription.days_left),
|
||
traffic_used=texts.format_traffic(subscription.traffic_used_gb, is_limit=False),
|
||
traffic_limit=traffic_text,
|
||
countries_count=len(subscription.connected_squads),
|
||
devices_used=devices_used,
|
||
devices_limit=subscription.device_limit,
|
||
autopay_status='✅ Включен' if subscription.autopay_enabled else '⌛ Выключен',
|
||
)
|
||
|
||
if subscription_cost > 0:
|
||
info_text += f'\n💰 <b>Стоимость подписки в месяц:</b> {texts.format_price(subscription_cost)}'
|
||
|
||
# Отображаем докупленный трафик
|
||
if (subscription.traffic_limit_gb or 0) > 0: # Только для лимитированных тарифов
|
||
from sqlalchemy import select as sql_select
|
||
|
||
from app.database.models import TrafficPurchase
|
||
|
||
now = datetime.now(UTC)
|
||
purchases_query = (
|
||
sql_select(TrafficPurchase)
|
||
.where(TrafficPurchase.subscription_id == subscription.id)
|
||
.where(TrafficPurchase.expires_at > now)
|
||
.order_by(TrafficPurchase.expires_at.asc())
|
||
)
|
||
purchases_result = await db.execute(purchases_query)
|
||
purchases = purchases_result.scalars().all()
|
||
|
||
if purchases:
|
||
info_text += '\n\n📦 <b>Докупленный трафик:</b>'
|
||
|
||
for purchase in purchases:
|
||
time_remaining = purchase.expires_at - now
|
||
days_remaining = max(0, int(time_remaining.total_seconds() / 86400))
|
||
|
||
# Генерируем прогресс-бар
|
||
total_duration_seconds = (purchase.expires_at - purchase.created_at).total_seconds()
|
||
elapsed_seconds = (now - purchase.created_at).total_seconds()
|
||
progress_percent = min(
|
||
100.0,
|
||
max(0.0, (elapsed_seconds / total_duration_seconds * 100) if total_duration_seconds > 0 else 0),
|
||
)
|
||
|
||
bar_length = 10
|
||
filled = int((progress_percent / 100) * bar_length)
|
||
bar = '▰' * filled + '▱' * (bar_length - filled)
|
||
|
||
# Форматируем дату истечения
|
||
expire_date = purchase.expires_at.strftime('%d.%m.%Y')
|
||
|
||
# Формируем текст о времени
|
||
if days_remaining == 0:
|
||
time_text = 'истекает сегодня'
|
||
elif days_remaining == 1:
|
||
time_text = 'остался 1 день'
|
||
elif days_remaining < 5:
|
||
time_text = f'осталось {days_remaining} дня'
|
||
else:
|
||
time_text = f'осталось {days_remaining} дней'
|
||
|
||
info_text += f'\n• {purchase.traffic_gb} ГБ — {time_text}'
|
||
info_text += f'\n {bar} {progress_percent:.0f}% | до {expire_date}'
|
||
|
||
if subscription_url and subscription_url != 'Генерируется...' and not settings.should_hide_subscription_link():
|
||
info_text += f'\n\n🔗 <b>Ваша ссылка для импорта в VPN приложениe:</b>\n<code>{subscription_url}</code>'
|
||
|
||
return info_text
|