Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 484d2f7e34 | |||
| 842fb697e6 | |||
| 3ac3a92e26 | |||
| 7648707ca2 | |||
| 7e466ef464 | |||
| 28321df4d2 | |||
| 6adf70b2da | |||
| 2d204275da | |||
| ebee8348ca |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.32.2"
|
||||
".": "3.32.3"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## [3.32.3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.2...v3.32.3) (2026-03-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* campaign registration, revenue calculation, backup restore, autopay errors, referral links ([7648707](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7648707ca26d6cd2703b50b0fe8c4697e6155784))
|
||||
* implement case-insensitive email checks in authentication and user retrieval ([7e466ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7e466ef464ce918d885bd6297d1e605a633fd43e))
|
||||
* implement case-insensitive email checks in authentication and user retrieval ([ebee834](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebee8348ca338b9be5f044537e5e2b4740dc6441))
|
||||
* **payment:** prioritize saved cart after topup over expired auto-extend ([28321df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/28321df4d274269536efebcf3da870f2e7d07d90))
|
||||
* refresh CLASSIC_PERIOD_PRICES when admin changes PRICE_*_DAYS or SALES_MODE ([6adf70b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6adf70b2da6e2250cc8e909dbb497b355302e72f))
|
||||
|
||||
## [3.32.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.1...v3.32.2) (2026-03-13)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.32.2" # x-release-please-version
|
||||
ARG VERSION="v3.32.3" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -721,8 +721,9 @@ async def register_email(
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Check if email already exists
|
||||
existing_user = await db.execute(select(User).where(User.email == request.email))
|
||||
# Check if email already exists (case-insensitive)
|
||||
email_lower = (request.email or '').strip().lower()
|
||||
existing_user = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
if existing_user.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -837,8 +838,9 @@ async def register_email_standalone(
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Проверить что email не занят
|
||||
existing = await db.execute(select(User).where(User.email == request.email))
|
||||
# Проверить что email не занят (без учёта регистра)
|
||||
email_lower = (request.email or '').strip().lower()
|
||||
existing = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -1096,8 +1098,9 @@ async def login_email(
|
||||
# Check if this is a test email login
|
||||
is_test_email = settings.is_test_email(request.email)
|
||||
|
||||
# Find user by email
|
||||
result = await db.execute(select(User).where(User.email == request.email))
|
||||
# Find user by email (case-insensitive)
|
||||
email_lower = (request.email or '').strip().lower()
|
||||
result = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
@@ -1317,7 +1320,8 @@ async def forgot_password(
|
||||
detail='Too many requests',
|
||||
headers={'Retry-After': '60'},
|
||||
)
|
||||
result = await db.execute(select(User).where(User.email == request.email))
|
||||
email_lower = (request.email or '').strip().lower()
|
||||
result = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# Always return success to prevent email enumeration
|
||||
|
||||
@@ -91,12 +91,20 @@ async def get_referral_info(
|
||||
referral_entitlement = max(0, total_earnings - withdrawn - pending)
|
||||
available_balance = min(user.balance_kopeks, referral_entitlement)
|
||||
|
||||
# Build referral link
|
||||
# Build referral links
|
||||
referral_link = settings.get_referral_link(user.referral_code) if user.referral_code else ''
|
||||
bot_username = settings.get_bot_username()
|
||||
bot_referral_link = ''
|
||||
if user.referral_code and bot_username:
|
||||
from urllib.parse import quote
|
||||
|
||||
safe_code = quote(user.referral_code, safe='')
|
||||
bot_referral_link = f'https://t.me/{bot_username}?start={safe_code}'
|
||||
|
||||
return ReferralInfoResponse(
|
||||
referral_code=user.referral_code or '',
|
||||
referral_link=referral_link,
|
||||
bot_referral_link=bot_referral_link,
|
||||
total_referrals=total_referrals,
|
||||
active_referrals=active_referrals,
|
||||
total_earnings_kopeks=total_earnings,
|
||||
|
||||
@@ -10,6 +10,7 @@ class ReferralInfoResponse(BaseModel):
|
||||
|
||||
referral_code: str
|
||||
referral_link: str
|
||||
bot_referral_link: str = ''
|
||||
total_referrals: int
|
||||
active_referrals: int
|
||||
total_earnings_kopeks: int
|
||||
|
||||
@@ -366,7 +366,8 @@ async def get_campaign_statistics(
|
||||
first_payment_amount_by_user[user_id] = amount_value
|
||||
first_payment_time_by_user[user_id] = created_at
|
||||
|
||||
total_revenue = deposits_total + subscription_payments_total
|
||||
# Revenue = only real deposits (exclude bonus-funded subscription spending)
|
||||
total_revenue = deposits_total
|
||||
|
||||
paid_user_ids = set(paid_users_from_transactions)
|
||||
paid_user_ids.update(conversion_user_ids)
|
||||
|
||||
@@ -1191,8 +1191,11 @@ async def create_user_by_email(
|
||||
|
||||
|
||||
async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
|
||||
"""Get user by email address."""
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
"""Get user by email address (case-insensitive)."""
|
||||
if not email or not email.strip():
|
||||
return None
|
||||
email_lower = email.strip().lower()
|
||||
result = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -1208,7 +1211,10 @@ async def is_email_taken(db: AsyncSession, email: str, exclude_user_id: int | No
|
||||
Returns:
|
||||
True if email is taken, False otherwise
|
||||
"""
|
||||
query = select(User.id).where(User.email == email)
|
||||
if not email or not email.strip():
|
||||
return False
|
||||
email_lower = email.strip().lower()
|
||||
query = select(User.id).where(func.lower(User.email) == email_lower)
|
||||
if exclude_user_id:
|
||||
query = query.where(User.id != exclude_user_id)
|
||||
result = await db.execute(query)
|
||||
|
||||
@@ -302,6 +302,9 @@ async def handle_potential_referral_code(message: types.Message, state: FSMConte
|
||||
language = data.get('language') or (getattr(user, 'language', None) if user else None) or DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
if not message.text:
|
||||
return False
|
||||
|
||||
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
|
||||
|
||||
potential_code = message.text.strip()
|
||||
@@ -1186,6 +1189,10 @@ async def process_referral_code_input(message: types.Message, state: FSMContext,
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
if not message.text:
|
||||
await message.answer(texts.t('REFERRAL_OR_PROMO_CODE_INVALID', '❌ Неверный реферальный код или промокод'))
|
||||
return
|
||||
|
||||
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
|
||||
|
||||
code = message.text.strip()
|
||||
@@ -2341,6 +2348,33 @@ async def required_sub_channel_check(
|
||||
except Exception as e:
|
||||
logger.error('Ошибка при обработке реферальной регистрации', error=e)
|
||||
|
||||
# Применяем бонус рекламной кампании (record_campaign_registration)
|
||||
campaign_message = await _apply_campaign_bonus_if_needed(db, user, state_data, texts)
|
||||
try:
|
||||
await db.refresh(user)
|
||||
except Exception as refresh_error:
|
||||
logger.error(
|
||||
'Ошибка обновления данных пользователя после бонуса кампании',
|
||||
telegram_id=user.telegram_id,
|
||||
refresh_error=refresh_error,
|
||||
)
|
||||
try:
|
||||
await db.refresh(user, ['subscription'])
|
||||
except Exception as refresh_sub_error:
|
||||
logger.error(
|
||||
'Ошибка обновления подписки после бонуса кампании',
|
||||
telegram_id=user.telegram_id,
|
||||
refresh_sub_error=refresh_sub_error,
|
||||
)
|
||||
if campaign_message:
|
||||
try:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
text=campaign_message,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки сообщения о бонусе кампании', error=e)
|
||||
|
||||
# Показываем главное меню после создания пользователя
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from aiogram import types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -114,7 +115,13 @@ async def toggle_autopay(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
status = texts.t('AUTOPAY_STATUS_ENABLED', 'включен') if enable else texts.t('AUTOPAY_STATUS_DISABLED', 'выключен')
|
||||
await callback.answer(texts.t('AUTOPAY_TOGGLE_SUCCESS', '✅ Автоплатеж {status}!').format(status=status))
|
||||
|
||||
await handle_autopay_menu(callback, db_user, db)
|
||||
try:
|
||||
await handle_autopay_menu(callback, db_user, db)
|
||||
except TelegramBadRequest as e:
|
||||
if 'message is not modified' in str(e):
|
||||
pass
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
async def show_autopay_days(callback: types.CallbackQuery, db_user: User):
|
||||
|
||||
@@ -1105,9 +1105,20 @@ class BackupService:
|
||||
existing = existing_user.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
for key, value in processed_data.items():
|
||||
if key != 'id':
|
||||
setattr(existing, key, value)
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
for key, value in processed_data.items():
|
||||
if key != 'id':
|
||||
setattr(existing, key, value)
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
db.expire(existing)
|
||||
logger.warning(
|
||||
'Конфликт уникального ключа при обновлении пользователя, пропускаем',
|
||||
user_id=processed_data.get('id'),
|
||||
telegram_id=processed_data.get('telegram_id'),
|
||||
)
|
||||
continue
|
||||
else:
|
||||
instance = User(**processed_data)
|
||||
try:
|
||||
@@ -1376,9 +1387,20 @@ class BackupService:
|
||||
existing = existing_record.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
for key, value in processed_data.items():
|
||||
if key not in pk_cols:
|
||||
setattr(existing, key, value)
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
for key, value in processed_data.items():
|
||||
if key not in pk_cols:
|
||||
setattr(existing, key, value)
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
db.expire(existing)
|
||||
logger.warning(
|
||||
'Конфликт уникального ключа при обновлении записи, пропускаем',
|
||||
table_name=table_name,
|
||||
pk={col: processed_data.get(col) for col in pk_cols},
|
||||
)
|
||||
continue
|
||||
else:
|
||||
instance = model(**processed_data)
|
||||
try:
|
||||
|
||||
@@ -939,8 +939,7 @@ class PartnerStatsService:
|
||||
registrations_dict = {str(row.date): int(row.count) for row in registrations_by_day.all()}
|
||||
|
||||
# --- Daily revenue (DAILY_STATS_DAYS days) ---
|
||||
# Revenue = real deposits (positive) + abs(subscription_payments) (stored negative)
|
||||
# Exclude promo/bonus deposits (payment_method IS NULL) from revenue
|
||||
# Revenue = real deposits only (exclude bonus/promo balance spending on subscriptions)
|
||||
revenue_amount_expr = func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
@@ -951,10 +950,6 @@ class PartnerStatsService:
|
||||
),
|
||||
Transaction.amount_kopeks,
|
||||
),
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
func.abs(Transaction.amount_kopeks),
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
@@ -971,12 +966,8 @@ class PartnerStatsService:
|
||||
Transaction.user_id.in_(campaign_user_ids_sq),
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.type.in_(
|
||||
[
|
||||
TransactionType.DEPOSIT.value,
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
]
|
||||
),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
)
|
||||
)
|
||||
.group_by(func.date(Transaction.created_at))
|
||||
@@ -1027,12 +1018,8 @@ class PartnerStatsService:
|
||||
Transaction.user_id.in_(campaign_user_ids_sq),
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.created_at >= week_ago,
|
||||
Transaction.type.in_(
|
||||
[
|
||||
TransactionType.DEPOSIT.value,
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
]
|
||||
),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -1046,12 +1033,8 @@ class PartnerStatsService:
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.created_at >= previous_start,
|
||||
Transaction.created_at < week_ago,
|
||||
Transaction.type.in_(
|
||||
[
|
||||
TransactionType.DEPOSIT.value,
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
]
|
||||
),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
+106
-99
@@ -325,7 +325,111 @@ async def send_cart_notification_after_topup(
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Try to auto-extend expired subscription (works without cart)
|
||||
cart_data = await user_cart_service.get_user_cart(user.id)
|
||||
# В приоритете всегда сохраненная корзина: она отражает явный выбор пользователя
|
||||
# (период/тариф/сумма). Автопродление expired — только когда корзины нет.
|
||||
if cart_data:
|
||||
cart_total = cart_data.get('total_price', 0)
|
||||
if not cart_total:
|
||||
logger.warning(
|
||||
'Сохраненная корзина найдена, но total_price отсутствует или некорректен',
|
||||
user_id=user.id,
|
||||
cart_total=cart_total,
|
||||
)
|
||||
return False
|
||||
|
||||
# Try auto-purchase first
|
||||
auto_purchase_success = False
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user, bot=bot)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
'Ошибка автоматической покупки подписки для пользователя',
|
||||
user_id=user.id,
|
||||
auto_error=auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
return False
|
||||
|
||||
if not bot or not getattr(user, 'telegram_id', None):
|
||||
return False
|
||||
|
||||
# Refresh balance from DB to account for any changes during auto-purchase attempt
|
||||
refreshed_user = await get_user_by_id(db, user.id)
|
||||
balance = getattr(refreshed_user or user, 'balance_kopeks', 0)
|
||||
|
||||
texts = get_texts(getattr(user, 'language', 'ru'))
|
||||
|
||||
# Build message based on whether balance is sufficient
|
||||
fmt = settings.format_price
|
||||
cart_total_formatted = fmt(cart_total)
|
||||
if balance >= cart_total:
|
||||
template = texts.get('BALANCE_TOPPED_UP_CART_SUFFICIENT', '')
|
||||
message_text = template.format(
|
||||
amount=fmt(amount_kopeks),
|
||||
balance=fmt(balance),
|
||||
cart_total=cart_total_formatted,
|
||||
total_amount=cart_total_formatted,
|
||||
)
|
||||
else:
|
||||
missing = cart_total - balance
|
||||
template = texts.get('BALANCE_TOPPED_UP_CART_INSUFFICIENT', '')
|
||||
message_text = template.format(
|
||||
amount=fmt(amount_kopeks),
|
||||
balance=fmt(balance),
|
||||
cart_total=cart_total_formatted,
|
||||
total_amount=cart_total_formatted,
|
||||
missing=fmt(missing),
|
||||
)
|
||||
|
||||
if not message_text:
|
||||
logger.warning('Missing cart notification template', language=getattr(user, 'language', 'ru'))
|
||||
return False
|
||||
|
||||
sent = False
|
||||
try:
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.get('RETURN_TO_SUBSCRIPTION_CHECKOUT', '⬅️ Checkout'),
|
||||
callback_data='return_to_saved_cart',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.get('MY_BALANCE_BUTTON', '💰 Balance'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.get('MAIN_MENU_BUTTON', '🏠 Menu'),
|
||||
callback_data='back_to_menu',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
sent = True
|
||||
logger.info('Sent cart notification to user', user_id=user.id)
|
||||
except Exception as send_error:
|
||||
logger.error(
|
||||
'Failed to send cart notification to user',
|
||||
user_id=user.id,
|
||||
error=send_error,
|
||||
)
|
||||
|
||||
return sent
|
||||
|
||||
# Try to auto-extend expired subscription only when there is no saved cart.
|
||||
try:
|
||||
auto_extended = await try_auto_extend_expired_after_topup(db, user, bot=bot)
|
||||
if auto_extended:
|
||||
@@ -338,104 +442,7 @@ async def send_cart_notification_after_topup(
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
cart_data = await user_cart_service.get_user_cart(user.id)
|
||||
if not cart_data:
|
||||
return False
|
||||
|
||||
cart_total = cart_data.get('total_price', 0)
|
||||
if not cart_total:
|
||||
return False
|
||||
|
||||
# Try auto-purchase first
|
||||
auto_purchase_success = False
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user, bot=bot)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
'Ошибка автоматической покупки подписки для пользователя',
|
||||
user_id=user.id,
|
||||
auto_error=auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
return False
|
||||
|
||||
if not bot or not getattr(user, 'telegram_id', None):
|
||||
return False
|
||||
|
||||
# Refresh balance from DB to account for any changes during auto-purchase attempt
|
||||
refreshed_user = await get_user_by_id(db, user.id)
|
||||
balance = getattr(refreshed_user or user, 'balance_kopeks', 0)
|
||||
|
||||
texts = get_texts(getattr(user, 'language', 'ru'))
|
||||
|
||||
# Build message based on whether balance is sufficient
|
||||
fmt = settings.format_price
|
||||
cart_total_formatted = fmt(cart_total)
|
||||
if balance >= cart_total:
|
||||
template = texts.get('BALANCE_TOPPED_UP_CART_SUFFICIENT', '')
|
||||
message_text = template.format(
|
||||
amount=fmt(amount_kopeks),
|
||||
balance=fmt(balance),
|
||||
cart_total=cart_total_formatted,
|
||||
total_amount=cart_total_formatted,
|
||||
)
|
||||
else:
|
||||
missing = cart_total - balance
|
||||
template = texts.get('BALANCE_TOPPED_UP_CART_INSUFFICIENT', '')
|
||||
message_text = template.format(
|
||||
amount=fmt(amount_kopeks),
|
||||
balance=fmt(balance),
|
||||
cart_total=cart_total_formatted,
|
||||
total_amount=cart_total_formatted,
|
||||
missing=fmt(missing),
|
||||
)
|
||||
|
||||
if not message_text:
|
||||
logger.warning('Missing cart notification template', language=getattr(user, 'language', 'ru'))
|
||||
return False
|
||||
|
||||
sent = False
|
||||
try:
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.get('RETURN_TO_SUBSCRIPTION_CHECKOUT', '⬅️ Checkout'),
|
||||
callback_data='return_to_saved_cart',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.get('MY_BALANCE_BUTTON', '💰 Balance'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.get('MAIN_MENU_BUTTON', '🏠 Menu'),
|
||||
callback_data='back_to_menu',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
sent = True
|
||||
logger.info('Sent cart notification to user', user_id=user.id)
|
||||
except Exception as send_error:
|
||||
logger.error(
|
||||
'Failed to send cart notification to user',
|
||||
user_id=user.id,
|
||||
error=send_error,
|
||||
)
|
||||
|
||||
return sent
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2455,8 +2455,8 @@ async def auto_purchase_saved_cart_after_topup(
|
||||
format_user_id=_format_user_id(user),
|
||||
total_seconds=(datetime.now(UTC) - last_tx.created_at).total_seconds(),
|
||||
)
|
||||
# Очищаем корзину чтобы не срабатывало повторно
|
||||
await user_cart_service.delete_user_cart(user.id)
|
||||
# Корзину не очищаем: транзакция могла быть из другого потока
|
||||
# (например, фоновое автопродление), чтобы не потерять явный выбор пользователя.
|
||||
return False
|
||||
except Exception as check_error:
|
||||
logger.warning(
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.config import (
|
||||
ENV_OVERRIDE_KEYS,
|
||||
Settings,
|
||||
clear_db_period_prices,
|
||||
refresh_classic_period_prices,
|
||||
refresh_period_prices,
|
||||
refresh_traffic_prices,
|
||||
settings,
|
||||
@@ -1517,6 +1518,7 @@ class BotConfigurationService:
|
||||
# т.к. ensure_tariffs_synced мог загрузить тарифные цены до того как
|
||||
# SALES_MODE=classic был применён из system_settings
|
||||
refresh_period_prices()
|
||||
refresh_classic_period_prices()
|
||||
|
||||
@classmethod
|
||||
async def reload(cls) -> None:
|
||||
@@ -1673,6 +1675,7 @@ class BotConfigurationService:
|
||||
if settings.is_classic_mode():
|
||||
clear_db_period_prices()
|
||||
refresh_period_prices()
|
||||
refresh_classic_period_prices()
|
||||
elif key in {
|
||||
'PRICE_14_DAYS',
|
||||
'PRICE_30_DAYS',
|
||||
@@ -1682,6 +1685,7 @@ class BotConfigurationService:
|
||||
'PRICE_360_DAYS',
|
||||
}:
|
||||
refresh_period_prices()
|
||||
refresh_classic_period_prices()
|
||||
elif key.startswith('PRICE_TRAFFIC_') or key == 'TRAFFIC_PACKAGES_CONFIG':
|
||||
refresh_traffic_prices()
|
||||
elif key in {'REMNAWAVE_AUTO_SYNC_ENABLED', 'REMNAWAVE_AUTO_SYNC_TIMES'}:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.32.2"
|
||||
version = "3.32.3"
|
||||
description = 'Telegram bot for RemnaWave VPN service'
|
||||
readme = 'README.md'
|
||||
license = { text = 'MIT' }
|
||||
|
||||
@@ -551,7 +551,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" },
|
||||
@@ -1116,7 +1115,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "remnawave-bedolaga-telegram-bot"
|
||||
version = "3.31.0"
|
||||
version = "3.32.2"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiogram" },
|
||||
|
||||
Reference in New Issue
Block a user