mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-28 23:35:59 +00:00
Fix addon discount calculations and availability checks
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Tuple
|
||||
from sqlalchemy import select, and_, func
|
||||
from sqlalchemy import select, and_, func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -13,7 +13,11 @@ from app.database.models import (
|
||||
PromoGroup,
|
||||
)
|
||||
from app.database.crud.notification import clear_notifications
|
||||
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
|
||||
from app.utils.pricing_utils import (
|
||||
calculate_months_from_days,
|
||||
get_remaining_months,
|
||||
resolve_addon_discount_percent,
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -517,6 +521,23 @@ def _get_discount_percent(
|
||||
return 0
|
||||
|
||||
|
||||
def _get_addon_discount_percent(
|
||||
user: Optional[User],
|
||||
promo_group: Optional[PromoGroup],
|
||||
category: str,
|
||||
*,
|
||||
period_days: Optional[int] = None,
|
||||
) -> int:
|
||||
group = promo_group or (getattr(user, "promo_group", None) if user else None)
|
||||
|
||||
return resolve_addon_discount_percent(
|
||||
user,
|
||||
group,
|
||||
category,
|
||||
period_days=period_days,
|
||||
)
|
||||
|
||||
|
||||
async def calculate_subscription_total_cost(
|
||||
db: AsyncSession,
|
||||
period_days: int,
|
||||
@@ -836,7 +857,7 @@ async def calculate_addon_cost_for_remaining_period(
|
||||
if additional_server_ids is None:
|
||||
additional_server_ids = []
|
||||
|
||||
months_to_pay = get_remaining_months(subscription.end_date)
|
||||
months_to_pay = max(1, get_remaining_months(subscription.end_date))
|
||||
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
|
||||
|
||||
total_cost = 0
|
||||
@@ -847,7 +868,7 @@ async def calculate_addon_cost_for_remaining_period(
|
||||
|
||||
if additional_traffic_gb > 0:
|
||||
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
|
||||
traffic_discount_percent = _get_discount_percent(
|
||||
traffic_discount_percent = _get_addon_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
@@ -868,7 +889,7 @@ async def calculate_addon_cost_for_remaining_period(
|
||||
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _get_discount_percent(
|
||||
devices_discount_percent = _get_addon_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
@@ -892,12 +913,19 @@ async def calculate_addon_cost_for_remaining_period(
|
||||
for server_id in additional_server_ids:
|
||||
result = await db.execute(
|
||||
select(ServerSquad.price_kopeks, ServerSquad.display_name)
|
||||
.where(ServerSquad.id == server_id)
|
||||
.where(
|
||||
ServerSquad.id == server_id,
|
||||
ServerSquad.is_available.is_(True),
|
||||
or_(
|
||||
ServerSquad.max_users.is_(None),
|
||||
ServerSquad.current_users < ServerSquad.max_users,
|
||||
),
|
||||
)
|
||||
)
|
||||
server_data = result.first()
|
||||
if server_data:
|
||||
server_price_per_month, server_name = server_data
|
||||
servers_discount_percent = _get_discount_percent(
|
||||
servers_discount_percent = _get_addon_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
|
||||
@@ -93,6 +93,51 @@ def _apply_discount_to_monthly_component(
|
||||
}
|
||||
|
||||
|
||||
def _get_addon_discount_percent_for_user(
|
||||
user: User,
|
||||
category: str,
|
||||
period_days: Optional[int] = None,
|
||||
) -> int:
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
|
||||
if promo_group is not None and not getattr(promo_group, "apply_discounts_to_addons", True):
|
||||
return 0
|
||||
|
||||
try:
|
||||
return user.get_promo_discount(category, period_days)
|
||||
except AttributeError:
|
||||
return 0
|
||||
|
||||
|
||||
def _calculate_discounted_addon_price(
|
||||
subscription: Subscription,
|
||||
user: User,
|
||||
base_price_per_month: int,
|
||||
category: str,
|
||||
) -> Dict[str, int]:
|
||||
months_to_pay = max(1, get_remaining_months(subscription.end_date))
|
||||
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
|
||||
discount_percent = _get_addon_discount_percent_for_user(
|
||||
user,
|
||||
category,
|
||||
period_hint_days,
|
||||
)
|
||||
|
||||
discount_per_month = base_price_per_month * discount_percent // 100
|
||||
discounted_per_month = base_price_per_month - discount_per_month
|
||||
total_price = discounted_per_month * months_to_pay
|
||||
total_discount = discount_per_month * months_to_pay
|
||||
|
||||
return {
|
||||
"total_price": total_price,
|
||||
"charged_months": months_to_pay,
|
||||
"discount_percent": discount_percent,
|
||||
"discount_total": total_discount,
|
||||
"discount_per_month": discount_per_month,
|
||||
"discounted_per_month": discounted_per_month,
|
||||
}
|
||||
|
||||
|
||||
async def _prepare_subscription_summary(
|
||||
db_user: User,
|
||||
data: Dict[str, Any],
|
||||
@@ -1287,35 +1332,64 @@ async def apply_countries_changes(
|
||||
|
||||
logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}")
|
||||
|
||||
months_to_pay = get_remaining_months(subscription.end_date)
|
||||
|
||||
months_to_pay = max(1, get_remaining_months(subscription.end_date))
|
||||
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
|
||||
servers_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
"servers",
|
||||
period_hint_days,
|
||||
)
|
||||
|
||||
cost_per_month = 0
|
||||
added_names = []
|
||||
removed_names = []
|
||||
|
||||
|
||||
added_server_prices = []
|
||||
|
||||
total_cost = 0
|
||||
total_discount = 0
|
||||
|
||||
for country in countries:
|
||||
if country['uuid'] in added:
|
||||
server_price_per_month = country['price_kopeks']
|
||||
cost_per_month += server_price_per_month
|
||||
added_names.append(country['name'])
|
||||
server_discount_per_month = (
|
||||
server_price_per_month * servers_discount_percent // 100
|
||||
)
|
||||
discounted_per_month = server_price_per_month - server_discount_per_month
|
||||
server_total_price = discounted_per_month * months_to_pay
|
||||
added_server_prices.append(server_total_price)
|
||||
total_cost += server_total_price
|
||||
total_discount += server_discount_per_month * months_to_pay
|
||||
if country['uuid'] in removed:
|
||||
removed_names.append(country['name'])
|
||||
|
||||
total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date)
|
||||
|
||||
for country in countries:
|
||||
if country['uuid'] in added:
|
||||
server_price_per_month = country['price_kopeks']
|
||||
server_total_price = server_price_per_month * charged_months
|
||||
added_server_prices.append(server_total_price)
|
||||
|
||||
logger.info(f"Стоимость новых серверов: {cost_per_month/100}₽/мес × {charged_months} мес = {total_cost/100}₽")
|
||||
|
||||
|
||||
charged_months = months_to_pay
|
||||
|
||||
if added and servers_discount_percent > 0:
|
||||
logger.info(
|
||||
"Стоимость новых серверов: %s₽/мес × %s мес = %s₽ (скидка %s%%: -%s₽)",
|
||||
cost_per_month / 100,
|
||||
charged_months,
|
||||
total_cost / 100,
|
||||
servers_discount_percent,
|
||||
total_discount / 100,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Стоимость новых серверов: %s₽/мес × %s мес = %s₽",
|
||||
cost_per_month / 100,
|
||||
charged_months,
|
||||
total_cost / 100,
|
||||
)
|
||||
|
||||
if total_cost > 0 and db_user.balance_kopeks < total_cost:
|
||||
missing_kopeks = total_cost - db_user.balance_kopeks
|
||||
required_text = f"{texts.format_price(total_cost)} (за {charged_months} мес)"
|
||||
if total_discount > 0:
|
||||
required_text += (
|
||||
f"\n💸 Скидка {servers_discount_percent}%: -{texts.format_price(total_discount)}"
|
||||
)
|
||||
message_text = texts.t(
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE",
|
||||
(
|
||||
@@ -1398,6 +1472,10 @@ async def apply_countries_changes(
|
||||
success_text += "\n".join(f"• {name}" for name in added_names)
|
||||
if total_cost > 0:
|
||||
success_text += f"\n💰 Списано: {texts.format_price(total_cost)} (за {charged_months} мес)"
|
||||
if total_discount > 0:
|
||||
success_text += (
|
||||
f"\n💸 Скидка {servers_discount_percent}%: -{texts.format_price(total_discount)}"
|
||||
)
|
||||
success_text += "\n"
|
||||
|
||||
if removed_names:
|
||||
@@ -1493,8 +1571,6 @@ async def confirm_change_devices(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price
|
||||
|
||||
new_devices_count = int(callback.data.split('_')[2])
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
@@ -1522,13 +1598,26 @@ async def confirm_change_devices(
|
||||
chargeable_devices = max(0, additional_devices - free_devices)
|
||||
else:
|
||||
chargeable_devices = additional_devices
|
||||
|
||||
|
||||
devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE
|
||||
price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date)
|
||||
|
||||
pricing = _calculate_discounted_addon_price(
|
||||
subscription,
|
||||
db_user,
|
||||
devices_price_per_month,
|
||||
"devices",
|
||||
)
|
||||
price = pricing["total_price"]
|
||||
charged_months = pricing["charged_months"]
|
||||
discount_percent = pricing["discount_percent"]
|
||||
discount_total = pricing["discount_total"]
|
||||
|
||||
if price > 0 and db_user.balance_kopeks < price:
|
||||
missing_kopeks = price - db_user.balance_kopeks
|
||||
required_text = f"{texts.format_price(price)} (за {charged_months} мес)"
|
||||
if discount_total > 0:
|
||||
required_text += (
|
||||
f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}"
|
||||
)
|
||||
message_text = texts.t(
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE",
|
||||
(
|
||||
@@ -1554,9 +1643,13 @@ async def confirm_change_devices(
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
|
||||
action_text = f"увеличить до {new_devices_count}"
|
||||
cost_text = f"Доплата: {texts.format_price(price)} (за {charged_months} мес)" if price > 0 else "Бесплатно"
|
||||
if price > 0 and discount_total > 0:
|
||||
cost_text += (
|
||||
f" (скидка {discount_percent}%: -{texts.format_price(discount_total)})"
|
||||
)
|
||||
|
||||
else:
|
||||
price = 0
|
||||
@@ -1605,7 +1698,7 @@ async def execute_change_devices(
|
||||
await callback.answer("⚠️ Ошибка списания средств", show_alert=True)
|
||||
return
|
||||
|
||||
charged_months = get_remaining_months(subscription.end_date)
|
||||
charged_months = max(1, get_remaining_months(subscription.end_date))
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
@@ -2120,8 +2213,6 @@ async def confirm_add_devices(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price
|
||||
|
||||
devices_count = int(callback.data.split('_')[2])
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
@@ -2139,13 +2230,43 @@ async def confirm_add_devices(
|
||||
return
|
||||
|
||||
devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE
|
||||
price, charged_months = calculate_prorated_price(devices_price_per_month, subscription.end_date)
|
||||
|
||||
logger.info(f"Добавление {devices_count} устройств: {devices_price_per_month/100}₽/мес × {charged_months} мес = {price/100}₽")
|
||||
pricing = _calculate_discounted_addon_price(
|
||||
subscription,
|
||||
db_user,
|
||||
devices_price_per_month,
|
||||
"devices",
|
||||
)
|
||||
price = pricing["total_price"]
|
||||
charged_months = pricing["charged_months"]
|
||||
discount_percent = pricing["discount_percent"]
|
||||
discount_total = pricing["discount_total"]
|
||||
|
||||
if discount_percent > 0:
|
||||
logger.info(
|
||||
"Добавление %s устройств: %s₽/мес × %s мес = %s₽ (скидка %s%%: -%s₽)",
|
||||
devices_count,
|
||||
devices_price_per_month / 100,
|
||||
charged_months,
|
||||
price / 100,
|
||||
discount_percent,
|
||||
discount_total / 100,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Добавление %s устройств: %s₽/мес × %s мес = %s₽",
|
||||
devices_count,
|
||||
devices_price_per_month / 100,
|
||||
charged_months,
|
||||
price / 100,
|
||||
)
|
||||
|
||||
if db_user.balance_kopeks < price:
|
||||
missing_kopeks = price - db_user.balance_kopeks
|
||||
required_text = f"{texts.format_price(price)} (за {charged_months} мес)"
|
||||
if discount_total > 0:
|
||||
required_text += (
|
||||
f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}"
|
||||
)
|
||||
message_text = texts.t(
|
||||
"ADDON_INSUFFICIENT_FUNDS_MESSAGE",
|
||||
(
|
||||
@@ -2204,7 +2325,12 @@ async def confirm_add_devices(
|
||||
f"✅ Устройства успешно добавлены!\n\n"
|
||||
f"📱 Добавлено: {devices_count} устройств\n"
|
||||
f"Новый лимит: {subscription.device_limit} устройств\n"
|
||||
f"💰 Списано: {texts.format_price(price)} (за {charged_months} мес)",
|
||||
f"💰 Списано: {texts.format_price(price)} (за {charged_months} мес)"
|
||||
+ (
|
||||
f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}"
|
||||
if discount_total > 0
|
||||
else ""
|
||||
),
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
@@ -3501,12 +3627,42 @@ async def add_traffic(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
price = settings.get_traffic_price(traffic_gb)
|
||||
|
||||
if price == 0 and traffic_gb != 0:
|
||||
price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
|
||||
if price_per_month == 0 and traffic_gb != 0:
|
||||
await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
pricing = _calculate_discounted_addon_price(
|
||||
subscription,
|
||||
db_user,
|
||||
price_per_month,
|
||||
"traffic",
|
||||
)
|
||||
price = pricing["total_price"]
|
||||
charged_months = pricing["charged_months"]
|
||||
discount_percent = pricing["discount_percent"]
|
||||
discount_total = pricing["discount_total"]
|
||||
|
||||
if discount_percent > 0:
|
||||
logger.info(
|
||||
"Добавление трафика +%s ГБ: %s₽/мес × %s мес = %s₽ (скидка %s%%: -%s₽)",
|
||||
traffic_gb,
|
||||
price_per_month / 100,
|
||||
charged_months,
|
||||
price / 100,
|
||||
discount_percent,
|
||||
discount_total / 100,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Добавление трафика +%s ГБ: %s₽/мес × %s мес = %s₽",
|
||||
traffic_gb,
|
||||
price_per_month / 100,
|
||||
charged_months,
|
||||
price / 100,
|
||||
)
|
||||
|
||||
if db_user.balance_kopeks < price:
|
||||
missing_kopeks = price - db_user.balance_kopeks
|
||||
message_text = texts.t(
|
||||
@@ -3519,7 +3675,14 @@ async def add_traffic(
|
||||
"Выберите способ пополнения. Сумма подставится автоматически."
|
||||
),
|
||||
).format(
|
||||
required=texts.format_price(price),
|
||||
required=(
|
||||
f"{texts.format_price(price)} (за {charged_months} мес)"
|
||||
+ (
|
||||
f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}"
|
||||
if discount_total > 0
|
||||
else ""
|
||||
)
|
||||
),
|
||||
balance=texts.format_price(db_user.balance_kopeks),
|
||||
missing=texts.format_price(missing_kopeks),
|
||||
)
|
||||
@@ -3538,7 +3701,7 @@ async def add_traffic(
|
||||
try:
|
||||
success = await subtract_user_balance(
|
||||
db, db_user, price,
|
||||
f"Добавление {traffic_gb} ГБ трафика"
|
||||
f"Добавление {traffic_gb} ГБ трафика на {charged_months} мес"
|
||||
)
|
||||
|
||||
if not success:
|
||||
@@ -3558,7 +3721,9 @@ async def add_traffic(
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price,
|
||||
description=f"Добавление {traffic_gb} ГБ трафика"
|
||||
description=(
|
||||
f"Добавление {traffic_gb} ГБ трафика на {charged_months} мес"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -3571,6 +3736,12 @@ async def add_traffic(
|
||||
else:
|
||||
success_text += f"📈 Добавлено: {traffic_gb} ГБ\n"
|
||||
success_text += f"Новый лимит: {texts.format_traffic(subscription.traffic_limit_gb)}"
|
||||
if price > 0:
|
||||
success_text += f"\n💰 Списано: {texts.format_price(price)} (за {charged_months} мес)"
|
||||
if discount_total > 0:
|
||||
success_text += (
|
||||
f"\n💸 Скидка {discount_percent}%: -{texts.format_price(discount_total)}"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_text,
|
||||
|
||||
@@ -14,7 +14,8 @@ from app.utils.pricing_utils import (
|
||||
calculate_months_from_days,
|
||||
get_remaining_months,
|
||||
calculate_prorated_price,
|
||||
validate_pricing_calculation
|
||||
validate_pricing_calculation,
|
||||
resolve_addon_discount_percent,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -48,12 +49,9 @@ def _resolve_addon_discount_percent(
|
||||
) -> int:
|
||||
group = promo_group or (getattr(user, "promo_group", None) if user else None)
|
||||
|
||||
if group is not None and not getattr(group, "apply_discounts_to_addons", True):
|
||||
return 0
|
||||
|
||||
return _resolve_discount_percent(
|
||||
return resolve_addon_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
group,
|
||||
category,
|
||||
period_days=period_days,
|
||||
)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Tuple
|
||||
from typing import Tuple, Optional, TYPE_CHECKING
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from app.database.models import User, PromoGroup
|
||||
|
||||
|
||||
def calculate_months_from_days(days: int) -> int:
|
||||
return max(1, round(days / 30))
|
||||
|
||||
@@ -61,6 +65,32 @@ def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]:
|
||||
return discounted_amount, discount_value
|
||||
|
||||
|
||||
def resolve_addon_discount_percent(
|
||||
user: Optional["User"],
|
||||
promo_group: Optional["PromoGroup"],
|
||||
category: str,
|
||||
*,
|
||||
period_days: Optional[int] = None,
|
||||
) -> int:
|
||||
"""Return discount percent for add-on purchases respecting promo-group rules."""
|
||||
|
||||
group = promo_group or (getattr(user, "promo_group", None) if user else None)
|
||||
|
||||
if group is not None and not getattr(group, "apply_discounts_to_addons", True):
|
||||
return 0
|
||||
|
||||
if user is not None:
|
||||
try:
|
||||
return user.get_promo_discount(category, period_days)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
if promo_group is not None:
|
||||
return promo_group.get_discount_percent(category, period_days)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def format_period_description(days: int, language: str = "ru") -> str:
|
||||
months = calculate_months_from_days(days)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user