diff --git a/.env.example b/.env.example index 1d455d5..80cfa66 100644 --- a/.env.example +++ b/.env.example @@ -118,6 +118,12 @@ LOG_LEVEL=INFO # Пример: 30:250,180:600,365:1000 PAYMENT_PLANS=30:250,180:600,365:1000 +# Типы подписок с разными squad-группами. +# Если заполнено, заменяет PAYMENT_PLANS. +# Коды: vpn_white = VPN + белые списки, white = только белые списки, vpn = только VPN. +# Формат: код:дни:цена_в_рублях +PAYMENT_PRODUCT_PLANS=vpn_white:30:450,white:30:250,vpn:30:200 + # Реквизиты или инструкция для перевода # Этот текст бот показывает пользователю после выбора тарифа PAYMENT_TRANSFER_TEXT=Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN @@ -172,8 +178,15 @@ PAYMENT_PLAN_TRAFFIC_RESET_PERIOD=NO_RESET # UUID внутренних групп Remnawave, которые назначаются новому или продлеваемому доступу # Несколько значений через запятую +# Используется старым режимом PAYMENT_PLANS. PAYMENT_INTERNAL_SQUAD_UUIDS=11111111-1111-1111-1111-111111111111 +# UUID squad для тарифа VPN и комбинированного тарифа VPN + белые списки +PAYMENT_VPN_SQUAD_UUIDS=11111111-1111-1111-1111-111111111111 + +# UUID squad для тарифа "Белые списки" и комбинированного тарифа VPN + белые списки +PAYMENT_WHITE_SQUAD_UUIDS=22222222-2222-2222-2222-222222222222 + # UUID внешней группы Remnawave, если используется PAYMENT_EXTERNAL_SQUAD_UUID= diff --git a/README.md b/README.md index 6e285f6..7477bf7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Telegram-бот на `aiogram 3` с интеграцией в `Remnawave API`, - Хранит локальный кэш пользователей и истории подписок в MariaDB - Поддерживает привязку существующего аккаунта Remnawave по `short_uuid` - Показывает профиль, статус подписки, срок и трафик -- Работает с реферальными кодами и скидкой перед оплатой +- Работает с реферальными кодами, промокодами и скидкой перед оплатой - Поддерживает ручную оплату переводом с отправкой чека в бот - Отправляет чек в отдельный Telegram-канал/топик на проверку - Даёт админу или модератору подтвердить или отклонить оплату @@ -24,8 +24,8 @@ Telegram-бот на `aiogram 3` с интеграцией в `Remnawave API`, Сейчас в проекте не используется Telegram Stars. Оплата работает вручную: 1. Пользователь нажимает `Купить подписку` -2. Бот показывает список тарифов из `PAYMENT_PLANS` -3. Если у пользователя применён реферальный код, скидка учитывается до выбора тарифа +2. Бот показывает список тарифов из `PAYMENT_PRODUCT_PLANS`, если они заданы, иначе из `PAYMENT_PLANS` +3. Если у пользователя применён реферальный код или промокод, скидка учитывается до выбора тарифа 4. После выбора тарифа бот показывает реквизиты из `PAYMENT_TRANSFER_TEXT` 5. Пользователь отправляет чек следующим сообщением в бота 6. Бот пересылает чек в review-чат оплаты @@ -34,7 +34,7 @@ Telegram-бот на `aiogram 3` с интеграцией в `Remnawave API`, ### Тарифы -Тарифы задаются одной переменной: +Обычные тарифы по срокам задаются одной переменной: ```env PAYMENT_PLANS=30:250,180:600,365:1000 @@ -51,6 +51,22 @@ PAYMENT_PLANS=30:250,180:600,365:1000 - 180 дней = 600 ₽ - 365 дней = 1000 ₽ +Для разных типов подписки можно включить продуктовые тарифы: + +```env +PAYMENT_PRODUCT_PLANS=vpn_white:30:450,white:30:250,vpn:30:200 +PAYMENT_VPN_SQUAD_UUIDS=11111111-1111-1111-1111-111111111111 +PAYMENT_WHITE_SQUAD_UUIDS=22222222-2222-2222-2222-222222222222 +``` + +Если `PAYMENT_PRODUCT_PLANS` заполнен, пользователь увидит выбор: + +- `VPN + Белые списки` — получает UUID из `PAYMENT_VPN_SQUAD_UUIDS` и `PAYMENT_WHITE_SQUAD_UUIDS` +- `Белые списки` — получает только `PAYMENT_WHITE_SQUAD_UUIDS` +- `VPN` — получает только `PAYMENT_VPN_SQUAD_UUIDS` + +Формат `PAYMENT_PRODUCT_PLANS`: `код:дни:цена_в_рублях`. Доступные коды: `vpn_white`, `white`, `vpn`. Эти значения также можно менять в админ-настройках бота. + ### Логин в Remnawave Логин создаётся в формате: @@ -237,6 +253,12 @@ telegabot/ - `referral_bonuses` - начисленные и ожидающие применения бонусы реферерам +- `promo_codes` + - промокоды, скидка, срок действия и ограничение по тарифу + +- `promo_code_applications` + - активный промокод, который пользователь ввёл перед оплатой + - `payment_orders` - ручные платежи, статусы и выданные доступы @@ -261,6 +283,26 @@ telegabot/ - за каждую подтверждённую оплату по рефкоду реферер получает бонус в днях - размер бонуса задаётся в `REFERRAL_BONUS_DAYS` +### Промокоды + +Админ может создать промокод из UI: `Админ-панель` -> `Промокоды` -> `Создать промокод`. + +Сценарий создания: + +1. Админ вводит слово промокода +2. Выбирает область действия: все тарифы или один тариф +3. Вводит процент скидки +4. Вводит срок действия + +Пользователь вводит промокод в том же разделе, где вводится реферальный код. Если промокод ограничен одним тарифом, скидка применяется только к этому тарифу. Если у пользователя есть и реферальное приглашение, и активный промокод, при оплате используется скидка промокода; реферальный бонус рефереру всё равно начислится после подтверждённой оплаты. + +Форматы срока действия: + +- `7` — 7 дней +- `24h` или `24ч` — 24 часа +- `2026-05-31` — до конца указанной даты +- `0` — без срока действия + ## Тикеты поддержки Сценарий: @@ -331,10 +373,11 @@ DB_NAME=oreolvpn DB_USER=oreolvpn DB_PASSWORD=strong_password -PAYMENT_PLANS=30:250,180:600,365:1000 +PAYMENT_PRODUCT_PLANS=vpn_white:30:450,white:30:250,vpn:30:200 PAYMENT_TRANSFER_TEXT=Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN PAYMENT_REVIEW_LINK=https://t.me/c/3646494169/56/57 -PAYMENT_INTERNAL_SQUAD_UUIDS=11111111-1111-1111-1111-111111111111 +PAYMENT_VPN_SQUAD_UUIDS=11111111-1111-1111-1111-111111111111 +PAYMENT_WHITE_SQUAD_UUIDS=22222222-2222-2222-2222-222222222222 REFERRAL_DISCOUNT_PERCENT=5 PAYMENT_USERNAME_PREFIX=Oreol ``` @@ -521,6 +564,21 @@ DB_PORT=3306 Если вы запускаете проект только в Docker, `DB_HOST` и `DB_PORT` можно не менять вручную. +`DB_ROOT_PASSWORD` нужен только контейнеру MariaDB. Сам бот подключается к базе исключительно через: + +- `DB_USER` +- `DB_PASSWORD` + +Это важно: + +- `DB_ROOT_PASSWORD` не используется приложением для SQLAlchemy-подключения +- если в `.env` указать `DB_USER=root`, но оставить `DB_PASSWORD` пустым, бот попытается зайти как `root` без пароля и упадёт с `Access denied` + +Для production рекомендуется не использовать `root` для бота, а создать отдельного пользователя БД через: + +- `DB_USER` +- `DB_PASSWORD` + MariaDB в текущем `docker-compose.yml` не публикуется наружу на хост, чтобы база не торчала во внешний интернет по `3306`. Бот подключается к ней по внутренней Docker-сети через hostname `db`. #### Первый запуск @@ -643,6 +701,23 @@ docker compose up -d - не пустые ли `DB_PASSWORD` и `DB_ROOT_PASSWORD` - есть ли у сервиса `db` статус `healthy` в `docker compose ps` +Если видите ошибку вида: + +```text +Access denied for user 'root' ... (using password: NO) +``` + +проверьте: + +- не стоит ли в `.env` `DB_USER=root` +- не пустой ли `DB_PASSWORD` +- совпадают ли `DB_USER` и `DB_PASSWORD` с пользователем MariaDB, который был создан при первом старте контейнера + +Если контейнер `db` уже один раз поднимался с неправильными DB-переменными, volume MariaDB мог сохранить старого пользователя и старый пароль. Тогда есть два варианта: + +- если данных ещё не жалко: `docker compose down -v` и затем `docker compose up -d --build` +- если данные нужно сохранить: зайти в MariaDB и вручную создать/выдать права нужному пользователю + Если хотите открыть MariaDB наружу для внешнего клиента, это нужно делать осознанно: добавьте `ports` обратно в сервис `db` и отдельно ограничьте доступ через firewall или private network. #### Минимальный сценарий запуска в Docker @@ -778,7 +853,7 @@ OperationalError: (1049, "Unknown database '...'" ) 6. Проверить руками: - `/start` - открытие раздела подписки через кнопку в панели - - ввод реферального кода + - ввод реферального кода или промокода - отправку чека - подтверждение оплаты модератором - выдачу ссылки пользователю diff --git a/app/bot/handlers/main.py b/app/bot/handlers/main.py index 82a1b42..21640ea 100644 --- a/app/bot/handlers/main.py +++ b/app/bot/handlers/main.py @@ -2,6 +2,7 @@ from __future__ import annotations import html import secrets +from datetime import datetime, timedelta, timezone from pathlib import Path from urllib.parse import quote_plus @@ -38,11 +39,18 @@ from app.services.payment_service import ( StoredPaymentOrder, ) from app.services.remnawave_client import RemnawaveApiError -from app.services.support_ticket_service import SupportTicketService +from app.services.support_ticket_service import ( + AdminSupportTicketsPage, + ANSWERED_SUPPORT_TICKET_STATUS, + CLOSED_SUPPORT_TICKET_STATUS, + OPEN_SUPPORT_TICKET_STATUS, + SupportTicketService, +) from app.services.sync_service import ( AdminTelegramUserProfile, AdminTelegramUsersPage, CachedUserView, + PromoCodeView, SyncService, ) from app.utils.formatters import ( @@ -58,6 +66,11 @@ from app.utils.formatters import ( class SupportTicketStates(StatesGroup): waiting_for_message = State() + waiting_for_note = State() + + +class SupportTicketAdminStates(StatesGroup): + waiting_for_note = State() class ReferralCodeStates(StatesGroup): @@ -76,6 +89,13 @@ class AdminConfigStates(StatesGroup): waiting_for_value = State() +class AdminPromoStates(StatesGroup): + waiting_for_code = State() + waiting_for_plan = State() + waiting_for_discount = State() + waiting_for_expires = State() + + def build_router( *, settings: Settings, @@ -194,10 +214,16 @@ def build_router( async def clear_interactive_states(state: FSMContext) -> None: if await state.get_state() in { SupportTicketStates.waiting_for_message.state, + SupportTicketStates.waiting_for_note.state, + SupportTicketAdminStates.waiting_for_note.state, ReferralCodeStates.waiting_for_code.state, ManualPaymentStates.waiting_for_receipt.state, AdminLookupStates.waiting_for_identifier.state, AdminConfigStates.waiting_for_value.state, + AdminPromoStates.waiting_for_code.state, + AdminPromoStates.waiting_for_plan.state, + AdminPromoStates.waiting_for_discount.state, + AdminPromoStates.waiting_for_expires.state, }: await state.clear() @@ -243,6 +269,9 @@ def build_router( display_name = username or "Пользователь" referral_summary = await sync_service.get_referral_summary(telegram_id) + active_discount = await payment_service.get_active_discount_for_user( + telegram_id=telegram_id, + ) referral_link = "" referral_share_url = "" if config.referral_enabled and config.bot_public_username_normalized: @@ -262,7 +291,8 @@ def build_router( ) plans = await payment_service.get_available_plans(telegram_id=telegram_id) - primary_plan = plans[0] if plans else None + primary_plan = next((plan for plan in plans if plan.is_ready), plans[0] if plans else None) + has_ready_payment_plan = any(plan.is_ready for plan in plans) admin_total_telegram_users = 0 admin_total_cached_users = 0 admin_active_cached_users = 0 @@ -293,12 +323,15 @@ def build_router( referral_code=referral_summary.referral_code, referral_count=referral_summary.total_invited, applied_referral_code=( - referral_summary.applied_referral_code if config.referral_enabled else "" + active_discount.code if config.referral_enabled else "" ), referral_link=referral_link, referral_share_url=referral_share_url, referral_recent_names=referral_summary.recent_names, - referral_discount_percent=max(config.referral_discount_percent, 0), + referral_discount_percent=max( + active_discount.discount_percent or config.referral_discount_percent, + 0, + ), referral_bonus_days=max(config.referral_bonus_days, 0), admin_total_telegram_users=admin_total_telegram_users, admin_total_cached_users=admin_total_cached_users, @@ -307,7 +340,7 @@ def build_router( admin_review_orders=admin_review_orders, admin_open_tickets=admin_open_tickets, admin_pending_referral_bonuses=admin_pending_referral_bonuses, - payment_enabled=bool(primary_plan and primary_plan.is_ready), + payment_enabled=has_ready_payment_plan, payment_plan_title=primary_plan.title if primary_plan else "", payment_plan_description=primary_plan.description if primary_plan else "", payment_plan_price_stars=primary_plan.amount_rub if primary_plan else 0, @@ -470,12 +503,191 @@ def build_router( def admin_user_profile_callback(*, telegram_id: int, page: int) -> str: return f"admin:user_view:{telegram_id}:{max(page, 1)}" + def admin_tickets_page_callback(page: int) -> str: + return admin_settings_callback("ticket_list", str(max(page, 1))) + + def admin_ticket_view_callback(*, ticket_id: int, page: int) -> str: + return f"admin:ticket_view:{ticket_id}:{max(page, 1)}" + + def admin_promo_plan_callback(plan_code: str) -> str: + return f"admin:promo_plan:{plan_code}" + def compact_inline_label(value: str, *, limit: int = 30) -> str: normalized = " ".join(value.split()) if len(normalized) <= limit: return normalized return f"{normalized[: max(0, limit - 3)].rstrip()}..." + def get_payment_plan_label_map(config: BotConfigSnapshot) -> dict[str, str]: + return { + plan.code: f"{plan.title} · {plan.days} дней" if plan.title else f"{plan.days} дней" + for plan in config.payment_plans + } + + def format_promo_scope(*, plan_code: str | None, config: BotConfigSnapshot) -> str: + if not plan_code: + return "все тарифы" + # "all_30" → "Все тарифы · 30 дней" + if plan_code.startswith("all_") and plan_code.removeprefix("all_").isdigit(): + days = plan_code.removeprefix("all_") + return f"Все тарифы · {days} дней" + label_map = get_payment_plan_label_map(config) + if plan_code in label_map: + return label_map[plan_code] + # Try product-level lookup (e.g. plan_code="vpn_white" → title from spec) + from app.config import PAYMENT_PRODUCT_PLAN_SPECS + spec = PAYMENT_PRODUCT_PLAN_SPECS.get(plan_code) + if spec is not None: + return spec[0] # title + return plan_code + + def format_promo_expires_at(expires_at) -> str: + return format_datetime(expires_at) if expires_at is not None else "без срока" + + def parse_promo_expires_at(raw_value: str): + value = (raw_value or "").strip().lower() + if value in {"0", "-", "none", "no", "без срока", "бессрочно"}: + return None + + now = datetime.now(timezone.utc).replace(tzinfo=None) + compact_value = "".join(value.split()) + suffix_map = { + "h": "hours", + "ч": "hours", + "hour": "hours", + "hours": "hours", + "d": "days", + "д": "days", + "day": "days", + "days": "days", + } + for suffix, unit in suffix_map.items(): + if not compact_value.endswith(suffix): + continue + raw_number = compact_value[: -len(suffix)] + if raw_number.isdigit() and int(raw_number) > 0: + return now + timedelta(**{unit: int(raw_number)}) + + if compact_value.isdigit() and int(compact_value) > 0: + return now + timedelta(days=int(compact_value)) + + for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%d", "%d.%m.%Y %H:%M", "%d.%m.%Y"): + try: + parsed = datetime.strptime(raw_value.strip(), fmt) + except ValueError: + continue + if "%H" not in fmt: + parsed = parsed.replace(hour=23, minute=59, second=59) + return parsed + + raise ValueError( + "Введите срок в днях, например 7, или дату 2026-05-31. Для бессрочного промокода отправьте 0." + ) + + def render_admin_promos_text( + *, + promos: list[PromoCodeView], + config: BotConfigSnapshot, + ) -> str: + lines = [ + "🎟 Промокоды", + "", + "
Промокод вводится пользователем в разделе реферальной системы и даёт скидку перед оплатой.", + ] + if not promos: + lines.extend(["", "Промокоды ещё не созданы."]) + return "\n".join(lines) + + lines.extend(["", "Последние промокоды"]) + for promo in promos[:10]: + status_text = "активен" if promo.is_valid else "неактивен" + lines.extend( + [ + "", + ( + "
"
+ f"{html.escape(promo.code)} · -{promo.discount_percent}%\n"
+ f"Тариф: {html.escape(format_promo_scope(plan_code=promo.plan_code, config=config))}\n"
+ f"Срок: {html.escape(format_promo_expires_at(promo.expires_at))}\n"
+ f"Статус: {status_text}"
+ ""
+ ),
+ ]
+ )
+ return "\n".join(lines)
+
+ def build_admin_promos_keyboard():
+ builder = InlineKeyboardBuilder()
+ builder.button(
+ text="➕ Создать промокод",
+ callback_data=admin_settings_callback("promo_create"),
+ )
+ builder.button(
+ text="↻ Обновить",
+ callback_data=admin_settings_callback("promos"),
+ )
+ builder.button(
+ text="🛠 Админ-панель",
+ callback_data=f"panel:{PANEL_SECTION_ADMIN}",
+ )
+ builder.adjust(1)
+ return builder.as_markup()
+
+ def build_admin_promo_plan_keyboard(*, config: BotConfigSnapshot):
+ builder = InlineKeyboardBuilder()
+ builder.button(
+ text="Все тарифы",
+ callback_data=admin_promo_plan_callback("all"),
+ )
+ # Show unique product types, not individual durations
+ seen_products: set[str] = set()
+ for plan in config.payment_plans:
+ product_code = _extract_product_code(plan.code)
+ if product_code in seen_products:
+ continue
+ seen_products.add(product_code)
+ builder.button(
+ text=plan.title or product_code,
+ callback_data=admin_promo_plan_callback(product_code),
+ )
+ builder.button(
+ text="⬅️ К промокодам",
+ callback_data=admin_settings_callback("promos"),
+ )
+ builder.adjust(1)
+ return builder.as_markup()
+
+ def build_admin_promo_duration_keyboard(
+ *,
+ config: BotConfigSnapshot,
+ product_code: str,
+ ):
+ """Build keyboard with duration options for a specific product (or 'all')."""
+ builder = InlineKeyboardBuilder()
+ if product_code == "all":
+ # Show unique durations across all products
+ seen_days: set[int] = set()
+ for plan in config.payment_plans:
+ if plan.days not in seen_days:
+ seen_days.add(plan.days)
+ builder.button(
+ text=f"{plan.days} дней",
+ callback_data=f"admin:promo_duration:all_{plan.days}",
+ )
+ else:
+ for plan in config.payment_plans:
+ if _extract_product_code(plan.code) == product_code:
+ builder.button(
+ text=f"{plan.days} дней · {plan.amount_rub} ₽",
+ callback_data=f"admin:promo_duration:{plan.code}",
+ )
+ builder.button(
+ text="⬅️ Назад к тарифам",
+ callback_data="admin:promo_back_to_plans",
+ )
+ builder.adjust(1)
+ return builder.as_markup()
+
def render_admin_settings_home_text(*, config: BotConfigSnapshot) -> str:
lines = [
"⚙️ Настройки бота",
@@ -683,6 +895,169 @@ def build_router(
)
return builder.as_markup()
+ def render_admin_tickets_page_text(*, page_data: AdminSupportTicketsPage) -> str:
+ lines = [
+ "🎫 История тикетов",
+ "",
+ (
+ "" + f"Всего тикетов: {page_data.total_items}\n" + f"Страница: {page_data.page}/{page_data.total_pages}" + "" + ), + ] + + if not page_data.items: + lines.extend(["", "Тикетов пока нет."]) + return "\n".join(lines) + + for index, item in enumerate( + page_data.items, + start=((page_data.page - 1) * page_data.page_size) + 1, + ): + username_text = f"@{item.username}" if item.username else "—" + lines.extend( + [ + "", + ( + f"{index}. #{html.escape(item.public_id)} — " + f"{html.escape(item.display_name)}\n" + "
"
+ f"Статус: {format_support_ticket_status(item.status)}\n"
+ f"Telegram ID: {item.telegram_id}\n"
+ f"Username: {html.escape(username_text)}\n"
+ f"Создан: {format_datetime(item.created_at)}"
+ ""
+ ),
+ ]
+ )
+
+ return "\n".join(lines)
+
+ def build_admin_tickets_page_keyboard(*, page_data: AdminSupportTicketsPage):
+ builder = InlineKeyboardBuilder()
+
+ for item in page_data.items:
+ username_suffix = f" (@{item.username})" if item.username else ""
+ builder.row(
+ InlineKeyboardButton(
+ text=(
+ f"🎫 #{item.public_id} • "
+ f"{compact_inline_label(item.display_name + username_suffix, limit=20)}"
+ ),
+ callback_data=admin_ticket_view_callback(
+ ticket_id=item.id,
+ page=page_data.page,
+ ),
+ )
+ )
+
+ pagination_buttons: list[InlineKeyboardButton] = []
+ if page_data.page > 1:
+ pagination_buttons.append(
+ InlineKeyboardButton(
+ text="⬅️ Назад",
+ callback_data=admin_tickets_page_callback(page_data.page - 1),
+ )
+ )
+ pagination_buttons.append(
+ InlineKeyboardButton(
+ text=f"{page_data.page}/{page_data.total_pages}",
+ callback_data=admin_tickets_page_callback(page_data.page),
+ )
+ )
+ if page_data.page < page_data.total_pages:
+ pagination_buttons.append(
+ InlineKeyboardButton(
+ text="Вперёд ➡️",
+ callback_data=admin_tickets_page_callback(page_data.page + 1),
+ )
+ )
+ builder.row(*pagination_buttons)
+ builder.row(
+ InlineKeyboardButton(
+ text="↻ Обновить",
+ callback_data=admin_tickets_page_callback(page_data.page),
+ ),
+ InlineKeyboardButton(
+ text="🛠 Админ-панель",
+ callback_data=f"panel:{PANEL_SECTION_ADMIN}",
+ ),
+ )
+ return builder.as_markup()
+
+ def render_admin_ticket_history_text(*, ticket) -> str:
+ username_text = f"@{html.escape(ticket.username)}" if ticket.username else "не указан"
+ lines = [
+ f"🎫 Тикет #{html.escape(ticket.public_id)}",
+ "",
+ (
+ ""
+ f"Статус: {format_support_ticket_status(ticket.status)}\n"
+ f"Пользователь: {html.escape(ticket.display_name)}\n"
+ f"Telegram ID: {ticket.telegram_id}\n"
+ f"Username: {username_text}\n"
+ f"Создан: {format_datetime(ticket.created_at)}\n"
+ f"Обновлён: {format_datetime(ticket.updated_at)}"
+ ""
+ ),
+ ]
+
+ if ticket.closed_at is not None:
+ lines.extend(
+ [
+ "",
+ (
+ ""
+ f"Закрыт: {format_datetime(ticket.closed_at)}\n"
+ f"Закрыл Telegram ID: {ticket.closed_by_telegram_id or '—'}"
+ ""
+ ),
+ ]
+ )
+
+ lines.extend(
+ [
+ "",
+ "Сообщение",
+ f"{html.escape(ticket.user_message)}", + ] + ) + + if ticket.support_note: + lines.extend( + [ + "", + "История текста", + f"
{html.escape(ticket.support_note)}", + ] + ) + + return "\n".join(lines) + + def build_admin_ticket_history_keyboard(*, ticket_id: int, page: int): + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton( + text="↻ Обновить", + callback_data=admin_ticket_view_callback( + ticket_id=ticket_id, + page=page, + ), + ), + InlineKeyboardButton( + text="⬅️ К списку", + callback_data=admin_tickets_page_callback(page), + ), + ) + builder.row( + InlineKeyboardButton( + text="🛠 Админ-панель", + callback_data=f"panel:{PANEL_SECTION_ADMIN}", + ) + ) + return builder.as_markup() + def render_admin_user_profile_text(*, profile: AdminTelegramUserProfile) -> str: user = profile.user display_name = " ".join( @@ -849,6 +1224,23 @@ def build_router( ) return builder.as_markup() + def _extract_product_code(plan_code: str) -> str: + """Extract product type from plan code, e.g. 'vpn_white_30' → 'vpn_white'.""" + parts = plan_code.rsplit("_", 1) + if len(parts) == 2 and parts[1].isdigit(): + return parts[0] + return plan_code + + def _get_product_groups( + plans: list[PaymentPlan], + ) -> dict[str, list[PaymentPlan]]: + """Group plans by product code, preserving order.""" + groups: dict[str, list[PaymentPlan]] = {} + for plan in plans: + product_code = _extract_product_code(plan.code) + groups.setdefault(product_code, []).append(plan) + return groups + def render_payment_menu_text( *, plans: list[PaymentPlan], @@ -870,11 +1262,11 @@ def build_router( if referral_enabled: lines.append( - "
Если у вас есть реферальный код, укажите его до оплаты. После выбора тарифа бот покажет реквизиты и попросит отправить чек." + "
Если у вас есть реферальный код или промокод, укажите его до оплаты." ) else: lines.append( - "
Выберите тариф. После выбора бот покажет реквизиты и попросит отправить чек." + "
Выберите тип подписки." ) if referral_code: @@ -886,20 +1278,19 @@ def build_router( ] ) - lines.extend(["", "Выберите тариф"]) - if not plans: - lines.append("
Покупка пока не настроена.") - return "\n".join(lines) + lines.extend(["", "Выберите тип подписки"]) - for plan in plans: - if plan.discount_percent > 0 and plan.original_amount_rub != plan.amount_rub: - price_text = ( - f"{plan.amount_rub} ₽ " - f"
Выберите срок подписки. После выбора бот покажет реквизиты и попросит отправить чек.", + ] + + if referral_code: + lines.extend( + [ + "", + f"🎟 Код:
{html.escape(referral_code)} · скидка {max(referral_discount_percent, 0)}%",
+ ]
+ )
+
+ lines.extend(["", "Выберите срок"])
+ for plan in plans:
+ if plan.discount_percent > 0 and plan.original_amount_rub != plan.amount_rub:
+ price_text = (
+ f"{plan.amount_rub} ₽ "
+ f"{html.escape(ticket.public_id)}",
+ f"Пользователь: {html.escape(ticket.display_name)}",
+ f"Telegram ID: {ticket.telegram_id}",
+ f"Username: {username_text}",
+ f"Создан: {format_datetime(ticket.created_at)}",
+ ]
+ if ticket.closed_at is not None:
+ lines.append(f"Закрыт: {format_datetime(ticket.closed_at)}")
+ lines.extend(
+ [
+ "",
+ "Сообщение",
+ f"{html.escape(ticket.user_message)}", + ] + ) + if ticket.support_note: + lines.extend( + [ + "", + "Текст к тикету", + f"
{html.escape(ticket.support_note)}", + ] + ) + if ticket.status == CLOSED_SUPPORT_TICKET_STATUS: + lines.extend( + [ + "", + "Тикет закрыт. Если вопрос снова станет актуален, пользователь может создать новый.", + ] + ) + else: + lines.extend( + [ + "", + "Ответьте реплаем на это сообщение, чтобы бот доставил ответ пользователю. Кнопками ниже можно добавить текст к тикету или закрыть его.", + ] + ) + return "\n".join(lines) + + async def refresh_support_ticket_message(bot: Bot, *, ticket) -> None: + if ticket.support_message_id is None: + return + + try: + await bot.edit_message_text( + chat_id=ticket.support_chat_id, + message_id=ticket.support_message_id, + text=render_support_ticket_text(ticket=ticket), + reply_markup=build_support_ticket_keyboard(ticket=ticket), + ) + except TelegramBadRequest as exc: + if "message is not modified" not in str(exc).lower(): + raise + + def render_user_ticket_created_text(*, ticket) -> str: + return "\n".join( + [ + f"🎫 Тикет #{html.escape(ticket.public_id)} отправлен в поддержку.", + f"Статус: {format_support_ticket_status(ticket.status)}", + "Ответ придёт сюда, как только его обработают.", + "", + "Если хотите уточнить вопрос, добавьте текст или закройте тикет кнопками ниже.", + ] + ) + + def render_user_ticket_note_added_text(*, ticket) -> str: + return "\n".join( + [ + f"📝 Текст добавлен к тикету #{html.escape(ticket.public_id)}.", + f"Статус: {format_support_ticket_status(ticket.status)}", + "Мы передали уточнение в поддержку.", + ] + ) + + def render_support_ticket_note_added_notice(*, ticket, author_name: str, note_text: str) -> str: + return "\n".join( + [ + f"📨 Обновление тикета #{html.escape(ticket.public_id)}", + f"Пользователь {html.escape(author_name)} добавил новый текст:", + "", + f"
{html.escape(note_text)}", + ] + ) + + def render_user_ticket_support_note_text(*, ticket, note_text: str) -> str: + return "\n".join( + [ + f"📨 Обновление по тикету #{html.escape(ticket.public_id)}", + "Поддержка добавила к тикету новую информацию:", + "", + f"
{html.escape(note_text)}", + "", + "Если нужно, вы можете дописать тикет кнопкой ниже или закрыть его.", + ] + ) + + def render_user_ticket_closed_text(*, ticket, closed_by_support: bool) -> str: + reason_text = ( + "Если вопрос ещё актуален, создайте новый тикет через панель бота." + if closed_by_support + else "Если понадобится помощь снова, создайте новый тикет через панель бота." + ) + return "\n".join( + [ + f"🎫 Тикет #{html.escape(ticket.public_id)} закрыт.", + f"Статус: {format_support_ticket_status(ticket.status)}", + reason_text, + ] + ) + def build_payment_receipt_keyboard(*, order_uuid: str): builder = InlineKeyboardBuilder() builder.button( @@ -1068,7 +1708,7 @@ def build_router( f"Логин Remnawave:
{html.escape(order.provision_username)}",
]
if referral_code:
- lines.append(f"Рефкод: {html.escape(referral_code)}")
+ lines.append(f"Код скидки: {html.escape(referral_code)}")
if receipt_note:
lines.extend(
[
@@ -1091,25 +1731,28 @@ def build_router(
telegram_id: int,
) -> None:
config = await get_runtime_config()
- plans = await payment_service.get_available_plans(telegram_id=telegram_id)
+ plans = [
+ plan
+ for plan in await payment_service.get_available_plans(telegram_id=telegram_id)
+ if plan.is_ready
+ ]
has_existing_access = bool(await sync_service.get_cached_users_for_telegram(telegram_id))
- referral_code = (
- (await sync_service.get_referral_summary(telegram_id)).applied_referral_code
- if config.referral_enabled
- else ""
+ active_discount = await payment_service.get_active_discount_for_user(
+ telegram_id=telegram_id,
)
await answer_branded(
message,
render_payment_menu_text(
plans=plans,
- referral_code=referral_code,
+ referral_code=active_discount.code if config.referral_enabled else "",
referral_enabled=config.referral_enabled,
- referral_discount_percent=config.referral_discount_percent,
+ referral_discount_percent=active_discount.discount_percent
+ or config.referral_discount_percent,
has_existing_access=has_existing_access,
),
reply_markup=build_payment_menu_keyboard(
plans=plans,
- referral_code=referral_code,
+ referral_code=active_discount.code if config.referral_enabled else "",
referral_enabled=config.referral_enabled,
),
)
@@ -1120,27 +1763,32 @@ def build_router(
return
config = await get_runtime_config()
- plans = await payment_service.get_available_plans(telegram_id=callback.from_user.id)
+ plans = [
+ plan
+ for plan in await payment_service.get_available_plans(
+ telegram_id=callback.from_user.id
+ )
+ if plan.is_ready
+ ]
has_existing_access = bool(
await sync_service.get_cached_users_for_telegram(callback.from_user.id)
)
- referral_code = (
- (await sync_service.get_referral_summary(callback.from_user.id)).applied_referral_code
- if config.referral_enabled
- else ""
+ active_discount = await payment_service.get_active_discount_for_user(
+ telegram_id=callback.from_user.id,
)
await edit_message_content(
callback,
text=render_payment_menu_text(
plans=plans,
- referral_code=referral_code,
+ referral_code=active_discount.code if config.referral_enabled else "",
referral_enabled=config.referral_enabled,
- referral_discount_percent=config.referral_discount_percent,
+ referral_discount_percent=active_discount.discount_percent
+ or config.referral_discount_percent,
has_existing_access=has_existing_access,
),
reply_markup=build_payment_menu_keyboard(
plans=plans,
- referral_code=referral_code,
+ referral_code=active_discount.code if config.referral_enabled else "",
referral_enabled=config.referral_enabled,
),
)
@@ -1186,16 +1834,11 @@ def build_router(
[
f"🎫 НОВЫЙ ТИКЕТ #{ticket_id}",
"",
- f"ID: {ticket_id}",
f"Пользователь: {html.escape(display_name)}",
f"Telegram ID: {message.from_user.id}",
f"Username: {username_text}",
- f"Создан: {format_datetime(message.date.replace(tzinfo=None))}",
"",
- "Сообщение",
f"{html.escape(raw_message_text)}", - "", - "Ответьте реплаем на это сообщение, чтобы бот доставил ответ пользователю.", ] ) @@ -1205,7 +1848,7 @@ def build_router( text=ticket_text, ) - await support_ticket_service.create_ticket( + ticket = await support_ticket_service.create_ticket( public_id=ticket_id, telegram_id=message.from_user.id, username=message.from_user.username, @@ -1215,12 +1858,13 @@ def build_router( support_thread_id=config.support_ticket_message_thread_id, support_message_id=support_message.message_id, ) + await refresh_support_ticket_message(message.bot, ticket=ticket) await state.clear() await answer_branded( message, - f"🎫 Тикет #{ticket_id} отправлен в поддержку.\n" - "Ответ придёт сюда, как только его обработают." + render_user_ticket_created_text(ticket=ticket), + reply_markup=build_user_support_ticket_keyboard(ticket=ticket), ) async def send_receipt_for_review( @@ -1344,6 +1988,52 @@ def build_router( await clear_interactive_states(state) await show_payment_menu_callback(callback) + @router.callback_query(F.data.startswith("payment:product:")) + async def on_payment_product(callback: CallbackQuery, state: FSMContext) -> None: + if callback.from_user is None: + await callback.answer() + return + + await clear_interactive_states(state) + product_code = (callback.data or "").split(":", maxsplit=2)[2] + + config = await get_runtime_config() + all_plans = [ + plan + for plan in await payment_service.get_available_plans( + telegram_id=callback.from_user.id + ) + if plan.is_ready + ] + + product_plans = [ + p for p in all_plans + if _extract_product_code(p.code) == product_code + ] + + if not product_plans: + await callback.answer("Тариф не найден", show_alert=True) + return + + active_discount = await payment_service.get_active_discount_for_user( + telegram_id=callback.from_user.id, + ) + + await edit_message_content( + callback, + text=render_product_duration_text( + product_label=product_plans[0].label, + plans=product_plans, + referral_code=active_discount.code if config.referral_enabled else "", + referral_discount_percent=active_discount.discount_percent + or config.referral_discount_percent, + ), + reply_markup=build_product_duration_keyboard( + plans=product_plans, + ), + ) + await callback.answer() + @router.callback_query(F.data.startswith("payment:select:")) async def on_payment_select(callback: CallbackQuery, state: FSMContext) -> None: if callback.from_user is None: @@ -1408,8 +2098,14 @@ def build_router( @router.message(ManualPaymentStates.waiting_for_receipt, Command("cancel")) @router.message(SupportTicketStates.waiting_for_message, Command("cancel")) + @router.message(SupportTicketStates.waiting_for_note, Command("cancel")) + @router.message(SupportTicketAdminStates.waiting_for_note, Command("cancel")) @router.message(ReferralCodeStates.waiting_for_code, Command("cancel")) @router.message(AdminConfigStates.waiting_for_value, Command("cancel")) + @router.message(AdminPromoStates.waiting_for_code, Command("cancel")) + @router.message(AdminPromoStates.waiting_for_plan, Command("cancel")) + @router.message(AdminPromoStates.waiting_for_discount, Command("cancel")) + @router.message(AdminPromoStates.waiting_for_expires, Command("cancel")) async def cancel_interactive_flow(message: Message, state: FSMContext) -> None: await state.clear() await message.answer("Действие отменено.") @@ -1439,15 +2135,24 @@ def build_router( if order.status == REVIEW_PAYMENT_STATUS: await state.clear() - await message.answer("Чек уже отправлен на проверку.") + await message.answer( + "Чек уже отправлен на проверку.", + reply_markup=build_home_keyboard(), + ) return if order.status == FULFILLED_PAYMENT_STATUS: await state.clear() - await message.answer("Этот заказ уже подтверждён.") + await message.answer( + "Этот заказ уже подтверждён.", + reply_markup=build_home_keyboard(), + ) return if order.status == REJECTED_PAYMENT_STATUS: await state.clear() - await message.answer("Этот заказ уже отклонён. Создайте новый платёж.") + await message.answer( + "Этот заказ уже отклонён. Создайте новый платёж.", + reply_markup=build_home_keyboard(), + ) return try: @@ -1477,7 +2182,8 @@ def build_router( await state.clear() await message.answer( "✅ Чек отправлен на проверку.\n" - "После подтверждения администратором доступ придёт сюда." + "После подтверждения администратором доступ придёт сюда.", + reply_markup=build_home_keyboard(), ) @router.callback_query(F.data == "referral:apply") @@ -1493,8 +2199,8 @@ def build_router( await state.set_state(ReferralCodeStates.waiting_for_code) await callback.message.answer( - "🎟 Отправьте реферальный код одним следующим сообщением.\n\n" - "Код будет сохранён и применён перед оплатой.\n" + "🎟 Отправьте реферальный код или промокод одним следующим сообщением.\n\n" + "Код будет сохранён и применён перед оплатой, если он подходит к выбранному тарифу.\n" "Чтобы отменить ввод, отправьте
/cancel."
)
await callback.answer()
@@ -1518,6 +2224,76 @@ def build_router(
)
await callback.answer()
+ @router.callback_query(F.data.startswith("ticket_user:note:"))
+ async def on_user_ticket_note_start(callback: CallbackQuery, state: FSMContext) -> None:
+ if callback.from_user is None or callback.message is None:
+ await callback.answer()
+ return
+
+ ticket_id_text = (callback.data or "").split(":")[-1]
+ if not ticket_id_text.isdigit():
+ await callback.answer("Тикет не найден", show_alert=True)
+ return
+
+ ticket = await support_ticket_service.get_ticket(ticket_id=int(ticket_id_text))
+ if ticket is None or ticket.telegram_id != callback.from_user.id:
+ await callback.answer("Тикет не найден", show_alert=True)
+ return
+ if ticket.status == CLOSED_SUPPORT_TICKET_STATUS:
+ await callback.answer("Тикет уже закрыт", show_alert=True)
+ return
+
+ await clear_interactive_states(state)
+ await state.set_state(SupportTicketStates.waiting_for_note)
+ await state.update_data(user_ticket_id=ticket.id)
+ await callback.message.answer(
+ f"📝 Отправьте текст для тикета #{ticket.public_id} одним следующим сообщением.\n"
+ "Он будет добавлен к текущему тикету и виден поддержке.\n"
+ "Чтобы отменить действие, отправьте /cancel."
+ )
+ await callback.answer()
+
+ @router.callback_query(F.data.startswith("ticket_user:close:"))
+ async def on_user_ticket_close(callback: CallbackQuery, state: FSMContext) -> None:
+ if callback.from_user is None or callback.message is None:
+ await callback.answer()
+ return
+
+ ticket_id_text = (callback.data or "").split(":")[-1]
+ if not ticket_id_text.isdigit():
+ await callback.answer("Тикет не найден", show_alert=True)
+ return
+
+ ticket = await support_ticket_service.get_ticket(ticket_id=int(ticket_id_text))
+ if ticket is None or ticket.telegram_id != callback.from_user.id:
+ await callback.answer("Тикет не найден", show_alert=True)
+ return
+ if ticket.status == CLOSED_SUPPORT_TICKET_STATUS:
+ await callback.answer("Тикет уже закрыт", show_alert=True)
+ return
+
+ closed_ticket = await support_ticket_service.close_ticket(
+ ticket_id=ticket.id,
+ closed_by_telegram_id=callback.from_user.id,
+ )
+ if closed_ticket is None:
+ await callback.answer("Не удалось закрыть тикет", show_alert=True)
+ return
+
+ await clear_interactive_states(state)
+ await refresh_support_ticket_message(callback.bot, ticket=closed_ticket)
+ await send_branded(
+ callback.bot,
+ chat_id=closed_ticket.telegram_id,
+ text=render_user_ticket_closed_text(ticket=closed_ticket, closed_by_support=False),
+ reply_markup=build_user_support_ticket_keyboard(ticket=closed_ticket),
+ )
+ await callback.answer("Тикет закрыт")
+
+ @router.callback_query(F.data.startswith("ticket_user:closed:"))
+ async def on_user_ticket_closed(callback: CallbackQuery) -> None:
+ await callback.answer("Тикет уже закрыт", show_alert=True)
+
@router.callback_query(F.data.startswith("panel:"))
async def on_panel_callback(callback: CallbackQuery, state: FSMContext) -> None:
if callback.from_user is None:
@@ -1567,6 +2343,73 @@ def build_router(
async def on_ticket_message(message: Message, state: FSMContext) -> None:
await send_ticket_to_support(message, state=state)
+ @router.message(SupportTicketStates.waiting_for_note)
+ async def on_user_ticket_note_message(message: Message, state: FSMContext) -> None:
+ if not await ensure_sender(message):
+ await state.clear()
+ return
+
+ note_text = (message.text or message.caption or "").strip()
+ if not note_text:
+ await message.answer("Отправьте текст уточнения одним сообщением.")
+ return
+
+ state_data = await state.get_data()
+ ticket_id = int(state_data.get("user_ticket_id") or 0)
+ if ticket_id <= 0:
+ await state.clear()
+ await message.answer("Тикет не найден. Нажмите кнопку ещё раз.")
+ return
+
+ ticket = await support_ticket_service.get_ticket(ticket_id=ticket_id)
+ if ticket is None or ticket.telegram_id != message.from_user.id:
+ await state.clear()
+ await message.answer("Тикет не найден.")
+ return
+ if ticket.status == CLOSED_SUPPORT_TICKET_STATUS:
+ await state.clear()
+ await message.answer(
+ "Тикет уже закрыт.",
+ reply_markup=build_user_support_ticket_keyboard(ticket=ticket),
+ )
+ return
+
+ display_name = " ".join(
+ part for part in [message.from_user.first_name, message.from_user.last_name] if part
+ ).strip() or message.from_user.full_name
+ note_entry = "\n".join(
+ [
+ f"[{format_datetime(message.date.replace(tzinfo=None))}] {display_name}",
+ note_text,
+ ]
+ )
+ updated_ticket = await support_ticket_service.append_note(
+ ticket_id=ticket.id,
+ note_text=note_entry,
+ status=OPEN_SUPPORT_TICKET_STATUS,
+ )
+ if updated_ticket is None:
+ await state.clear()
+ await message.answer("Не удалось добавить текст к тикету.")
+ return
+
+ await refresh_support_ticket_message(message.bot, ticket=updated_ticket)
+ await message.bot.send_message(
+ chat_id=updated_ticket.support_chat_id,
+ text=render_support_ticket_note_added_notice(
+ ticket=updated_ticket,
+ author_name=display_name,
+ note_text=note_text,
+ ),
+ message_thread_id=updated_ticket.support_thread_id,
+ )
+ await state.clear()
+ await answer_branded(
+ message,
+ render_user_ticket_note_added_text(ticket=updated_ticket),
+ reply_markup=build_user_support_ticket_keyboard(ticket=updated_ticket),
+ )
+
@router.message(ReferralCodeStates.waiting_for_code)
async def on_referral_code_message(message: Message, state: FSMContext) -> None:
if message.from_user is None:
@@ -1581,7 +2424,7 @@ def build_router(
raw_code = (message.text or "").strip().upper()
if not raw_code:
- await message.answer("Отправьте реферальный код текстом.")
+ await message.answer("Отправьте реферальный код или промокод текстом.")
return
try:
@@ -1598,9 +2441,14 @@ def build_router(
return
await state.clear()
+ active_discount = await payment_service.get_active_discount_for_user(
+ telegram_id=message.from_user.id,
+ )
+ discount_percent = active_discount.discount_percent or config.referral_discount_percent
+ code_kind_text = "Промокод" if active_discount.source == "promo" else "Реферальный код"
await message.answer(
- f"✅ Реферальный код {applied_code} сохранён.\n"
- f"Скидка {max(config.referral_discount_percent, 0)}% будет применена перед оплатой."
+ f"✅ {code_kind_text} {applied_code} сохранён.\n"
+ f"Скидка {max(discount_percent, 0)}% будет применена перед оплатой."
)
await show_payment_menu_message(message, telegram_id=message.from_user.id)
@@ -1644,6 +2492,155 @@ def build_router(
)
await update_panel(callback, context=context, section=PANEL_SECTION_ADMIN)
+ @router.callback_query(F.data == "admin:promos")
+ async def on_admin_promos(callback: CallbackQuery, state: FSMContext) -> None:
+ if callback.from_user is None:
+ await callback.answer()
+ return
+ if not settings.is_admin(callback.from_user.id):
+ await callback.answer("Недостаточно прав", show_alert=True)
+ return
+
+ await clear_interactive_states(state)
+ config = await get_runtime_config()
+ promos = await sync_service.get_admin_promo_codes()
+ await show_branded_view(
+ callback,
+ text=render_admin_promos_text(promos=promos, config=config),
+ reply_markup=build_admin_promos_keyboard(),
+ )
+ await callback.answer()
+
+ @router.callback_query(F.data == "admin:promo_create")
+ async def on_admin_promo_create(callback: CallbackQuery, state: FSMContext) -> None:
+ if callback.from_user is None or callback.message is None:
+ await callback.answer()
+ return
+ if not settings.is_admin(callback.from_user.id):
+ await callback.answer("Недостаточно прав", show_alert=True)
+ return
+
+ await state.clear()
+ await state.set_state(AdminPromoStates.waiting_for_code)
+ await callback.message.answer(
+ "🎟 Отправьте слово для промокода.\n\n"
+ "Можно использовать буквы, цифры, _ и -. "
+ "Чтобы отменить создание, отправьте /cancel."
+ )
+ await callback.answer()
+
+ @router.callback_query(F.data.startswith("admin:promo_plan:"))
+ async def on_admin_promo_plan(callback: CallbackQuery, state: FSMContext) -> None:
+ if callback.from_user is None or callback.message is None:
+ await callback.answer()
+ return
+ if not settings.is_admin(callback.from_user.id):
+ await callback.answer("Недостаточно прав", show_alert=True)
+ return
+
+ state_data = await state.get_data()
+ promo_code = str(state_data.get("promo_code") or "").strip()
+ if not promo_code:
+ await callback.answer("Сначала введите слово промокода", show_alert=True)
+ return
+
+ raw_plan_code = (callback.data or "").split(":", maxsplit=2)[2].removeprefix("promo_plan:")
+
+ config = await get_runtime_config()
+
+ if raw_plan_code == "all":
+ # "all" → show duration submenu with unique durations
+ product_label = "Все тарифы"
+ else:
+ # Specific product selected
+ product_plans = [
+ p for p in config.payment_plans
+ if _extract_product_code(p.code) == raw_plan_code
+ ]
+ if not product_plans:
+ await callback.answer("Тариф не найден", show_alert=True)
+ return
+ product_label = product_plans[0].title or raw_plan_code
+
+ await edit_message_content(
+ callback,
+ text=(
+ f"Промокод: {html.escape(promo_code)}\n\n"
+ f"Тип: {html.escape(product_label)}\n\n"
+ "Выберите период, на который будет действовать скидка."
+ ),
+ reply_markup=build_admin_promo_duration_keyboard(
+ config=config,
+ product_code=raw_plan_code,
+ ),
+ )
+ await callback.answer()
+
+ @router.callback_query(F.data == "admin:promo_back_to_plans")
+ async def on_admin_promo_back_to_plans(callback: CallbackQuery, state: FSMContext) -> None:
+ if callback.from_user is None or callback.message is None:
+ await callback.answer()
+ return
+ if not settings.is_admin(callback.from_user.id):
+ await callback.answer("Недостаточно прав", show_alert=True)
+ return
+
+ state_data = await state.get_data()
+ promo_code = str(state_data.get("promo_code") or "").strip()
+ config = await get_runtime_config()
+ await edit_message_content(
+ callback,
+ text=(
+ f"Промокод: {html.escape(promo_code)}\n\n"
+ "Выберите тариф, на который будет действовать скидка."
+ ),
+ reply_markup=build_admin_promo_plan_keyboard(config=config),
+ )
+ await callback.answer()
+
+ @router.callback_query(F.data.startswith("admin:promo_duration:"))
+ async def on_admin_promo_duration(callback: CallbackQuery, state: FSMContext) -> None:
+ if callback.from_user is None or callback.message is None:
+ await callback.answer()
+ return
+ if not settings.is_admin(callback.from_user.id):
+ await callback.answer("Недостаточно прав", show_alert=True)
+ return
+
+ state_data = await state.get_data()
+ promo_code = str(state_data.get("promo_code") or "").strip()
+ if not promo_code:
+ await callback.answer("Сначала введите слово промокода", show_alert=True)
+ return
+
+ plan_code = (callback.data or "").removeprefix("admin:promo_duration:")
+ config = await get_runtime_config()
+
+ # Validate the plan code
+ available_plan_codes = {plan.code for plan in config.payment_plans}
+ is_all_duration = plan_code.startswith("all_") and plan_code.removeprefix("all_").isdigit()
+
+ if not is_all_duration and plan_code not in available_plan_codes:
+ await callback.answer("Тариф не найден", show_alert=True)
+ return
+
+ # Build display label
+ if is_all_duration:
+ days = plan_code.removeprefix("all_")
+ plan_label = f"Все тарифы · {days} дней"
+ else:
+ plan_info = next((p for p in config.payment_plans if p.code == plan_code), None)
+ plan_label = f"{plan_info.title} · {plan_info.days} дней" if plan_info and plan_info.title else plan_code
+
+ await state.set_state(AdminPromoStates.waiting_for_discount)
+ await state.update_data(promo_plan_code=plan_code)
+ await callback.message.answer(
+ f"Тариф: {html.escape(plan_label)}\n\n"
+ "Введите скидку по промокоду в процентах.\n"
+ "Например: 15. Допустимо от 1 до 100."
+ )
+ await callback.answer()
+
@router.callback_query(F.data == "admin:settings")
async def on_admin_settings(callback: CallbackQuery, state: FSMContext) -> None:
if callback.from_user is None:
@@ -1767,6 +2764,85 @@ def build_router(
# ── Admin: Block / Unblock user ──────────────────────────────────
+ @router.callback_query(F.data == "admin:tickets")
+ async def on_admin_tickets(callback: CallbackQuery, state: FSMContext) -> None:
+ if callback.from_user is None:
+ await callback.answer()
+ return
+ if not settings.is_admin(callback.from_user.id):
+ await callback.answer("Недостаточно прав", show_alert=True)
+ return
+
+ await clear_interactive_states(state)
+ page_data = await support_ticket_service.get_admin_tickets_page(page=1)
+ await show_branded_view(
+ callback,
+ text=render_admin_tickets_page_text(page_data=page_data),
+ reply_markup=build_admin_tickets_page_keyboard(page_data=page_data),
+ )
+ await callback.answer()
+
+ @router.callback_query(F.data.startswith("admin:ticket_list:"))
+ async def on_admin_tickets_page(callback: CallbackQuery, state: FSMContext) -> None:
+ if callback.from_user is None:
+ await callback.answer()
+ return
+ if not settings.is_admin(callback.from_user.id):
+ await callback.answer("Недостаточно прав", show_alert=True)
+ return
+
+ await clear_interactive_states(state)
+ raw_page = (callback.data or "").split(":", maxsplit=2)[2]
+ try:
+ page = int(raw_page)
+ except ValueError:
+ page = 1
+
+ page_data = await support_ticket_service.get_admin_tickets_page(page=page)
+ await show_branded_view(
+ callback,
+ text=render_admin_tickets_page_text(page_data=page_data),
+ reply_markup=build_admin_tickets_page_keyboard(page_data=page_data),
+ )
+ await callback.answer()
+
+ @router.callback_query(F.data.startswith("admin:ticket_view:"))
+ async def on_admin_ticket_view(callback: CallbackQuery, state: FSMContext) -> None:
+ if callback.from_user is None:
+ await callback.answer()
+ return
+ if not settings.is_admin(callback.from_user.id):
+ await callback.answer("Недостаточно прав", show_alert=True)
+ return
+
+ await clear_interactive_states(state)
+ parts = (callback.data or "").split(":")
+ if len(parts) < 4:
+ await callback.answer("Некорректный тикет", show_alert=True)
+ return
+
+ try:
+ ticket_id = int(parts[2])
+ page = int(parts[3])
+ except ValueError:
+ await callback.answer("Некорректный тикет", show_alert=True)
+ return
+
+ ticket = await support_ticket_service.get_ticket(ticket_id=ticket_id)
+ if ticket is None:
+ await callback.answer("Тикет не найден", show_alert=True)
+ return
+
+ await show_branded_view(
+ callback,
+ text=render_admin_ticket_history_text(ticket=ticket),
+ reply_markup=build_admin_ticket_history_keyboard(
+ ticket_id=ticket.id,
+ page=page,
+ ),
+ )
+ await callback.answer()
+
@router.callback_query(F.data.startswith("admin:block:"))
async def on_admin_block_user(callback: CallbackQuery) -> None:
if callback.from_user is None or callback.message is None:
@@ -2143,6 +3219,130 @@ def build_router(
)
await callback.answer()
+ @router.message(AdminPromoStates.waiting_for_code)
+ async def on_admin_promo_code_message(message: Message, state: FSMContext) -> None:
+ if not await ensure_sender(message):
+ await state.clear()
+ return
+ if not settings.is_admin(message.from_user.id):
+ await state.clear()
+ await message.answer("Недостаточно прав.")
+ return
+
+ promo_code = sync_service.normalize_discount_code(message.text or message.caption or "")
+ if not promo_code:
+ await message.answer("Отправьте слово для промокода текстом.")
+ return
+ if len(promo_code) > 32:
+ await message.answer("Промокод должен быть не длиннее 32 символов.")
+ return
+ if not all(ch.isalnum() or ch in "_-" for ch in promo_code):
+ await message.answer("Промокод может содержать только буквы, цифры, _ и -.")
+ return
+
+ config = await get_runtime_config()
+ await state.set_state(AdminPromoStates.waiting_for_plan)
+ await state.update_data(promo_code=promo_code)
+ await message.answer(
+ f"Промокод: {html.escape(promo_code)}\n\n"
+ "Выберите тариф, на который будет действовать скидка.",
+ reply_markup=build_admin_promo_plan_keyboard(config=config),
+ )
+
+ @router.message(AdminPromoStates.waiting_for_plan)
+ async def on_admin_promo_plan_message(message: Message, state: FSMContext) -> None:
+ if not await ensure_sender(message):
+ await state.clear()
+ return
+ if not settings.is_admin(message.from_user.id):
+ await state.clear()
+ await message.answer("Недостаточно прав.")
+ return
+
+ config = await get_runtime_config()
+ await message.answer(
+ "Выберите тариф кнопкой ниже.",
+ reply_markup=build_admin_promo_plan_keyboard(config=config),
+ )
+
+ @router.message(AdminPromoStates.waiting_for_discount)
+ async def on_admin_promo_discount_message(message: Message, state: FSMContext) -> None:
+ if not await ensure_sender(message):
+ await state.clear()
+ return
+ if not settings.is_admin(message.from_user.id):
+ await state.clear()
+ await message.answer("Недостаточно прав.")
+ return
+
+ raw_discount = (message.text or message.caption or "").strip().removesuffix("%")
+ try:
+ discount_percent = int(raw_discount)
+ except ValueError:
+ await message.answer("Введите скидку числом, например 15.")
+ return
+ if discount_percent <= 0 or discount_percent > 100:
+ await message.answer("Скидка должна быть от 1 до 100%.")
+ return
+
+ await state.set_state(AdminPromoStates.waiting_for_expires)
+ await state.update_data(promo_discount_percent=discount_percent)
+ await message.answer(
+ "Введите срок действия промокода.\n\n"
+ "Примеры: 7 = 7 дней, 24h = 24 часа, "
+ "2026-05-31 = до даты. Для бессрочного промокода отправьте 0."
+ )
+
+ @router.message(AdminPromoStates.waiting_for_expires)
+ async def on_admin_promo_expires_message(message: Message, state: FSMContext) -> None:
+ if not await ensure_sender(message):
+ await state.clear()
+ return
+ if not settings.is_admin(message.from_user.id):
+ await state.clear()
+ await message.answer("Недостаточно прав.")
+ return
+
+ state_data = await state.get_data()
+ promo_code = str(state_data.get("promo_code") or "").strip()
+ plan_code = state_data.get("promo_plan_code")
+ discount_percent = int(state_data.get("promo_discount_percent") or 0)
+ if not promo_code or discount_percent <= 0:
+ await state.clear()
+ await message.answer("Данные промокода потерялись. Начните создание заново.")
+ return
+
+ try:
+ expires_at = parse_promo_expires_at(message.text or message.caption or "")
+ promo = await sync_service.create_promo_code(
+ code=promo_code,
+ plan_code=str(plan_code) if plan_code else None,
+ discount_percent=discount_percent,
+ expires_at=expires_at,
+ created_by_telegram_id=message.from_user.id,
+ )
+ except ValueError as exc:
+ await message.answer(html.escape(str(exc)))
+ return
+
+ await state.clear()
+ config = await get_runtime_config()
+ promos = await sync_service.get_admin_promo_codes()
+ await message.answer(
+ "\n".join(
+ [
+ f"✅ Промокод {html.escape(promo.code)} создан.",
+ f"Скидка: {promo.discount_percent}%",
+ f"Тариф: {html.escape(format_promo_scope(plan_code=promo.plan_code, config=config))}",
+ f"Срок: {html.escape(format_promo_expires_at(promo.expires_at))}",
+ ]
+ )
+ )
+ await message.answer(
+ render_admin_promos_text(promos=promos, config=config),
+ reply_markup=build_admin_promos_keyboard(),
+ )
+
@router.message(AdminLookupStates.waiting_for_identifier)
async def on_admin_lookup_message(message: Message, state: FSMContext) -> None:
if not await ensure_sender(message):
@@ -2317,6 +3517,7 @@ def build_router(
"Проверьте реквизиты и чек. "
f"{html.escape(config.payment_support_text.strip() or 'Если это ошибка, обратитесь в поддержку.')}"
),
+ reply_markup=build_home_keyboard(),
)
except TelegramForbiddenError:
await append_review_status(callback, status_text="❌ отклонено, бот у пользователя недоступен")
@@ -2331,6 +3532,184 @@ def build_router(
if True:
+ @router.callback_query(F.data.startswith("ticket_admin:note:"))
+ async def on_support_ticket_note_start(callback: CallbackQuery, state: FSMContext) -> None:
+ if callback.from_user is None or callback.message is None:
+ await callback.answer()
+ return
+
+ if not (
+ settings.is_admin(callback.from_user.id)
+ or settings.is_moderator(callback.from_user.id)
+ ):
+ await callback.answer("Недостаточно прав", show_alert=True)
+ return
+
+ config = await get_runtime_config()
+ if config.support_ticket_chat_id is None or callback.message.chat.id != config.support_ticket_chat_id:
+ await callback.answer()
+ return
+
+ ticket_id_text = (callback.data or "").split(":")[-1]
+ if not ticket_id_text.isdigit():
+ await callback.answer("Тикет не найден", show_alert=True)
+ return
+
+ ticket = await support_ticket_service.get_ticket(ticket_id=int(ticket_id_text))
+ if ticket is None:
+ await callback.answer("Тикет не найден", show_alert=True)
+ return
+ if ticket.status == CLOSED_SUPPORT_TICKET_STATUS:
+ await callback.answer("Тикет уже закрыт", show_alert=True)
+ return
+
+ await state.set_state(SupportTicketAdminStates.waiting_for_note)
+ await state.update_data(support_ticket_id=ticket.id)
+ await callback.message.reply(
+ f"📝 Отправьте текст для тикета #{ticket.public_id} одним следующим сообщением.\n"
+ "Он будет добавлен к карточке тикета в чате поддержки.\n"
+ "Чтобы отменить действие, отправьте /cancel."
+ )
+ await callback.answer()
+
+ @router.callback_query(F.data.startswith("ticket_admin:close:"))
+ async def on_support_ticket_close(callback: CallbackQuery) -> None:
+ if callback.from_user is None or callback.message is None:
+ await callback.answer()
+ return
+
+ if not (
+ settings.is_admin(callback.from_user.id)
+ or settings.is_moderator(callback.from_user.id)
+ ):
+ await callback.answer("Недостаточно прав", show_alert=True)
+ return
+
+ config = await get_runtime_config()
+ if config.support_ticket_chat_id is None or callback.message.chat.id != config.support_ticket_chat_id:
+ await callback.answer()
+ return
+
+ ticket_id_text = (callback.data or "").split(":")[-1]
+ if not ticket_id_text.isdigit():
+ await callback.answer("Тикет не найден", show_alert=True)
+ return
+
+ ticket = await support_ticket_service.get_ticket(ticket_id=int(ticket_id_text))
+ if ticket is None:
+ await callback.answer("Тикет не найден", show_alert=True)
+ return
+ if ticket.status == CLOSED_SUPPORT_TICKET_STATUS:
+ await callback.answer("Тикет уже закрыт", show_alert=True)
+ return
+
+ closed_ticket = await support_ticket_service.close_ticket(
+ ticket_id=ticket.id,
+ closed_by_telegram_id=callback.from_user.id,
+ )
+ if closed_ticket is None:
+ await callback.answer("Не удалось закрыть тикет", show_alert=True)
+ return
+
+ await refresh_support_ticket_message(callback.bot, ticket=closed_ticket)
+
+ try:
+ await send_branded(
+ callback.bot,
+ chat_id=closed_ticket.telegram_id,
+ text=render_user_ticket_closed_text(
+ ticket=closed_ticket,
+ closed_by_support=True,
+ ),
+ reply_markup=build_user_support_ticket_keyboard(ticket=closed_ticket),
+ )
+ except TelegramForbiddenError:
+ await callback.answer(
+ "Тикет закрыт, но пользователь недоступен для уведомления.",
+ show_alert=True,
+ )
+ return
+
+ await callback.answer("Тикет закрыт")
+
+ @router.callback_query(F.data.startswith("ticket_admin:closed:"))
+ async def on_support_ticket_closed(callback: CallbackQuery) -> None:
+ await callback.answer("Тикет уже закрыт", show_alert=True)
+
+ @router.message(SupportTicketAdminStates.waiting_for_note)
+ async def on_support_ticket_note_message(message: Message, state: FSMContext) -> None:
+ if message.from_user is None:
+ await state.clear()
+ return
+
+ if not (
+ settings.is_admin(message.from_user.id)
+ or settings.is_moderator(message.from_user.id)
+ ):
+ await state.clear()
+ await message.answer("Недостаточно прав.")
+ return
+
+ config = await get_runtime_config()
+ if config.support_ticket_chat_id is None or message.chat.id != config.support_ticket_chat_id:
+ await message.answer("Добавьте текст к тикету прямо в чате поддержки.")
+ return
+
+ note_text = (message.text or message.caption or "").strip()
+ if not note_text:
+ await message.answer("Отправьте текст заметки одним сообщением.")
+ return
+
+ state_data = await state.get_data()
+ ticket_id = int(state_data.get("support_ticket_id") or 0)
+ if ticket_id <= 0:
+ await state.clear()
+ await message.answer("Тикет не найден. Нажмите кнопку ещё раз.")
+ return
+
+ ticket = await support_ticket_service.get_ticket(ticket_id=ticket_id)
+ if ticket is None:
+ await state.clear()
+ await message.answer("Тикет не найден.")
+ return
+ if ticket.status == CLOSED_SUPPORT_TICKET_STATUS:
+ await state.clear()
+ await message.answer("Тикет уже закрыт.")
+ return
+
+ note_entry = "\n".join(
+ [
+ f"[{format_datetime(message.date.replace(tzinfo=None))}] {message.from_user.full_name}",
+ note_text,
+ ]
+ )
+ updated_ticket = await support_ticket_service.append_note(
+ ticket_id=ticket.id,
+ note_text=note_entry,
+ )
+ if updated_ticket is None:
+ await state.clear()
+ await message.answer("Не удалось добавить текст к тикету.")
+ return
+
+ await refresh_support_ticket_message(message.bot, ticket=updated_ticket)
+ await state.clear()
+ await message.reply(f"Текст добавлен к тикету #{updated_ticket.public_id}.")
+
+ try:
+ await send_branded(
+ message.bot,
+ chat_id=updated_ticket.telegram_id,
+ text=render_user_ticket_support_note_text(
+ ticket=updated_ticket,
+ note_text=note_text,
+ ),
+ reply_markup=build_user_support_ticket_keyboard(ticket=updated_ticket),
+ )
+ except TelegramForbiddenError:
+ await message.reply("Текст добавлен, но пользователь недоступен для уведомления.")
+ return
+
@router.message(F.reply_to_message)
async def on_support_reply(message: Message) -> None:
if message.reply_to_message is None or message.from_user is None:
@@ -2357,6 +3736,9 @@ def build_router(
)
if ticket is None:
return
+ if ticket.status == CLOSED_SUPPORT_TICKET_STATUS:
+ await message.reply(f"Тикет #{ticket.public_id} уже закрыт.")
+ return
try:
await send_branded(
@@ -2364,8 +3746,10 @@ def build_router(
chat_id=ticket.telegram_id,
text=(
f"🆘 Ответ по тикету #{ticket.public_id}\n\n"
- f"{html.escape(reply_text)}"
+ f"{html.escape(reply_text)}\n\n"
+ "Если вопрос решён, закройте тикет. Если нужно что-то уточнить, добавьте текст кнопкой ниже."
),
+ reply_markup=build_user_support_ticket_keyboard(ticket=ticket),
)
except TelegramForbiddenError:
await message.reply(
@@ -2373,7 +3757,9 @@ def build_router(
)
return
- await support_ticket_service.mark_answered(ticket_id=ticket.id)
+ updated_ticket = await support_ticket_service.mark_answered(ticket_id=ticket.id)
+ if updated_ticket is not None:
+ await refresh_support_ticket_message(message.bot, ticket=updated_ticket)
await message.reply(f"Ответ по тикету #{ticket.public_id} отправлен пользователю.")
@router.message(Command("help"))
diff --git a/app/bot/ui/panel.py b/app/bot/ui/panel.py
index ee32f0a..d6de9e3 100644
--- a/app/bot/ui/panel.py
+++ b/app/bot/ui/panel.py
@@ -202,6 +202,14 @@ def build_panel_keyboard(
text="👤 Пользователи",
callback_data=_admin_callback("users"),
)
+ builder.button(
+ text="🗂 История тикетов",
+ callback_data=_admin_callback("tickets"),
+ )
+ builder.button(
+ text="🎟 Промокоды",
+ callback_data=_admin_callback("promos"),
+ )
builder.button(
text="🔄 Полная синхронизация",
callback_data=_admin_callback("sync_all"),
@@ -213,13 +221,13 @@ def build_panel_keyboard(
if payment_review_url.strip():
builder.button(text="💸 Очередь оплат", url=payment_review_url.strip())
if support_ticket_url.strip():
- builder.button(text="🎫 Тикеты", url=support_ticket_url.strip())
+ builder.button(text="🎫 Чат тикетов", url=support_ticket_url.strip())
builder.button(
text="↻ Обновить",
callback_data=_callback(PANEL_SECTION_ADMIN, refresh=True),
)
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
- builder.adjust(2, 2, 2, 2)
+ builder.adjust(2, 2, 2, 2, 1)
return builder.as_markup()
if section == PANEL_SECTION_SUBSCRIPTION:
@@ -248,7 +256,7 @@ def build_panel_keyboard(
url=context.referral_share_url,
)
builder.button(
- text="🎟 Ввести реферальный код",
+ text="🎟 Ввести код скидки",
callback_data=_referral_callback("apply"),
)
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
diff --git a/app/config.py b/app/config.py
index 057d9ab..8c6581e 100644
--- a/app/config.py
+++ b/app/config.py
@@ -13,6 +13,28 @@ class PaymentPlanSettings:
code: str
days: int
amount_rub: int
+ title: str = ""
+ description: str = ""
+ squad_groups: tuple[str, ...] = ()
+
+
+PAYMENT_PRODUCT_PLAN_SPECS: dict[str, tuple[str, str, tuple[str, ...]]] = {
+ "vpn_white": (
+ "VPN + Белые списки",
+ "VPN и белые списки в одном тарифе",
+ ("vpn", "white"),
+ ),
+ "white": (
+ "Белые списки",
+ "Доступ только к белым спискам",
+ ("white",),
+ ),
+ "vpn": (
+ "VPN",
+ "Доступ только к VPN без белых списков",
+ ("vpn",),
+ ),
+}
class Settings(BaseSettings):
@@ -78,6 +100,10 @@ class Settings(BaseSettings):
default="30:250,180:600,365:1000",
validation_alias="PAYMENT_PLANS",
)
+ payment_product_plans_raw: str = Field(
+ default="",
+ validation_alias="PAYMENT_PRODUCT_PLANS",
+ )
payment_transfer_text: str = Field(
default="Переведите оплату по указанным реквизитам и отправьте чек в бот.",
validation_alias="PAYMENT_TRANSFER_TEXT",
@@ -125,6 +151,14 @@ class Settings(BaseSettings):
default="",
validation_alias="PAYMENT_INTERNAL_SQUAD_UUIDS",
)
+ payment_vpn_squad_uuids_raw: str = Field(
+ default="",
+ validation_alias="PAYMENT_VPN_SQUAD_UUIDS",
+ )
+ payment_white_squad_uuids_raw: str = Field(
+ default="",
+ validation_alias="PAYMENT_WHITE_SQUAD_UUIDS",
+ )
payment_external_squad_uuid: str = Field(
default="",
validation_alias="PAYMENT_EXTERNAL_SQUAD_UUID",
@@ -237,12 +271,15 @@ class Settings(BaseSettings):
@cached_property
def payment_internal_squad_uuids(self) -> list[str]:
- values: list[str] = []
- for raw_part in self.payment_internal_squad_uuids_raw.split(","):
- part = raw_part.strip()
- if part:
- values.append(part)
- return values
+ return self._parse_csv_list(self.payment_internal_squad_uuids_raw)
+
+ @cached_property
+ def payment_vpn_squad_uuids(self) -> list[str]:
+ return self._parse_csv_list(self.payment_vpn_squad_uuids_raw)
+
+ @cached_property
+ def payment_white_squad_uuids(self) -> list[str]:
+ return self._parse_csv_list(self.payment_white_squad_uuids_raw)
@cached_property
def payment_external_squad_uuid_normalized(self) -> str | None:
@@ -265,6 +302,13 @@ class Settings(BaseSettings):
@cached_property
def payment_plans(self) -> list[PaymentPlanSettings]:
+ product_plans = self._parse_product_plans(
+ self.payment_product_plans_raw,
+ default_days=max(self.payment_plan_duration_days, 1),
+ )
+ if product_plans:
+ return product_plans
+
values: list[PaymentPlanSettings] = []
for raw_part in self.payment_plans_raw.split(","):
part = raw_part.strip()
@@ -303,3 +347,77 @@ class Settings(BaseSettings):
]
return []
+
+ @staticmethod
+ def _parse_csv_list(raw_value: str) -> list[str]:
+ values: list[str] = []
+ for raw_part in raw_value.split(","):
+ part = raw_part.strip()
+ if part:
+ values.append(part)
+ return values
+
+ @staticmethod
+ def _parse_product_plans(
+ raw_value: str,
+ *,
+ default_days: int,
+ ) -> list[PaymentPlanSettings]:
+ values: list[PaymentPlanSettings] = []
+ seen_codes: set[str] = set()
+
+ for raw_part in raw_value.split(","):
+ part = raw_part.strip()
+ if not part:
+ continue
+
+ fragments = [fragment.strip() for fragment in part.split(":")]
+
+ # Legacy format: code:amount (2 fields) → single duration using default_days
+ if len(fragments) == 2:
+ code, raw_amount = fragments
+ pairs = [(str(default_days), raw_amount)]
+ # Standard format: code:days:amount (3 fields) → single duration
+ elif len(fragments) == 3:
+ code = fragments[0]
+ pairs = [(fragments[1], fragments[2])]
+ # Extended format: code:days1:amount1:days2:amount2:... (odd number ≥ 5)
+ elif len(fragments) >= 5 and len(fragments) % 2 == 1:
+ code = fragments[0]
+ pairs = [
+ (fragments[i], fragments[i + 1])
+ for i in range(1, len(fragments), 2)
+ ]
+ else:
+ continue
+
+ normalized_code = code.lower()
+ spec = PAYMENT_PRODUCT_PLAN_SPECS.get(normalized_code)
+ if spec is None or normalized_code in seen_codes:
+ continue
+
+ title, description, squad_groups = spec
+ for raw_days, raw_amount in pairs:
+ if not raw_days.isdigit() or not raw_amount.isdigit():
+ continue
+
+ days = int(raw_days)
+ amount_rub = int(raw_amount)
+ if days <= 0 or amount_rub <= 0:
+ continue
+
+ plan_code = f"{normalized_code}_{days}"
+ values.append(
+ PaymentPlanSettings(
+ code=plan_code,
+ days=days,
+ amount_rub=amount_rub,
+ title=title,
+ description=description,
+ squad_groups=squad_groups,
+ )
+ )
+
+ seen_codes.add(normalized_code)
+
+ return values
diff --git a/app/db/models.py b/app/db/models.py
index 6a22c5f..1786a28 100644
--- a/app/db/models.py
+++ b/app/db/models.py
@@ -17,6 +17,7 @@ class TelegramUser(Base):
language_code: Mapped[str | None] = mapped_column(String(16), nullable=True)
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_blocked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+ discount_ever_used: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
last_seen_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
@@ -131,6 +132,9 @@ class SupportTicket(Base):
support_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="OPEN")
+ support_note: Mapped[str | None] = mapped_column(Text, nullable=True)
+ closed_by_telegram_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
+ closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
@@ -143,6 +147,7 @@ class ReferralInvite(Base):
invited_telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True)
invited_username: Mapped[str | None] = mapped_column(String(64), nullable=True)
invited_display_name: Mapped[str] = mapped_column(String(255), nullable=False)
+ discount_used: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
@@ -156,6 +161,34 @@ class ReferralCode(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
+class PromoCode(Base):
+ __tablename__ = "promo_codes"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ code: Mapped[str] = mapped_column(String(32), nullable=False, unique=True, index=True)
+ plan_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
+ discount_percent: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+ expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
+ created_by_telegram_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
+
+
+class PromoCodeApplication(Base):
+ __tablename__ = "promo_code_applications"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True)
+ promo_code_id: Mapped[int] = mapped_column(
+ ForeignKey("promo_codes.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ )
+ created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
+
+
class ReferralBonus(Base):
__tablename__ = "referral_bonuses"
diff --git a/app/db/session.py b/app/db/session.py
index 1959c29..b13841f 100644
--- a/app/db/session.py
+++ b/app/db/session.py
@@ -17,6 +17,9 @@ logger = logging.getLogger(__name__)
PAYMENT_ORDERS_TABLE_NAME = "payment_orders"
PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN = "provision_username"
PAYMENT_ORDERS_PROVISION_USERNAME_INDEX = "ix_payment_orders_provision_username"
+SUPPORT_TICKETS_TABLE_NAME = "support_tickets"
+REFERRAL_INVITES_TABLE_NAME = "referral_invites"
+TELEGRAM_USERS_TABLE_NAME = "telegram_users"
def create_engine_and_session_factory(
@@ -121,6 +124,60 @@ async def _normalize_payment_order_provision_username_index(
)
+async def _get_mysql_column_names(
+ connection: AsyncConnection,
+ *,
+ table_name: str,
+) -> set[str]:
+ result = await connection.execute(
+ text(
+ """
+ SELECT column_name
+ FROM information_schema.columns
+ WHERE table_schema = DATABASE()
+ AND table_name = :table_name
+ """
+ ),
+ {"table_name": table_name},
+ )
+ return {str(row[0]) for row in result}
+
+
+async def _normalize_support_ticket_columns(connection: AsyncConnection) -> None:
+ if connection.dialect.name != "mysql":
+ return
+
+ existing_columns = await _get_mysql_column_names(
+ connection,
+ table_name=SUPPORT_TICKETS_TABLE_NAME,
+ )
+ quoted_table_name = _quote_mysql_identifier(SUPPORT_TICKETS_TABLE_NAME)
+
+ required_columns = {
+ "support_note": "TEXT NULL",
+ "closed_by_telegram_id": "BIGINT NULL",
+ "closed_at": "DATETIME NULL",
+ }
+
+ for column_name, ddl in required_columns.items():
+ if column_name in existing_columns:
+ continue
+
+ logger.info(
+ "Adding missing column %s to %s",
+ column_name,
+ SUPPORT_TICKETS_TABLE_NAME,
+ )
+ await connection.execute(
+ text(
+ "ALTER TABLE "
+ f"{quoted_table_name} "
+ "ADD COLUMN "
+ f"{_quote_mysql_identifier(column_name)} {ddl}"
+ )
+ )
+
+
async def ensure_database_exists(
server_url: str,
database_name: str,
@@ -146,7 +203,60 @@ async def ensure_database_exists(
await engine.dispose()
+async def _normalize_referral_invites_columns(connection: AsyncConnection) -> None:
+ """Add discount_used column to referral_invites if missing."""
+ if connection.dialect.name != "mysql":
+ return
+
+ existing_columns = await _get_mysql_column_names(
+ connection,
+ table_name=REFERRAL_INVITES_TABLE_NAME,
+ )
+ quoted_table_name = _quote_mysql_identifier(REFERRAL_INVITES_TABLE_NAME)
+
+ if "discount_used" not in existing_columns:
+ logger.info(
+ "Adding missing column discount_used to %s",
+ REFERRAL_INVITES_TABLE_NAME,
+ )
+ await connection.execute(
+ text(
+ "ALTER TABLE "
+ f"{quoted_table_name} "
+ "ADD COLUMN `discount_used` BOOLEAN NOT NULL DEFAULT FALSE"
+ )
+ )
+
+
+async def _normalize_telegram_users_columns(connection: AsyncConnection) -> None:
+ """Add discount_ever_used column to telegram_users if missing."""
+ if connection.dialect.name != "mysql":
+ return
+
+ existing_columns = await _get_mysql_column_names(
+ connection,
+ table_name=TELEGRAM_USERS_TABLE_NAME,
+ )
+ quoted_table_name = _quote_mysql_identifier(TELEGRAM_USERS_TABLE_NAME)
+
+ if "discount_ever_used" not in existing_columns:
+ logger.info(
+ "Adding missing column discount_ever_used to %s",
+ TELEGRAM_USERS_TABLE_NAME,
+ )
+ await connection.execute(
+ text(
+ "ALTER TABLE "
+ f"{quoted_table_name} "
+ "ADD COLUMN `discount_ever_used` BOOLEAN NOT NULL DEFAULT FALSE"
+ )
+ )
+
+
async def init_db(engine: AsyncEngine) -> None:
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
await _normalize_payment_order_provision_username_index(connection)
+ await _normalize_support_ticket_columns(connection)
+ await _normalize_referral_invites_columns(connection)
+ await _normalize_telegram_users_columns(connection)
diff --git a/app/services/bot_config_service.py b/app/services/bot_config_service.py
index 0c1f4d5..9aeec17 100644
--- a/app/services/bot_config_service.py
+++ b/app/services/bot_config_service.py
@@ -4,7 +4,7 @@ from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
-from app.config import PaymentPlanSettings, Settings
+from app.config import PAYMENT_PRODUCT_PLAN_SPECS, PaymentPlanSettings, Settings
from app.db.base import utcnow
from app.db.models import BotSetting
@@ -34,11 +34,14 @@ class BotConfigSnapshot:
referral_discount_percent: int
referral_bonus_days: int
payment_plans_raw: str
+ payment_product_plans_raw: str
payment_transfer_text: str
payment_support_text: str
payment_plan_traffic_limit_gb: int
payment_plan_traffic_reset_period: str
payment_internal_squad_uuids_raw: str
+ payment_vpn_squad_uuids_raw: str
+ payment_white_squad_uuids_raw: str
payment_external_squad_uuid: str
payment_username_prefix: str
payment_user_tag: str
@@ -75,12 +78,15 @@ class BotConfigSnapshot:
@property
def payment_internal_squad_uuids(self) -> list[str]:
- values: list[str] = []
- for raw_part in self.payment_internal_squad_uuids_raw.split(","):
- part = raw_part.strip()
- if part:
- values.append(part)
- return values
+ return self._parse_csv_list(self.payment_internal_squad_uuids_raw)
+
+ @property
+ def payment_vpn_squad_uuids(self) -> list[str]:
+ return self._parse_csv_list(self.payment_vpn_squad_uuids_raw)
+
+ @property
+ def payment_white_squad_uuids(self) -> list[str]:
+ return self._parse_csv_list(self.payment_white_squad_uuids_raw)
@property
def notification_days_list(self) -> list[int]:
@@ -95,6 +101,13 @@ class BotConfigSnapshot:
@property
def payment_plans(self) -> list[PaymentPlanSettings]:
+ product_plans = self._parse_product_plans(
+ self.payment_product_plans_raw,
+ default_days=30,
+ )
+ if product_plans:
+ return product_plans
+
values: list[PaymentPlanSettings] = []
for raw_part in self.payment_plans_raw.split(","):
part = raw_part.strip()
@@ -120,6 +133,80 @@ class BotConfigSnapshot:
return sorted(values, key=lambda item: item.days)
+ @staticmethod
+ def _parse_csv_list(raw_value: str) -> list[str]:
+ values: list[str] = []
+ for raw_part in raw_value.split(","):
+ part = raw_part.strip()
+ if part:
+ values.append(part)
+ return values
+
+ @staticmethod
+ def _parse_product_plans(
+ raw_value: str,
+ *,
+ default_days: int,
+ ) -> list[PaymentPlanSettings]:
+ values: list[PaymentPlanSettings] = []
+ seen_codes: set[str] = set()
+
+ for raw_part in raw_value.split(","):
+ part = raw_part.strip()
+ if not part:
+ continue
+
+ fragments = [fragment.strip() for fragment in part.split(":")]
+
+ # Legacy format: code:amount (2 fields)
+ if len(fragments) == 2:
+ code, raw_amount = fragments
+ pairs = [(str(default_days), raw_amount)]
+ # Standard format: code:days:amount (3 fields)
+ elif len(fragments) == 3:
+ code = fragments[0]
+ pairs = [(fragments[1], fragments[2])]
+ # Extended format: code:days1:amount1:days2:amount2:... (odd ≥ 5)
+ elif len(fragments) >= 5 and len(fragments) % 2 == 1:
+ code = fragments[0]
+ pairs = [
+ (fragments[i], fragments[i + 1])
+ for i in range(1, len(fragments), 2)
+ ]
+ else:
+ continue
+
+ normalized_code = code.lower()
+ spec = PAYMENT_PRODUCT_PLAN_SPECS.get(normalized_code)
+ if spec is None or normalized_code in seen_codes:
+ continue
+
+ title, description, squad_groups = spec
+ for raw_days, raw_amount in pairs:
+ if not raw_days.isdigit() or not raw_amount.isdigit():
+ continue
+
+ days = int(raw_days)
+ amount_rub = int(raw_amount)
+ if days <= 0 or amount_rub <= 0:
+ continue
+
+ plan_code = f"{normalized_code}_{days}"
+ values.append(
+ PaymentPlanSettings(
+ code=plan_code,
+ days=days,
+ amount_rub=amount_rub,
+ title=title,
+ description=description,
+ squad_groups=squad_groups,
+ )
+ )
+
+ seen_codes.add(normalized_code)
+
+ return values
+
@staticmethod
def _compact_text(raw_value: str, *, max_length: int = 80) -> str:
normalized = " ".join(raw_value.split()).strip()
@@ -176,6 +263,7 @@ class StaticBotConfigService:
referral_discount_percent=self._settings.referral_discount_percent,
referral_bonus_days=self._settings.referral_bonus_days,
payment_plans_raw=self._settings.payment_plans_raw,
+ payment_product_plans_raw=self._settings.payment_product_plans_raw,
payment_transfer_text=self._settings.payment_transfer_text,
payment_support_text=self._settings.payment_support_text,
payment_plan_traffic_limit_gb=self._settings.payment_plan_traffic_limit_gb,
@@ -184,6 +272,8 @@ class StaticBotConfigService:
notification_days_before="1,3",
notification_check_interval_hours=6,
payment_internal_squad_uuids_raw=self._settings.payment_internal_squad_uuids_raw,
+ payment_vpn_squad_uuids_raw=self._settings.payment_vpn_squad_uuids_raw,
+ payment_white_squad_uuids_raw=self._settings.payment_white_squad_uuids_raw,
payment_external_squad_uuid=self._settings.payment_external_squad_uuid,
payment_username_prefix=self._settings.payment_username_prefix,
payment_user_tag=self._settings.payment_user_tag,
@@ -233,9 +323,24 @@ class BotConfigService(StaticBotConfigService):
label="Тарифы и цены",
section="pricing",
prompt="Введите тарифы в формате `30:250,180:600,365:1000`.",
- description="Каждая пара — это `дни:цена_в_рублях`.",
+ description="Каждая пара - это `дни:цена_в_рублях`.",
placeholder="30:250,180:600,365:1000",
),
+ "payment_product_plans_raw": BotConfigFieldSpec(
+ key="payment_product_plans_raw",
+ label="Типы подписок и цены",
+ section="pricing",
+ prompt=(
+ "Введите типы подписок в формате "
+ "`vpn_white:30:450:180:2200:365:4000,white:30:250,vpn:30:200`.\n"
+ "Каждый продукт: `код:дни1:цена1:дни2:цена2:...`"
+ ),
+ description=(
+ "Если заполнено, заменяет обычные PAYMENT_PLANS. "
+ "Коды: vpn_white, white, vpn. Формат: `код:дни1:цена1[:дни2:цена2:...]`."
+ ),
+ placeholder="vpn_white:30:450:180:2200:365:4000,white:30:250:180:1200,vpn:30:200:180:1000",
+ ),
"payment_plan_traffic_limit_gb": BotConfigFieldSpec(
key="payment_plan_traffic_limit_gb",
label="Лимит трафика, GB",
@@ -332,6 +437,22 @@ class BotConfigService(StaticBotConfigService):
description="Эти группы назначаются пользователю в Remnawave.",
placeholder="11111111-1111-1111-1111-111111111111",
),
+ "payment_vpn_squad_uuids_raw": BotConfigFieldSpec(
+ key="payment_vpn_squad_uuids_raw",
+ label="UUID VPN-групп",
+ section="provisioning",
+ prompt="Введите UUID squad для тарифа VPN через запятую.",
+ description="Назначается тарифам `vpn` и `vpn_white`.",
+ placeholder="11111111-1111-1111-1111-111111111111",
+ ),
+ "payment_white_squad_uuids_raw": BotConfigFieldSpec(
+ key="payment_white_squad_uuids_raw",
+ label="UUID групп белых списков",
+ section="provisioning",
+ prompt="Введите UUID squad для белых списков через запятую.",
+ description="Назначается тарифам `white` и `vpn_white`.",
+ placeholder="22222222-2222-2222-2222-222222222222",
+ ),
"payment_external_squad_uuid": BotConfigFieldSpec(
key="payment_external_squad_uuid",
label="UUID внешней группы",
@@ -429,6 +550,12 @@ class BotConfigService(StaticBotConfigService):
default=self._settings.referral_bonus_days,
),
payment_plans_raw=str(overrides.get("payment_plans_raw", self._settings.payment_plans_raw)),
+ payment_product_plans_raw=str(
+ overrides.get(
+ "payment_product_plans_raw",
+ self._settings.payment_product_plans_raw,
+ )
+ ),
payment_transfer_text=str(overrides.get("payment_transfer_text", self._settings.payment_transfer_text)),
payment_support_text=str(overrides.get("payment_support_text", self._settings.payment_support_text)),
payment_plan_traffic_limit_gb=self._parse_int(
@@ -447,6 +574,18 @@ class BotConfigService(StaticBotConfigService):
self._settings.payment_internal_squad_uuids_raw,
)
),
+ payment_vpn_squad_uuids_raw=str(
+ overrides.get(
+ "payment_vpn_squad_uuids_raw",
+ self._settings.payment_vpn_squad_uuids_raw,
+ )
+ ),
+ payment_white_squad_uuids_raw=str(
+ overrides.get(
+ "payment_white_squad_uuids_raw",
+ self._settings.payment_white_squad_uuids_raw,
+ )
+ ),
payment_external_squad_uuid=str(
overrides.get(
"payment_external_squad_uuid",
@@ -533,11 +672,17 @@ class BotConfigService(StaticBotConfigService):
"bot_support_ticket_link",
"payment_review_link",
"bot_start_image_path",
+ "payment_product_plans_raw",
+ "payment_vpn_squad_uuids_raw",
+ "payment_white_squad_uuids_raw",
"payment_external_squad_uuid",
"payment_user_tag",
"payment_username_prefix",
"payment_internal_squad_uuids_raw",
+ "payment_vpn_squad_uuids_raw",
+ "payment_white_squad_uuids_raw",
"payment_plans_raw",
+ "payment_product_plans_raw",
"payment_plan_traffic_reset_period",
"bot_brand_name",
"notification_days_before",
@@ -611,6 +756,20 @@ class BotConfigService(StaticBotConfigService):
plans = self._parse_payment_plans(value)
return ",".join(f"{plan.days}:{plan.amount_rub}" for plan in plans)
+ if key == "payment_product_plans_raw":
+ plans = self._parse_product_plans(value)
+ # Re-serialize: group plans by product code prefix
+ product_groups: dict[str, list[PaymentPlanSettings]] = {}
+ for plan in plans:
+ # Extract product code from plan code (e.g. "vpn_white" from "vpn_white_30")
+ product_code = "_".join(plan.code.split("_")[:-1]) if "_" in plan.code and plan.code.split("_")[-1].isdigit() else plan.code
+ product_groups.setdefault(product_code, []).append(plan)
+ parts = []
+ for product_code, group_plans in product_groups.items():
+ pairs = ":".join(f"{p.days}:{p.amount_rub}" for p in group_plans)
+ parts.append(f"{product_code}:{pairs}")
+ return ",".join(parts)
+
if key == "payment_plan_traffic_limit_gb":
amount = self._parse_non_negative_int(value, "Лимит трафика")
return str(amount)
@@ -652,6 +811,12 @@ class BotConfigService(StaticBotConfigService):
raise ValueError("Нужно указать хотя бы один UUID внутренней группы.")
return ",".join(normalized_parts)
+ if key in {"payment_vpn_squad_uuids_raw", "payment_white_squad_uuids_raw"}:
+ normalized_parts = [part.strip() for part in value.split(",") if part.strip()]
+ if not normalized_parts:
+ raise ValueError("Нужно указать хотя бы один UUID группы.")
+ return ",".join(normalized_parts)
+
if key == "payment_external_squad_uuid":
return value
@@ -711,6 +876,75 @@ class BotConfigService(StaticBotConfigService):
raise ValueError(f"{label} должен быть целым неотрицательным числом.")
return int(raw_value.strip())
+ @staticmethod
+ def _parse_product_plans(raw_value: str) -> list[PaymentPlanSettings]:
+ values: list[PaymentPlanSettings] = []
+ seen_codes: set[str] = set()
+
+ for raw_part in raw_value.split(","):
+ part = raw_part.strip()
+ if not part:
+ continue
+
+ fragments = [fragment.strip() for fragment in part.split(":")]
+
+ # Legacy: code:amount (2 fields)
+ if len(fragments) == 2:
+ code, raw_amount = fragments
+ pairs = [("30", raw_amount)]
+ # Standard: code:days:amount (3 fields)
+ elif len(fragments) == 3:
+ code = fragments[0]
+ pairs = [(fragments[1], fragments[2])]
+ # Extended: code:days1:amount1:days2:amount2:... (odd ≥ 5)
+ elif len(fragments) >= 5 and len(fragments) % 2 == 1:
+ code = fragments[0]
+ pairs = [
+ (fragments[i], fragments[i + 1])
+ for i in range(1, len(fragments), 2)
+ ]
+ else:
+ raise ValueError(
+ "Типы подписок: `код:дни1:цена1[:дни2:цена2:...]`, "
+ "например `vpn_white:30:450:180:2200,white:30:250,vpn:30:200`."
+ )
+
+ normalized_code = code.lower()
+ spec = PAYMENT_PRODUCT_PLAN_SPECS.get(normalized_code)
+ if spec is None:
+ raise ValueError("Неизвестный код подписки. Доступны: vpn_white, white, vpn.")
+ if normalized_code in seen_codes:
+ raise ValueError("Коды подписок не должны повторяться.")
+
+ title, description, squad_groups = spec
+ for raw_days, raw_amount in pairs:
+ if not raw_days.isdigit() or not raw_amount.isdigit():
+ raise ValueError("Дни и цена должны быть целыми положительными числами.")
+
+ days = int(raw_days)
+ amount_rub = int(raw_amount)
+ if days <= 0 or amount_rub <= 0:
+ raise ValueError("У подписок дни и цена должны быть больше нуля.")
+
+ plan_code = f"{normalized_code}_{days}"
+ values.append(
+ PaymentPlanSettings(
+ code=plan_code,
+ days=days,
+ amount_rub=amount_rub,
+ title=title,
+ description=description,
+ squad_groups=squad_groups,
+ )
+ )
+
+ seen_codes.add(normalized_code)
+
+ if not values:
+ raise ValueError("Нужно указать хотя бы один тип подписки.")
+
+ return values
+
@staticmethod
def _parse_payment_plans(raw_value: str) -> list[PaymentPlanSettings]:
values: list[PaymentPlanSettings] = []
diff --git a/app/services/payment_service.py b/app/services/payment_service.py
index a48ca82..570d0cf 100644
--- a/app/services/payment_service.py
+++ b/app/services/payment_service.py
@@ -31,9 +31,31 @@ REFERRAL_BONUS_APPLIED_STATUS = "APPLIED"
logger = logging.getLogger(__name__)
+@dataclass(slots=True)
+class AppliedDiscount:
+ code: str = ""
+ source: str = ""
+ discount_percent: int = 0
+ plan_code: str | None = None
+ expires_at: datetime | None = None
+
+ def applies_to_plan(self, plan_code: str) -> bool:
+ if not self.code:
+ return False
+ if self.plan_code is None:
+ return True
+ # "all_30" matches any product with 30 days (vpn_white_30, white_30, vpn_30)
+ if self.plan_code.startswith("all_"):
+ suffix = self.plan_code.removeprefix("all") # "_30"
+ return plan_code.endswith(suffix)
+ # Exact match or product-prefix match (e.g. promo plan_code="vpn_white" matches plan "vpn_white_30")
+ return self.plan_code == plan_code or plan_code.startswith(f"{self.plan_code}_")
+
+
@dataclass(slots=True)
class PaymentPlan:
code: str
+ label: str
title: str
description: str
duration_days: int
@@ -41,6 +63,9 @@ class PaymentPlan:
discount_percent: int
original_amount_rub: int
applied_referral_code: str = ""
+ discount_code: str = ""
+ discount_source: str = ""
+ discount_scope_plan_code: str | None = None
traffic_limit_bytes: int = 0
traffic_limit_strategy: str = "NO_RESET"
internal_squad_uuids: list[str] | None = None
@@ -130,14 +155,14 @@ class PaymentService:
async def get_available_plans(self, *, telegram_id: int) -> list[PaymentPlan]:
config = await self._get_config()
- applied_referral_code = await self._get_applied_referral_code(
+ applied_discount = await self._get_applied_discount(
telegram_id,
config=config,
)
return [
self._build_plan(
plan,
- applied_referral_code=applied_referral_code,
+ applied_discount=applied_discount,
config=config,
)
for plan in config.payment_plans
@@ -154,13 +179,13 @@ class PaymentService:
plan_code: str,
) -> CreatedPaymentOrder:
config = await self._get_config()
- applied_referral_code = await self._get_applied_referral_code(
+ applied_discount = await self._get_applied_discount(
telegram_id,
config=config,
)
plan = self._get_plan_by_code(
plan_code,
- applied_referral_code=applied_referral_code,
+ applied_discount=applied_discount,
config=config,
)
if not plan.is_ready:
@@ -203,7 +228,7 @@ class PaymentService:
status=PENDING_PAYMENT_STATUS,
invoice_payload=invoice_payload,
provision_username=provision_username,
- error_message=applied_referral_code or None,
+ error_message=plan.discount_code or None,
)
)
await session.commit()
@@ -293,10 +318,15 @@ class PaymentService:
async def approve_order(self, *, order_uuid: str) -> IssuedAccess:
order = await self._claim_order_for_approval(order_uuid)
- config = await self._get_config()
is_renewal = False
try:
+ config = await self._get_config()
+ plan = self._get_plan_by_code(
+ order.plan_code,
+ applied_discount=AppliedDiscount(),
+ config=config,
+ )
remote_user: RemnawaveUser | None = None
if order.remnawave_user_uuid:
try:
@@ -324,19 +354,39 @@ class PaymentService:
remote_user = self._select_referral_bonus_target(remote_users)
if remote_user is None:
- remote_user = await self._create_remnawave_user(order, config=config)
+ remote_user = await self._create_remnawave_user(
+ order,
+ plan=plan,
+ config=config,
+ )
else:
is_renewal = True
remote_user = await self._extend_remnawave_user(
order,
remote_user,
+ plan=plan,
config=config,
)
+ except ValueError as exc:
+ await self._restore_review_after_failed_approval(order.order_uuid, str(exc))
+ raise
except RemnawaveApiError as exc:
await self._restore_review_after_failed_approval(order.order_uuid, exc.message)
raise
await self._save_fulfilled_order(order.order_uuid, remote_user)
+
+ # Burn the applied discount (promo or referral) so it cannot be reused
+ try:
+ await self._sync_service.consume_discount_for_user(order.telegram_id)
+ except Exception:
+ logger.warning(
+ "Could not consume discount for telegram_id=%s after order=%s",
+ order.telegram_id,
+ order.order_uuid,
+ exc_info=True,
+ )
+
own_bonus = None
try:
own_bonus = await self.apply_pending_referral_bonuses(
@@ -422,9 +472,14 @@ class PaymentService:
"",
f"Сумма к переводу: {order.plan.amount_rub} ₽",
]
- if order.plan.applied_referral_code:
+ if order.plan.discount_code:
+ discount_label = (
+ "Промокод"
+ if order.plan.discount_source == "promo"
+ else "Реферальный код"
+ )
lines.append(
- f"Реферальный код: {order.plan.applied_referral_code} "
+ f"{discount_label}: {order.plan.discount_code} "
f"(-{order.plan.discount_percent}%)"
)
if order.extends_existing_access:
@@ -458,14 +513,14 @@ class PaymentService:
self,
plan_code: str,
*,
- applied_referral_code: str,
+ applied_discount: AppliedDiscount,
config: BotConfigSnapshot,
) -> PaymentPlan:
for plan in config.payment_plans:
if plan.code == plan_code:
return self._build_plan(
plan,
- applied_referral_code=applied_referral_code,
+ applied_discount=applied_discount,
config=config,
)
@@ -475,10 +530,15 @@ class PaymentService:
self,
plan_settings: PaymentPlanSettings,
*,
- applied_referral_code: str,
+ applied_discount: AppliedDiscount,
config: BotConfigSnapshot,
) -> PaymentPlan:
- discount_percent = max(config.referral_discount_percent, 0) if applied_referral_code else 0
+ resolved_discount = (
+ applied_discount
+ if applied_discount.applies_to_plan(plan_settings.code)
+ else AppliedDiscount()
+ )
+ discount_percent = max(resolved_discount.discount_percent, 0)
discounted_amount = self._apply_discount(
amount_rub=plan_settings.amount_rub,
discount_percent=discount_percent,
@@ -486,36 +546,105 @@ class PaymentService:
traffic_limit_bytes = max(config.payment_plan_traffic_limit_gb, 0) * 1024**3
return PaymentPlan(
code=plan_settings.code,
- title=f"{config.bot_brand_name} на {plan_settings.days} дней",
- description=f"Доступ к VPN на {plan_settings.days} дней",
+ label=plan_settings.title or f"{plan_settings.days} дней",
+ title=(
+ f"{config.bot_brand_name} · {plan_settings.title}"
+ if plan_settings.title
+ else f"{config.bot_brand_name} на {plan_settings.days} дней"
+ ),
+ description=plan_settings.description or f"Доступ к VPN на {plan_settings.days} дней",
duration_days=plan_settings.days,
amount_rub=discounted_amount,
discount_percent=discount_percent,
original_amount_rub=plan_settings.amount_rub,
- applied_referral_code=applied_referral_code,
+ applied_referral_code=(
+ resolved_discount.code if resolved_discount.source == "referral" else ""
+ ),
+ discount_code=resolved_discount.code,
+ discount_source=resolved_discount.source,
+ discount_scope_plan_code=resolved_discount.plan_code,
traffic_limit_bytes=traffic_limit_bytes,
traffic_limit_strategy=config.payment_plan_traffic_reset_period.strip() or "NO_RESET",
- internal_squad_uuids=config.payment_internal_squad_uuids,
+ internal_squad_uuids=self._resolve_plan_internal_squad_uuids(
+ plan_settings,
+ config=config,
+ ),
external_squad_uuid=config.payment_external_squad_uuid_normalized,
)
+ @staticmethod
+ def _resolve_plan_internal_squad_uuids(
+ plan_settings: PaymentPlanSettings,
+ *,
+ config: BotConfigSnapshot,
+ ) -> list[str]:
+ if not plan_settings.squad_groups:
+ return config.payment_internal_squad_uuids
+
+ values: list[str] = []
+ for group in plan_settings.squad_groups:
+ if group == "vpn":
+ group_values = config.payment_vpn_squad_uuids
+ elif group == "white":
+ group_values = config.payment_white_squad_uuids
+ else:
+ group_values = []
+
+ for squad_uuid in group_values:
+ if squad_uuid not in values:
+ values.append(squad_uuid)
+
+ return values
+
@staticmethod
def _apply_discount(*, amount_rub: int, discount_percent: int) -> int:
if discount_percent <= 0:
return amount_rub
return max(1, ceil(amount_rub * (100 - discount_percent) / 100))
- async def _get_applied_referral_code(
+ async def get_active_discount_for_user(self, *, telegram_id: int) -> AppliedDiscount:
+ config = await self._get_config()
+ return await self._get_applied_discount(telegram_id, config=config)
+
+ async def _get_applied_discount(
self,
telegram_id: int,
*,
config: BotConfigSnapshot | None = None,
- ) -> str:
+ ) -> AppliedDiscount:
resolved_config = config or await self._get_config()
+ promo_getter = getattr(self._sync_service, "get_active_promo_for_user", None)
+ if callable(promo_getter):
+ promo = await promo_getter(telegram_id)
+ if promo is not None:
+ return AppliedDiscount(
+ code=str(promo.code).strip().upper(),
+ source="promo",
+ discount_percent=max(int(promo.discount_percent), 0),
+ plan_code=promo.plan_code,
+ expires_at=promo.expires_at,
+ )
+
if not resolved_config.referral_enabled:
- return ""
+ return AppliedDiscount()
+
summary = await self._sync_service.get_referral_summary(telegram_id)
- return summary.applied_referral_code.strip().upper()
+ referral_code = summary.applied_referral_code.strip().upper()
+ if not referral_code:
+ return AppliedDiscount()
+
+ # Check if referral discount was already consumed by a previous payment
+ discount_used_checker = getattr(
+ self._sync_service, "is_referral_discount_used", None
+ )
+ if callable(discount_used_checker) and await discount_used_checker(telegram_id):
+ return AppliedDiscount()
+
+ return AppliedDiscount(
+ code=referral_code,
+ source="referral",
+ discount_percent=max(resolved_config.referral_discount_percent, 0),
+ )
async def apply_pending_referral_bonuses(
self,
@@ -655,6 +784,7 @@ class PaymentService:
self,
order: PaymentOrder,
*,
+ plan: PaymentPlan,
config: BotConfigSnapshot,
) -> RemnawaveUser:
expire_at = datetime.now(timezone.utc) + timedelta(days=order.plan_duration_days)
@@ -665,7 +795,7 @@ class PaymentService:
"trafficLimitStrategy": order.traffic_limit_strategy,
"telegramId": order.telegram_id,
"description": f"Created by Telegram bot manual payment {order.order_uuid}",
- "activeInternalSquads": config.payment_internal_squad_uuids,
+ "activeInternalSquads": plan.internal_squad_uuids or [],
}
if config.payment_user_tag_normalized:
body["tag"] = config.payment_user_tag_normalized
@@ -678,6 +808,7 @@ class PaymentService:
order: PaymentOrder,
remote_user: RemnawaveUser,
*,
+ plan: PaymentPlan,
config: BotConfigSnapshot,
) -> RemnawaveUser:
current_expire_at = remote_user.expire_at
@@ -699,7 +830,7 @@ class PaymentService:
"trafficLimitStrategy": order.traffic_limit_strategy,
"telegramId": order.telegram_id,
"description": f"Updated by Telegram bot manual payment {order.order_uuid}",
- "activeInternalSquads": config.payment_internal_squad_uuids,
+ "activeInternalSquads": plan.internal_squad_uuids or [],
}
if config.payment_user_tag_normalized:
body["tag"] = config.payment_user_tag_normalized
diff --git a/app/services/support_ticket_service.py b/app/services/support_ticket_service.py
index 6978583..bfc1613 100644
--- a/app/services/support_ticket_service.py
+++ b/app/services/support_ticket_service.py
@@ -1,11 +1,41 @@
from __future__ import annotations
+import math
+from dataclasses import dataclass, field
+from datetime import datetime
+
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.db.base import utcnow
from app.db.models import SupportTicket
+OPEN_SUPPORT_TICKET_STATUS = "OPEN"
+ANSWERED_SUPPORT_TICKET_STATUS = "ANSWERED"
+CLOSED_SUPPORT_TICKET_STATUS = "CLOSED"
+
+
+@dataclass(slots=True)
+class AdminSupportTicketListItem:
+ id: int
+ public_id: str
+ telegram_id: int
+ username: str | None
+ display_name: str
+ status: str
+ created_at: datetime
+ updated_at: datetime
+ closed_at: datetime | None
+
+
+@dataclass(slots=True)
+class AdminSupportTicketsPage:
+ items: list[AdminSupportTicketListItem] = field(default_factory=list)
+ page: int = 1
+ page_size: int = 8
+ total_items: int = 0
+ total_pages: int = 1
+
class SupportTicketService:
def __init__(self, *, session_factory: async_sessionmaker[AsyncSession]) -> None:
@@ -33,13 +63,17 @@ class SupportTicketService:
support_chat_id=support_chat_id,
support_thread_id=support_thread_id,
support_message_id=support_message_id,
- status="OPEN",
+ status=OPEN_SUPPORT_TICKET_STATUS,
)
session.add(ticket)
await session.commit()
await session.refresh(ticket)
return ticket
+ async def get_ticket(self, *, ticket_id: int) -> SupportTicket | None:
+ async with self._session_factory() as session:
+ return await session.get(SupportTicket, ticket_id)
+
async def get_ticket_by_support_message(
self,
*,
@@ -54,19 +88,121 @@ class SupportTicketService:
)
)
- async def mark_answered(self, *, ticket_id: int) -> None:
+ async def mark_answered(self, *, ticket_id: int) -> SupportTicket | None:
async with self._session_factory() as session:
ticket = await session.get(SupportTicket, ticket_id)
if ticket is None:
- return
+ return None
+
+ if ticket.status != CLOSED_SUPPORT_TICKET_STATUS:
+ ticket.status = ANSWERED_SUPPORT_TICKET_STATUS
- ticket.status = "ANSWERED"
ticket.updated_at = utcnow()
await session.commit()
+ await session.refresh(ticket)
+ return ticket
+
+ async def append_note(
+ self,
+ *,
+ ticket_id: int,
+ note_text: str,
+ status: str | None = None,
+ ) -> SupportTicket | None:
+ cleaned_note = note_text.strip()
+ if not cleaned_note:
+ return None
+
+ async with self._session_factory() as session:
+ ticket = await session.get(SupportTicket, ticket_id)
+ if ticket is None:
+ return None
+
+ if ticket.support_note:
+ ticket.support_note = f"{ticket.support_note}\n\n{cleaned_note}"
+ else:
+ ticket.support_note = cleaned_note
+ if status and ticket.status != CLOSED_SUPPORT_TICKET_STATUS:
+ ticket.status = status
+ ticket.updated_at = utcnow()
+ await session.commit()
+ await session.refresh(ticket)
+ return ticket
+
+ async def close_ticket(
+ self,
+ *,
+ ticket_id: int,
+ closed_by_telegram_id: int | None,
+ ) -> SupportTicket | None:
+ async with self._session_factory() as session:
+ ticket = await session.get(SupportTicket, ticket_id)
+ if ticket is None:
+ return None
+
+ ticket.status = CLOSED_SUPPORT_TICKET_STATUS
+ ticket.closed_by_telegram_id = closed_by_telegram_id
+ ticket.closed_at = utcnow()
+ ticket.updated_at = utcnow()
+ await session.commit()
+ await session.refresh(ticket)
+ return ticket
async def get_open_tickets_count(self) -> int:
async with self._session_factory() as session:
total = await session.scalar(
- select(func.count(SupportTicket.id)).where(SupportTicket.status == "OPEN")
+ select(func.count(SupportTicket.id)).where(
+ SupportTicket.status != CLOSED_SUPPORT_TICKET_STATUS
+ )
)
return int(total or 0)
+
+ async def get_admin_tickets_page(
+ self,
+ *,
+ page: int,
+ page_size: int = 8,
+ ) -> AdminSupportTicketsPage:
+ resolved_page_size = max(1, min(page_size, 20))
+
+ async with self._session_factory() as session:
+ total_items = int(
+ await session.scalar(select(func.count(SupportTicket.id))) or 0
+ )
+ total_pages = max(1, math.ceil(total_items / resolved_page_size)) if total_items else 1
+ resolved_page = min(max(page, 1), total_pages)
+
+ rows = list(
+ (
+ await session.scalars(
+ select(SupportTicket)
+ .order_by(
+ SupportTicket.created_at.desc(),
+ SupportTicket.id.desc(),
+ )
+ .offset((resolved_page - 1) * resolved_page_size)
+ .limit(resolved_page_size)
+ )
+ ).all()
+ )
+
+ return AdminSupportTicketsPage(
+ items=[
+ AdminSupportTicketListItem(
+ id=row.id,
+ public_id=row.public_id,
+ telegram_id=row.telegram_id,
+ username=row.username,
+ display_name=row.display_name,
+ status=row.status,
+ created_at=row.created_at,
+ updated_at=row.updated_at,
+ closed_at=row.closed_at,
+ )
+ for row in rows
+ ],
+ page=resolved_page,
+ page_size=resolved_page_size,
+ total_items=total_items,
+ total_pages=total_pages,
+ )
diff --git a/app/services/sync_service.py b/app/services/sync_service.py
index 2bff8fd..db0d5e0 100644
--- a/app/services/sync_service.py
+++ b/app/services/sync_service.py
@@ -5,15 +5,17 @@ import math
from dataclasses import dataclass, field
from datetime import datetime, timezone
-from sqlalchemy import delete, func, or_, select
+from sqlalchemy import delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.config import Settings
from app.db.base import utcnow
from app.db.models import (
+ InternalSquad,
+ PromoCode,
+ PromoCodeApplication,
ReferralCode,
ReferralInvite,
- InternalSquad,
RemnawaveUser,
RemnawaveUserInternalSquad,
SubscriptionRequestLog,
@@ -36,9 +38,33 @@ class ReferralSummary:
total_invited: int = 0
referral_code: str = ""
applied_referral_code: str = ""
+ applied_promo_code: str = ""
+ applied_promo_discount_percent: int = 0
+ applied_promo_plan_code: str | None = None
+ applied_promo_expires_at: datetime | None = None
recent_names: list[str] = field(default_factory=list)
+@dataclass(slots=True)
+class PromoCodeView:
+ code: str
+ plan_code: str | None
+ discount_percent: int
+ expires_at: datetime | None
+ is_active: bool
+ created_by_telegram_id: int | None = None
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+ @property
+ def is_expired(self) -> bool:
+ return self.expires_at is not None and self.expires_at <= utcnow()
+
+ @property
+ def is_valid(self) -> bool:
+ return self.is_active and not self.is_expired and self.discount_percent > 0
+
+
@dataclass(slots=True)
class AdminStats:
total_telegram_users: int = 0
@@ -174,6 +200,124 @@ class SyncService:
await self._get_or_create_referral_code(session, telegram_id=telegram_id)
await session.commit()
+ async def create_promo_code(
+ self,
+ *,
+ code: str,
+ plan_code: str | None,
+ discount_percent: int,
+ expires_at: datetime | None,
+ created_by_telegram_id: int,
+ ) -> PromoCodeView:
+ normalized_code = self.normalize_discount_code(code)
+ if not normalized_code:
+ raise ValueError("Введите слово для промокода.")
+ if len(normalized_code) > 32:
+ raise ValueError("Промокод должен быть не длиннее 32 символов.")
+ if not all(ch.isalnum() or ch in "_-" for ch in normalized_code):
+ raise ValueError("Промокод может содержать только буквы, цифры, _ и -.")
+
+ resolved_discount = int(discount_percent)
+ if resolved_discount <= 0 or resolved_discount > 100:
+ raise ValueError("Скидка должна быть от 1 до 100%.")
+
+ resolved_plan_code = (plan_code or "").strip() or None
+ if resolved_plan_code and len(resolved_plan_code) > 64:
+ raise ValueError("Код тарифа слишком длинный.")
+
+ resolved_expires_at = self._normalize_datetime(expires_at)
+ if resolved_expires_at is not None and resolved_expires_at <= utcnow():
+ raise ValueError("Срок действия промокода должен быть в будущем.")
+
+ async with self._session_factory() as session:
+ duplicate = await session.scalar(
+ select(PromoCode.id).where(PromoCode.code == normalized_code)
+ )
+ if duplicate is not None:
+ raise ValueError("Такой промокод уже существует.")
+
+ promo = PromoCode(
+ code=normalized_code,
+ plan_code=resolved_plan_code,
+ discount_percent=resolved_discount,
+ expires_at=resolved_expires_at,
+ is_active=True,
+ created_by_telegram_id=created_by_telegram_id,
+ )
+ session.add(promo)
+ await session.commit()
+ await session.refresh(promo)
+ return self._promo_code_view(promo)
+
+ async def get_admin_promo_codes(self, *, limit: int = 20) -> list[PromoCodeView]:
+ resolved_limit = max(1, min(limit, 100))
+ async with self._session_factory() as session:
+ rows = list(
+ (
+ await session.scalars(
+ select(PromoCode)
+ .order_by(PromoCode.created_at.desc(), PromoCode.id.desc())
+ .limit(resolved_limit)
+ )
+ ).all()
+ )
+ return [self._promo_code_view(row) for row in rows]
+
+ async def get_active_promo_for_user(self, telegram_id: int) -> PromoCodeView | None:
+ async with self._session_factory() as session:
+ promo = await self._get_active_promo_for_user(session, telegram_id=telegram_id)
+ return self._promo_code_view(promo) if promo is not None else None
+
+ async def consume_discount_for_user(self, telegram_id: int) -> None:
+ """Burn the applied promo / referral discount so it cannot be reused.
+
+ - For promo codes: deletes the row from ``promo_code_applications``.
+ - For referral invites: sets ``discount_used = True`` so the referral
+ bonus logic (inviter days) is preserved while the invited user
+ no longer receives a price discount.
+ - Sets ``discount_ever_used = True`` on ``telegram_users`` to permanently
+ prevent any future discount code activation.
+ """
+ async with self._session_factory() as session:
+ # 1. Burn promo-code application
+ await session.execute(
+ delete(PromoCodeApplication).where(
+ PromoCodeApplication.telegram_id == telegram_id
+ )
+ )
+
+ # 2. Mark referral invite discount as used
+ await session.execute(
+ update(ReferralInvite)
+ .where(
+ ReferralInvite.invited_telegram_id == telegram_id,
+ ReferralInvite.discount_used == False, # noqa: E712
+ )
+ .values(discount_used=True)
+ )
+
+ # 3. Permanently mark the user so no new codes can be activated
+ await session.execute(
+ update(TelegramUser)
+ .where(
+ TelegramUser.telegram_id == telegram_id,
+ TelegramUser.discount_ever_used == False, # noqa: E712
+ )
+ .values(discount_ever_used=True)
+ )
+
+ await session.commit()
+
+ async def is_referral_discount_used(self, telegram_id: int) -> bool:
+ """Return True if the referral invite discount was already consumed."""
+ async with self._session_factory() as session:
+ discount_used = await session.scalar(
+ select(ReferralInvite.discount_used).where(
+ ReferralInvite.invited_telegram_id == telegram_id
+ )
+ )
+ return bool(discount_used)
+
async def apply_referral_code(
self,
*,
@@ -193,8 +337,28 @@ class SyncService:
last_name=last_name,
language_code=language_code,
)
+
+ # Block activation if the user has already used a discount
+ if invited.discount_ever_used:
+ raise ValueError(
+ "Вы уже использовали промокод или реферальный код. "
+ "Повторная активация невозможна."
+ )
+
await self._get_or_create_referral_code(session, telegram_id=telegram_id)
+ raw_code = self.normalize_discount_code(referral_code)
+ promo = await self._find_promo_code(session, code=raw_code)
+ if promo is not None:
+ self._ensure_promo_can_be_applied(promo)
+ await self._upsert_promo_application(
+ session,
+ telegram_id=telegram_id,
+ promo_code_id=promo.id,
+ )
+ await session.commit()
+ return promo.code
+
existing = await session.scalar(
select(ReferralInvite).where(
ReferralInvite.invited_telegram_id == telegram_id
@@ -217,6 +381,11 @@ class SyncService:
if not invite_created:
raise ValueError("Не удалось применить реферальный код.")
+ await session.execute(
+ delete(PromoCodeApplication).where(
+ PromoCodeApplication.telegram_id == telegram_id
+ )
+ )
await session.commit()
return referral_code.strip().upper()
@@ -247,11 +416,21 @@ class SyncService:
)
.where(ReferralInvite.invited_telegram_id == telegram_id)
)
+ applied_promo = await self._get_active_promo_for_user(
+ session,
+ telegram_id=telegram_id,
+ )
return ReferralSummary(
total_invited=int(total_invited or 0),
referral_code=referral_code,
applied_referral_code=applied_referral_code or "",
+ applied_promo_code=applied_promo.code if applied_promo is not None else "",
+ applied_promo_discount_percent=(
+ int(applied_promo.discount_percent) if applied_promo is not None else 0
+ ),
+ applied_promo_plan_code=applied_promo.plan_code if applied_promo is not None else None,
+ applied_promo_expires_at=applied_promo.expires_at if applied_promo is not None else None,
recent_names=recent_names,
)
@@ -623,6 +802,11 @@ class SyncService:
remote_user: RemoteRemnawaveUser,
owner: TelegramUser | None,
) -> RemnawaveUser:
+ owner_id = owner.id if owner is not None else None
+ if owner_id is None and remote_user.telegram_id is not None:
+ with session.no_autoflush:
+ owner_id = await self._find_owner_id(session, remote_user.telegram_id)
+
record = await session.scalar(
select(RemnawaveUser).where(RemnawaveUser.rw_uuid == str(remote_user.uuid))
)
@@ -631,8 +815,6 @@ class SyncService:
record = RemnawaveUser(rw_uuid=str(remote_user.uuid))
session.add(record)
- owner_id = owner.id if owner is not None else await self._find_owner_id(session, remote_user.telegram_id)
-
record.owner_telegram_user_id = owner_id
record.rw_id = remote_user.id
record.short_uuid = remote_user.short_uuid
@@ -781,6 +963,62 @@ class SyncService:
select(ReferralCode.telegram_id).where(ReferralCode.code == raw_code)
)
+ async def _find_promo_code(
+ self,
+ session: AsyncSession,
+ *,
+ code: str,
+ ) -> PromoCode | None:
+ raw_code = self.normalize_discount_code(code)
+ if not raw_code:
+ return None
+
+ return await session.scalar(
+ select(PromoCode).where(PromoCode.code == raw_code)
+ )
+
+ async def _get_active_promo_for_user(
+ self,
+ session: AsyncSession,
+ *,
+ telegram_id: int,
+ ) -> PromoCode | None:
+ promo = await session.scalar(
+ select(PromoCode)
+ .join(
+ PromoCodeApplication,
+ PromoCodeApplication.promo_code_id == PromoCode.id,
+ )
+ .where(PromoCodeApplication.telegram_id == telegram_id)
+ )
+ if promo is None or not self._promo_is_valid(promo):
+ return None
+ return promo
+
+ async def _upsert_promo_application(
+ self,
+ session: AsyncSession,
+ *,
+ telegram_id: int,
+ promo_code_id: int,
+ ) -> None:
+ application = await session.scalar(
+ select(PromoCodeApplication).where(
+ PromoCodeApplication.telegram_id == telegram_id
+ )
+ )
+ if application is None:
+ session.add(
+ PromoCodeApplication(
+ telegram_id=telegram_id,
+ promo_code_id=promo_code_id,
+ )
+ )
+ return
+
+ application.promo_code_id = promo_code_id
+ application.updated_at = utcnow()
+
async def _find_owner_id(self, session: AsyncSession, telegram_id: int | None) -> int | None:
if telegram_id is None:
return None
@@ -801,6 +1039,40 @@ class SyncService:
def _maybe_str(value: object | None) -> str | None:
return str(value) if value is not None else None
+ @staticmethod
+ def normalize_discount_code(value: str | None) -> str:
+ return "".join((value or "").strip().upper().split())
+
+ @classmethod
+ def _ensure_promo_can_be_applied(cls, promo: PromoCode) -> None:
+ if not promo.is_active:
+ raise ValueError("Промокод отключён.")
+ if cls._promo_is_expired(promo):
+ raise ValueError("Срок действия промокода истёк.")
+ if promo.discount_percent <= 0:
+ raise ValueError("Промокод не даёт скидку.")
+
+ @classmethod
+ def _promo_is_valid(cls, promo: PromoCode) -> bool:
+ return bool(promo.is_active) and not cls._promo_is_expired(promo) and promo.discount_percent > 0
+
+ @staticmethod
+ def _promo_is_expired(promo: PromoCode) -> bool:
+ return promo.expires_at is not None and promo.expires_at <= utcnow()
+
+ @staticmethod
+ def _promo_code_view(promo: PromoCode) -> PromoCodeView:
+ return PromoCodeView(
+ code=promo.code,
+ plan_code=promo.plan_code,
+ discount_percent=int(promo.discount_percent),
+ expires_at=promo.expires_at,
+ is_active=bool(promo.is_active),
+ created_by_telegram_id=promo.created_by_telegram_id,
+ created_at=promo.created_at,
+ updated_at=promo.updated_at,
+ )
+
@staticmethod
def _build_views(rows: list[tuple[RemnawaveUser, str | None]]) -> list[CachedUserView]:
view_map: dict[int, CachedUserView] = {}
diff --git a/app/utils/formatters.py b/app/utils/formatters.py
index 764b47c..67f885c 100644
--- a/app/utils/formatters.py
+++ b/app/utils/formatters.py
@@ -132,6 +132,14 @@ def format_issued_access(
"2. Импортируйте подписку или ключ в приложение VPN-клиента.",
"3. Управлять доступом и продлевать его можно в панели бота.",
"",
+ (
+ "" + "💡 О белых списках: подписки с белыми списками предназначены " + "для использования в условиях полной блокировки. Если полной блокировки нет — " + "рекомендуем пользоваться VPN" + "" + ), + "", "Если ссылка не открывается автоматически, скопируйте её в клиент вручную.", ] return "\n".join(lines) diff --git a/assets/Thumbs.db b/assets/Thumbs.db index a895555..9f9c2d2 100644 Binary files a/assets/Thumbs.db and b/assets/Thumbs.db differ diff --git a/assets/mai1n.png b/assets/mai1n.png new file mode 100644 index 0000000..1fefb07 Binary files /dev/null and b/assets/mai1n.png differ diff --git a/assets/main.png b/assets/main.png index 1fefb07..68fe0de 100644 Binary files a/assets/main.png and b/assets/main.png differ diff --git a/sql/schema.sql b/sql/schema.sql index ad6957a..ff39b86 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS telegram_users ( language_code VARCHAR(16) NULL, is_admin BOOLEAN NOT NULL DEFAULT FALSE, is_blocked BOOLEAN NOT NULL DEFAULT FALSE, + discount_ever_used BOOLEAN NOT NULL DEFAULT FALSE, last_seen_at DATETIME NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, @@ -119,6 +120,7 @@ CREATE TABLE IF NOT EXISTS referral_invites ( invited_telegram_id BIGINT NOT NULL UNIQUE, invited_username VARCHAR(64) NULL, invited_display_name VARCHAR(255) NOT NULL, + discount_used BOOLEAN NOT NULL DEFAULT FALSE, created_at DATETIME NOT NULL, INDEX ix_referral_invites_inviter_telegram_id (inviter_telegram_id), INDEX ix_referral_invites_invited_telegram_id (invited_telegram_id) @@ -134,6 +136,37 @@ CREATE TABLE IF NOT EXISTS referral_codes ( INDEX ix_referral_codes_code (code) ); +CREATE TABLE IF NOT EXISTS promo_codes ( + id INT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(32) NOT NULL UNIQUE, + plan_code VARCHAR(64) NULL, + discount_percent INT NOT NULL DEFAULT 0, + expires_at DATETIME NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_by_telegram_id BIGINT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX ix_promo_codes_code (code), + INDEX ix_promo_codes_plan_code (plan_code), + INDEX ix_promo_codes_expires_at (expires_at), + INDEX ix_promo_codes_is_active (is_active), + INDEX ix_promo_codes_created_by_telegram_id (created_by_telegram_id) +); + +CREATE TABLE IF NOT EXISTS promo_code_applications ( + id INT AUTO_INCREMENT PRIMARY KEY, + telegram_id BIGINT NOT NULL UNIQUE, + promo_code_id INT NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + CONSTRAINT fk_promo_code_applications_promo_code + FOREIGN KEY (promo_code_id) + REFERENCES promo_codes (id) + ON DELETE CASCADE, + INDEX ix_promo_code_applications_telegram_id (telegram_id), + INDEX ix_promo_code_applications_promo_code_id (promo_code_id) +); + CREATE TABLE IF NOT EXISTS payment_orders ( id INT AUTO_INCREMENT PRIMARY KEY, order_uuid CHAR(36) NOT NULL UNIQUE, diff --git a/tests/test_bot_config_service.py b/tests/test_bot_config_service.py index 8d5b820..f2403d1 100644 --- a/tests/test_bot_config_service.py +++ b/tests/test_bot_config_service.py @@ -19,11 +19,14 @@ def _make_settings() -> Settings: referral_discount_percent=5, referral_bonus_days=7, payment_plans_raw="30:250,180:600", + payment_product_plans_raw="", payment_transfer_text="Реквизиты", payment_support_text="Напишите в поддержку", payment_plan_traffic_limit_gb=0, payment_plan_traffic_reset_period="NO_RESET", payment_internal_squad_uuids_raw="uuid-1", + payment_vpn_squad_uuids_raw="vpn-uuid", + payment_white_squad_uuids_raw="white-uuid", payment_external_squad_uuid="", payment_username_prefix="Oreol", payment_user_tag="BOT", @@ -46,6 +49,11 @@ async def test_bot_config_service_updates_runtime_snapshot(session_factory) -> N raw_value="30:300,90:700", updated_by_telegram_id=1, ) + await service.update_setting( + key="payment_product_plans_raw", + raw_value="vpn_white:30:450,white:30:250,vpn:30:200", + updated_by_telegram_id=1, + ) await service.update_setting( key="referral_enabled", raw_value="off", @@ -56,9 +64,10 @@ async def test_bot_config_service_updates_runtime_snapshot(session_factory) -> N assert snapshot.bot_brand_name == "NEW BRAND" assert snapshot.referral_enabled is False - assert [(plan.days, plan.amount_rub) for plan in snapshot.payment_plans] == [ - (30, 300), - (90, 700), + assert [(plan.code, plan.days, plan.amount_rub) for plan in snapshot.payment_plans] == [ + ("vpn_white", 30, 450), + ("white", 30, 250), + ("vpn", 30, 200), ] @@ -72,3 +81,4 @@ async def test_bot_config_service_formats_values_for_admin_ui(session_factory) - assert BotConfigService.format_value(snapshot=snapshot, key="bot_public_username") == "@oreol_vpn_bot" assert BotConfigService.format_value(snapshot=snapshot, key="referral_enabled") == "on" assert BotConfigService.format_value(snapshot=snapshot, key="payment_plan_traffic_limit_gb") == "0 GB (unlimited)" + assert BotConfigService.format_value(snapshot=snapshot, key="payment_vpn_squad_uuids_raw") == "vpn-uuid" diff --git a/tests/test_config.py b/tests/test_config.py index 997f806..8ec9987 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -79,3 +79,22 @@ def test_payment_plans_are_parsed() -> None: assert [plan.code for plan in plans] == ["30d", "180d", "365d"] assert [plan.days for plan in plans] == [30, 180, 365] assert [plan.amount_rub for plan in plans] == [250, 600, 1000] + + +def test_payment_product_plans_override_legacy_plans() -> None: + settings = Settings.model_construct( + payment_product_plans_raw="vpn_white:30:450,white:30:250,vpn:30:200", + payment_plans_raw="30:999", + payment_plan_duration_days=30, + ) + + plans = settings.payment_plans + + assert [plan.code for plan in plans] == ["vpn_white", "white", "vpn"] + assert [plan.title for plan in plans] == ["VPN + Белые списки", "Белые списки", "VPN"] + assert [plan.amount_rub for plan in plans] == [450, 250, 200] + assert [plan.squad_groups for plan in plans] == [ + ("vpn", "white"), + ("white",), + ("vpn",), + ] diff --git a/tests/test_panel_ui.py b/tests/test_panel_ui.py index 97f289e..1c50861 100644 --- a/tests/test_panel_ui.py +++ b/tests/test_panel_ui.py @@ -158,7 +158,8 @@ def test_admin_panel_keyboard_has_expected_buttons() -> None: assert "👤 Пользователи" in button_texts assert "🔄 Полная синхронизация" in button_texts assert "💸 Очередь оплат" in button_texts - assert "🎫 Тикеты" in button_texts + assert "🗂 История тикетов" in button_texts + assert "🎫 Чат тикетов" in button_texts assert "↻ Обновить" in button_texts assert "🏠 Главное меню" in button_texts diff --git a/tests/test_payment_service.py b/tests/test_payment_service.py index c87cad6..f901f92 100644 --- a/tests/test_payment_service.py +++ b/tests/test_payment_service.py @@ -21,9 +21,15 @@ from tests.helpers import make_remote_user class _StubSyncService: - def __init__(self, *, cached_users_by_telegram: dict[int, list] | None = None) -> None: + def __init__( + self, + *, + cached_users_by_telegram: dict[int, list] | None = None, + active_promo_by_telegram: dict[int, object] | None = None, + ) -> None: self.refreshed: list[int] = [] self.cached_users_by_telegram = cached_users_by_telegram or {} + self.active_promo_by_telegram = active_promo_by_telegram or {} async def get_referral_summary(self, telegram_id: int): return type( @@ -44,6 +50,9 @@ class _StubSyncService: async def get_cached_users_for_telegram(self, telegram_id: int): return list(self.cached_users_by_telegram.get(telegram_id, [])) + async def get_active_promo_for_user(self, telegram_id: int): + return self.active_promo_by_telegram.get(telegram_id) + class _StubRemnawaveClient: def __init__(self, *, created_user, users_by_telegram_id: dict[int, list] | None = None) -> None: @@ -98,6 +107,9 @@ async def _seed_order( telegram_id: int, status: str, order_uuid: str | None = None, + plan_code: str = "30d", + plan_title: str = "OREOL VPN на 30 дней", + amount_stars: int = 250, ) -> str: resolved_order_uuid = order_uuid or str(uuid4()) @@ -116,12 +128,12 @@ async def _seed_order( order_uuid=resolved_order_uuid, telegram_user_id=user.id, telegram_id=telegram_id, - plan_code="30d", - plan_title="OREOL VPN на 30 дней", + plan_code=plan_code, + plan_title=plan_title, plan_duration_days=30, traffic_limit_bytes=0, traffic_limit_strategy="NO_RESET", - amount_stars=250, + amount_stars=amount_stars, currency="RUB", status=status, invoice_payload=f"payload-{resolved_order_uuid}", @@ -155,7 +167,29 @@ async def _seed_referral_invite( def _make_settings() -> Settings: return Settings.model_construct( bot_admin_ids_raw="", + payment_product_plans_raw="", payment_internal_squad_uuids_raw="11111111-1111-1111-1111-111111111111", + payment_vpn_squad_uuids_raw="", + payment_white_squad_uuids_raw="", + payment_user_tag="BOT", + payment_external_squad_uuid="", + payment_username_prefix="Oreol", + payment_plan_traffic_limit_gb=0, + payment_plan_traffic_reset_period="NO_RESET", + referral_discount_percent=5, + referral_bonus_days=7, + ) + + +def _make_product_settings() -> Settings: + return Settings.model_construct( + bot_admin_ids_raw="", + payment_plans_raw="30:999", + payment_product_plans_raw="vpn_white:30:450,white:30:250,vpn:30:200", + payment_transfer_text="Реквизиты", + payment_internal_squad_uuids_raw="", + payment_vpn_squad_uuids_raw="11111111-1111-1111-1111-111111111111", + payment_white_squad_uuids_raw="22222222-2222-2222-2222-222222222222", payment_user_tag="BOT", payment_external_squad_uuid="", payment_username_prefix="Oreol", @@ -258,6 +292,37 @@ async def test_approve_order_fulfills_and_refreshes_cache(session_factory) -> No assert order.remnawave_user_uuid == str(remote_user.uuid) +async def test_approve_product_order_sends_product_squads_to_remnawave(session_factory) -> None: + remote_user = make_remote_user( + user_uuid="99999999-9999-9999-9999-999999999999", + user_id=9, + short_uuid="product999", + username="oreol-product", + telegram_id=1001, + ) + remnawave_client = _StubRemnawaveClient(created_user=remote_user) + service = PaymentService( + settings=_make_product_settings(), + session_factory=session_factory, + remnawave_client=remnawave_client, + sync_service=_StubSyncService(), + ) + order_uuid = await _seed_order( + session_factory, + telegram_id=1001, + status=REVIEW_PAYMENT_STATUS, + plan_code="white", + plan_title="OREOL VPN · Белые списки", + amount_stars=250, + ) + + await service.approve_order(order_uuid=order_uuid) + + assert remnawave_client.created_bodies[0]["activeInternalSquads"] == [ + "22222222-2222-2222-2222-222222222222", + ] + + async def test_approve_order_grants_and_applies_referral_bonus(session_factory) -> None: buyer_remote_user = make_remote_user( user_uuid="44444444-4444-4444-4444-444444444444", @@ -410,6 +475,95 @@ async def test_create_order_generates_unique_provision_username(session_factory) assert second_order.extends_existing_access is False +async def test_product_plan_uses_matching_squads_and_price(session_factory) -> None: + service = PaymentService( + settings=_make_product_settings(), + session_factory=session_factory, + remnawave_client=_StubRemnawaveClient( + created_user=make_remote_user( + user_uuid="99999999-9999-9999-9999-999999999999", + user_id=9, + short_uuid="buyer999", + username="oreol-product", + telegram_id=1001, + ) + ), + sync_service=_StubSyncService(), + ) + + order = await service.create_order( + telegram_id=1001, + username="tester", + first_name="Test", + last_name=None, + language_code="ru", + plan_code="vpn_white", + ) + + assert order.plan.label == "VPN + Белые списки" + assert order.plan.amount_rub == 450 + assert order.plan.internal_squad_uuids == [ + "11111111-1111-1111-1111-111111111111", + "22222222-2222-2222-2222-222222222222", + ] + + +async def test_promo_code_discounts_only_matching_plan(session_factory) -> None: + promo = type( + "PromoStub", + (), + { + "code": "WHITE50", + "discount_percent": 50, + "plan_code": "white", + "expires_at": None, + }, + )() + service = PaymentService( + settings=_make_product_settings(), + session_factory=session_factory, + remnawave_client=_StubRemnawaveClient( + created_user=make_remote_user( + user_uuid="10101010-1010-1010-1010-101010101010", + user_id=10, + short_uuid="buyer101", + username="oreol-promo", + telegram_id=1001, + ) + ), + sync_service=_StubSyncService(active_promo_by_telegram={1001: promo}), + ) + + plans = await service.get_available_plans(telegram_id=1001) + + assert [(plan.code, plan.amount_rub, plan.discount_code) for plan in plans] == [ + ("vpn_white", 450, ""), + ("white", 125, "WHITE50"), + ("vpn", 200, ""), + ] + + order = await service.create_order( + telegram_id=1001, + username="tester", + first_name="Test", + last_name=None, + language_code="ru", + plan_code="white", + ) + + assert order.plan.amount_rub == 125 + assert order.plan.discount_source == "promo" + + async with session_factory() as session: + stored_order = await session.scalar( + select(PaymentOrder).where(PaymentOrder.order_uuid == order.order_uuid) + ) + + assert stored_order is not None + assert stored_order.amount_stars == 125 + assert stored_order.error_message == "WHITE50" + + async def test_create_order_marks_existing_access_as_renewal(session_factory) -> None: service = PaymentService( settings=Settings.model_construct( diff --git a/tests/test_support_ticket_service.py b/tests/test_support_ticket_service.py new file mode 100644 index 0000000..30f8ea7 --- /dev/null +++ b/tests/test_support_ticket_service.py @@ -0,0 +1,104 @@ +from app.services.support_ticket_service import ( + ANSWERED_SUPPORT_TICKET_STATUS, + CLOSED_SUPPORT_TICKET_STATUS, + OPEN_SUPPORT_TICKET_STATUS, + SupportTicketService, +) + + +async def test_support_ticket_lifecycle(session_factory) -> None: + service = SupportTicketService(session_factory=session_factory) + + ticket = await service.create_ticket( + public_id="ABCD1234", + telegram_id=1001, + username="tester", + display_name="Test User", + user_message="Need help", + support_chat_id=-1001234567890, + support_thread_id=12, + support_message_id=345, + ) + + assert ticket.status == OPEN_SUPPORT_TICKET_STATUS + assert await service.get_open_tickets_count() == 1 + + answered_ticket = await service.mark_answered(ticket_id=ticket.id) + assert answered_ticket is not None + assert answered_ticket.status == ANSWERED_SUPPORT_TICKET_STATUS + assert await service.get_open_tickets_count() == 1 + + noted_ticket = await service.append_note( + ticket_id=ticket.id, + note_text="First note", + status=OPEN_SUPPORT_TICKET_STATUS, + ) + assert noted_ticket is not None + assert noted_ticket.support_note == "First note" + assert noted_ticket.status == OPEN_SUPPORT_TICKET_STATUS + + appended_ticket = await service.append_note(ticket_id=ticket.id, note_text="Second note") + assert appended_ticket is not None + assert appended_ticket.support_note == "First note\n\nSecond note" + + closed_ticket = await service.close_ticket( + ticket_id=ticket.id, + closed_by_telegram_id=9999, + ) + assert closed_ticket is not None + assert closed_ticket.status == CLOSED_SUPPORT_TICKET_STATUS + assert closed_ticket.closed_by_telegram_id == 9999 + assert closed_ticket.closed_at is not None + assert await service.get_open_tickets_count() == 0 + + still_closed_ticket = await service.mark_answered(ticket_id=ticket.id) + assert still_closed_ticket is not None + assert still_closed_ticket.status == CLOSED_SUPPORT_TICKET_STATUS + + +async def test_get_ticket_by_support_message_returns_ticket(session_factory) -> None: + service = SupportTicketService(session_factory=session_factory) + + created_ticket = await service.create_ticket( + public_id="EFGH5678", + telegram_id=2002, + username=None, + display_name="Another User", + user_message="Another issue", + support_chat_id=-1009876543210, + support_thread_id=None, + support_message_id=777, + ) + + loaded_ticket = await service.get_ticket_by_support_message( + support_chat_id=-1009876543210, + support_message_id=777, + ) + + assert loaded_ticket is not None + assert loaded_ticket.id == created_ticket.id + assert loaded_ticket.public_id == "EFGH5678" + + +async def test_get_admin_tickets_page_returns_latest_first(session_factory) -> None: + service = SupportTicketService(session_factory=session_factory) + + for index in range(3): + await service.create_ticket( + public_id=f"TICKET{index}", + telegram_id=3000 + index, + username=f"user{index}", + display_name=f"User {index}", + user_message=f"Message {index}", + support_chat_id=-100500, + support_thread_id=None, + support_message_id=900 + index, + ) + + first_page = await service.get_admin_tickets_page(page=1, page_size=2) + second_page = await service.get_admin_tickets_page(page=2, page_size=2) + + assert first_page.total_items == 3 + assert first_page.total_pages == 2 + assert [item.public_id for item in first_page.items] == ["TICKET2", "TICKET1"] + assert [item.public_id for item in second_page.items] == ["TICKET0"] diff --git a/tests/test_sync_service.py b/tests/test_sync_service.py index 4dfa94c..1f2e6c7 100644 --- a/tests/test_sync_service.py +++ b/tests/test_sync_service.py @@ -1,11 +1,20 @@ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta from sqlalchemy import select from app.config import Settings -from app.db.models import ReferralCode, ReferralInvite, RemnawaveUser, TelegramUser +from app.db.base import utcnow +from app.db.models import ( + PromoCode, + PromoCodeApplication, + ReferralCode, + ReferralInvite, + RemnawaveUser, + TelegramUser, +) +from app.schemas.remnawave import PaginatedUsers from app.services.sync_service import SyncService from tests.helpers import make_remote_user @@ -17,6 +26,10 @@ class _StubRemnawaveClient: async def get_users_by_telegram_id(self, telegram_id: int): return list(self.users) + async def get_all_users(self, *, start: int = 0, size: int = 100): + items = list(self.users)[start : start + size] + return PaginatedUsers(users=items, total=len(self.users)) + async def test_refresh_cached_users_clears_stale_links_and_keeps_profile( session_factory, @@ -206,3 +219,103 @@ async def test_get_admin_telegram_user_profile_returns_referral_and_access_data( assert profile.recent_invited_names == ["Child User"] assert len(profile.accesses) == 1 assert profile.accesses[0].record.username == "oreol-profile" + + +async def test_promo_code_can_be_applied_through_referral_input(session_factory) -> None: + service = SyncService( + settings=Settings.model_construct( + bot_admin_ids_raw="", + sync_subscription_history=False, + ), + session_factory=session_factory, + remnawave_client=_StubRemnawaveClient([]), + ) + + promo = await service.create_promo_code( + code="white50", + plan_code="white", + discount_percent=50, + expires_at=utcnow() + timedelta(days=7), + created_by_telegram_id=1001, + ) + applied_code = await service.apply_referral_code( + telegram_id=2001, + username="buyer", + first_name="Buyer", + last_name=None, + language_code="ru", + referral_code="white50", + ) + summary = await service.get_referral_summary(2001) + active_promo = await service.get_active_promo_for_user(2001) + + assert promo.code == "WHITE50" + assert applied_code == "WHITE50" + assert summary.applied_referral_code == "" + assert summary.applied_promo_code == "WHITE50" + assert summary.applied_promo_discount_percent == 50 + assert summary.applied_promo_plan_code == "white" + assert active_promo is not None + assert active_promo.code == "WHITE50" + + async with session_factory() as session: + promo_record = await session.scalar( + select(PromoCode).where(PromoCode.code == "WHITE50") + ) + application = await session.scalar( + select(PromoCodeApplication).where(PromoCodeApplication.telegram_id == 2001) + ) + + assert promo_record is not None + assert application is not None + assert application.promo_code_id == promo_record.id + + +async def test_sync_all_users_does_not_autoflush_half_built_user( + session_factory, +) -> None: + remote_user = make_remote_user( + user_uuid="77777777-7777-7777-7777-777777777777", + user_id=7, + short_uuid="sync-all-user", + username="oreol-sync-all", + telegram_id=4001, + ) + service = SyncService( + settings=Settings.model_construct( + bot_admin_ids_raw="", + sync_subscription_history=False, + ), + session_factory=session_factory, + remnawave_client=_StubRemnawaveClient([remote_user]), + ) + + async with session_factory() as session: + session.add( + TelegramUser( + telegram_id=4001, + username="owner", + first_name="Sync", + last_name="Owner", + language_code="ru", + ) + ) + await session.commit() + + total = await service.sync_all_users(batch_size=50) + + assert total == 1 + + async with session_factory() as session: + owner = await session.scalar( + select(TelegramUser).where(TelegramUser.telegram_id == 4001) + ) + cached_user = await session.scalar( + select(RemnawaveUser).where(RemnawaveUser.rw_uuid == str(remote_user.uuid)) + ) + + assert owner is not None + assert cached_user is not None + assert cached_user.rw_id == 7 + assert cached_user.owner_telegram_user_id == owner.id + assert cached_user.username == "oreol-sync-all"