RemnaWave-bot Update
This commit is contained in:
13
.env.example
13
.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=
|
||||
|
||||
|
||||
89
README.md
89
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`
|
||||
- открытие раздела подписки через кнопку в панели
|
||||
- ввод реферального кода
|
||||
- ввод реферального кода или промокода
|
||||
- отправку чека
|
||||
- подтверждение оплаты модератором
|
||||
- выдачу ссылки пользователю
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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))
|
||||
|
||||
130
app/config.py
130
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -132,6 +132,14 @@ def format_issued_access(
|
||||
"2. Импортируйте подписку или ключ в приложение VPN-клиента.",
|
||||
"3. Управлять доступом и продлевать его можно в панели бота.",
|
||||
"",
|
||||
(
|
||||
"<blockquote>"
|
||||
"💡 <b>О белых списках:</b> подписки с белыми списками предназначены "
|
||||
"для использования в условиях полной блокировки. Если полной блокировки нет — "
|
||||
"рекомендуем пользоваться VPN"
|
||||
"</blockquote>"
|
||||
),
|
||||
"",
|
||||
"<i>Если ссылка не открывается автоматически, скопируйте её в клиент вручную.</i>",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
BIN
assets/Thumbs.db
BIN
assets/Thumbs.db
Binary file not shown.
BIN
assets/mai1n.png
Normal file
BIN
assets/mai1n.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
BIN
assets/main.png
BIN
assets/main.png
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.4 MiB |
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
104
tests/test_support_ticket_service.py
Normal file
104
tests/test_support_ticket_service.py
Normal 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"]
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user