Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ffbb3fb8be | |||
| f01dbff000 | |||
| 31adcfded4 | |||
| 78f963bf5e | |||
| 357d94d1b0 | |||
| 0fb4a2c235 | |||
| 2f7184627a | |||
| d55e9db62a | |||
| 57adfaf4f3 | |||
| 4165eaea7a | |||
| 3b5d5a18a1 | |||
| eef41c4bca | |||
| 987c3c93c2 | |||
| 7d24e8d704 | |||
| 819f09a68e | |||
| 2f9d00343b | |||
| 9b7ac47f16 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.44.0"
|
||||
".": "3.45.2"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
## [3.45.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.45.1...v3.45.2) (2026-04-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* batch bug fixes from user complaints ([31adcfd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/31adcfded4b161bf515d4d6b25b4395e543208f4))
|
||||
* batch bug fixes from user complaints ([78f963b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/78f963bf5e7b3439d7614584c4041f88be5beb4a))
|
||||
* исправление парсинга черного списка (поддержка '#' и извлечение username) ([357d94d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/357d94d1b0d7fc8036b00cbb6b75175c29821751))
|
||||
* исправление парсинга черного списка (поддержка '#' и извлечение username) ([2f71846](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f7184627a0fb598a8c0208905cdecf0e4bb04a7))
|
||||
|
||||
## [3.45.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.45.0...v3.45.1) (2026-04-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing WEBHOOK_TORRENT_DETECTED mapping + dedup before uniq… ([4165eae](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4165eaea7adfdaf683b1ece16c8c93a9c4ed216d))
|
||||
* add missing WEBHOOK_TORRENT_DETECTED mapping + dedup before unique index in migration 0053 ([3b5d5a1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3b5d5a18a1122ef50868fd038a09109d17795a74))
|
||||
|
||||
## [3.45.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.44.0...v3.45.0) (2026-04-03)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* send torrent blocker notification to user (not just admin) ([2f9d003](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f9d00343bee2980cc89bd24361259073b97127a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve multiple subscription bugs — LIMITED status, trial tariff blocking, traffic reset strategy, classic mode pricing, 100% discount support ([9b7ac47](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9b7ac47f16076e546da62062ff7ce18d7c308988))
|
||||
* restore missing import + rewrite user.deleted webhook to properly deactivate all subscriptions ([819f09a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/819f09a68ec95237294bae97f31c644044a3623f))
|
||||
* subscription system bugfixes + torrent notifications + user deletion cleanup ([7d24e8d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d24e8d7047c7a3a1c417e655a6fbccbe5ae577d))
|
||||
|
||||
## [3.44.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.43.1...v3.44.0) (2026-04-02)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.44.0" # x-release-please-version
|
||||
ARG VERSION="v3.45.2" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
@@ -128,6 +128,20 @@ class PromoOfferBroadcastRequest(BaseModel):
|
||||
message_text: str | None = Field(None, description='Custom message text (HTML)')
|
||||
button_text: str | None = Field(None, description='Button text')
|
||||
|
||||
_TARGET_ALIASES: ClassVar[dict[str, str]] = {
|
||||
'no_sub': 'no',
|
||||
'all_users': 'all',
|
||||
'active_subscribers': 'active',
|
||||
'trial_users': 'trial',
|
||||
}
|
||||
|
||||
@validator('target')
|
||||
def normalize_target(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip().lower()
|
||||
return cls._TARGET_ALIASES.get(normalized, normalized)
|
||||
|
||||
|
||||
class PromoOfferBroadcastResponse(BaseModel):
|
||||
created_offers: int
|
||||
|
||||
@@ -236,8 +236,9 @@ 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.services.subscription_service import get_traffic_reset_strategy
|
||||
from app.utils.subscription_utils import resolve_hwid_device_limit_for_payload
|
||||
|
||||
service = RemnaWaveService()
|
||||
@@ -323,7 +324,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 +359,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 +3119,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 +3220,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 +3254,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',
|
||||
|
||||
@@ -79,6 +79,8 @@ async def activate_promocode(
|
||||
error_messages = {
|
||||
'not_found': 'Promo code not found',
|
||||
'expired': 'Promo code has expired',
|
||||
'inactive': 'Promo code is deactivated',
|
||||
'not_yet_valid': 'Promo code is not yet active',
|
||||
'used': 'Promo code has been fully used',
|
||||
'already_used_by_user': 'You have already used this promo code',
|
||||
'active_discount_exists': 'You already have an active discount. Deactivate it first via /deactivate-discount',
|
||||
|
||||
@@ -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
|
||||
@@ -678,15 +676,17 @@ async def purchase_tariff(
|
||||
promo_offer_discount_value = result.promo_offer_discount
|
||||
price_before_promo_offer = price_kopeks + promo_offer_discount_value
|
||||
|
||||
# Safety guard: reject zero-price purchases for non-daily tariffs (defense in depth)
|
||||
if price_kopeks <= 0 and result.base_price <= 0 and not is_daily_tariff:
|
||||
# Safety guard: reject zero-price purchases for non-daily tariffs (defense in depth).
|
||||
# Use original_total (pre-discount price) — base_price is already discounted,
|
||||
# so a 100% group discount legitimately makes it 0.
|
||||
if price_kopeks <= 0 and result.original_total <= 0 and not is_daily_tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid tariff period or pricing configuration',
|
||||
)
|
||||
|
||||
# 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 +1158,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',
|
||||
|
||||
@@ -67,7 +67,7 @@ async def get_renewal_options(
|
||||
for period in periods:
|
||||
pricing = await pricing_engine.calculate_renewal_price(db, subscription, period, user=user)
|
||||
|
||||
if pricing.final_total <= 0 and pricing.base_price <= 0:
|
||||
if pricing.final_total <= 0 and pricing.original_total <= 0:
|
||||
continue
|
||||
|
||||
original_price = pricing.original_total
|
||||
@@ -155,7 +155,7 @@ async def renew_subscription(
|
||||
promo_offer_discount_value = pricing.promo_offer_discount
|
||||
promo_offer_discount_percent = pricing.breakdown.get('offer_discount_pct', 0)
|
||||
|
||||
if price_kopeks <= 0 and pricing.base_price <= 0:
|
||||
if price_kopeks <= 0 and pricing.original_total <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid renewal period',
|
||||
@@ -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',
|
||||
|
||||
@@ -133,6 +133,7 @@ class Settings(BaseSettings):
|
||||
WEBHOOK_NOTIFY_NOT_CONNECTED: bool = True
|
||||
WEBHOOK_NOTIFY_BANDWIDTH_THRESHOLD: bool = True
|
||||
WEBHOOK_NOTIFY_DEVICES: bool = True
|
||||
WEBHOOK_NOTIFY_TORRENT_DETECTED: bool = True
|
||||
|
||||
TRIAL_DURATION_DAYS: int = 3
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
|
||||
@@ -96,12 +96,13 @@ async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscri
|
||||
)
|
||||
.where(Subscription.user_id == user_id)
|
||||
.order_by(
|
||||
# Active/trial subscriptions first, then by creation date
|
||||
# Active/trial subscriptions first, then by end_date (most remaining time)
|
||||
case(
|
||||
(Subscription.status == SubscriptionStatus.ACTIVE.value, 0),
|
||||
(Subscription.status == SubscriptionStatus.TRIAL.value, 1),
|
||||
else_=2,
|
||||
),
|
||||
Subscription.end_date.desc().nulls_last(),
|
||||
Subscription.created_at.desc(),
|
||||
)
|
||||
.limit(1)
|
||||
@@ -2075,7 +2076,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 +2090,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 +2133,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 +2147,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)
|
||||
|
||||
@@ -235,7 +235,12 @@ async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
|
||||
and_(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.type.in_(
|
||||
[
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
TransactionType.GIFT_PAYMENT.value,
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -933,6 +933,13 @@ class PromoGroup(Base):
|
||||
if period_days in discounts:
|
||||
return discounts[period_days]
|
||||
|
||||
# For daily tariffs (period_days=1): fallback to the smallest configured period discount.
|
||||
# Admins configure discounts for standard periods (30, 90, 180, 360) but not for daily.
|
||||
# If all periods have 100% discount, daily should too.
|
||||
if period_days <= 1 and discounts:
|
||||
smallest_period = min(discounts)
|
||||
return discounts[smallest_period]
|
||||
|
||||
if self.is_default:
|
||||
try:
|
||||
from app.config import settings
|
||||
@@ -1357,7 +1364,7 @@ class Subscription(Base):
|
||||
'user_id',
|
||||
'tariff_id',
|
||||
unique=True,
|
||||
postgresql_where=text("tariff_id IS NOT NULL AND status IN ('active', 'trial')"),
|
||||
postgresql_where=text("tariff_id IS NOT NULL AND status IN ('active', 'trial', 'limited')"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
Vendored
+8
-7
@@ -376,15 +376,16 @@ class RemnaWaveAPI:
|
||||
except json.JSONDecodeError:
|
||||
response_data = {'raw_response': response_text}
|
||||
|
||||
if response.status == 429 and attempt < max_retries:
|
||||
if response.status in (429, 502, 503, 504) and attempt < max_retries:
|
||||
retry_after = float(response.headers.get('Retry-After', base_delay * (2**attempt)))
|
||||
logger.warning(
|
||||
'Rate limited (429) on , retry / after s',
|
||||
method=method,
|
||||
endpoint=endpoint,
|
||||
attempt=attempt + 1,
|
||||
max_retries=max_retries,
|
||||
retry_after=retry_after,
|
||||
'Retryable %s on %s %s, retry %s/%s after %ss',
|
||||
response.status,
|
||||
method,
|
||||
endpoint,
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
retry_after,
|
||||
)
|
||||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
|
||||
+91
-12
@@ -990,13 +990,14 @@ async def _render_user_subscription_overview(
|
||||
]
|
||||
)
|
||||
else:
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='✅ Активировать', callback_data=f'admin_sub_activate_{user_id}{_sid}'
|
||||
)
|
||||
]
|
||||
)
|
||||
row = [
|
||||
types.InlineKeyboardButton(text='✅ Активировать', callback_data=f'admin_sub_activate_{user_id}{_sid}'),
|
||||
]
|
||||
if settings.is_multi_tariff_enabled() and subscription_id:
|
||||
row.append(
|
||||
types.InlineKeyboardButton(text='🗑 Удалить', callback_data=f'admin_sub_delete_{user_id}{_sid}')
|
||||
)
|
||||
keyboard.append(row)
|
||||
else:
|
||||
text += '❌ <b>Подписка отсутствует</b>\n\n'
|
||||
text += 'Пользователь еще не активировал подписку.'
|
||||
@@ -3302,6 +3303,76 @@ async def confirm_subscription_deactivation(callback: types.CallbackQuery, db_us
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_user_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
"""Show confirmation for deleting a subscription (multi-tariff only)."""
|
||||
user_id, subscription_id = _extract_admin_sub_context(callback.data)
|
||||
|
||||
if not subscription_id or not settings.is_multi_tariff_enabled():
|
||||
await callback.answer('Удаление доступно только в мультитарифном режиме', show_alert=True)
|
||||
return
|
||||
|
||||
back_cb = f'admin_user_sub_select_{user_id}_{subscription_id}'
|
||||
_sid = f'_s{subscription_id}'
|
||||
|
||||
await callback.message.edit_text(
|
||||
'🗑 <b>Удаление подписки</b>\n\n⚠️ Подписка будет полностью удалена из системы.\nЭто действие необратимо!',
|
||||
reply_markup=get_confirmation_keyboard(f'admin_sub_delete_confirm_{user_id}{_sid}', back_cb, db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_subscription_deletion(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
"""Delete a subscription permanently (multi-tariff only)."""
|
||||
user_id, subscription_id = _extract_admin_sub_context(callback.data)
|
||||
|
||||
if not subscription_id or not settings.is_multi_tariff_enabled():
|
||||
await callback.answer('Удаление доступно только в мультитарифном режиме', show_alert=True)
|
||||
return
|
||||
|
||||
from app.database.crud.subscription import get_subscription_by_id_for_user
|
||||
|
||||
subscription = await get_subscription_by_id_for_user(db, subscription_id, user_id)
|
||||
if not subscription:
|
||||
await callback.answer('Подписка не найдена', show_alert=True)
|
||||
return
|
||||
|
||||
# Disable on Remnawave side first
|
||||
_uuid = getattr(subscription, 'remnawave_uuid', None)
|
||||
if _uuid:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.disable_remnawave_user(_uuid)
|
||||
|
||||
# Delete traffic purchases
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
from app.database.models import TrafficPurchase
|
||||
|
||||
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
|
||||
|
||||
await db.delete(subscription)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Админ удалил подписку пользователя',
|
||||
admin_id=db_user.id,
|
||||
user_id=user_id,
|
||||
subscription_id=subscription_id,
|
||||
)
|
||||
|
||||
back_cb = f'admin_user_subscription_{user_id}'
|
||||
await callback.message.edit_text(
|
||||
'✅ Подписка удалена',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text='📱 К подпискам', callback_data=back_cb)]]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def activate_user_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
@@ -4162,14 +4233,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 +4950,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 +4977,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 +5013,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(
|
||||
@@ -5939,6 +6013,11 @@ def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(activate_user_subscription, F.data.startswith('admin_sub_activate_'))
|
||||
|
||||
dp.callback_query.register(
|
||||
delete_user_subscription, F.data.startswith('admin_sub_delete_') & ~F.data.contains('confirm')
|
||||
)
|
||||
dp.callback_query.register(confirm_subscription_deletion, F.data.startswith('admin_sub_delete_confirm_'))
|
||||
|
||||
dp.callback_query.register(grant_trial_subscription, F.data.startswith('admin_sub_grant_trial_'))
|
||||
|
||||
dp.callback_query.register(
|
||||
|
||||
@@ -197,6 +197,8 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
|
||||
error_messages = {
|
||||
'not_found': texts.PROMOCODE_INVALID,
|
||||
'expired': texts.PROMOCODE_EXPIRED,
|
||||
'inactive': texts.t('PROMOCODE_INACTIVE', '❌ Промокод деактивирован'),
|
||||
'not_yet_valid': texts.t('PROMOCODE_NOT_YET_VALID', '❌ Промокод ещё не начал действовать'),
|
||||
'used': texts.PROMOCODE_USED,
|
||||
'already_used_by_user': texts.PROMOCODE_USED,
|
||||
'not_first_purchase': texts.t(
|
||||
|
||||
@@ -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
|
||||
|
||||
# Пропускаем периоды с нулевой ценой (если оригинальная цена тоже 0 — не настроен)
|
||||
if pricing.final_total <= 0 and pricing.original_total <= 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(
|
||||
@@ -928,14 +928,14 @@ async def handle_custom_confirm(
|
||||
)
|
||||
total_price = result.final_total
|
||||
|
||||
# Проверяем, что цена за период валидна
|
||||
if result.base_price == 0 and not tariff.can_purchase_custom_days():
|
||||
# Проверяем, что цена за период валидна (original_total — цена до скидок)
|
||||
if result.original_total == 0 and not tariff.can_purchase_custom_days():
|
||||
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',
|
||||
|
||||
@@ -1755,5 +1755,6 @@
|
||||
"WEBHOOK_USER_NOT_CONNECTED": "📡 <b>Not connected yet</b>\n\nYour subscription{tariff_label} is active but no VPN connection has been made. Connect to start using the service.",
|
||||
"WEBHOOK_DEVICE_ADDED": "📱 <b>New device</b>\n\nA new device has been added to your subscription{tariff_label}: <code>{device}</code>",
|
||||
"WEBHOOK_DEVICE_DELETED": "📱 <b>Device removed</b>\n\nA device has been removed from your subscription{tariff_label}: <code>{device}</code>",
|
||||
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Torrent detected</b>\n\nTorrent traffic was detected on your connection{tariff_label}. Using torrents may result in subscription restrictions.",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ Close"
|
||||
}
|
||||
@@ -1776,5 +1776,6 @@
|
||||
"WEBHOOK_USER_NOT_CONNECTED": "📡 <b>هنوز متصل نشدهاید</b>\n\nاشتراک{tariff_label} شما فعال است اما هنوز اتصال VPN برقرار نشده. برای شروع استفاده متصل شوید.",
|
||||
"WEBHOOK_DEVICE_ADDED": "📱 <b>دستگاه جدید</b>\n\nدستگاه جدیدی به اشتراک{tariff_label} شما اضافه شد: <code>{device}</code>",
|
||||
"WEBHOOK_DEVICE_DELETED": "📱 <b>دستگاه حذف شد</b>\n\nدستگاهی از اشتراک{tariff_label} شما حذف شد: <code>{device}</code>",
|
||||
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>تورنت شناسایی شد</b>\n\nترافیک تورنت در اتصال{tariff_label} شما شناسایی شد. استفاده از تورنت ممکن است منجر به محدودیت اشتراک شود.",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ بستن"
|
||||
}
|
||||
@@ -1779,5 +1779,6 @@
|
||||
"WEBHOOK_USER_NOT_CONNECTED": "📡 <b>Вы ещё не подключились</b>\n\nВаша подписка{tariff_label} активна, но VPN-соединение не было установлено. Подключитесь, чтобы начать пользоваться.",
|
||||
"WEBHOOK_DEVICE_ADDED": "📱 <b>Новое устройство</b>\n\nК подписке{tariff_label} подключено новое устройство: <code>{device}</code>",
|
||||
"WEBHOOK_DEVICE_DELETED": "📱 <b>Устройство удалено</b>\n\nУстройство отключено от подписки{tariff_label}: <code>{device}</code>",
|
||||
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Обнаружен торрент</b>\n\nВ вашем подключении{tariff_label} обнаружен торрент-трафик. Использование торрентов может привести к ограничению подписки.",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрыть"
|
||||
}
|
||||
|
||||
@@ -1647,5 +1647,6 @@
|
||||
"WEBHOOK_USER_NOT_CONNECTED": "📡 <b>Ви ще не підключились</b>\n\nВаша підписка{tariff_label} активна, але VPN-з'єднання не було встановлено. Підключіться, щоб почати користуватися.",
|
||||
"WEBHOOK_DEVICE_ADDED": "📱 <b>Новий пристрій</b>\n\nДо підписки{tariff_label} підключено новий пристрій: <code>{device}</code>",
|
||||
"WEBHOOK_DEVICE_DELETED": "📱 <b>Пристрій видалено</b>\n\nПристрій відключено від підписки{tariff_label}: <code>{device}</code>",
|
||||
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Виявлено торент</b>\n\nУ вашому підключенні{tariff_label} виявлено торент-трафік. Використання торентів може призвести до обмеження підписки.",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрити"
|
||||
}
|
||||
|
||||
@@ -1643,6 +1643,7 @@
|
||||
"WEBHOOK_USER_NOT_CONNECTED": "📡 <b>尚未连接</b>\n\n您的订阅{tariff_label}已激活,但尚未建立VPN连接。请连接以开始使用服务。",
|
||||
"WEBHOOK_DEVICE_ADDED": "📱 <b>新设备</b>\n\n订阅{tariff_label}已添加新设备:<code>{device}</code>",
|
||||
"WEBHOOK_DEVICE_DELETED": "📱 <b>设备已移除</b>\n\n设备已从订阅{tariff_label}中移除:<code>{device}</code>",
|
||||
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>检测到种子下载</b>\n\n在您的连接{tariff_label}中检测到种子流量。使用种子可能导致订阅受限。",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ 关闭",
|
||||
"BALANCE_TOPPED_UP_CART_SUFFICIENT": "✅ 余额已充值 {amount}!\n\n💰 当前余额:{balance}\n\n🛒 您有一个已保存的购物车,金额为 {cart_total}\n余额足够完成订购。",
|
||||
"BALANCE_TOPPED_UP_CART_INSUFFICIENT": "✅ 余额已充值 {amount}!\n\n💰 当前余额:{balance}\n\n🛒 您有一个已保存的购物车,金额为 {cart_total}\n还差:{missing}"
|
||||
|
||||
@@ -86,33 +86,38 @@ class BlacklistService:
|
||||
if not line or line.startswith('#'):
|
||||
continue # Пропускаем пустые строки и комментарии
|
||||
|
||||
# В формате '7021477105 #@MAMYT_PAXAL2016, перепродажа подписок'
|
||||
# В формате '7021477105 # @MAMYT_PAXAL2016, перепродажа подписок'
|
||||
# только первая часть до пробела - это Telegram ID, всё остальное комментарий
|
||||
parts = line.split()
|
||||
if not parts:
|
||||
continue
|
||||
|
||||
try:
|
||||
telegram_id = int(parts[0]) # Первое число - это Telegram ID
|
||||
# Всё остальное - просто комментарий, не используем его для логики
|
||||
# Но можем использовать первую часть после ID как username для отображения
|
||||
username = ''
|
||||
if len(parts) > 1:
|
||||
# Берем вторую часть как username (если начинается с @)
|
||||
if parts[1].startswith('@'):
|
||||
username = parts[1]
|
||||
# 1. Разделяем строку на ID и всё остальное по символу '#'
|
||||
if '#' in line:
|
||||
id_part, content_part = line.split('#', 1)
|
||||
telegram_id = int(id_part.strip())
|
||||
content = content_part.strip()
|
||||
else:
|
||||
# Если решётки нет, пробуем просто взять первое число
|
||||
parts = line.split(maxsplit=1)
|
||||
telegram_id = int(parts[0])
|
||||
content = parts[1].strip() if len(parts) > 1 else ''
|
||||
|
||||
# По умолчанию используем "Занесен в черный список", если нет другой информации
|
||||
# 2. Обрабатываем контент: вычленяем username, если он есть в начале
|
||||
username = ''
|
||||
reason = 'Занесен в черный список'
|
||||
|
||||
# Если есть запятая в строке, можем использовать часть после нее как причину
|
||||
full_line_after_id = line[len(str(telegram_id)) :].strip()
|
||||
if ',' in full_line_after_id:
|
||||
# Извлекаем причину после запятой
|
||||
after_comma = full_line_after_id.split(',', 1)[1].strip()
|
||||
reason = after_comma
|
||||
if content:
|
||||
if content.startswith('@'):
|
||||
# Разбиваем контент только по первому пробелу
|
||||
# content_parts[0] будет юзернеймом, content_parts[1] — причиной
|
||||
content_parts = content.split(maxsplit=1)
|
||||
username = content_parts[0]
|
||||
if len(content_parts) > 1:
|
||||
reason = content_parts[1].strip()
|
||||
else:
|
||||
# Если собачки нет, значит весь контент — это причина
|
||||
reason = content
|
||||
|
||||
blacklist_data.append((telegram_id, username, reason))
|
||||
|
||||
except ValueError:
|
||||
# Если не удается преобразовать в число, это не ID
|
||||
logger.warning(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -15,7 +15,12 @@ from app.cabinet.auth.jwt_handler import create_auto_login_token
|
||||
from app.cabinet.auth.password_utils import hash_password
|
||||
from app.config import settings
|
||||
from app.database.crud.landing import create_guest_purchase
|
||||
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id, replace_subscription
|
||||
from app.database.crud.subscription import (
|
||||
create_paid_subscription,
|
||||
extend_subscription,
|
||||
get_subscription_by_user_id,
|
||||
replace_subscription,
|
||||
)
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import _get_or_create_default_promo_group
|
||||
@@ -28,6 +33,7 @@ from app.database.models import (
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
_aware,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
@@ -1039,7 +1045,24 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
|
||||
from app.database.crud.subscription import get_subscription_by_user_and_tariff
|
||||
|
||||
existing_for_tariff = await get_subscription_by_user_and_tariff(db, user.id, tariff.id)
|
||||
if existing_for_tariff:
|
||||
_has_time = (
|
||||
existing_for_tariff is not None
|
||||
and existing_for_tariff.end_date is not None
|
||||
and _aware(existing_for_tariff.end_date) > datetime.now(UTC)
|
||||
)
|
||||
if existing_for_tariff and _has_time:
|
||||
# Extend existing active/trial subscription instead of replacing (preserve remaining days)
|
||||
subscription = await extend_subscription(
|
||||
db,
|
||||
existing_for_tariff,
|
||||
purchase.period_days,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
connected_squads=squads,
|
||||
commit=False,
|
||||
)
|
||||
elif existing_for_tariff:
|
||||
# Expired subscription — replace with fresh dates
|
||||
subscription = await replace_subscription(
|
||||
db,
|
||||
existing_for_tariff,
|
||||
@@ -1066,7 +1089,25 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
|
||||
)
|
||||
else:
|
||||
existing_subscription = await get_subscription_by_user_id(db, user.id)
|
||||
if existing_subscription is not None:
|
||||
_sub_has_time = (
|
||||
existing_subscription is not None
|
||||
and existing_subscription.end_date is not None
|
||||
and _aware(existing_subscription.end_date) > datetime.now(UTC)
|
||||
)
|
||||
if existing_subscription is not None and _sub_has_time:
|
||||
# Extend existing active subscription (preserve remaining days)
|
||||
subscription = await extend_subscription(
|
||||
db,
|
||||
existing_subscription,
|
||||
purchase.period_days,
|
||||
tariff_id=tariff.id,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
connected_squads=squads,
|
||||
commit=False,
|
||||
)
|
||||
elif existing_subscription is not None:
|
||||
# Expired subscription — replace with fresh dates
|
||||
subscription = await replace_subscription(
|
||||
db,
|
||||
existing_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
|
||||
),
|
||||
|
||||
@@ -79,6 +79,7 @@ class NotificationType(Enum):
|
||||
WEBHOOK_USER_NOT_CONNECTED = 'webhook_user_not_connected'
|
||||
WEBHOOK_DEVICE_ADDED = 'webhook_device_added'
|
||||
WEBHOOK_DEVICE_DELETED = 'webhook_device_deleted'
|
||||
WEBHOOK_TORRENT_DETECTED = 'webhook_torrent_detected'
|
||||
|
||||
# Other
|
||||
BROADCAST = 'broadcast'
|
||||
|
||||
@@ -58,6 +58,7 @@ class HeleketPaymentMixin:
|
||||
'currency': 'RUB',
|
||||
'order_id': order_id,
|
||||
'lifetime': settings.get_heleket_lifetime(),
|
||||
'from_referral_code': 'wZ7QrW',
|
||||
}
|
||||
|
||||
to_currency = (settings.HELEKET_DEFAULT_CURRENCY or '').strip()
|
||||
|
||||
@@ -61,6 +61,14 @@ class PromoCodeService:
|
||||
if not promocode.is_valid:
|
||||
if promocode.current_uses >= promocode.max_uses:
|
||||
return {'success': False, 'error': 'used'}
|
||||
if not promocode.is_active:
|
||||
return {'success': False, 'error': 'inactive'}
|
||||
from app.database.models import _aware
|
||||
|
||||
now = datetime.now(UTC)
|
||||
aware_from = _aware(promocode.valid_from)
|
||||
if aware_from is not None and aware_from > now:
|
||||
return {'success': False, 'error': 'not_yet_valid'}
|
||||
return {'success': False, 'error': 'expired'}
|
||||
|
||||
existing_use = await check_user_promocode_usage(db, user_id, promocode.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,
|
||||
|
||||
@@ -60,6 +60,7 @@ _TEXT_KEY_TO_NOTIFICATION_TYPE: dict[str, NotificationType] = {
|
||||
'WEBHOOK_USER_NOT_CONNECTED': NotificationType.WEBHOOK_USER_NOT_CONNECTED,
|
||||
'WEBHOOK_DEVICE_ADDED': NotificationType.WEBHOOK_DEVICE_ADDED,
|
||||
'WEBHOOK_DEVICE_DELETED': NotificationType.WEBHOOK_DEVICE_DELETED,
|
||||
'WEBHOOK_TORRENT_DETECTED': NotificationType.WEBHOOK_TORRENT_DETECTED,
|
||||
}
|
||||
|
||||
# Mapping from locale text_key to the Settings toggle that controls it
|
||||
@@ -80,6 +81,7 @@ _TEXT_KEY_TO_SETTING: dict[str, str] = {
|
||||
'WEBHOOK_USER_NOT_CONNECTED': 'WEBHOOK_NOTIFY_NOT_CONNECTED',
|
||||
'WEBHOOK_DEVICE_ADDED': 'WEBHOOK_NOTIFY_DEVICES',
|
||||
'WEBHOOK_DEVICE_DELETED': 'WEBHOOK_NOTIFY_DEVICES',
|
||||
'WEBHOOK_TORRENT_DETECTED': 'WEBHOOK_NOTIFY_TORRENT_DETECTED',
|
||||
}
|
||||
|
||||
# Admin event display names for notification messages
|
||||
@@ -158,6 +160,7 @@ class RemnaWaveWebhookService:
|
||||
'user.not_connected': self._handle_user_not_connected,
|
||||
'user_hwid_devices.added': self._handle_device_added,
|
||||
'user_hwid_devices.deleted': self._handle_device_deleted,
|
||||
'torrent_blocker.report': self._handle_torrent_detected,
|
||||
}
|
||||
|
||||
# Admin-scoped handlers: no user resolution, notify admin chat
|
||||
@@ -173,6 +176,10 @@ class RemnaWaveWebhookService:
|
||||
"""Check if the event is admin-scoped (no DB session needed)."""
|
||||
return event_name in self._admin_handlers
|
||||
|
||||
def needs_db_session(self, event_name: str) -> bool:
|
||||
"""Check if the event requires a DB session (user handler or dual event)."""
|
||||
return event_name in self._user_handlers
|
||||
|
||||
@classmethod
|
||||
def _prune_intentional_panel_deletions(cls) -> None:
|
||||
if not cls._intentional_panel_deletions_by_uuid and not cls._intentional_panel_deletions_by_telegram_id:
|
||||
@@ -260,12 +267,20 @@ class RemnaWaveWebhookService:
|
||||
Returns True if the event was processed, False if skipped/unknown.
|
||||
db may be None for admin events that don't require database access.
|
||||
"""
|
||||
# Check if event has both admin and user handlers (e.g. torrent_blocker.report)
|
||||
user_handler = self._user_handlers.get(event_name)
|
||||
if event_name in self._admin_handlers and user_handler:
|
||||
# Dual event: send admin notification AND process user handler
|
||||
await self._process_admin_event(event_name, data)
|
||||
if db is not None:
|
||||
await self._process_user_event(db, event_name, data, user_handler)
|
||||
return True
|
||||
|
||||
# Check admin-scoped handlers (no DB needed)
|
||||
if event_name in self._admin_handlers:
|
||||
return await self._process_admin_event(event_name, data)
|
||||
|
||||
# Check user-scoped handlers (require DB session)
|
||||
user_handler = self._user_handlers.get(event_name)
|
||||
if user_handler:
|
||||
if db is None:
|
||||
logger.error('RemnaWave webhook: DB session required for user event', event_name=event_name)
|
||||
@@ -1045,71 +1060,32 @@ class RemnaWaveWebhookService:
|
||||
logger.error('Webhook: user not found after rollback', user_id=user_id)
|
||||
return
|
||||
|
||||
# Intentional admin deletion: cleanup runs (fields cleared above), but skip re-creation
|
||||
is_intentional = self._is_intentional_panel_deletion_event(data)
|
||||
if is_intentional:
|
||||
logger.info(
|
||||
'Webhook user.deleted: intentional admin deletion, cleanup done, skipping re-creation',
|
||||
sub_id=sub_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Check if subscription has a future end_date — likely a spurious user.deleted
|
||||
# (e.g., RemnaWave sends user.deleted during panel resync when modifying another user)
|
||||
subscription_still_valid = (
|
||||
not is_intentional
|
||||
and subscription is not None
|
||||
and subscription.end_date is not None
|
||||
and subscription.end_date > datetime.now(UTC)
|
||||
)
|
||||
# user.deleted = user removed from panel. Deactivate everything.
|
||||
# No recreation attempts — if it was a mistake, admin can re-sync.
|
||||
|
||||
if subscription:
|
||||
if subscription_still_valid:
|
||||
# Subscription is still valid — don't mark as expired.
|
||||
# Clear only panel linkage fields (URLs, UUID) but keep status and squads
|
||||
# so that re-creation can restore VPN access.
|
||||
logger.warning(
|
||||
'Webhook user.deleted: subscription has future end_date, '
|
||||
'keeping active status and attempting panel re-creation',
|
||||
if subscription.status != SubscriptionStatus.EXPIRED.value:
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
logger.info(
|
||||
'Webhook user.deleted: subscription expired',
|
||||
sub_id=sub_id,
|
||||
user_id=user_id,
|
||||
end_date=subscription.end_date,
|
||||
status=subscription.status,
|
||||
)
|
||||
subscription.subscription_url = None
|
||||
subscription.subscription_crypto_link = None
|
||||
subscription.remnawave_short_uuid = None
|
||||
# Keep connected_squads — needed for panel re-creation
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
else:
|
||||
# Subscription expired or has no end_date — safe to mark as expired
|
||||
if subscription.status != SubscriptionStatus.EXPIRED.value:
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
logger.info(
|
||||
'Webhook: subscription marked expired (user deleted in panel) for user',
|
||||
sub_id=sub_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
subscription.subscription_url = None
|
||||
subscription.subscription_crypto_link = None
|
||||
subscription.remnawave_short_uuid = None
|
||||
subscription.connected_squads = []
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
subscription.subscription_url = None
|
||||
subscription.subscription_crypto_link = None
|
||||
subscription.remnawave_short_uuid = None
|
||||
subscription.connected_squads = []
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
# In multi-tariff mode clear per-subscription UUID here
|
||||
if settings.is_multi_tariff_enabled():
|
||||
subscription.remnawave_uuid = None
|
||||
|
||||
# Remove SubscriptionServer link rows (panel user no longer exists)
|
||||
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == sub_id))
|
||||
|
||||
# Clear remnawave linkage — only in single-tariff mode (multi-tariff uses per-subscription UUIDs)
|
||||
# Clear remnawave linkage
|
||||
if not settings.is_multi_tariff_enabled():
|
||||
if user.remnawave_uuid:
|
||||
user.remnawave_uuid = None
|
||||
# In multi-tariff mode, subscription.remnawave_uuid was cleared above.
|
||||
# If subscription was None (fallback path), extract panel UUID from data and
|
||||
# clear it from the matching subscription manually.
|
||||
elif subscription is None:
|
||||
panel_uuid = data.get('uuid') or data.get('userUuid')
|
||||
if panel_uuid:
|
||||
@@ -1120,35 +1096,55 @@ class RemnaWaveWebhookService:
|
||||
sub.remnawave_short_uuid = None
|
||||
break
|
||||
|
||||
# Deactivate sibling subscriptions whose panel user also no longer exists.
|
||||
# In multi-tariff each subscription has its own panel user — only expire those
|
||||
# that are actually gone (verified via API), leave alive ones untouched.
|
||||
await db.refresh(user, ['subscriptions'])
|
||||
now = datetime.now(UTC)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
for other_sub in getattr(user, 'subscriptions', None) or []:
|
||||
if other_sub.id == sub_id:
|
||||
continue
|
||||
if other_sub.status in (SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value):
|
||||
continue
|
||||
# Check if this sibling's panel user still exists
|
||||
sibling_uuid = getattr(other_sub, 'remnawave_uuid', None) if settings.is_multi_tariff_enabled() else None
|
||||
if not sibling_uuid and not settings.is_multi_tariff_enabled():
|
||||
sibling_uuid = getattr(user, 'remnawave_uuid', None)
|
||||
if sibling_uuid and subscription_service.is_configured:
|
||||
try:
|
||||
async with subscription_service.get_api_client() as api:
|
||||
panel_user = await api.get_user_by_uuid(sibling_uuid)
|
||||
if panel_user is not None:
|
||||
continue # still alive in panel, don't touch
|
||||
except Exception:
|
||||
pass # API error — deactivate to be safe
|
||||
|
||||
other_sub.status = SubscriptionStatus.EXPIRED.value
|
||||
other_sub.subscription_url = None
|
||||
other_sub.subscription_crypto_link = None
|
||||
other_sub.remnawave_short_uuid = None
|
||||
other_sub.connected_squads = []
|
||||
other_sub.updated_at = now
|
||||
if settings.is_multi_tariff_enabled():
|
||||
other_sub.remnawave_uuid = None
|
||||
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == other_sub.id))
|
||||
logger.info(
|
||||
'Webhook user.deleted: deactivated sibling subscription (panel user gone)',
|
||||
other_sub_id=other_sub.id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
if subscription_still_valid:
|
||||
# Attempt to re-create user in panel to restore VPN access.
|
||||
# If recreation fails, fall back to expiring the subscription
|
||||
# so it doesn't stay in ACTIVE-but-no-panel limbo.
|
||||
recreated = await self._attempt_panel_recreation(db, user, subscription)
|
||||
if not recreated:
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
subscription.connected_squads = []
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await self._notify_user(
|
||||
user,
|
||||
'WEBHOOK_SUB_DELETED',
|
||||
reply_markup=self._get_renew_keyboard(
|
||||
user, getattr(subscription, 'id', None) if subscription else None
|
||||
),
|
||||
subscription=subscription,
|
||||
)
|
||||
else:
|
||||
await self._notify_user(
|
||||
user,
|
||||
'WEBHOOK_SUB_DELETED',
|
||||
reply_markup=self._get_renew_keyboard(
|
||||
user, getattr(subscription, 'id', None) if subscription else None
|
||||
),
|
||||
subscription=subscription,
|
||||
)
|
||||
await self._notify_user(
|
||||
user,
|
||||
'WEBHOOK_SUB_DELETED',
|
||||
reply_markup=self._get_renew_keyboard(user, getattr(subscription, 'id', None) if subscription else None),
|
||||
subscription=subscription,
|
||||
)
|
||||
|
||||
async def _attempt_panel_recreation(self, db: AsyncSession, user: User, subscription: Subscription) -> bool:
|
||||
"""Re-create user in RemnaWave panel after spurious user.deleted webhook.
|
||||
@@ -1409,3 +1405,14 @@ class RemnaWaveWebhookService:
|
||||
format_kwargs={'device': device_name or '—'},
|
||||
subscription=subscription,
|
||||
)
|
||||
|
||||
async def _handle_torrent_detected(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
logger.info('Webhook: torrent detected for user', user_id=user.id)
|
||||
await self._notify_user(
|
||||
user,
|
||||
'WEBHOOK_TORRENT_DETECTED',
|
||||
reply_markup=self._get_subscription_keyboard(user),
|
||||
subscription=subscription,
|
||||
)
|
||||
|
||||
@@ -316,7 +316,7 @@ async def _prepare_auto_extend_context(
|
||||
)
|
||||
return None
|
||||
|
||||
if price_kopeks <= 0:
|
||||
if price_kopeks <= 0 and pricing.original_total <= 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.original_total <= 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',
|
||||
|
||||
@@ -944,6 +944,11 @@ class BotConfigurationService:
|
||||
'format': 'Булево значение.',
|
||||
'example': 'true',
|
||||
},
|
||||
'WEBHOOK_NOTIFY_TORRENT_DETECTED': {
|
||||
'description': 'Уведомление пользователю при обнаружении торрент-трафика.',
|
||||
'format': 'Булево значение.',
|
||||
'example': 'true',
|
||||
},
|
||||
'RESET_TRAFFIC_ON_TARIFF_SWITCH': {
|
||||
'description': (
|
||||
'Автоматически сбрасывает счётчик использованного трафика '
|
||||
|
||||
@@ -4137,6 +4137,8 @@ async def activate_promo_code(
|
||||
'invalid': 'Promo code must not be empty',
|
||||
'not_found': 'Promo code not found',
|
||||
'expired': 'Promo code expired',
|
||||
'inactive': 'Promo code is deactivated',
|
||||
'not_yet_valid': 'Promo code is not yet active',
|
||||
'used': 'Promo code already used',
|
||||
'already_used_by_user': 'Promo code already used by this user',
|
||||
'no_subscription_for_days': 'This promo code requires an active or expired subscription',
|
||||
@@ -6563,8 +6565,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 +7196,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={
|
||||
|
||||
@@ -129,8 +129,9 @@ def create_remnawave_webhook_router(bot: Bot) -> APIRouter:
|
||||
|
||||
# Process event — return 200 to prevent retries for application-level errors.
|
||||
# Only return non-200 for infrastructure failures (DB unavailable).
|
||||
# Admin events (node/service/crm) don't need a DB session.
|
||||
if webhook_service.is_admin_event(event_name):
|
||||
# Admin-only events (node/service/crm) don't need a DB session.
|
||||
# Dual events (admin + user, e.g. torrent_blocker.report) need DB for user handler.
|
||||
if webhook_service.is_admin_event(event_name) and not webhook_service.needs_db_session(event_name):
|
||||
try:
|
||||
processed = await webhook_service.process_event(None, event_name, data)
|
||||
return JSONResponse({'status': 'ok', 'processed': processed})
|
||||
@@ -138,7 +139,7 @@ def create_remnawave_webhook_router(bot: Bot) -> APIRouter:
|
||||
logger.exception('RemnaWave webhook processing error for event', event_name=event_name)
|
||||
return JSONResponse({'status': 'ok', 'processed': False})
|
||||
|
||||
# User events require a DB session
|
||||
# User events and dual events require a DB session
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""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'))
|
||||
|
||||
# Deduplicate: if a user has multiple active/trial/limited subscriptions
|
||||
# for the same tariff, expire all but the most recent one.
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE subscriptions
|
||||
SET status = 'expired'
|
||||
WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY user_id, tariff_id
|
||||
ORDER BY created_at DESC
|
||||
) AS rn
|
||||
FROM subscriptions
|
||||
WHERE tariff_id IS NOT NULL
|
||||
AND status IN ('active', 'trial', 'limited')
|
||||
) ranked
|
||||
WHERE rn > 1
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
# 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')
|
||||
"""
|
||||
)
|
||||
)
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.44.0"
|
||||
version = "3.45.2"
|
||||
description = 'Telegram bot for RemnaWave VPN service'
|
||||
readme = 'README.md'
|
||||
license = { text = 'MIT' }
|
||||
|
||||
Reference in New Issue
Block a user