Compare commits

...

11 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
26 changed files with 267 additions and 56 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.45.0"
".": "3.45.2"
}
+18
View File
@@ -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
View File
@@ -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
+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
+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',
@@ -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',
+2 -1
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)
+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
+83 -7
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):
@@ -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(
+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
@@ -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] = {
+2 -2
View File
@@ -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
+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(
+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,
@@ -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)
@@ -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),
+2
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',
@@ -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
View File
@@ -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' }
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" },