RemnaWave-bot Update

This commit is contained in:
2026-05-06 15:41:56 +03:00
parent ce7a1f70b2
commit 2cc2f12a21
22 changed files with 3087 additions and 129 deletions

View File

@@ -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=

View File

@@ -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`
- открытие раздела подписки через кнопку в панели
- ввод реферального кода
- ввод реферального кода или промокода
- отправку чека
- подтверждение оплаты модератором
- выдачу ссылки пользователю

File diff suppressed because it is too large Load Diff

View File

@@ -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))

View File

@@ -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

View File

@@ -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"

View File

@@ -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)

View File

@@ -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] = []

View File

@@ -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"Сумма к переводу: <b>{order.plan.amount_rub} ₽</b>",
]
if order.plan.applied_referral_code:
if order.plan.discount_code:
discount_label = (
"Промокод"
if order.plan.discount_source == "promo"
else "Реферальный код"
)
lines.append(
f"Реферальный код: <code>{order.plan.applied_referral_code}</code> "
f"{discount_label}: <code>{order.plan.discount_code}</code> "
f"(<b>-{order.plan.discount_percent}%</b>)"
)
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

View File

@@ -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,
)

View File

@@ -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] = {}

View File

@@ -132,6 +132,14 @@ def format_issued_access(
"2. Импортируйте подписку или ключ в приложение VPN-клиента.",
"3. Управлять доступом и продлевать его можно в панели бота.",
"",
(
"<blockquote>"
"💡 <b>О белых списках:</b> подписки с белыми списками предназначены "
"для использования в условиях полной блокировки. Если полной блокировки нет — "
"рекомендуем пользоваться VPN"
"</blockquote>"
),
"",
"<i>Если ссылка не открывается автоматически, скопируйте её в клиент вручную.</i>",
]
return "\n".join(lines)

Binary file not shown.

BIN
assets/mai1n.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

@@ -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,

View File

@@ -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"

View File

@@ -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",),
]

View File

@@ -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

View File

@@ -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(

View File

@@ -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"]

View File

@@ -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"