fix: quick topup buttons include device/server/traffic costs, broadcast button crash on media messages

- Quick amount buttons now calculate full renewal cost (base + devices + servers + traffic with discounts)
- Tariff mode uses tariff-specific device pricing (device_price_kopeks, device_limit)
- Broadcast inline buttons no longer crash with "no text in message to edit" on photo/video messages
- Media messages are now handled in _edit_with_photo: delete old message + send new text
This commit is contained in:
Fringg
2026-03-08 14:58:17 +03:00
parent 20727b1017
commit 5ebe1072c9
2 changed files with 124 additions and 24 deletions
+98 -24
View File
@@ -145,6 +145,8 @@ async def get_quick_amount_buttons(language: str, user: User) -> list:
"""
Generate quick amount buttons with user-specific pricing and discounts.
Includes full subscription cost: base period price + devices + servers + traffic.
Args:
language: User's language for formatting
user: User object to calculate personalized discounts
@@ -156,25 +158,63 @@ async def get_quick_amount_buttons(language: str, user: User) -> list:
return []
from app.config import PERIOD_PRICES
from app.localization.texts import get_texts
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.database import AsyncSessionLocal
from app.utils.pricing_utils import apply_percentage_discount, calculate_months_from_days
texts = get_texts(language)
# В режиме тарифов получаем цены из тарифа пользователя
tariff = None
tariff_prices = None
tariff_periods = None
if settings.is_tariffs_mode():
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.crud.tariff import get_tariff_by_id
from app.database.database import AsyncSessionLocal
devices_price_per_month = 0
servers_per_month_prices: list[int] = []
traffic_price_per_month = 0
async with AsyncSessionLocal() as db:
subscription = await get_subscription_by_user_id(db, user.id)
if subscription and subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and tariff.period_prices:
tariff_prices = {int(k): v for k, v in tariff.period_prices.items()}
tariff_periods = sorted(tariff_prices.keys())
async with AsyncSessionLocal() as db:
subscription = await get_subscription_by_user_id(db, user.id)
# В режиме тарифов получаем цены из тарифа пользователя
if settings.is_tariffs_mode() and subscription and subscription.tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff and tariff.period_prices:
tariff_prices = {int(k): v for k, v in tariff.period_prices.items()}
tariff_periods = sorted(tariff_prices.keys())
# Получаем стоимость устройств, серверов и трафика из подписки
if subscription and not subscription.is_trial:
# Устройства: в режиме тарифов используем цену и базовый лимит из тарифа
if settings.is_tariffs_mode() and tariff and tariff_prices:
tariff_device_price = getattr(tariff, 'device_price_kopeks', None)
if tariff_device_price and tariff_device_price > 0:
device_unit_price = tariff_device_price
base_device_limit = tariff.device_limit or 0
else:
device_unit_price = settings.PRICE_PER_DEVICE
base_device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
device_unit_price = settings.PRICE_PER_DEVICE
base_device_limit = settings.DEFAULT_DEVICE_LIMIT
device_limit = subscription.device_limit or base_device_limit
additional_devices = max(0, device_limit - base_device_limit)
if additional_devices > 0:
devices_price_per_month = additional_devices * device_unit_price
# Серверы
connected_squads = subscription.connected_squads or []
if connected_squads:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
_, servers_per_month_prices = await subscription_service.get_countries_price_by_uuids(
connected_squads, db, promo_group_id=user.promo_group_id
)
# Трафик
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
buttons = []
@@ -192,23 +232,57 @@ async def get_quick_amount_buttons(language: str, user: User) -> list:
base_price_kopeks = PERIOD_PRICES.get(period, 0)
if base_price_kopeks > 0:
# Calculate price with user's promo group discount using unified system
# Базовая цена периода с промо-скидками
price_info = calculate_user_price(user, base_price_kopeks, period, 'period')
callback_data = f'quick_amount_{price_info.final_price}'
months = calculate_months_from_days(period)
# Стоимость устройств со скидкой
devices_addon = 0
if devices_price_per_month > 0:
devices_discount = user.get_promo_discount('devices', period)
devices_discounted, _ = apply_percentage_discount(devices_price_per_month, devices_discount)
devices_addon = devices_discounted * months
# Стоимость серверов со скидкой
servers_addon = 0
if servers_per_month_prices:
servers_discount = user.get_promo_discount('servers', period)
for server_price in servers_per_month_prices:
discounted, _ = apply_percentage_discount(server_price, servers_discount)
servers_addon += discounted
servers_addon *= months
# Стоимость трафика со скидкой
traffic_addon = 0
if traffic_price_per_month > 0:
traffic_discount = user.get_promo_discount('traffic', period)
traffic_discounted, _ = apply_percentage_discount(traffic_price_per_month, traffic_discount)
traffic_addon = traffic_discounted * months
total_price = price_info.final_price + devices_addon + servers_addon + traffic_addon
callback_data = f'quick_amount_{total_price}'
# Format button text with discount display
period_label = f'{period} дней'
# For balance buttons, use simpler format without emoji and period label prefix
if price_info.has_discount:
button_text = (
f'{texts.format_price(price_info.base_price)}'
f'{texts.format_price(price_info.final_price)} '
f'(-{price_info.discount_percent}%) • {period_label}'
)
# Скидка считается от полной базовой стоимости (период + аддоны без скидок)
total_base = base_price_kopeks + (
devices_price_per_month + sum(servers_per_month_prices) + traffic_price_per_month
) * months
has_discount = total_base > total_price and total_base > 0
if has_discount:
discount_pct = round((total_base - total_price) * 100 / total_base)
if discount_pct > 0:
button_text = (
f'{texts.format_price(total_base)}'
f'{texts.format_price(total_price)} '
f'(-{discount_pct}%) • {period_label}'
)
else:
button_text = f'{texts.format_price(total_price)}{period_label}'
else:
button_text = f'{texts.format_price(price_info.final_price)}{period_label}'
button_text = f'{texts.format_price(total_price)}{period_label}'
buttons.append(types.InlineKeyboardButton(text=button_text, callback_data=callback_data))
+26
View File
@@ -189,6 +189,19 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
# Уважаем флаг в рантайме: если логотип выключен — не подменяем редактирование
if not settings.ENABLE_LOGO_MODE:
kwargs.setdefault('disable_web_page_preview', True)
# Медиа-сообщения (фото/видео из рассылки и т.д.) не имеют text — edit_text упадёт.
# Удаляем старое сообщение и отправляем новое.
if self.text is None:
try:
await self.delete()
except TelegramBadRequest:
pass
try:
return await _original_answer(self, text, **kwargs)
except TelegramBadRequest as error:
if is_topic_required_error(error):
return None
raise
return await _original_edit_text(self, text, **kwargs)
if self.photo:
language = _get_language(self)
@@ -242,6 +255,19 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
if is_topic_required_error(inner_error):
return None
raise
# Не-фото медиа (видео, анимация и т.д.) с включённым логотипом — удаляем и отправляем с фото
if self.text is None:
try:
await self.delete()
except TelegramBadRequest:
pass
try:
return await _answer_with_photo(self, text, **kwargs)
except TelegramBadRequest as error:
if is_topic_required_error(error):
return None
raise
# Обработка ошибок MESSAGE_ID_INVALID для сообщений без фото
try:
return await _text_edit(self, text, **kwargs)