Compare commits

..

17 Commits

Author SHA1 Message Date
c0mrade ffbb3fb8be Merge pull request #2861 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.45.2
2026-04-08 18:46:58 +03:00
github-actions[bot] f01dbff000 chore(main): release 3.45.2 2026-04-08 15:44:09 +00:00
c0mrade 31adcfded4 Merge pull request #2860 from BEDOLAGA-DEV/dev
fix: batch bug fixes from user complaints
2026-04-08 18:42:47 +03:00
c0mrade 78f963bf5e fix: batch bug fixes from user complaints
- 100% discount: daily tariff fallback to smallest configured period discount
- 100% discount: purchase blocked by safety guard (base_price → original_total in 6 guards)
- Gift subscription reset existing days (replace → extend for active/trial subs)
- Cabinet broadcast: target alias active_subscribers not mapped to active
- Promo code: error always "expired" — split into inactive/not_yet_valid/expired
- Multi-tariff: add delete subscription button in admin bot
- Multi-tariff → single: select subscription with most remaining time (end_date DESC)
- Gift purchases not counted in total spent (added GIFT_PAYMENT type)
- Remnawave API: retry on 502/503/504 (was only 429)
- Heleket: add from_referral_code to invoice payload
- Whitespace fix in blacklist_service
2026-04-08 18:33:17 +03:00
Egor 357d94d1b0 Merge pull request #2855 from andreycoast/fix/blacklist-parsing-logic
fix: исправление парсинга черного списка (поддержка '#' и извлечение username)
2026-04-07 15:49:54 +03:00
Egor 0fb4a2c235 Merge pull request #2856 from BEDOLAGA-DEV/main
w
2026-04-07 15:49:20 +03:00
andreycoast 2f7184627a fix: исправление парсинга черного списка (поддержка '#' и извлечение username) 2026-04-07 15:38:32 +03:00
c0mrade d55e9db62a Merge pull request #2849 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.45.1
2026-04-03 20:58:07 +03:00
github-actions[bot] 57adfaf4f3 chore(main): release 3.45.1 2026-04-03 17:57:12 +00:00
c0mrade 4165eaea7a Merge pull request #2848 from BEDOLAGA-DEV/dev
fix: add missing WEBHOOK_TORRENT_DETECTED mapping + dedup before uniq…
2026-04-03 20:56:51 +03:00
c0mrade 3b5d5a18a1 fix: add missing WEBHOOK_TORRENT_DETECTED mapping + dedup before unique index in migration 0053 2026-04-03 20:50:13 +03:00
c0mrade eef41c4bca Merge pull request #2846 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.45.0
2026-04-03 19:13:53 +03:00
github-actions[bot] 987c3c93c2 chore(main): release 3.45.0 2026-04-03 16:12:28 +00:00
c0mrade 7d24e8d704 Merge pull request #2845 from BEDOLAGA-DEV/dev
fix: subscription system bugfixes + torrent notifications + user deletion cleanup
2026-04-03 19:12:06 +03:00
c0mrade 819f09a68e fix: restore missing import + rewrite user.deleted webhook to properly deactivate all subscriptions
- Fix NameError in admin_users.py: re-add get_traffic_reset_strategy import that ruff auto-removed
- user.deleted: remove auto-recreation logic — deleted means deleted, no more recreating users back in panel
- user.deleted: deactivate primary subscription unconditionally (expire + clear all linkage)
- user.deleted: sweep sibling subscriptions — verify each via panel API, deactivate only those whose panel user is gone (safe for multi-tariff where only one of N panel users may be deleted)
- Works across multi-tariff, single-tariff, and classic modes
2026-04-03 18:56:14 +03:00
c0mrade 2f9d00343b feat: send torrent blocker notification to user (not just admin)
- torrent_blocker.report is now a dual event: admin notification + user message
- New _handle_torrent_detected user handler sends WEBHOOK_TORRENT_DETECTED
- process_event handles events registered in both admin and user handlers
- Webhook router passes DB session for dual events (needs_db_session check)
- Add WEBHOOK_NOTIFY_TORRENT_DETECTED setting (default: true)
- Add WEBHOOK_TORRENT_DETECTED locale texts (ru/en/ua/zh/fa)
2026-04-03 18:19:45 +03:00
c0mrade 9b7ac47f16 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
2026-04-03 17:22:42 +03:00
46 changed files with 587 additions and 234 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.44.0"
".": "3.45.2"
}
+32
View File
@@ -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
View File
@@ -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
+16 -2
View File
@@ -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
+8 -6
View File
@@ -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,
+2 -2
View File
@@ -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',
+2
View File
@@ -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',
+1
View File
@@ -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
+27 -5
View File
@@ -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)
+6 -1
View File
@@ -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,
]
),
)
)
)
+8 -1
View File
@@ -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')"),
),
)
+8 -7
View File
@@ -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
View File
@@ -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(
+2
View File
@@ -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(
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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(
+50 -17
View File
@@ -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',
+23 -19
View File
@@ -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':
+3 -3
View File
@@ -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',
+1
View File
@@ -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"
}
+1
View File
@@ -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": "✖️ بستن"
}
+1
View File
@@ -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": "✖️ Закрыть"
}
+1
View File
@@ -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": "✖️ Закрити"
}
+1
View File
@@ -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}"
+25 -20
View File
@@ -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(
+2 -2
View File
@@ -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)
+44 -3
View File
@@ -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,
+2 -3
View File
@@ -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'
+1
View File
@@ -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()
+8
View File
@@ -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)
+3 -3
View File
@@ -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,
+86 -79
View File
@@ -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',
+5
View File
@@ -944,6 +944,11 @@ class BotConfigurationService:
'format': 'Булево значение.',
'example': 'true',
},
'WEBHOOK_NOTIFY_TORRENT_DETECTED': {
'description': 'Уведомление пользователю при обнаружении торрент-трафика.',
'format': 'Булево значение.',
'example': 'true',
},
'RESET_TRAFFIC_ON_TARIFF_SWITCH': {
'description': (
'Автоматически сбрасывает счётчик использованного трафика '
+6 -4
View File
@@ -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={
+4 -3
View File
@@ -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
View File
@@ -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' }
Generated
+1 -1
View File
@@ -1143,7 +1143,7 @@ wheels = [
[[package]]
name = "remnawave-bedolaga-telegram-bot"
version = "3.38.0"
version = "3.43.1"
source = { virtual = "." }
dependencies = [
{ name = "aiogram" },