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"{plan.original_amount_rub} ₽" - ) - else: - price_text = f"{plan.amount_rub} ₽" - lines.append(f"• {plan.duration_days} дней — {price_text}") + product_groups = _get_product_groups(plans) + for product_code, group_plans in product_groups.items(): + if group_plans: + label = group_plans[0].label + # Show price range + prices = [p.amount_rub for p in group_plans] + if len(prices) == 1: + price_text = f"от {min(prices)} ₽" + else: + price_text = f"от {min(prices)} ₽" + lines.append(f"• {html.escape(label)} — {price_text}") return "\n".join(lines) @@ -912,15 +1303,29 @@ def build_router( builder = InlineKeyboardBuilder() if referral_enabled: builder.button( - text="🎟 Реферальный код" if not referral_code else "🎟 Изменить рефкод", + text="🎟 Код скидки" if not referral_code else "🎟 Изменить код", callback_data="referral:apply", ) - for plan in plans: - price_text = f"{plan.duration_days} дней · {plan.amount_rub} ₽" - builder.button( - text=price_text, - callback_data=payment_callback("select", plan.code), - ) + + product_groups = _get_product_groups(plans) + for product_code, group_plans in product_groups.items(): + if not group_plans: + continue + label = group_plans[0].label + if len(group_plans) == 1: + # Single duration — go directly to payment:select + plan = group_plans[0] + builder.button( + text=f"{label} · {plan.duration_days} дней · {plan.amount_rub} ₽", + callback_data=payment_callback("select", plan.code), + ) + else: + # Multiple durations — open product submenu + builder.button( + text=label, + callback_data=payment_callback("product", product_code), + ) + builder.button( text="🏠 Главное меню", callback_data=f"panel:{PANEL_SECTION_HOME}", @@ -928,6 +1333,241 @@ def build_router( builder.adjust(1) return builder.as_markup() + def render_product_duration_text( + *, + product_label: str, + plans: list[PaymentPlan], + referral_code: str, + referral_discount_percent: int, + ) -> str: + lines = [ + f"💳 {html.escape(product_label)}", + "", + "
Выберите срок подписки. После выбора бот покажет реквизиты и попросит отправить чек.
", + ] + + 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"{plan.original_amount_rub} ₽" + ) + else: + price_text = f"{plan.amount_rub} ₽" + lines.append(f"• {plan.duration_days} дней — {price_text}") + + return "\n".join(lines) + + def build_product_duration_keyboard( + *, + plans: list[PaymentPlan], + ): + builder = InlineKeyboardBuilder() + for plan in plans: + builder.button( + text=f"{plan.duration_days} дней · {plan.amount_rub} ₽", + callback_data=payment_callback("select", plan.code), + ) + builder.button( + text="⬅️ Назад", + callback_data=payment_callback("menu"), + ) + builder.button( + text="🏠 Главное меню", + callback_data=f"panel:{PANEL_SECTION_HOME}", + ) + builder.adjust(1) + return builder.as_markup() + + def build_home_keyboard(): + builder = InlineKeyboardBuilder() + builder.button(text="🏠 Главное меню", callback_data=f"panel:{PANEL_SECTION_HOME}") + builder.adjust(1) + return builder.as_markup() + + def support_ticket_callback(action: str, ticket_id: int) -> str: + return f"ticket_admin:{action}:{ticket_id}" + + def support_ticket_user_callback(action: str, ticket_id: int) -> str: + return f"ticket_user:{action}:{ticket_id}" + + def format_support_ticket_status(status: str) -> str: + mapping = { + OPEN_SUPPORT_TICKET_STATUS: "🟡 Открыт", + ANSWERED_SUPPORT_TICKET_STATUS: "🔵 Есть ответ", + CLOSED_SUPPORT_TICKET_STATUS: "⚫ Закрыт", + } + return mapping.get(status.upper(), f"⚪ {html.escape(status)}") + + def build_support_ticket_keyboard(*, ticket): + builder = InlineKeyboardBuilder() + if ticket.status == CLOSED_SUPPORT_TICKET_STATUS: + builder.button( + text="🔒 Тикет закрыт", + callback_data=support_ticket_callback("closed", ticket.id), + ) + builder.adjust(1) + return builder.as_markup() + + builder.button( + text="📝 Добавить текст", + callback_data=support_ticket_callback("note", ticket.id), + ) + builder.button( + text="✅ Закрыть тикет", + callback_data=support_ticket_callback("close", ticket.id), + ) + builder.adjust(1, 1) + return builder.as_markup() + + def build_user_support_ticket_keyboard(*, ticket): + builder = InlineKeyboardBuilder() + if ticket.status == CLOSED_SUPPORT_TICKET_STATUS: + builder.button( + text="🔒 Тикет закрыт", + callback_data=support_ticket_user_callback("closed", ticket.id), + ) + builder.button(text="🏠 Главное меню", callback_data=f"panel:{PANEL_SECTION_HOME}") + builder.adjust(1, 1) + return builder.as_markup() + + builder.button( + text="📝 Добавить текст", + callback_data=support_ticket_user_callback("note", ticket.id), + ) + builder.button( + text="✅ Закрыть тикет", + callback_data=support_ticket_user_callback("close", ticket.id), + ) + builder.button(text="🏠 Главное меню", callback_data=f"panel:{PANEL_SECTION_HOME}") + builder.adjust(1, 1, 1) + return builder.as_markup() + + def render_support_ticket_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)}", + f"ID: {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"