fix: resolve multiple subscription bugs — LIMITED status, trial tariff blocking, traffic reset strategy, classic mode pricing, 100% discount support
- Include LIMITED status in subscription lookups (get_active_subscriptions_by_user_id, get_subscription_by_user_and_tariff) — fixes duplicate subscriptions when traffic exhausted - Migration 0053: update partial unique index to include LIMITED - Trial subscriptions no longer block tariff purchase — excluded from purchased_tariff_ids, handle_extend_subscription routes trial+tariff to tariff extend flow - Replace hardcoded TrafficLimitStrategy.MONTH with get_traffic_reset_strategy() across all sync/create paths (remnawave_service, monitoring_service, admin_users) - Subscriptions with tariff_id always use tariff pricing flow regardless of global sales mode — fixes 0₽ renewal in classic mode - Support 100% promo group discount across all purchase/renewal flows — balance checks skip when price=0, validation allows final_total=0 when base_price>0
This commit is contained in:
@@ -236,7 +236,7 @@ async def _sync_subscription_to_panel(
|
||||
"""
|
||||
try:
|
||||
from app.config import settings
|
||||
from app.external.remnawave_api import TrafficLimitStrategy, UserStatus as PanelUserStatus
|
||||
from app.external.remnawave_api import UserStatus as PanelUserStatus
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.utils.subscription_utils import resolve_hwid_device_limit_for_payload
|
||||
|
||||
@@ -323,7 +323,7 @@ async def _sync_subscription_to_panel(
|
||||
'uuid': panel_uuid,
|
||||
'status': panel_status,
|
||||
'traffic_limit_bytes': traffic_limit_bytes,
|
||||
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
|
||||
'traffic_limit_strategy': get_traffic_reset_strategy(subscription.tariff),
|
||||
'description': description,
|
||||
}
|
||||
if expire_at:
|
||||
@@ -358,7 +358,7 @@ async def _sync_subscription_to_panel(
|
||||
'expire_at': expire_at or (datetime.now(UTC) + timedelta(days=30)),
|
||||
'status': panel_status,
|
||||
'traffic_limit_bytes': traffic_limit_bytes,
|
||||
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
|
||||
'traffic_limit_strategy': get_traffic_reset_strategy(subscription.tariff),
|
||||
'telegram_id': user.telegram_id,
|
||||
'email': user.email,
|
||||
'description': description,
|
||||
@@ -3118,8 +3118,9 @@ async def sync_user_to_panel(
|
||||
|
||||
try:
|
||||
from app.config import settings
|
||||
from app.external.remnawave_api import TrafficLimitStrategy, UserStatus as PanelUserStatus
|
||||
from app.external.remnawave_api import UserStatus as PanelUserStatus
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_service import get_traffic_reset_strategy
|
||||
from app.utils.subscription_utils import resolve_hwid_device_limit_for_payload
|
||||
|
||||
service = RemnaWaveService()
|
||||
@@ -3218,7 +3219,7 @@ async def sync_user_to_panel(
|
||||
|
||||
if request.update_traffic_limit:
|
||||
update_kwargs['traffic_limit_bytes'] = traffic_limit_bytes
|
||||
update_kwargs['traffic_limit_strategy'] = TrafficLimitStrategy.MONTH
|
||||
update_kwargs['traffic_limit_strategy'] = get_traffic_reset_strategy(sub.tariff)
|
||||
changes['traffic_limit_gb'] = sub.traffic_limit_gb
|
||||
|
||||
if request.update_squads and sub.connected_squads:
|
||||
@@ -3252,7 +3253,7 @@ async def sync_user_to_panel(
|
||||
'expire_at': expire_at or (datetime.now(UTC) + timedelta(days=30)),
|
||||
'status': panel_status,
|
||||
'traffic_limit_bytes': traffic_limit_bytes,
|
||||
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
|
||||
'traffic_limit_strategy': get_traffic_reset_strategy(sub.tariff),
|
||||
'telegram_id': user.telegram_id,
|
||||
'email': user.email,
|
||||
'description': description,
|
||||
|
||||
@@ -425,8 +425,8 @@ async def create_gift_purchase(
|
||||
warning=recipient_warning,
|
||||
)
|
||||
|
||||
# Balance mode
|
||||
if user.balance_kopeks < price_kopeks:
|
||||
# Balance mode (skip for 100% discount)
|
||||
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Insufficient balance',
|
||||
|
||||
@@ -134,8 +134,8 @@ async def purchase_devices_legacy(
|
||||
detail=f'Максимальное количество устройств: {max_device_limit}',
|
||||
)
|
||||
|
||||
# Check balance
|
||||
if user.balance_kopeks < total_price:
|
||||
# Check balance (skip for 100% discount)
|
||||
if total_price > 0 and user.balance_kopeks < total_price:
|
||||
missing = total_price - user.balance_kopeks
|
||||
|
||||
# Сохраняем корзину для автопокупки после пополнения
|
||||
@@ -375,8 +375,8 @@ async def purchase_devices(
|
||||
if devices_discount_percent < 100:
|
||||
price_kopeks = max(100, price_kopeks)
|
||||
|
||||
# Check balance
|
||||
if user.balance_kopeks < price_kopeks:
|
||||
# Check balance (skip for 100% discount)
|
||||
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
|
||||
missing = price_kopeks - user.balance_kopeks
|
||||
|
||||
# Сохраняем корзину для автопокупки после пополнения
|
||||
|
||||
@@ -304,9 +304,7 @@ async def get_purchase_options(
|
||||
from app.database.crud.subscription import get_active_subscriptions_by_user_id
|
||||
|
||||
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
|
||||
purchased_tariff_ids = {
|
||||
s.tariff_id for s in active_subs if s.tariff_id and s.status in ('active', 'trial')
|
||||
}
|
||||
purchased_tariff_ids = {s.tariff_id for s in active_subs if s.tariff_id and not s.is_trial}
|
||||
|
||||
if subscription_id:
|
||||
from app.database.crud.subscription import get_subscription_by_id_for_user
|
||||
@@ -686,7 +684,7 @@ async def purchase_tariff(
|
||||
)
|
||||
|
||||
# Check balance
|
||||
if user.balance_kopeks < price_kopeks:
|
||||
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
|
||||
missing = price_kopeks - user.balance_kopeks
|
||||
|
||||
# Save cart for auto-purchase after balance top-up
|
||||
@@ -1158,7 +1156,7 @@ async def activate_trial(
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
|
||||
price_kopeks = settings.TRIAL_ACTIVATION_PRICE
|
||||
if user.balance_kopeks < price_kopeks:
|
||||
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Insufficient balance. Need {price_kopeks / 100:.2f} RUB',
|
||||
|
||||
@@ -168,8 +168,8 @@ async def renew_subscription(
|
||||
|
||||
tariff = subscription.tariff if subscription.tariff_id else None
|
||||
|
||||
# Check balance
|
||||
if user.balance_kopeks < price_kopeks:
|
||||
# Check balance (skip for 100% discount)
|
||||
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
|
||||
missing = price_kopeks - user.balance_kopeks
|
||||
|
||||
# Get tariff info for cart
|
||||
|
||||
@@ -254,7 +254,7 @@ async def purchase_traffic(
|
||||
final_price = max(100, final_price)
|
||||
|
||||
# Проверяем баланс
|
||||
if user.balance_kopeks < final_price:
|
||||
if final_price > 0 and user.balance_kopeks < final_price:
|
||||
missing = final_price - user.balance_kopeks
|
||||
|
||||
# Save cart for auto-purchase after balance top-up
|
||||
@@ -560,7 +560,7 @@ async def switch_traffic_package(
|
||||
# Prorated calculation
|
||||
final_price, days_charged = calculate_prorated_price(price_diff, subscription.end_date)
|
||||
|
||||
if user.balance_kopeks < final_price:
|
||||
if final_price > 0 and user.balance_kopeks < final_price:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail=f'Insufficient balance. Need {final_price / 100:.2f} RUB',
|
||||
|
||||
@@ -2075,7 +2075,12 @@ async def toggle_daily_subscription_pause(
|
||||
|
||||
|
||||
async def get_active_subscriptions_by_user_id(db: AsyncSession, user_id: int) -> list[Subscription]:
|
||||
"""Get all active/trial subscriptions for a user."""
|
||||
"""Get all active/trial/limited subscriptions for a user.
|
||||
|
||||
Includes LIMITED status because those subscriptions still have time remaining
|
||||
(just ran out of traffic) and should be treated as "alive" for renewal,
|
||||
duplicate prevention, and display purposes.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
@@ -2084,7 +2089,13 @@ async def get_active_subscriptions_by_user_id(db: AsyncSession, user_id: int) ->
|
||||
)
|
||||
.where(
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
]
|
||||
),
|
||||
)
|
||||
.order_by(Subscription.created_at.desc())
|
||||
)
|
||||
@@ -2121,7 +2132,11 @@ async def get_subscription_by_id(db: AsyncSession, subscription_id: int) -> Subs
|
||||
|
||||
|
||||
async def get_subscription_by_user_and_tariff(db: AsyncSession, user_id: int, tariff_id: int) -> Subscription | None:
|
||||
"""Get active/trial subscription for a specific user+tariff combination."""
|
||||
"""Get active/trial/limited subscription for a specific user+tariff combination.
|
||||
|
||||
Includes LIMITED status because those subscriptions still have time remaining
|
||||
(just ran out of traffic) and should be extended rather than duplicated.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
@@ -2131,7 +2146,13 @@ async def get_subscription_by_user_and_tariff(db: AsyncSession, user_id: int, ta
|
||||
.where(
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
]
|
||||
),
|
||||
)
|
||||
.order_by(Subscription.created_at.desc())
|
||||
.limit(1)
|
||||
|
||||
@@ -4162,14 +4162,16 @@ async def _update_user_traffic(
|
||||
) or getattr(user, 'remnawave_uuid', None)
|
||||
if _uuid:
|
||||
try:
|
||||
from app.external.remnawave_api import TrafficLimitStrategy
|
||||
from app.services.subscription_service import get_traffic_reset_strategy
|
||||
|
||||
remnawave_service = RemnaWaveService()
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
await api.update_user(
|
||||
uuid=_uuid,
|
||||
traffic_limit_bytes=traffic_gb * (1024**3) if traffic_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
traffic_limit_strategy=get_traffic_reset_strategy(
|
||||
subscription.tariff if subscription else None
|
||||
),
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name, username=user.username, telegram_id=user.telegram_id
|
||||
),
|
||||
@@ -4877,8 +4879,9 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
)
|
||||
|
||||
try:
|
||||
from app.external.remnawave_api import TrafficLimitStrategy, UserStatus
|
||||
from app.external.remnawave_api import UserStatus
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_service import get_traffic_reset_strategy
|
||||
|
||||
remnawave_service = RemnaWaveService()
|
||||
|
||||
@@ -4903,7 +4906,7 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3)
|
||||
if subscription.traffic_limit_gb > 0
|
||||
else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
@@ -4939,7 +4942,7 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3)
|
||||
if subscription.traffic_limit_gb > 0
|
||||
else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
|
||||
telegram_id=target_user.telegram_id,
|
||||
email=target_user.email,
|
||||
description=settings.format_remnawave_user_description(
|
||||
|
||||
@@ -441,7 +441,7 @@ async def handle_simple_subscription_pay_with_balance(
|
||||
# Проверяем баланс пользователя
|
||||
user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0)
|
||||
|
||||
if user_balance_kopeks < total_required:
|
||||
if total_required > 0 and user_balance_kopeks < total_required:
|
||||
await callback.answer('❌ Недостаточно средств на балансе для оплаты подписки', show_alert=True)
|
||||
return
|
||||
|
||||
@@ -2181,7 +2181,7 @@ async def confirm_simple_subscription_purchase(
|
||||
# Проверяем баланс пользователя
|
||||
user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0)
|
||||
|
||||
if user_balance_kopeks < total_required:
|
||||
if total_required > 0 and user_balance_kopeks < total_required:
|
||||
await callback.answer('❌ Недостаточно средств на балансе для оплаты подписки', show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
@@ -861,7 +861,7 @@ async def confirm_add_countries_to_subscription(
|
||||
if country['uuid'] in removed_countries:
|
||||
removed_countries_names.append(html.escape(country['name']))
|
||||
|
||||
if new_countries and db_user.balance_kopeks < total_price:
|
||||
if new_countries and total_price > 0 and db_user.balance_kopeks < total_price:
|
||||
missing_kopeks = total_price - db_user.balance_kopeks
|
||||
message_text = texts.t(
|
||||
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
|
||||
|
||||
@@ -1273,7 +1273,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
total_discount=total_discount / 100,
|
||||
)
|
||||
|
||||
if db_user.balance_kopeks < price:
|
||||
if price > 0 and db_user.balance_kopeks < price:
|
||||
missing_kopeks = price - db_user.balance_kopeks
|
||||
required_text = f'{texts.format_price(price)} (за {period_label})'
|
||||
message_text = texts.t(
|
||||
|
||||
@@ -1537,7 +1537,7 @@ async def return_to_saved_cart(callback: types.CallbackQuery, state: FSMContext,
|
||||
|
||||
total_price = prepared_cart_data.get('total_price', 0)
|
||||
|
||||
if db_user.balance_kopeks < total_price:
|
||||
if total_price > 0 and db_user.balance_kopeks < total_price:
|
||||
missing_amount = total_price - db_user.balance_kopeks
|
||||
insufficient_keyboard = get_insufficient_balance_keyboard_with_cart(
|
||||
db_user.language,
|
||||
@@ -1635,7 +1635,7 @@ async def handle_extend_subscription(
|
||||
else:
|
||||
subscription = db_user.subscription
|
||||
|
||||
if not subscription or subscription.is_trial:
|
||||
if not subscription:
|
||||
await callback.message.edit_text(
|
||||
'🎯 <b>Пробный период заканчивается</b>\n\nЧтобы продолжить пользоваться VPN, выберите подходящий тариф.',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
@@ -1654,24 +1654,53 @@ async def handle_extend_subscription(
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# В режиме тарифов проверяем наличие tariff_id
|
||||
if settings.is_tariffs_mode():
|
||||
if subscription.tariff_id:
|
||||
# Проверяем, суточный ли тариф — у суточных нет period_prices, продление через resume
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
# Триальная подписка с тарифом — направляем на покупку этого тарифа
|
||||
if subscription.is_trial:
|
||||
if subscription.tariff_id and settings.is_tariffs_mode():
|
||||
from .tariff_purchase import show_tariff_extend
|
||||
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff and getattr(tariff, 'is_daily', False):
|
||||
# Суточный тариф: перенаправляем на страницу подписки (там кнопка «Возобновить»)
|
||||
await show_subscription_info(callback, db_user, db)
|
||||
return
|
||||
await show_tariff_extend(callback, db_user, db)
|
||||
return
|
||||
# Триал без тарифа — предлагаем выбрать
|
||||
await callback.message.edit_text(
|
||||
'🎯 <b>Пробный период заканчивается</b>\n\nЧтобы продолжить пользоваться VPN, выберите подходящий тариф.',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data='menu_buy')],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t('WEBHOOK_CLOSE_BUTTON', '✖️ Закрыть'),
|
||||
callback_data='webhook:close',
|
||||
)
|
||||
],
|
||||
]
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Подписка с тарифом — всегда используем тарифный flow,
|
||||
# даже если бот в классическом режиме (подписка могла быть куплена через кабинет)
|
||||
if subscription.tariff_id:
|
||||
# Проверяем, суточный ли тариф — у суточных нет period_prices, продление через resume
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff and getattr(tariff, 'is_daily', False):
|
||||
# Суточный тариф: перенаправляем на страницу подписки (там кнопка «Возобновить»)
|
||||
await show_subscription_info(callback, db_user, db)
|
||||
return
|
||||
|
||||
if tariff:
|
||||
# У подписки есть тариф - перенаправляем на продление по тарифу
|
||||
from .tariff_purchase import show_tariff_extend
|
||||
|
||||
await show_tariff_extend(callback, db_user, db)
|
||||
return
|
||||
# У подписки нет тарифа - предлагаем выбрать тариф
|
||||
|
||||
if settings.is_tariffs_mode():
|
||||
# У подписки нет тарифа, но режим тарифов включён - предлагаем выбрать тариф
|
||||
await callback.message.edit_text(
|
||||
'📦 <b>Выберите тариф для продления</b>\n\n'
|
||||
'Ваша текущая подписка была создана до введения тарифов.\n'
|
||||
@@ -1706,6 +1735,10 @@ async def handle_extend_subscription(
|
||||
# original = price before ALL discounts, final = price with all discounts
|
||||
total_original_price = pricing.original_total
|
||||
|
||||
# Пропускаем периоды с нулевой ценой — защита от бесплатного продления
|
||||
if pricing.final_total <= 0 and pricing.base_price <= 0:
|
||||
continue
|
||||
|
||||
renewal_prices[days] = {
|
||||
'final': pricing.final_total,
|
||||
'original': total_original_price,
|
||||
@@ -1899,7 +1932,7 @@ async def confirm_extend_subscription(
|
||||
await callback.answer('⚠ Ошибка расчета стоимости', show_alert=True)
|
||||
return
|
||||
|
||||
if db_user.balance_kopeks < price:
|
||||
if price > 0 and db_user.balance_kopeks < price:
|
||||
missing_kopeks = price - db_user.balance_kopeks
|
||||
required_text = texts.format_price(price)
|
||||
message_text = texts.t(
|
||||
@@ -2307,7 +2340,7 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
|
||||
)
|
||||
logger.info('ИТОГО: ₽', final_price=final_price / 100)
|
||||
|
||||
if db_user.balance_kopeks < final_price:
|
||||
if final_price > 0 and db_user.balance_kopeks < final_price:
|
||||
missing_kopeks = final_price - db_user.balance_kopeks
|
||||
message_text = texts.t(
|
||||
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
|
||||
@@ -4415,8 +4448,8 @@ async def _extend_existing_subscription(
|
||||
device_limit=device_limit,
|
||||
)
|
||||
|
||||
# Проверяем баланс пользователя
|
||||
if db_user.balance_kopeks < price_kopeks:
|
||||
# Проверяем баланс пользователя (при 100% скидке — пропускаем)
|
||||
if price_kopeks > 0 and db_user.balance_kopeks < price_kopeks:
|
||||
missing_kopeks = price_kopeks - db_user.balance_kopeks
|
||||
message_text = texts.t(
|
||||
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
|
||||
|
||||
@@ -576,7 +576,7 @@ async def show_tariffs_list(
|
||||
from app.database.crud.subscription import get_active_subscriptions_by_user_id
|
||||
|
||||
active_subs = await get_active_subscriptions_by_user_id(db, db_user.id)
|
||||
purchased_tariff_ids = {s.tariff_id for s in active_subs if s.tariff_id and s.status in ('active', 'trial')}
|
||||
purchased_tariff_ids = {s.tariff_id for s in active_subs if s.tariff_id and not s.is_trial}
|
||||
|
||||
# Проверяем есть ли у пользователя скидки по периодам
|
||||
promo_group = db_user.get_primary_promo_group() if hasattr(db_user, 'get_primary_promo_group') else None
|
||||
@@ -619,7 +619,7 @@ async def select_tariff(
|
||||
from app.database.crud.subscription import get_active_subscriptions_by_user_id
|
||||
|
||||
_active = await get_active_subscriptions_by_user_id(db, db_user.id)
|
||||
_existing = next((s for s in _active if s.tariff_id == tariff_id and s.status in ('active', 'trial')), None)
|
||||
_existing = next((s for s in _active if s.tariff_id == tariff_id and not s.is_trial), None)
|
||||
if _existing:
|
||||
days_left = max(0, (_existing.end_date - datetime.now(UTC)).days) if _existing.end_date else 0
|
||||
await callback.answer(
|
||||
@@ -933,9 +933,9 @@ async def handle_custom_confirm(
|
||||
await callback.answer('Выбранный период недоступен для этого тарифа', show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем баланс (user already locked, balance is fresh)
|
||||
# Проверяем баланс (при 100% скидке — пропускаем)
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
if user_balance < total_price:
|
||||
if total_price > 0 and user_balance < total_price:
|
||||
await callback.answer('Недостаточно средств на балансе', show_alert=True)
|
||||
return
|
||||
|
||||
@@ -1353,7 +1353,7 @@ async def confirm_tariff_purchase(
|
||||
|
||||
# Проверяем баланс (user already locked, balance is fresh)
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
if user_balance < final_price:
|
||||
if final_price > 0 and user_balance < final_price:
|
||||
await callback.answer('Недостаточно средств на балансе', show_alert=True)
|
||||
return
|
||||
|
||||
@@ -1694,7 +1694,7 @@ async def confirm_daily_tariff_purchase(
|
||||
|
||||
# Проверяем баланс (user already locked, balance is fresh)
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
if user_balance < final_daily_price:
|
||||
if final_daily_price > 0 and user_balance < final_daily_price:
|
||||
await callback.answer('Недостаточно средств на балансе', show_alert=True)
|
||||
return
|
||||
|
||||
@@ -2013,8 +2013,6 @@ async def show_tariff_extend(
|
||||
# Show subscription picker for extending
|
||||
keyboard = []
|
||||
for sub in sorted(active_subs, key=lambda s: s.id):
|
||||
if sub.is_trial:
|
||||
continue
|
||||
tariff_name = ''
|
||||
if sub.tariff_id:
|
||||
_t = await get_tariff_by_id(db, sub.tariff_id)
|
||||
@@ -2246,7 +2244,7 @@ async def confirm_tariff_extend(
|
||||
|
||||
# Проверяем баланс
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
if user_balance < final_price:
|
||||
if final_price > 0 and user_balance < final_price:
|
||||
await callback.answer('Недостаточно средств на балансе', show_alert=True)
|
||||
return
|
||||
|
||||
@@ -2266,11 +2264,17 @@ async def confirm_tariff_extend(
|
||||
await callback.answer('Ошибка списания баланса', show_alert=True)
|
||||
return
|
||||
|
||||
# Продлеваем подписку (параметры тарифа не меняются, только добавляется время)
|
||||
# Запоминаем, был ли триал ДО продления
|
||||
was_trial = subscription.is_trial
|
||||
|
||||
# Продлеваем подписку; для триала передаём tariff_id чтобы сбросить is_trial
|
||||
subscription = await extend_subscription(
|
||||
db,
|
||||
subscription,
|
||||
days=period,
|
||||
tariff_id=tariff.id if was_trial else None,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb if was_trial else None,
|
||||
device_limit=actual_device_limit if was_trial else None,
|
||||
)
|
||||
|
||||
# Обновляем пользователя в Remnawave
|
||||
@@ -2279,8 +2283,8 @@ async def confirm_tariff_extend(
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason='продление тарифа',
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT or was_trial,
|
||||
reset_reason='конвертация триала' if was_trial else 'продление тарифа',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка обновления Remnawave', error=e)
|
||||
@@ -2303,7 +2307,7 @@ async def confirm_tariff_extend(
|
||||
subscription,
|
||||
None, # Транзакция отсутствует, оплата с баланса
|
||||
period,
|
||||
was_trial_conversion=False,
|
||||
was_trial_conversion=was_trial,
|
||||
amount_kopeks=final_price,
|
||||
purchase_type='renewal',
|
||||
)
|
||||
@@ -2836,7 +2840,7 @@ async def confirm_tariff_switch(
|
||||
|
||||
# Проверяем баланс
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
if user_balance < final_price:
|
||||
if final_price > 0 and user_balance < final_price:
|
||||
await callback.answer('Недостаточно средств на балансе', show_alert=True)
|
||||
return
|
||||
|
||||
@@ -3042,7 +3046,7 @@ async def confirm_daily_tariff_switch(
|
||||
|
||||
# Проверяем баланс (user already locked, balance is fresh)
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
if user_balance < final_daily_price:
|
||||
if final_daily_price > 0 and user_balance < final_daily_price:
|
||||
await callback.answer('Недостаточно средств на балансе', show_alert=True)
|
||||
return
|
||||
|
||||
@@ -3946,8 +3950,8 @@ async def return_to_saved_tariff_cart(
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
traffic = format_traffic(tariff.traffic_limit_gb)
|
||||
|
||||
# Проверяем баланс
|
||||
if user_balance < total_price:
|
||||
# Проверяем баланс (при 100% скидке — пропускаем)
|
||||
if total_price > 0 and user_balance < total_price:
|
||||
missing = total_price - user_balance
|
||||
|
||||
if cart_mode == 'daily_tariff_purchase':
|
||||
|
||||
@@ -332,7 +332,7 @@ async def confirm_reset_traffic(
|
||||
|
||||
reset_price = _calculate_traffic_reset_price(subscription)
|
||||
|
||||
if db_user.balance_kopeks < reset_price:
|
||||
if reset_price > 0 and db_user.balance_kopeks < reset_price:
|
||||
missing_kopeks = reset_price - db_user.balance_kopeks
|
||||
message_text = texts.t(
|
||||
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
|
||||
@@ -574,7 +574,7 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
|
||||
|
||||
total_discount_value = int(discount_per_month * charged_days / 30)
|
||||
|
||||
if db_user.balance_kopeks < price:
|
||||
if price > 0 and db_user.balance_kopeks < price:
|
||||
missing_kopeks = price - db_user.balance_kopeks
|
||||
|
||||
# Save cart for auto-purchase after balance top-up
|
||||
@@ -830,7 +830,7 @@ async def confirm_switch_traffic(
|
||||
total_price_difference = int(price_difference_per_month * days_remaining / 30)
|
||||
total_price_difference = max(100, total_price_difference)
|
||||
|
||||
if db_user.balance_kopeks < total_price_difference:
|
||||
if total_price_difference > 0 and db_user.balance_kopeks < total_price_difference:
|
||||
missing_kopeks = total_price_difference - db_user.balance_kopeks
|
||||
message_text = texts.t(
|
||||
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
|
||||
|
||||
@@ -141,8 +141,8 @@ class DailySubscriptionService:
|
||||
PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price
|
||||
)
|
||||
|
||||
# Проверяем баланс
|
||||
if user.balance_kopeks < daily_price:
|
||||
# Проверяем баланс (при 100% скидке — пропускаем)
|
||||
if daily_price > 0 and user.balance_kopeks < daily_price:
|
||||
# Недостаточно средств - приостанавливаем подписку
|
||||
await suspend_daily_subscription_insufficient_balance(db, subscription)
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ from app.database.models import (
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveAPIError,
|
||||
RemnaWaveUser,
|
||||
TrafficLimitStrategy,
|
||||
UserStatus as RemnaWaveUserStatus,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
@@ -58,7 +57,7 @@ from app.services.notification_delivery_service import (
|
||||
)
|
||||
from app.services.notification_settings_service import NotificationSettingsService
|
||||
from app.services.promo_offer_service import promo_offer_service
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.subscription_service import SubscriptionService, get_traffic_reset_strategy
|
||||
from app.utils.cache import cache
|
||||
from app.utils.message_patch import caption_exceeds_telegram_limit
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
@@ -464,7 +463,7 @@ class MonitoringService:
|
||||
if is_active
|
||||
else max(subscription.end_date, current_time + timedelta(minutes=1)),
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name, username=user.username, telegram_id=user.telegram_id
|
||||
),
|
||||
|
||||
@@ -31,9 +31,9 @@ from app.database.models import (
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveAPI,
|
||||
RemnaWaveAPIError,
|
||||
TrafficLimitStrategy,
|
||||
UserStatus,
|
||||
)
|
||||
from app.services.subscription_service import get_traffic_reset_strategy
|
||||
from app.utils.subscription_utils import (
|
||||
resolve_hwid_device_limit_for_payload,
|
||||
)
|
||||
@@ -2240,7 +2240,7 @@ class RemnaWaveService:
|
||||
traffic_limit_bytes=sub.traffic_limit_gb * (1024**3)
|
||||
if sub.traffic_limit_gb > 0
|
||||
else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
traffic_limit_strategy=get_traffic_reset_strategy(sub.tariff),
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
description=settings.format_remnawave_user_description(
|
||||
@@ -2325,7 +2325,7 @@ class RemnaWaveService:
|
||||
status=status,
|
||||
expire_at=expire_at,
|
||||
traffic_limit_bytes=create_kwargs['traffic_limit_bytes'],
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
traffic_limit_strategy=get_traffic_reset_strategy(sub.tariff),
|
||||
email=user.email,
|
||||
description=create_kwargs['description'],
|
||||
active_internal_squads=sub.connected_squads,
|
||||
|
||||
@@ -316,7 +316,7 @@ async def _prepare_auto_extend_context(
|
||||
)
|
||||
return None
|
||||
|
||||
if price_kopeks <= 0:
|
||||
if price_kopeks <= 0 and pricing.base_price <= 0:
|
||||
logger.warning(
|
||||
'🔁 Автопокупка: некорректная цена продления у пользователя',
|
||||
price_kopeks=price_kopeks,
|
||||
@@ -424,7 +424,7 @@ async def _auto_extend_subscription(
|
||||
if prepared is None:
|
||||
return False
|
||||
|
||||
if user.balance_kopeks < prepared.price_kopeks:
|
||||
if prepared.price_kopeks > 0 and user.balance_kopeks < prepared.price_kopeks:
|
||||
logger.info(
|
||||
'🔁 Автопокупка: у пользователя недостаточно средств для продления (<)',
|
||||
format_user_id=_format_user_id(user),
|
||||
@@ -801,7 +801,7 @@ async def _auto_purchase_tariff(
|
||||
final_price = result.final_total
|
||||
consume_promo = result.promo_offer_discount > 0
|
||||
|
||||
if user.balance_kopeks < final_price:
|
||||
if final_price > 0 and user.balance_kopeks < final_price:
|
||||
logger.info(
|
||||
'🔁 Автопокупка тарифа: у пользователя недостаточно средств (<)',
|
||||
format_user_id=_format_user_id(user),
|
||||
@@ -1131,7 +1131,7 @@ async def _auto_purchase_daily_tariff(
|
||||
final_price, _, _ = PricingEngine.apply_stacked_discounts(daily_price, group_pct, offer_pct)
|
||||
consume_promo = offer_pct > 0
|
||||
|
||||
if user.balance_kopeks < final_price:
|
||||
if final_price > 0 and user.balance_kopeks < final_price:
|
||||
logger.info(
|
||||
'🔁 Автопокупка суточного тарифа: у пользователя недостаточно средств (<)',
|
||||
format_user_id=_format_user_id(user),
|
||||
@@ -1532,8 +1532,8 @@ async def _auto_add_devices(
|
||||
days_left=days_left,
|
||||
)
|
||||
|
||||
# Проверяем баланс (с актуальной ценой)
|
||||
if user.balance_kopeks < price_kopeks:
|
||||
# Проверяем баланс (при 100% скидке — пропускаем)
|
||||
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
|
||||
logger.info(
|
||||
'🔁 Автопокупка устройств: у пользователя недостаточно средств (<)',
|
||||
format_user_id=_format_user_id(user),
|
||||
@@ -1883,8 +1883,8 @@ async def _auto_add_traffic(
|
||||
period_hint_days=period_hint_days,
|
||||
)
|
||||
|
||||
# Verify balance (with fresh price)
|
||||
if user.balance_kopeks < price_kopeks:
|
||||
# Verify balance (при 100% скидке — пропускаем)
|
||||
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
|
||||
logger.info(
|
||||
'🔁 Автопокупка трафика: у пользователя недостаточно средств (<)',
|
||||
format_user_id=_format_user_id(user),
|
||||
@@ -2172,7 +2172,7 @@ async def try_auto_extend_expired_after_topup(
|
||||
breakdown=pricing.breakdown,
|
||||
)
|
||||
|
||||
if renewal_cost <= 0:
|
||||
if renewal_cost <= 0 and pricing.base_price <= 0:
|
||||
logger.warning(
|
||||
'❌ Автопродление expired: некорректная стоимость',
|
||||
format_user_id=_format_user_id(user),
|
||||
@@ -2180,8 +2180,8 @@ async def try_auto_extend_expired_after_topup(
|
||||
)
|
||||
return False
|
||||
|
||||
# Check balance
|
||||
if user.balance_kopeks < renewal_cost:
|
||||
# Check balance (skip for 100% discount)
|
||||
if renewal_cost > 0 and user.balance_kopeks < renewal_cost:
|
||||
logger.info(
|
||||
'🔄 Автопродление expired: недостаточно средств',
|
||||
format_user_id=_format_user_id(user),
|
||||
@@ -2523,8 +2523,8 @@ async def try_resume_disabled_daily_after_topup(
|
||||
PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price
|
||||
)
|
||||
|
||||
# Check balance (uses locked user's balance_kopeks — safe from concurrent reads)
|
||||
if user.balance_kopeks < daily_price:
|
||||
# Check balance (при 100% скидке — пропускаем)
|
||||
if daily_price > 0 and user.balance_kopeks < daily_price:
|
||||
logger.info(
|
||||
'🔄 Авто-возобновление daily: недостаточно средств',
|
||||
format_user_id=_format_user_id(user),
|
||||
@@ -3039,7 +3039,7 @@ async def _process_legacy_generic_cart(
|
||||
pricing = prepared.pricing
|
||||
selection = prepared.selection
|
||||
|
||||
if pricing.final_total <= 0:
|
||||
if pricing.final_total <= 0 and pricing.base_original_total <= 0:
|
||||
logger.warning(
|
||||
'Автопокупка: итоговая сумма для пользователя некорректна',
|
||||
format_user_id=_format_user_id(user),
|
||||
@@ -3047,7 +3047,7 @@ async def _process_legacy_generic_cart(
|
||||
)
|
||||
return False
|
||||
|
||||
if user.balance_kopeks < pricing.final_total:
|
||||
if pricing.final_total > 0 and user.balance_kopeks < pricing.final_total:
|
||||
logger.info(
|
||||
'Автопокупка: у пользователя недостаточно средств',
|
||||
format_user_id=_format_user_id(user),
|
||||
|
||||
@@ -989,10 +989,12 @@ class MiniAppSubscriptionPurchaseService:
|
||||
user = context.user
|
||||
texts = get_texts(getattr(user, 'language', None))
|
||||
|
||||
if pricing.final_total <= 0:
|
||||
# Block only if pricing is genuinely invalid (no base price configured).
|
||||
# final_total == 0 with base_original_total > 0 means a valid 100% discount.
|
||||
if pricing.final_total <= 0 and pricing.base_original_total <= 0:
|
||||
raise PurchaseValidationError('Invalid total amount', code='calculation_error')
|
||||
|
||||
if user.balance_kopeks < pricing.final_total:
|
||||
if pricing.final_total > 0 and user.balance_kopeks < pricing.final_total:
|
||||
raise PurchaseBalanceError(
|
||||
texts.t(
|
||||
'MINIAPP_PURCHASE_STATUS_INSUFFICIENT',
|
||||
|
||||
@@ -6563,8 +6563,8 @@ async def purchase_tariff_endpoint(
|
||||
group_pcts = bd.get('group_discount_pct', {})
|
||||
discount_percent = group_pcts.get('period', 0)
|
||||
|
||||
# Проверяем баланс
|
||||
if user.balance_kopeks < price_kopeks:
|
||||
# Проверяем баланс (при 100% скидке — пропускаем)
|
||||
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
|
||||
missing = price_kopeks - user.balance_kopeks
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
@@ -7194,8 +7194,8 @@ async def purchase_traffic_topup_endpoint(
|
||||
subscription.end_date,
|
||||
)
|
||||
|
||||
# Проверяем баланс
|
||||
if user.balance_kopeks < final_price:
|
||||
# Проверяем баланс (при 100% скидке — пропускаем)
|
||||
if final_price > 0 and user.balance_kopeks < final_price:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""include limited status in partial unique index for subscriptions
|
||||
|
||||
Revision ID: 0053
|
||||
Revises: 0052
|
||||
Create Date: 2026-04-03
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0053'
|
||||
down_revision: Union[str, None] = '0052'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop old partial unique index that only covered active/trial
|
||||
op.execute(sa.text('DROP INDEX IF EXISTS uq_subscriptions_user_tariff_active'))
|
||||
|
||||
# Recreate with limited status included — a limited subscription (traffic
|
||||
# exhausted but time remaining) is still "alive" and should prevent
|
||||
# duplicate subscriptions for the same user+tariff combination.
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
CREATE UNIQUE INDEX uq_subscriptions_user_tariff_active
|
||||
ON subscriptions (user_id, tariff_id)
|
||||
WHERE tariff_id IS NOT NULL AND status IN ('active', 'trial', 'limited')
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(sa.text('DROP INDEX IF EXISTS uq_subscriptions_user_tariff_active'))
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
CREATE UNIQUE INDEX uq_subscriptions_user_tariff_active
|
||||
ON subscriptions (user_id, tariff_id)
|
||||
WHERE tariff_id IS NOT NULL AND status IN ('active', 'trial')
|
||||
"""
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user