diff --git a/app/handlers/admin/messages.py b/app/handlers/admin/messages.py
index 5579417a..d4b34980 100644
--- a/app/handlers/admin/messages.py
+++ b/app/handlers/admin/messages.py
@@ -711,29 +711,56 @@ async def handle_media_selection(
await state.update_data(has_media=False)
await show_button_selector_callback(callback, db_user, state)
return
-
+
media_type = callback.data.replace('add_media_', '')
-
+
media_instructions = {
"photo": "📷 Отправьте фотографию для рассылки:",
"video": "🎥 Отправьте видео для рассылки:",
"document": "📄 Отправьте документ для рассылки:"
}
-
+
await state.update_data(
media_type=media_type,
waiting_for_media=True
)
-
- await callback.message.edit_text(
+
+ instruction_text = (
f"{media_instructions.get(media_type, 'Отправьте медиафайл:')}\n\n"
- f"Размер файла не должен превышать 50 МБ",
- reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
- [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
- ]),
- parse_mode="HTML"
+ f"Размер файла не должен превышать 50 МБ"
)
-
+ instruction_keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
+ [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
+ ])
+
+ # Проверяем, является ли текущее сообщение медиа-сообщением
+ is_media_message = (
+ callback.message.photo
+ or callback.message.video
+ or callback.message.document
+ or callback.message.animation
+ or callback.message.audio
+ or callback.message.voice
+ )
+
+ if is_media_message:
+ # Удаляем медиа-сообщение и отправляем новое текстовое
+ try:
+ await callback.message.delete()
+ except Exception:
+ pass
+ await callback.message.answer(
+ instruction_text,
+ reply_markup=instruction_keyboard,
+ parse_mode="HTML"
+ )
+ else:
+ await callback.message.edit_text(
+ instruction_text,
+ reply_markup=instruction_keyboard,
+ parse_mode="HTML"
+ )
+
await state.set_state(AdminStates.waiting_for_broadcast_media)
await callback.answer()
@@ -860,12 +887,12 @@ async def show_button_selector_callback(
if selected_buttons is None:
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
await state.update_data(selected_buttons=selected_buttons)
-
+
media_info = ""
if has_media:
media_type = data.get('media_type', 'файл')
media_info = f"\n🖼️ Медиафайл: {media_type} добавлен"
-
+
text = f"""
📘 Выбор дополнительных кнопок
@@ -882,16 +909,39 @@ async def show_button_selector_callback(
Выберите нужные кнопки и нажмите "Продолжить":
"""
-
+
keyboard = get_updated_message_buttons_selector_keyboard_with_media(
selected_buttons, has_media, db_user.language
)
-
- await callback.message.edit_text(
- text,
- reply_markup=keyboard,
- parse_mode="HTML"
+
+ # Проверяем, является ли текущее сообщение медиа-сообщением
+ # (фото, видео, документ и т.д.) - для них нельзя использовать edit_text
+ is_media_message = (
+ callback.message.photo
+ or callback.message.video
+ or callback.message.document
+ or callback.message.animation
+ or callback.message.audio
+ or callback.message.voice
)
+
+ if is_media_message:
+ # Удаляем медиа-сообщение и отправляем новое текстовое
+ try:
+ await callback.message.delete()
+ except Exception:
+ pass # Игнорируем ошибки удаления
+ await callback.message.answer(
+ text,
+ reply_markup=keyboard,
+ parse_mode="HTML"
+ )
+ else:
+ await callback.message.edit_text(
+ text,
+ reply_markup=keyboard,
+ parse_mode="HTML"
+ )
await callback.answer()
diff --git a/app/handlers/admin/tickets.py b/app/handlers/admin/tickets.py
index 2e7e38cf..123a9501 100644
--- a/app/handlers/admin/tickets.py
+++ b/app/handlers/admin/tickets.py
@@ -26,8 +26,67 @@ from app.utils.cache import RateLimitCache
logger = logging.getLogger(__name__)
+# Максимальная длина сообщения Telegram (с запасом)
+MAX_MESSAGE_LEN = 3500
+
+
+def _split_long_block(block: str, max_len: int) -> list[str]:
+ """Разбивает слишком длинный блок на части."""
+ if len(block) <= max_len:
+ return [block]
+
+ parts = []
+ remaining = block
+ while remaining:
+ if len(remaining) <= max_len:
+ parts.append(remaining)
+ break
+ cut_at = max_len
+ newline_pos = remaining.rfind('\n', 0, max_len)
+ space_pos = remaining.rfind(' ', 0, max_len)
+
+ if newline_pos > max_len // 2:
+ cut_at = newline_pos + 1
+ elif space_pos > max_len // 2:
+ cut_at = space_pos + 1
+
+ parts.append(remaining[:cut_at])
+ remaining = remaining[cut_at:]
+
+ return parts
+
+
+def _split_text_into_pages(header: str, message_blocks: list[str], max_len: int = MAX_MESSAGE_LEN) -> list[str]:
+ """Разбивает текст на страницы с учётом лимита Telegram."""
+ pages: list[str] = []
+ current = header
+ header_len = len(header)
+ block_max_len = max_len - header_len - 50
+
+ for block in message_blocks:
+ if len(block) > block_max_len:
+ block_parts = _split_long_block(block, block_max_len)
+ for part in block_parts:
+ if len(current) + len(part) > max_len:
+ if current.strip() and current != header:
+ pages.append(current)
+ current = header + part
+ else:
+ current += part
+ elif len(current) + len(block) > max_len:
+ if current.strip() and current != header:
+ pages.append(current)
+ current = header + block
+ else:
+ current += block
+
+ if current.strip():
+ pages.append(current)
+
+ return pages if pages else [header]
+
+
-
async def show_admin_tickets(
@@ -133,15 +192,27 @@ async def view_admin_ticket(
state: Optional[FSMContext] = None,
ticket_id: Optional[int] = None
):
- """Показать детали тикета для админа"""
+ """Показать детали тикета для админа с пагинацией"""
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
texts = get_texts(db_user.language)
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
return
-
- if ticket_id is None:
+
+ # Парсим ticket_id и page из callback_data
+ page = 1
+ data_str = callback.data or ""
+
+ if data_str.startswith("admin_ticket_page_"):
+ # format: admin_ticket_page_{ticket_id}_{page}
try:
- ticket_id = int((callback.data or "").split("_")[-1])
+ parts = data_str.split("_")
+ ticket_id = int(parts[3])
+ page = max(1, int(parts[4]))
+ except (ValueError, IndexError):
+ pass
+ elif ticket_id is None:
+ try:
+ ticket_id = int(data_str.split("_")[-1])
except (ValueError, AttributeError):
texts = get_texts(db_user.language)
await callback.answer(
@@ -152,9 +223,9 @@ async def view_admin_ticket(
if state is None:
state = FSMContext(callback.bot, callback.from_user.id)
-
+
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=True)
-
+
if not ticket:
texts = get_texts(db_user.language)
await callback.answer(
@@ -162,59 +233,60 @@ async def view_admin_ticket(
show_alert=True
)
return
-
+
texts = get_texts(db_user.language)
-
- # Формируем текст тикета
+
+ # Формируем заголовок тикета
status_text = {
TicketStatus.OPEN.value: texts.t("TICKET_STATUS_OPEN", "Открыт"),
TicketStatus.ANSWERED.value: texts.t("TICKET_STATUS_ANSWERED", "Отвечен"),
TicketStatus.CLOSED.value: texts.t("TICKET_STATUS_CLOSED", "Закрыт"),
TicketStatus.PENDING.value: texts.t("TICKET_STATUS_PENDING", "В ожидании")
}.get(ticket.status, ticket.status)
-
+
user_name = ticket.user.full_name if ticket.user else "Unknown"
telegram_id_display = ticket.user.telegram_id if ticket.user else "—"
username_value = ticket.user.username if ticket.user else None
- ticket_text = f"🎫 Тикет #{ticket.id}\n\n"
- ticket_text += f"👤 Пользователь: {user_name}\n"
- ticket_text += f"🆔 Telegram ID: {telegram_id_display}\n"
+ header = f"🎫 Тикет #{ticket.id}\n\n"
+ header += f"👤 Пользователь: {user_name}\n"
+ header += f"🆔 Telegram ID: {telegram_id_display}\n"
if username_value:
safe_username = html.escape(username_value)
- ticket_text += f"📱 Username: @{safe_username}\n"
- ticket_text += (
- f"🔗 ЛС: "
- f"tg://resolve?domain={safe_username}\n"
- )
+ header += f"📱 Username: @{safe_username}\n"
else:
- ticket_text += "📱 Username: отсутствует\n"
- if ticket.user and ticket.user.telegram_id:
- chat_link = f"tg://user?id={int(ticket.user.telegram_id)}"
- ticket_text += f"🔗 Чат по ID: {chat_link}\n"
- ticket_text += "\n"
- ticket_text += f"📝 Заголовок: {ticket.title}\n"
- ticket_text += f"📊 Статус: {ticket.status_emoji} {status_text}\n"
- ticket_text += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n"
- ticket_text += f"🔄 Обновлен: {ticket.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
-
+ header += "📱 Username: отсутствует\n"
+ header += f"📝 Заголовок: {ticket.title}\n"
+ header += f"📊 Статус: {ticket.status_emoji} {status_text}\n"
+ header += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
+
if ticket.is_user_reply_blocked:
if ticket.user_reply_block_permanent:
- ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n"
+ header += "🚫 Пользователь заблокирован навсегда\n\n"
elif ticket.user_reply_block_until:
- ticket_text += f"⏳ Блок до: {ticket.user_reply_block_until.strftime('%d.%m.%Y %H:%M')}\n"
-
+ header += f"⏳ Блок до: {ticket.user_reply_block_until.strftime('%d.%m.%Y %H:%M')}\n\n"
+
+ # Формируем блоки сообщений
+ message_blocks: list[str] = []
if ticket.messages:
- ticket_text += f"💬 Сообщения ({len(ticket.messages)}):\n\n"
-
+ message_blocks.append(f"💬 Сообщения ({len(ticket.messages)}):\n\n")
for msg in ticket.messages:
sender = "👤 Пользователь" if msg.is_user_message else "🛠️ Поддержка"
- ticket_text += f"{sender} ({msg.created_at.strftime('%d.%m %H:%M')}):\n"
- ticket_text += f"{msg.message_text}\n\n"
+ block = (
+ f"{sender} ({msg.created_at.strftime('%d.%m %H:%M')}):\n"
+ f"{msg.message_text}\n\n"
+ )
if getattr(msg, "has_media", False) and getattr(msg, "media_type", None) == "photo":
- ticket_text += "📎 Вложение: фото\n\n"
-
- # Добавим кнопку "Вложения", если есть фото
+ block += "📎 Вложение: фото\n\n"
+ message_blocks.append(block)
+
+ # Разбиваем на страницы
+ pages = _split_text_into_pages(header, message_blocks, max_len=MAX_MESSAGE_LEN)
+ total_pages = len(pages)
+ if page > total_pages:
+ page = total_pages
+
+ # Формируем клавиатуру
has_photos = any(getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" for m in ticket.messages or [])
keyboard = get_admin_ticket_view_keyboard(
ticket_id,
@@ -222,7 +294,8 @@ async def view_admin_ticket(
db_user.language,
is_user_blocked=ticket.is_user_reply_blocked
)
- # Кнопка открытия профиля пользователя в админке
+
+ # Кнопка профиля пользователя
try:
if ticket.user:
admin_profile_btn = types.InlineKeyboardButton(
@@ -232,34 +305,76 @@ async def view_admin_ticket(
keyboard.inline_keyboard.insert(0, [admin_profile_btn])
except Exception:
pass
+
# Кнопки ЛС и профиль
try:
if ticket.user and ticket.user.telegram_id and ticket.user.username:
safe_username = html.escape(ticket.user.username)
buttons_row = []
pm_url = f"tg://resolve?domain={safe_username}"
- buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
+ buttons_row.append(types.InlineKeyboardButton(text="✉ ЛС", url=pm_url))
profile_url = f"tg://user?id={ticket.user.telegram_id}"
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
if buttons_row:
keyboard.inline_keyboard.insert(0, buttons_row)
except Exception:
pass
+
+ # Кнопка вложений
if has_photos:
try:
- keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"), callback_data=f"admin_ticket_attachments_{ticket_id}")])
+ keyboard.inline_keyboard.insert(0, [
+ types.InlineKeyboardButton(
+ text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"),
+ callback_data=f"admin_ticket_attachments_{ticket_id}"
+ )
+ ])
except Exception:
pass
- # Рендер через фото-утилиту (с логотипом), внутри есть фоллбеки на текст
- from app.utils.photo_message import edit_or_answer_photo
- await edit_or_answer_photo(
- callback=callback,
- caption=ticket_text,
- keyboard=keyboard,
- parse_mode="HTML",
- )
- # сохраняем id для дальнейших действий (ответ/статусы)
+ # Пагинация
+ if total_pages > 1:
+ nav_row = []
+ if page > 1:
+ nav_row.append(types.InlineKeyboardButton(
+ text="⬅️",
+ callback_data=f"admin_ticket_page_{ticket_id}_{page - 1}"
+ ))
+ nav_row.append(types.InlineKeyboardButton(
+ text=f"{page}/{total_pages}",
+ callback_data="noop"
+ ))
+ if page < total_pages:
+ nav_row.append(types.InlineKeyboardButton(
+ text="➡️",
+ callback_data=f"admin_ticket_page_{ticket_id}_{page + 1}"
+ ))
+ try:
+ keyboard.inline_keyboard.insert(0, nav_row)
+ except Exception:
+ pass
+
+ page_text = pages[page - 1]
+
+ # Отправка сообщения
+ try:
+ await callback.message.edit_text(
+ page_text,
+ reply_markup=keyboard,
+ parse_mode="HTML"
+ )
+ except TelegramBadRequest:
+ try:
+ await callback.message.delete()
+ except Exception:
+ pass
+ await callback.message.answer(
+ page_text,
+ reply_markup=keyboard,
+ parse_mode="HTML"
+ )
+
+ # Сохраняем id для дальнейших действий
if state is not None:
try:
await state.update_data(ticket_id=ticket_id)
@@ -1034,7 +1149,8 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(close_all_open_admin_tickets, F.data == "admin_tickets_close_all_open")
dp.callback_query.register(view_admin_ticket, F.data.startswith("admin_view_ticket_"))
-
+ dp.callback_query.register(view_admin_ticket, F.data.startswith("admin_ticket_page_"))
+
# Ответы на тикеты
dp.callback_query.register(
reply_to_admin_ticket,
diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py
index 50825ae3..aaf37a3e 100644
--- a/app/handlers/subscription/purchase.py
+++ b/app/handlers/subscription/purchase.py
@@ -68,6 +68,8 @@ from app.services.trial_activation_service import (
rollback_trial_subscription_activation,
)
+logger = logging.getLogger(__name__)
+
def _serialize_markup(markup: Optional[InlineKeyboardMarkup]) -> Optional[Any]:
if markup is None:
@@ -211,7 +213,13 @@ async def show_subscription_info(
subscription_service = SubscriptionService()
await subscription_service.sync_subscription_usage(db, subscription)
+ # Проверяем и синхронизируем подписку с RemnaWave если необходимо
+ sync_success, sync_error = await subscription_service.ensure_subscription_synced(db, subscription)
+ if not sync_success:
+ logger.warning(f"Не удалось синхронизировать подписку {subscription.id} с RemnaWave: {sync_error}")
+
await db.refresh(subscription)
+ await db.refresh(db_user)
current_time = datetime.utcnow()
@@ -2051,17 +2059,32 @@ async def confirm_purchase(
promo_offer_discount_percent = 0
# Валидация: проверяем что cached_total_price соответствует ожидаемой финальной цене
- # Допускаем небольшое расхождение из-за округления
- is_valid = True
- if abs(final_price - cached_total_price) > 100: # допуск 1₽
- # Если есть расхождение, логируем предупреждение но продолжаем с пересчитанной ценой
+ # Допускаем небольшое расхождение из-за округления (до 5%)
+ price_difference = abs(final_price - cached_total_price)
+ max_allowed_difference = max(500, int(final_price * 0.05)) # 5% или минимум 5₽
+
+ if price_difference > max_allowed_difference:
+ # Слишком большое расхождение - блокируем покупку
+ logger.error(
+ f"Критическое расхождение цены для пользователя {db_user.telegram_id}: "
+ f"кэш={cached_total_price/100}₽, пересчет={final_price/100}₽, "
+ f"разница={price_difference/100}₽ (>{max_allowed_difference/100}₽). "
+ f"Покупка заблокирована."
+ )
+ await callback.answer(
+ "Цена изменилась. Пожалуйста, начните оформление заново.",
+ show_alert=True
+ )
+ return
+ elif price_difference > 100: # допуск 1₽
+ # Небольшое расхождение - логируем предупреждение но продолжаем
logger.warning(
f"Расхождение цены для пользователя {db_user.telegram_id}: "
f"кэш={cached_total_price/100}₽, пересчет={final_price/100}₽. "
f"Используем пересчитанную цену."
)
- # Используем пересчитанную цену вместо закэшированной для надежности
+ # Используем пересчитанную цену
validation_total_price = calculated_total_before_promo
logger.info(f"Расчет покупки подписки на {data['period_days']} дней ({months_in_period} мес):")
diff --git a/app/handlers/tickets.py b/app/handlers/tickets.py
index f4f5e301..48395e17 100644
--- a/app/handlers/tickets.py
+++ b/app/handlers/tickets.py
@@ -464,17 +464,61 @@ async def show_my_tickets_closed(
await callback.answer()
+def _split_long_block(block: str, max_len: int) -> list[str]:
+ """Разбивает слишком длинный блок на части."""
+ if len(block) <= max_len:
+ return [block]
+
+ parts = []
+ remaining = block
+ while remaining:
+ if len(remaining) <= max_len:
+ parts.append(remaining)
+ break
+ # Ищем место для разрыва (перенос строки или пробел)
+ cut_at = max_len
+ newline_pos = remaining.rfind('\n', 0, max_len)
+ space_pos = remaining.rfind(' ', 0, max_len)
+
+ if newline_pos > max_len // 2:
+ cut_at = newline_pos + 1
+ elif space_pos > max_len // 2:
+ cut_at = space_pos + 1
+
+ parts.append(remaining[:cut_at])
+ remaining = remaining[cut_at:]
+
+ return parts
+
+
def _split_text_into_pages(header: str, message_blocks: list[str], max_len: int = 3500) -> list[str]:
+ """Разбивает текст на страницы с учётом лимита Telegram."""
pages: list[str] = []
current = header
+ header_len = len(header)
+ block_max_len = max_len - header_len - 50 # запас для безопасности
+
for block in message_blocks:
- if len(current) + len(block) > max_len:
- pages.append(current)
+ # Если блок сам по себе слишком длинный — разбиваем его
+ if len(block) > block_max_len:
+ block_parts = _split_long_block(block, block_max_len)
+ for part in block_parts:
+ if len(current) + len(part) > max_len:
+ if current.strip() and current != header:
+ pages.append(current)
+ current = header + part
+ else:
+ current += part
+ elif len(current) + len(block) > max_len:
+ if current.strip() and current != header:
+ pages.append(current)
current = header + block
else:
current += block
+
if current.strip():
pages.append(current)
+
return pages if pages else [header]
diff --git a/app/services/subscription_purchase_service.py b/app/services/subscription_purchase_service.py
index 272a0d83..163255da 100644
--- a/app/services/subscription_purchase_service.py
+++ b/app/services/subscription_purchase_service.py
@@ -373,13 +373,16 @@ class MiniAppSubscriptionPurchaseService:
period_map: Dict[str, PurchasePeriodConfig] = {}
default_devices = settings.DEFAULT_DEVICE_LIMIT
- if subscription and getattr(subscription, "device_limit", None):
+ # Для триала НЕ используем его ограничения как дефолтные,
+ # чтобы при продлении клиент получил стандартные значения платной подписки
+ is_trial_subscription = subscription and getattr(subscription, "is_trial", False)
+ if subscription and getattr(subscription, "device_limit", None) and not is_trial_subscription:
default_devices = max(default_devices, int(subscription.device_limit))
fixed_traffic_value = None
if settings.is_traffic_fixed():
fixed_traffic_value = settings.get_fixed_traffic_limit()
- elif subscription and subscription.traffic_limit_gb is not None:
+ elif subscription and subscription.traffic_limit_gb is not None and not is_trial_subscription:
fixed_traffic_value = subscription.traffic_limit_gb
default_period_days = available_periods[0] if available_periods else 30
diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py
index 1d8c0f96..5c7be1cc 100644
--- a/app/services/subscription_service.py
+++ b/app/services/subscription_service.py
@@ -465,7 +465,105 @@ class SubscriptionService:
except Exception as e:
logger.error(f"Ошибка синхронизации трафика: {e}")
return False
-
+
+ async def ensure_subscription_synced(
+ self,
+ db: AsyncSession,
+ subscription: Subscription,
+ ) -> Tuple[bool, Optional[str]]:
+ """
+ Проверяет и синхронизирует подписку с RemnaWave при необходимости.
+
+ Если subscription_url отсутствует или данные не синхронизированы,
+ пытается обновить/создать пользователя в RemnaWave.
+
+ Returns:
+ Tuple[bool, Optional[str]]: (успех, сообщение об ошибке)
+ """
+ try:
+ user = await get_user_by_id(db, subscription.user_id)
+ if not user:
+ logger.error(f"Пользователь не найден для подписки {subscription.id}")
+ return False, "user_not_found"
+
+ # Проверяем, нужна ли синхронизация
+ needs_sync = (
+ not subscription.subscription_url
+ or not user.remnawave_uuid
+ )
+
+ if not needs_sync:
+ # Проверяем, существует ли пользователь в RemnaWave
+ try:
+ async with self.get_api_client() as api:
+ remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid)
+ if not remnawave_user:
+ needs_sync = True
+ logger.warning(
+ f"Пользователь {user.remnawave_uuid} не найден в RemnaWave, требуется синхронизация"
+ )
+ except Exception as check_error:
+ logger.warning(f"Не удалось проверить пользователя в RemnaWave: {check_error}")
+ # Продолжаем, возможно проблема временная
+
+ if not needs_sync:
+ return True, None
+
+ logger.info(
+ f"Синхронизация подписки {subscription.id} с RemnaWave "
+ f"(subscription_url={bool(subscription.subscription_url)}, "
+ f"remnawave_uuid={bool(user.remnawave_uuid)})"
+ )
+
+ # Пытаемся синхронизировать
+ result = None
+ if user.remnawave_uuid:
+ # Пробуем обновить существующего пользователя
+ result = await self.update_remnawave_user(
+ db,
+ subscription,
+ reset_traffic=False,
+ )
+ # Если update не удался (пользователь удалён из RemnaWave) — пробуем создать
+ if not result:
+ logger.warning(
+ f"Не удалось обновить пользователя {user.remnawave_uuid} в RemnaWave, "
+ f"пробуем создать заново"
+ )
+ # Сбрасываем старый UUID, create_remnawave_user установит новый
+ user.remnawave_uuid = None
+ result = await self.create_remnawave_user(
+ db,
+ subscription,
+ reset_traffic=False,
+ )
+ else:
+ # Создаём нового пользователя
+ result = await self.create_remnawave_user(
+ db,
+ subscription,
+ reset_traffic=False,
+ )
+
+ if result:
+ await db.refresh(subscription)
+ await db.refresh(user)
+ logger.info(
+ f"Подписка {subscription.id} успешно синхронизирована с RemnaWave. "
+ f"URL: {subscription.subscription_url}"
+ )
+ return True, None
+ else:
+ logger.error(f"Не удалось синхронизировать подписку {subscription.id} с RemnaWave")
+ return False, "sync_failed"
+
+ except RemnaWaveAPIError as api_error:
+ logger.error(f"Ошибка RemnaWave API при синхронизации подписки {subscription.id}: {api_error}")
+ return False, "api_error"
+ except Exception as e:
+ logger.error(f"Ошибка синхронизации подписки {subscription.id}: {e}")
+ return False, "unknown_error"
+
async def calculate_subscription_price(
self,
period_days: int,