Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ffbb3fb8be | |||
| f01dbff000 | |||
| 31adcfded4 | |||
| 78f963bf5e | |||
| 357d94d1b0 | |||
| 0fb4a2c235 | |||
| 2f7184627a | |||
| d55e9db62a | |||
| 57adfaf4f3 | |||
| 4165eaea7a | |||
| 3b5d5a18a1 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.45.0"
|
||||
".": "3.45.2"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.45.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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -676,8 +676,10 @@ 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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
@@ -5942,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(
|
||||
|
||||
@@ -1735,8 +1735,8 @@ async def handle_extend_subscription(
|
||||
# original = price before ALL discounts, final = price with all discounts
|
||||
total_original_price = pricing.original_total
|
||||
|
||||
# Пропускаем периоды с нулевой ценой — защита от бесплатного продления
|
||||
if pricing.final_total <= 0 and pricing.base_price <= 0:
|
||||
# Пропускаем периоды с нулевой ценой (если оригинальная цена тоже 0 — не настроен)
|
||||
if pricing.final_total <= 0 and pricing.original_total <= 0:
|
||||
continue
|
||||
|
||||
renewal_prices[days] = {
|
||||
|
||||
@@ -928,8 +928,8 @@ 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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -316,7 +316,7 @@ async def _prepare_auto_extend_context(
|
||||
)
|
||||
return None
|
||||
|
||||
if price_kopeks <= 0 and pricing.base_price <= 0:
|
||||
if price_kopeks <= 0 and pricing.original_total <= 0:
|
||||
logger.warning(
|
||||
'🔁 Автопокупка: некорректная цена продления у пользователя',
|
||||
price_kopeks=price_kopeks,
|
||||
@@ -2172,7 +2172,7 @@ async def try_auto_extend_expired_after_topup(
|
||||
breakdown=pricing.breakdown,
|
||||
)
|
||||
|
||||
if renewal_cost <= 0 and pricing.base_price <= 0:
|
||||
if renewal_cost <= 0 and pricing.original_total <= 0:
|
||||
logger.warning(
|
||||
'❌ Автопродление expired: некорректная стоимость',
|
||||
format_user_id=_format_user_id(user),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -21,6 +21,30 @@ 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.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.45.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