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
|
# Пример: 30:250,180:600,365:1000
|
||||||
PAYMENT_PLANS=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
|
PAYMENT_TRANSFER_TEXT=Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN
|
||||||
@@ -172,8 +178,15 @@ PAYMENT_PLAN_TRAFFIC_RESET_PERIOD=NO_RESET
|
|||||||
|
|
||||||
# UUID внутренних групп Remnawave, которые назначаются новому или продлеваемому доступу
|
# UUID внутренних групп Remnawave, которые назначаются новому или продлеваемому доступу
|
||||||
# Несколько значений через запятую
|
# Несколько значений через запятую
|
||||||
|
# Используется старым режимом PAYMENT_PLANS.
|
||||||
PAYMENT_INTERNAL_SQUAD_UUIDS=11111111-1111-1111-1111-111111111111
|
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, если используется
|
# UUID внешней группы Remnawave, если используется
|
||||||
PAYMENT_EXTERNAL_SQUAD_UUID=
|
PAYMENT_EXTERNAL_SQUAD_UUID=
|
||||||
|
|
||||||
|
|||||||
89
README.md
89
README.md
@@ -11,7 +11,7 @@ Telegram-бот на `aiogram 3` с интеграцией в `Remnawave API`,
|
|||||||
- Хранит локальный кэш пользователей и истории подписок в MariaDB
|
- Хранит локальный кэш пользователей и истории подписок в MariaDB
|
||||||
- Поддерживает привязку существующего аккаунта Remnawave по `short_uuid`
|
- Поддерживает привязку существующего аккаунта Remnawave по `short_uuid`
|
||||||
- Показывает профиль, статус подписки, срок и трафик
|
- Показывает профиль, статус подписки, срок и трафик
|
||||||
- Работает с реферальными кодами и скидкой перед оплатой
|
- Работает с реферальными кодами, промокодами и скидкой перед оплатой
|
||||||
- Поддерживает ручную оплату переводом с отправкой чека в бот
|
- Поддерживает ручную оплату переводом с отправкой чека в бот
|
||||||
- Отправляет чек в отдельный Telegram-канал/топик на проверку
|
- Отправляет чек в отдельный Telegram-канал/топик на проверку
|
||||||
- Даёт админу или модератору подтвердить или отклонить оплату
|
- Даёт админу или модератору подтвердить или отклонить оплату
|
||||||
@@ -24,8 +24,8 @@ Telegram-бот на `aiogram 3` с интеграцией в `Remnawave API`,
|
|||||||
Сейчас в проекте не используется Telegram Stars. Оплата работает вручную:
|
Сейчас в проекте не используется Telegram Stars. Оплата работает вручную:
|
||||||
|
|
||||||
1. Пользователь нажимает `Купить подписку`
|
1. Пользователь нажимает `Купить подписку`
|
||||||
2. Бот показывает список тарифов из `PAYMENT_PLANS`
|
2. Бот показывает список тарифов из `PAYMENT_PRODUCT_PLANS`, если они заданы, иначе из `PAYMENT_PLANS`
|
||||||
3. Если у пользователя применён реферальный код, скидка учитывается до выбора тарифа
|
3. Если у пользователя применён реферальный код или промокод, скидка учитывается до выбора тарифа
|
||||||
4. После выбора тарифа бот показывает реквизиты из `PAYMENT_TRANSFER_TEXT`
|
4. После выбора тарифа бот показывает реквизиты из `PAYMENT_TRANSFER_TEXT`
|
||||||
5. Пользователь отправляет чек следующим сообщением в бота
|
5. Пользователь отправляет чек следующим сообщением в бота
|
||||||
6. Бот пересылает чек в review-чат оплаты
|
6. Бот пересылает чек в review-чат оплаты
|
||||||
@@ -34,7 +34,7 @@ Telegram-бот на `aiogram 3` с интеграцией в `Remnawave API`,
|
|||||||
|
|
||||||
### Тарифы
|
### Тарифы
|
||||||
|
|
||||||
Тарифы задаются одной переменной:
|
Обычные тарифы по срокам задаются одной переменной:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
PAYMENT_PLANS=30:250,180:600,365:1000
|
PAYMENT_PLANS=30:250,180:600,365:1000
|
||||||
@@ -51,6 +51,22 @@ PAYMENT_PLANS=30:250,180:600,365:1000
|
|||||||
- 180 дней = 600 ₽
|
- 180 дней = 600 ₽
|
||||||
- 365 дней = 1000 ₽
|
- 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
|
### Логин в Remnawave
|
||||||
|
|
||||||
Логин создаётся в формате:
|
Логин создаётся в формате:
|
||||||
@@ -237,6 +253,12 @@ telegabot/
|
|||||||
- `referral_bonuses`
|
- `referral_bonuses`
|
||||||
- начисленные и ожидающие применения бонусы реферерам
|
- начисленные и ожидающие применения бонусы реферерам
|
||||||
|
|
||||||
|
- `promo_codes`
|
||||||
|
- промокоды, скидка, срок действия и ограничение по тарифу
|
||||||
|
|
||||||
|
- `promo_code_applications`
|
||||||
|
- активный промокод, который пользователь ввёл перед оплатой
|
||||||
|
|
||||||
- `payment_orders`
|
- `payment_orders`
|
||||||
- ручные платежи, статусы и выданные доступы
|
- ручные платежи, статусы и выданные доступы
|
||||||
|
|
||||||
@@ -261,6 +283,26 @@ telegabot/
|
|||||||
- за каждую подтверждённую оплату по рефкоду реферер получает бонус в днях
|
- за каждую подтверждённую оплату по рефкоду реферер получает бонус в днях
|
||||||
- размер бонуса задаётся в `REFERRAL_BONUS_DAYS`
|
- размер бонуса задаётся в `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_USER=oreolvpn
|
||||||
DB_PASSWORD=strong_password
|
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_TRANSFER_TEXT=Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN
|
||||||
PAYMENT_REVIEW_LINK=https://t.me/c/3646494169/56/57
|
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
|
REFERRAL_DISCOUNT_PERCENT=5
|
||||||
PAYMENT_USERNAME_PREFIX=Oreol
|
PAYMENT_USERNAME_PREFIX=Oreol
|
||||||
```
|
```
|
||||||
@@ -521,6 +564,21 @@ DB_PORT=3306
|
|||||||
|
|
||||||
Если вы запускаете проект только в Docker, `DB_HOST` и `DB_PORT` можно не менять вручную.
|
Если вы запускаете проект только в 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`.
|
MariaDB в текущем `docker-compose.yml` не публикуется наружу на хост, чтобы база не торчала во внешний интернет по `3306`. Бот подключается к ней по внутренней Docker-сети через hostname `db`.
|
||||||
|
|
||||||
#### Первый запуск
|
#### Первый запуск
|
||||||
@@ -643,6 +701,23 @@ docker compose up -d
|
|||||||
- не пустые ли `DB_PASSWORD` и `DB_ROOT_PASSWORD`
|
- не пустые ли `DB_PASSWORD` и `DB_ROOT_PASSWORD`
|
||||||
- есть ли у сервиса `db` статус `healthy` в `docker compose ps`
|
- есть ли у сервиса `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.
|
Если хотите открыть MariaDB наружу для внешнего клиента, это нужно делать осознанно: добавьте `ports` обратно в сервис `db` и отдельно ограничьте доступ через firewall или private network.
|
||||||
|
|
||||||
#### Минимальный сценарий запуска в Docker
|
#### Минимальный сценарий запуска в Docker
|
||||||
@@ -778,7 +853,7 @@ OperationalError: (1049, "Unknown database '...'" )
|
|||||||
6. Проверить руками:
|
6. Проверить руками:
|
||||||
- `/start`
|
- `/start`
|
||||||
- открытие раздела подписки через кнопку в панели
|
- открытие раздела подписки через кнопку в панели
|
||||||
- ввод реферального кода
|
- ввод реферального кода или промокода
|
||||||
- отправку чека
|
- отправку чека
|
||||||
- подтверждение оплаты модератором
|
- подтверждение оплаты модератором
|
||||||
- выдачу ссылки пользователю
|
- выдачу ссылки пользователю
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -202,6 +202,14 @@ def build_panel_keyboard(
|
|||||||
text="👤 Пользователи",
|
text="👤 Пользователи",
|
||||||
callback_data=_admin_callback("users"),
|
callback_data=_admin_callback("users"),
|
||||||
)
|
)
|
||||||
|
builder.button(
|
||||||
|
text="🗂 История тикетов",
|
||||||
|
callback_data=_admin_callback("tickets"),
|
||||||
|
)
|
||||||
|
builder.button(
|
||||||
|
text="🎟 Промокоды",
|
||||||
|
callback_data=_admin_callback("promos"),
|
||||||
|
)
|
||||||
builder.button(
|
builder.button(
|
||||||
text="🔄 Полная синхронизация",
|
text="🔄 Полная синхронизация",
|
||||||
callback_data=_admin_callback("sync_all"),
|
callback_data=_admin_callback("sync_all"),
|
||||||
@@ -213,13 +221,13 @@ def build_panel_keyboard(
|
|||||||
if payment_review_url.strip():
|
if payment_review_url.strip():
|
||||||
builder.button(text="💸 Очередь оплат", url=payment_review_url.strip())
|
builder.button(text="💸 Очередь оплат", url=payment_review_url.strip())
|
||||||
if support_ticket_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(
|
builder.button(
|
||||||
text="↻ Обновить",
|
text="↻ Обновить",
|
||||||
callback_data=_callback(PANEL_SECTION_ADMIN, refresh=True),
|
callback_data=_callback(PANEL_SECTION_ADMIN, refresh=True),
|
||||||
)
|
)
|
||||||
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
|
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()
|
return builder.as_markup()
|
||||||
|
|
||||||
if section == PANEL_SECTION_SUBSCRIPTION:
|
if section == PANEL_SECTION_SUBSCRIPTION:
|
||||||
@@ -248,7 +256,7 @@ def build_panel_keyboard(
|
|||||||
url=context.referral_share_url,
|
url=context.referral_share_url,
|
||||||
)
|
)
|
||||||
builder.button(
|
builder.button(
|
||||||
text="🎟 Ввести реферальный код",
|
text="🎟 Ввести код скидки",
|
||||||
callback_data=_referral_callback("apply"),
|
callback_data=_referral_callback("apply"),
|
||||||
)
|
)
|
||||||
builder.button(text="🏠 Главное меню", callback_data=_callback(PANEL_SECTION_HOME))
|
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
|
code: str
|
||||||
days: int
|
days: int
|
||||||
amount_rub: 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):
|
class Settings(BaseSettings):
|
||||||
@@ -78,6 +100,10 @@ class Settings(BaseSettings):
|
|||||||
default="30:250,180:600,365:1000",
|
default="30:250,180:600,365:1000",
|
||||||
validation_alias="PAYMENT_PLANS",
|
validation_alias="PAYMENT_PLANS",
|
||||||
)
|
)
|
||||||
|
payment_product_plans_raw: str = Field(
|
||||||
|
default="",
|
||||||
|
validation_alias="PAYMENT_PRODUCT_PLANS",
|
||||||
|
)
|
||||||
payment_transfer_text: str = Field(
|
payment_transfer_text: str = Field(
|
||||||
default="Переведите оплату по указанным реквизитам и отправьте чек в бот.",
|
default="Переведите оплату по указанным реквизитам и отправьте чек в бот.",
|
||||||
validation_alias="PAYMENT_TRANSFER_TEXT",
|
validation_alias="PAYMENT_TRANSFER_TEXT",
|
||||||
@@ -125,6 +151,14 @@ class Settings(BaseSettings):
|
|||||||
default="",
|
default="",
|
||||||
validation_alias="PAYMENT_INTERNAL_SQUAD_UUIDS",
|
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(
|
payment_external_squad_uuid: str = Field(
|
||||||
default="",
|
default="",
|
||||||
validation_alias="PAYMENT_EXTERNAL_SQUAD_UUID",
|
validation_alias="PAYMENT_EXTERNAL_SQUAD_UUID",
|
||||||
@@ -237,12 +271,15 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def payment_internal_squad_uuids(self) -> list[str]:
|
def payment_internal_squad_uuids(self) -> list[str]:
|
||||||
values: list[str] = []
|
return self._parse_csv_list(self.payment_internal_squad_uuids_raw)
|
||||||
for raw_part in self.payment_internal_squad_uuids_raw.split(","):
|
|
||||||
part = raw_part.strip()
|
@cached_property
|
||||||
if part:
|
def payment_vpn_squad_uuids(self) -> list[str]:
|
||||||
values.append(part)
|
return self._parse_csv_list(self.payment_vpn_squad_uuids_raw)
|
||||||
return values
|
|
||||||
|
@cached_property
|
||||||
|
def payment_white_squad_uuids(self) -> list[str]:
|
||||||
|
return self._parse_csv_list(self.payment_white_squad_uuids_raw)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def payment_external_squad_uuid_normalized(self) -> str | None:
|
def payment_external_squad_uuid_normalized(self) -> str | None:
|
||||||
@@ -265,6 +302,13 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def payment_plans(self) -> list[PaymentPlanSettings]:
|
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] = []
|
values: list[PaymentPlanSettings] = []
|
||||||
for raw_part in self.payment_plans_raw.split(","):
|
for raw_part in self.payment_plans_raw.split(","):
|
||||||
part = raw_part.strip()
|
part = raw_part.strip()
|
||||||
@@ -303,3 +347,77 @@ class Settings(BaseSettings):
|
|||||||
]
|
]
|
||||||
|
|
||||||
return []
|
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)
|
language_code: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||||
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
is_blocked: 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)
|
last_seen_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||||
created_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)
|
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)
|
support_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
|
||||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="OPEN")
|
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)
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=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_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_username: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
invited_display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
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)
|
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)
|
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):
|
class ReferralBonus(Base):
|
||||||
__tablename__ = "referral_bonuses"
|
__tablename__ = "referral_bonuses"
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ logger = logging.getLogger(__name__)
|
|||||||
PAYMENT_ORDERS_TABLE_NAME = "payment_orders"
|
PAYMENT_ORDERS_TABLE_NAME = "payment_orders"
|
||||||
PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN = "provision_username"
|
PAYMENT_ORDERS_PROVISION_USERNAME_COLUMN = "provision_username"
|
||||||
PAYMENT_ORDERS_PROVISION_USERNAME_INDEX = "ix_payment_orders_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(
|
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(
|
async def ensure_database_exists(
|
||||||
server_url: str,
|
server_url: str,
|
||||||
database_name: str,
|
database_name: str,
|
||||||
@@ -146,7 +203,60 @@ async def ensure_database_exists(
|
|||||||
await engine.dispose()
|
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 def init_db(engine: AsyncEngine) -> None:
|
||||||
async with engine.begin() as connection:
|
async with engine.begin() as connection:
|
||||||
await connection.run_sync(Base.metadata.create_all)
|
await connection.run_sync(Base.metadata.create_all)
|
||||||
await _normalize_payment_order_provision_username_index(connection)
|
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 import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
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.base import utcnow
|
||||||
from app.db.models import BotSetting
|
from app.db.models import BotSetting
|
||||||
|
|
||||||
@@ -34,11 +34,14 @@ class BotConfigSnapshot:
|
|||||||
referral_discount_percent: int
|
referral_discount_percent: int
|
||||||
referral_bonus_days: int
|
referral_bonus_days: int
|
||||||
payment_plans_raw: str
|
payment_plans_raw: str
|
||||||
|
payment_product_plans_raw: str
|
||||||
payment_transfer_text: str
|
payment_transfer_text: str
|
||||||
payment_support_text: str
|
payment_support_text: str
|
||||||
payment_plan_traffic_limit_gb: int
|
payment_plan_traffic_limit_gb: int
|
||||||
payment_plan_traffic_reset_period: str
|
payment_plan_traffic_reset_period: str
|
||||||
payment_internal_squad_uuids_raw: 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_external_squad_uuid: str
|
||||||
payment_username_prefix: str
|
payment_username_prefix: str
|
||||||
payment_user_tag: str
|
payment_user_tag: str
|
||||||
@@ -75,12 +78,15 @@ class BotConfigSnapshot:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def payment_internal_squad_uuids(self) -> list[str]:
|
def payment_internal_squad_uuids(self) -> list[str]:
|
||||||
values: list[str] = []
|
return self._parse_csv_list(self.payment_internal_squad_uuids_raw)
|
||||||
for raw_part in self.payment_internal_squad_uuids_raw.split(","):
|
|
||||||
part = raw_part.strip()
|
@property
|
||||||
if part:
|
def payment_vpn_squad_uuids(self) -> list[str]:
|
||||||
values.append(part)
|
return self._parse_csv_list(self.payment_vpn_squad_uuids_raw)
|
||||||
return values
|
|
||||||
|
@property
|
||||||
|
def payment_white_squad_uuids(self) -> list[str]:
|
||||||
|
return self._parse_csv_list(self.payment_white_squad_uuids_raw)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def notification_days_list(self) -> list[int]:
|
def notification_days_list(self) -> list[int]:
|
||||||
@@ -95,6 +101,13 @@ class BotConfigSnapshot:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def payment_plans(self) -> list[PaymentPlanSettings]:
|
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] = []
|
values: list[PaymentPlanSettings] = []
|
||||||
for raw_part in self.payment_plans_raw.split(","):
|
for raw_part in self.payment_plans_raw.split(","):
|
||||||
part = raw_part.strip()
|
part = raw_part.strip()
|
||||||
@@ -120,6 +133,80 @@ class BotConfigSnapshot:
|
|||||||
|
|
||||||
return sorted(values, key=lambda item: item.days)
|
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
|
@staticmethod
|
||||||
def _compact_text(raw_value: str, *, max_length: int = 80) -> str:
|
def _compact_text(raw_value: str, *, max_length: int = 80) -> str:
|
||||||
normalized = " ".join(raw_value.split()).strip()
|
normalized = " ".join(raw_value.split()).strip()
|
||||||
@@ -176,6 +263,7 @@ class StaticBotConfigService:
|
|||||||
referral_discount_percent=self._settings.referral_discount_percent,
|
referral_discount_percent=self._settings.referral_discount_percent,
|
||||||
referral_bonus_days=self._settings.referral_bonus_days,
|
referral_bonus_days=self._settings.referral_bonus_days,
|
||||||
payment_plans_raw=self._settings.payment_plans_raw,
|
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_transfer_text=self._settings.payment_transfer_text,
|
||||||
payment_support_text=self._settings.payment_support_text,
|
payment_support_text=self._settings.payment_support_text,
|
||||||
payment_plan_traffic_limit_gb=self._settings.payment_plan_traffic_limit_gb,
|
payment_plan_traffic_limit_gb=self._settings.payment_plan_traffic_limit_gb,
|
||||||
@@ -184,6 +272,8 @@ class StaticBotConfigService:
|
|||||||
notification_days_before="1,3",
|
notification_days_before="1,3",
|
||||||
notification_check_interval_hours=6,
|
notification_check_interval_hours=6,
|
||||||
payment_internal_squad_uuids_raw=self._settings.payment_internal_squad_uuids_raw,
|
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_external_squad_uuid=self._settings.payment_external_squad_uuid,
|
||||||
payment_username_prefix=self._settings.payment_username_prefix,
|
payment_username_prefix=self._settings.payment_username_prefix,
|
||||||
payment_user_tag=self._settings.payment_user_tag,
|
payment_user_tag=self._settings.payment_user_tag,
|
||||||
@@ -233,9 +323,24 @@ class BotConfigService(StaticBotConfigService):
|
|||||||
label="Тарифы и цены",
|
label="Тарифы и цены",
|
||||||
section="pricing",
|
section="pricing",
|
||||||
prompt="Введите тарифы в формате `30:250,180:600,365:1000`.",
|
prompt="Введите тарифы в формате `30:250,180:600,365:1000`.",
|
||||||
description="Каждая пара — это `дни:цена_в_рублях`.",
|
description="Каждая пара - это `дни:цена_в_рублях`.",
|
||||||
placeholder="30:250,180:600,365:1000",
|
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(
|
"payment_plan_traffic_limit_gb": BotConfigFieldSpec(
|
||||||
key="payment_plan_traffic_limit_gb",
|
key="payment_plan_traffic_limit_gb",
|
||||||
label="Лимит трафика, GB",
|
label="Лимит трафика, GB",
|
||||||
@@ -332,6 +437,22 @@ class BotConfigService(StaticBotConfigService):
|
|||||||
description="Эти группы назначаются пользователю в Remnawave.",
|
description="Эти группы назначаются пользователю в Remnawave.",
|
||||||
placeholder="11111111-1111-1111-1111-111111111111",
|
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(
|
"payment_external_squad_uuid": BotConfigFieldSpec(
|
||||||
key="payment_external_squad_uuid",
|
key="payment_external_squad_uuid",
|
||||||
label="UUID внешней группы",
|
label="UUID внешней группы",
|
||||||
@@ -429,6 +550,12 @@ class BotConfigService(StaticBotConfigService):
|
|||||||
default=self._settings.referral_bonus_days,
|
default=self._settings.referral_bonus_days,
|
||||||
),
|
),
|
||||||
payment_plans_raw=str(overrides.get("payment_plans_raw", self._settings.payment_plans_raw)),
|
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_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_support_text=str(overrides.get("payment_support_text", self._settings.payment_support_text)),
|
||||||
payment_plan_traffic_limit_gb=self._parse_int(
|
payment_plan_traffic_limit_gb=self._parse_int(
|
||||||
@@ -447,6 +574,18 @@ class BotConfigService(StaticBotConfigService):
|
|||||||
self._settings.payment_internal_squad_uuids_raw,
|
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(
|
payment_external_squad_uuid=str(
|
||||||
overrides.get(
|
overrides.get(
|
||||||
"payment_external_squad_uuid",
|
"payment_external_squad_uuid",
|
||||||
@@ -533,11 +672,17 @@ class BotConfigService(StaticBotConfigService):
|
|||||||
"bot_support_ticket_link",
|
"bot_support_ticket_link",
|
||||||
"payment_review_link",
|
"payment_review_link",
|
||||||
"bot_start_image_path",
|
"bot_start_image_path",
|
||||||
|
"payment_product_plans_raw",
|
||||||
|
"payment_vpn_squad_uuids_raw",
|
||||||
|
"payment_white_squad_uuids_raw",
|
||||||
"payment_external_squad_uuid",
|
"payment_external_squad_uuid",
|
||||||
"payment_user_tag",
|
"payment_user_tag",
|
||||||
"payment_username_prefix",
|
"payment_username_prefix",
|
||||||
"payment_internal_squad_uuids_raw",
|
"payment_internal_squad_uuids_raw",
|
||||||
|
"payment_vpn_squad_uuids_raw",
|
||||||
|
"payment_white_squad_uuids_raw",
|
||||||
"payment_plans_raw",
|
"payment_plans_raw",
|
||||||
|
"payment_product_plans_raw",
|
||||||
"payment_plan_traffic_reset_period",
|
"payment_plan_traffic_reset_period",
|
||||||
"bot_brand_name",
|
"bot_brand_name",
|
||||||
"notification_days_before",
|
"notification_days_before",
|
||||||
@@ -611,6 +756,20 @@ class BotConfigService(StaticBotConfigService):
|
|||||||
plans = self._parse_payment_plans(value)
|
plans = self._parse_payment_plans(value)
|
||||||
return ",".join(f"{plan.days}:{plan.amount_rub}" for plan in plans)
|
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":
|
if key == "payment_plan_traffic_limit_gb":
|
||||||
amount = self._parse_non_negative_int(value, "Лимит трафика")
|
amount = self._parse_non_negative_int(value, "Лимит трафика")
|
||||||
return str(amount)
|
return str(amount)
|
||||||
@@ -652,6 +811,12 @@ class BotConfigService(StaticBotConfigService):
|
|||||||
raise ValueError("Нужно указать хотя бы один UUID внутренней группы.")
|
raise ValueError("Нужно указать хотя бы один UUID внутренней группы.")
|
||||||
return ",".join(normalized_parts)
|
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":
|
if key == "payment_external_squad_uuid":
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@@ -711,6 +876,75 @@ class BotConfigService(StaticBotConfigService):
|
|||||||
raise ValueError(f"{label} должен быть целым неотрицательным числом.")
|
raise ValueError(f"{label} должен быть целым неотрицательным числом.")
|
||||||
return int(raw_value.strip())
|
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
|
@staticmethod
|
||||||
def _parse_payment_plans(raw_value: str) -> list[PaymentPlanSettings]:
|
def _parse_payment_plans(raw_value: str) -> list[PaymentPlanSettings]:
|
||||||
values: list[PaymentPlanSettings] = []
|
values: list[PaymentPlanSettings] = []
|
||||||
|
|||||||
@@ -31,9 +31,31 @@ REFERRAL_BONUS_APPLIED_STATUS = "APPLIED"
|
|||||||
logger = logging.getLogger(__name__)
|
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)
|
@dataclass(slots=True)
|
||||||
class PaymentPlan:
|
class PaymentPlan:
|
||||||
code: str
|
code: str
|
||||||
|
label: str
|
||||||
title: str
|
title: str
|
||||||
description: str
|
description: str
|
||||||
duration_days: int
|
duration_days: int
|
||||||
@@ -41,6 +63,9 @@ class PaymentPlan:
|
|||||||
discount_percent: int
|
discount_percent: int
|
||||||
original_amount_rub: int
|
original_amount_rub: int
|
||||||
applied_referral_code: str = ""
|
applied_referral_code: str = ""
|
||||||
|
discount_code: str = ""
|
||||||
|
discount_source: str = ""
|
||||||
|
discount_scope_plan_code: str | None = None
|
||||||
traffic_limit_bytes: int = 0
|
traffic_limit_bytes: int = 0
|
||||||
traffic_limit_strategy: str = "NO_RESET"
|
traffic_limit_strategy: str = "NO_RESET"
|
||||||
internal_squad_uuids: list[str] | None = None
|
internal_squad_uuids: list[str] | None = None
|
||||||
@@ -130,14 +155,14 @@ class PaymentService:
|
|||||||
|
|
||||||
async def get_available_plans(self, *, telegram_id: int) -> list[PaymentPlan]:
|
async def get_available_plans(self, *, telegram_id: int) -> list[PaymentPlan]:
|
||||||
config = await self._get_config()
|
config = await self._get_config()
|
||||||
applied_referral_code = await self._get_applied_referral_code(
|
applied_discount = await self._get_applied_discount(
|
||||||
telegram_id,
|
telegram_id,
|
||||||
config=config,
|
config=config,
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
self._build_plan(
|
self._build_plan(
|
||||||
plan,
|
plan,
|
||||||
applied_referral_code=applied_referral_code,
|
applied_discount=applied_discount,
|
||||||
config=config,
|
config=config,
|
||||||
)
|
)
|
||||||
for plan in config.payment_plans
|
for plan in config.payment_plans
|
||||||
@@ -154,13 +179,13 @@ class PaymentService:
|
|||||||
plan_code: str,
|
plan_code: str,
|
||||||
) -> CreatedPaymentOrder:
|
) -> CreatedPaymentOrder:
|
||||||
config = await self._get_config()
|
config = await self._get_config()
|
||||||
applied_referral_code = await self._get_applied_referral_code(
|
applied_discount = await self._get_applied_discount(
|
||||||
telegram_id,
|
telegram_id,
|
||||||
config=config,
|
config=config,
|
||||||
)
|
)
|
||||||
plan = self._get_plan_by_code(
|
plan = self._get_plan_by_code(
|
||||||
plan_code,
|
plan_code,
|
||||||
applied_referral_code=applied_referral_code,
|
applied_discount=applied_discount,
|
||||||
config=config,
|
config=config,
|
||||||
)
|
)
|
||||||
if not plan.is_ready:
|
if not plan.is_ready:
|
||||||
@@ -203,7 +228,7 @@ class PaymentService:
|
|||||||
status=PENDING_PAYMENT_STATUS,
|
status=PENDING_PAYMENT_STATUS,
|
||||||
invoice_payload=invoice_payload,
|
invoice_payload=invoice_payload,
|
||||||
provision_username=provision_username,
|
provision_username=provision_username,
|
||||||
error_message=applied_referral_code or None,
|
error_message=plan.discount_code or None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -293,10 +318,15 @@ class PaymentService:
|
|||||||
|
|
||||||
async def approve_order(self, *, order_uuid: str) -> IssuedAccess:
|
async def approve_order(self, *, order_uuid: str) -> IssuedAccess:
|
||||||
order = await self._claim_order_for_approval(order_uuid)
|
order = await self._claim_order_for_approval(order_uuid)
|
||||||
config = await self._get_config()
|
|
||||||
is_renewal = False
|
is_renewal = False
|
||||||
|
|
||||||
try:
|
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
|
remote_user: RemnawaveUser | None = None
|
||||||
if order.remnawave_user_uuid:
|
if order.remnawave_user_uuid:
|
||||||
try:
|
try:
|
||||||
@@ -324,19 +354,39 @@ class PaymentService:
|
|||||||
remote_user = self._select_referral_bonus_target(remote_users)
|
remote_user = self._select_referral_bonus_target(remote_users)
|
||||||
|
|
||||||
if remote_user is None:
|
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:
|
else:
|
||||||
is_renewal = True
|
is_renewal = True
|
||||||
remote_user = await self._extend_remnawave_user(
|
remote_user = await self._extend_remnawave_user(
|
||||||
order,
|
order,
|
||||||
remote_user,
|
remote_user,
|
||||||
|
plan=plan,
|
||||||
config=config,
|
config=config,
|
||||||
)
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
await self._restore_review_after_failed_approval(order.order_uuid, str(exc))
|
||||||
|
raise
|
||||||
except RemnawaveApiError as exc:
|
except RemnawaveApiError as exc:
|
||||||
await self._restore_review_after_failed_approval(order.order_uuid, exc.message)
|
await self._restore_review_after_failed_approval(order.order_uuid, exc.message)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
await self._save_fulfilled_order(order.order_uuid, remote_user)
|
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
|
own_bonus = None
|
||||||
try:
|
try:
|
||||||
own_bonus = await self.apply_pending_referral_bonuses(
|
own_bonus = await self.apply_pending_referral_bonuses(
|
||||||
@@ -422,9 +472,14 @@ class PaymentService:
|
|||||||
"",
|
"",
|
||||||
f"Сумма к переводу: <b>{order.plan.amount_rub} ₽</b>",
|
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(
|
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>)"
|
f"(<b>-{order.plan.discount_percent}%</b>)"
|
||||||
)
|
)
|
||||||
if order.extends_existing_access:
|
if order.extends_existing_access:
|
||||||
@@ -458,14 +513,14 @@ class PaymentService:
|
|||||||
self,
|
self,
|
||||||
plan_code: str,
|
plan_code: str,
|
||||||
*,
|
*,
|
||||||
applied_referral_code: str,
|
applied_discount: AppliedDiscount,
|
||||||
config: BotConfigSnapshot,
|
config: BotConfigSnapshot,
|
||||||
) -> PaymentPlan:
|
) -> PaymentPlan:
|
||||||
for plan in config.payment_plans:
|
for plan in config.payment_plans:
|
||||||
if plan.code == plan_code:
|
if plan.code == plan_code:
|
||||||
return self._build_plan(
|
return self._build_plan(
|
||||||
plan,
|
plan,
|
||||||
applied_referral_code=applied_referral_code,
|
applied_discount=applied_discount,
|
||||||
config=config,
|
config=config,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -475,10 +530,15 @@ class PaymentService:
|
|||||||
self,
|
self,
|
||||||
plan_settings: PaymentPlanSettings,
|
plan_settings: PaymentPlanSettings,
|
||||||
*,
|
*,
|
||||||
applied_referral_code: str,
|
applied_discount: AppliedDiscount,
|
||||||
config: BotConfigSnapshot,
|
config: BotConfigSnapshot,
|
||||||
) -> PaymentPlan:
|
) -> 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(
|
discounted_amount = self._apply_discount(
|
||||||
amount_rub=plan_settings.amount_rub,
|
amount_rub=plan_settings.amount_rub,
|
||||||
discount_percent=discount_percent,
|
discount_percent=discount_percent,
|
||||||
@@ -486,36 +546,105 @@ class PaymentService:
|
|||||||
traffic_limit_bytes = max(config.payment_plan_traffic_limit_gb, 0) * 1024**3
|
traffic_limit_bytes = max(config.payment_plan_traffic_limit_gb, 0) * 1024**3
|
||||||
return PaymentPlan(
|
return PaymentPlan(
|
||||||
code=plan_settings.code,
|
code=plan_settings.code,
|
||||||
title=f"{config.bot_brand_name} на {plan_settings.days} дней",
|
label=plan_settings.title or f"{plan_settings.days} дней",
|
||||||
description=f"Доступ к VPN на {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,
|
duration_days=plan_settings.days,
|
||||||
amount_rub=discounted_amount,
|
amount_rub=discounted_amount,
|
||||||
discount_percent=discount_percent,
|
discount_percent=discount_percent,
|
||||||
original_amount_rub=plan_settings.amount_rub,
|
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_bytes=traffic_limit_bytes,
|
||||||
traffic_limit_strategy=config.payment_plan_traffic_reset_period.strip() or "NO_RESET",
|
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,
|
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
|
@staticmethod
|
||||||
def _apply_discount(*, amount_rub: int, discount_percent: int) -> int:
|
def _apply_discount(*, amount_rub: int, discount_percent: int) -> int:
|
||||||
if discount_percent <= 0:
|
if discount_percent <= 0:
|
||||||
return amount_rub
|
return amount_rub
|
||||||
return max(1, ceil(amount_rub * (100 - discount_percent) / 100))
|
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,
|
self,
|
||||||
telegram_id: int,
|
telegram_id: int,
|
||||||
*,
|
*,
|
||||||
config: BotConfigSnapshot | None = None,
|
config: BotConfigSnapshot | None = None,
|
||||||
) -> str:
|
) -> AppliedDiscount:
|
||||||
resolved_config = config or await self._get_config()
|
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:
|
if not resolved_config.referral_enabled:
|
||||||
return ""
|
return AppliedDiscount()
|
||||||
|
|
||||||
summary = await self._sync_service.get_referral_summary(telegram_id)
|
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(
|
async def apply_pending_referral_bonuses(
|
||||||
self,
|
self,
|
||||||
@@ -655,6 +784,7 @@ class PaymentService:
|
|||||||
self,
|
self,
|
||||||
order: PaymentOrder,
|
order: PaymentOrder,
|
||||||
*,
|
*,
|
||||||
|
plan: PaymentPlan,
|
||||||
config: BotConfigSnapshot,
|
config: BotConfigSnapshot,
|
||||||
) -> RemnawaveUser:
|
) -> RemnawaveUser:
|
||||||
expire_at = datetime.now(timezone.utc) + timedelta(days=order.plan_duration_days)
|
expire_at = datetime.now(timezone.utc) + timedelta(days=order.plan_duration_days)
|
||||||
@@ -665,7 +795,7 @@ class PaymentService:
|
|||||||
"trafficLimitStrategy": order.traffic_limit_strategy,
|
"trafficLimitStrategy": order.traffic_limit_strategy,
|
||||||
"telegramId": order.telegram_id,
|
"telegramId": order.telegram_id,
|
||||||
"description": f"Created by Telegram bot manual payment {order.order_uuid}",
|
"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:
|
if config.payment_user_tag_normalized:
|
||||||
body["tag"] = config.payment_user_tag_normalized
|
body["tag"] = config.payment_user_tag_normalized
|
||||||
@@ -678,6 +808,7 @@ class PaymentService:
|
|||||||
order: PaymentOrder,
|
order: PaymentOrder,
|
||||||
remote_user: RemnawaveUser,
|
remote_user: RemnawaveUser,
|
||||||
*,
|
*,
|
||||||
|
plan: PaymentPlan,
|
||||||
config: BotConfigSnapshot,
|
config: BotConfigSnapshot,
|
||||||
) -> RemnawaveUser:
|
) -> RemnawaveUser:
|
||||||
current_expire_at = remote_user.expire_at
|
current_expire_at = remote_user.expire_at
|
||||||
@@ -699,7 +830,7 @@ class PaymentService:
|
|||||||
"trafficLimitStrategy": order.traffic_limit_strategy,
|
"trafficLimitStrategy": order.traffic_limit_strategy,
|
||||||
"telegramId": order.telegram_id,
|
"telegramId": order.telegram_id,
|
||||||
"description": f"Updated by Telegram bot manual payment {order.order_uuid}",
|
"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:
|
if config.payment_user_tag_normalized:
|
||||||
body["tag"] = config.payment_user_tag_normalized
|
body["tag"] = config.payment_user_tag_normalized
|
||||||
|
|||||||
@@ -1,11 +1,41 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from app.db.base import utcnow
|
from app.db.base import utcnow
|
||||||
from app.db.models import SupportTicket
|
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:
|
class SupportTicketService:
|
||||||
def __init__(self, *, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
def __init__(self, *, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||||
@@ -33,13 +63,17 @@ class SupportTicketService:
|
|||||||
support_chat_id=support_chat_id,
|
support_chat_id=support_chat_id,
|
||||||
support_thread_id=support_thread_id,
|
support_thread_id=support_thread_id,
|
||||||
support_message_id=support_message_id,
|
support_message_id=support_message_id,
|
||||||
status="OPEN",
|
status=OPEN_SUPPORT_TICKET_STATUS,
|
||||||
)
|
)
|
||||||
session.add(ticket)
|
session.add(ticket)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(ticket)
|
await session.refresh(ticket)
|
||||||
return 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(
|
async def get_ticket_by_support_message(
|
||||||
self,
|
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:
|
async with self._session_factory() as session:
|
||||||
ticket = await session.get(SupportTicket, ticket_id)
|
ticket = await session.get(SupportTicket, ticket_id)
|
||||||
if ticket is None:
|
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()
|
ticket.updated_at = utcnow()
|
||||||
await session.commit()
|
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 def get_open_tickets_count(self) -> int:
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
total = await session.scalar(
|
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)
|
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 dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
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 sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.db.base import utcnow
|
from app.db.base import utcnow
|
||||||
from app.db.models import (
|
from app.db.models import (
|
||||||
|
InternalSquad,
|
||||||
|
PromoCode,
|
||||||
|
PromoCodeApplication,
|
||||||
ReferralCode,
|
ReferralCode,
|
||||||
ReferralInvite,
|
ReferralInvite,
|
||||||
InternalSquad,
|
|
||||||
RemnawaveUser,
|
RemnawaveUser,
|
||||||
RemnawaveUserInternalSquad,
|
RemnawaveUserInternalSquad,
|
||||||
SubscriptionRequestLog,
|
SubscriptionRequestLog,
|
||||||
@@ -36,9 +38,33 @@ class ReferralSummary:
|
|||||||
total_invited: int = 0
|
total_invited: int = 0
|
||||||
referral_code: str = ""
|
referral_code: str = ""
|
||||||
applied_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)
|
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)
|
@dataclass(slots=True)
|
||||||
class AdminStats:
|
class AdminStats:
|
||||||
total_telegram_users: int = 0
|
total_telegram_users: int = 0
|
||||||
@@ -174,6 +200,124 @@ class SyncService:
|
|||||||
await self._get_or_create_referral_code(session, telegram_id=telegram_id)
|
await self._get_or_create_referral_code(session, telegram_id=telegram_id)
|
||||||
await session.commit()
|
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(
|
async def apply_referral_code(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -193,8 +337,28 @@ class SyncService:
|
|||||||
last_name=last_name,
|
last_name=last_name,
|
||||||
language_code=language_code,
|
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)
|
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(
|
existing = await session.scalar(
|
||||||
select(ReferralInvite).where(
|
select(ReferralInvite).where(
|
||||||
ReferralInvite.invited_telegram_id == telegram_id
|
ReferralInvite.invited_telegram_id == telegram_id
|
||||||
@@ -217,6 +381,11 @@ class SyncService:
|
|||||||
if not invite_created:
|
if not invite_created:
|
||||||
raise ValueError("Не удалось применить реферальный код.")
|
raise ValueError("Не удалось применить реферальный код.")
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
delete(PromoCodeApplication).where(
|
||||||
|
PromoCodeApplication.telegram_id == telegram_id
|
||||||
|
)
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return referral_code.strip().upper()
|
return referral_code.strip().upper()
|
||||||
|
|
||||||
@@ -247,11 +416,21 @@ class SyncService:
|
|||||||
)
|
)
|
||||||
.where(ReferralInvite.invited_telegram_id == telegram_id)
|
.where(ReferralInvite.invited_telegram_id == telegram_id)
|
||||||
)
|
)
|
||||||
|
applied_promo = await self._get_active_promo_for_user(
|
||||||
|
session,
|
||||||
|
telegram_id=telegram_id,
|
||||||
|
)
|
||||||
|
|
||||||
return ReferralSummary(
|
return ReferralSummary(
|
||||||
total_invited=int(total_invited or 0),
|
total_invited=int(total_invited or 0),
|
||||||
referral_code=referral_code,
|
referral_code=referral_code,
|
||||||
applied_referral_code=applied_referral_code or "",
|
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,
|
recent_names=recent_names,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -623,6 +802,11 @@ class SyncService:
|
|||||||
remote_user: RemoteRemnawaveUser,
|
remote_user: RemoteRemnawaveUser,
|
||||||
owner: TelegramUser | None,
|
owner: TelegramUser | None,
|
||||||
) -> RemnawaveUser:
|
) -> 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(
|
record = await session.scalar(
|
||||||
select(RemnawaveUser).where(RemnawaveUser.rw_uuid == str(remote_user.uuid))
|
select(RemnawaveUser).where(RemnawaveUser.rw_uuid == str(remote_user.uuid))
|
||||||
)
|
)
|
||||||
@@ -631,8 +815,6 @@ class SyncService:
|
|||||||
record = RemnawaveUser(rw_uuid=str(remote_user.uuid))
|
record = RemnawaveUser(rw_uuid=str(remote_user.uuid))
|
||||||
session.add(record)
|
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.owner_telegram_user_id = owner_id
|
||||||
record.rw_id = remote_user.id
|
record.rw_id = remote_user.id
|
||||||
record.short_uuid = remote_user.short_uuid
|
record.short_uuid = remote_user.short_uuid
|
||||||
@@ -781,6 +963,62 @@ class SyncService:
|
|||||||
select(ReferralCode.telegram_id).where(ReferralCode.code == raw_code)
|
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:
|
async def _find_owner_id(self, session: AsyncSession, telegram_id: int | None) -> int | None:
|
||||||
if telegram_id is None:
|
if telegram_id is None:
|
||||||
return None
|
return None
|
||||||
@@ -801,6 +1039,40 @@ class SyncService:
|
|||||||
def _maybe_str(value: object | None) -> str | None:
|
def _maybe_str(value: object | None) -> str | None:
|
||||||
return str(value) if value is not None else 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
|
@staticmethod
|
||||||
def _build_views(rows: list[tuple[RemnawaveUser, str | None]]) -> list[CachedUserView]:
|
def _build_views(rows: list[tuple[RemnawaveUser, str | None]]) -> list[CachedUserView]:
|
||||||
view_map: dict[int, CachedUserView] = {}
|
view_map: dict[int, CachedUserView] = {}
|
||||||
|
|||||||
@@ -132,6 +132,14 @@ def format_issued_access(
|
|||||||
"2. Импортируйте подписку или ключ в приложение VPN-клиента.",
|
"2. Импортируйте подписку или ключ в приложение VPN-клиента.",
|
||||||
"3. Управлять доступом и продлевать его можно в панели бота.",
|
"3. Управлять доступом и продлевать его можно в панели бота.",
|
||||||
"",
|
"",
|
||||||
|
(
|
||||||
|
"<blockquote>"
|
||||||
|
"💡 <b>О белых списках:</b> подписки с белыми списками предназначены "
|
||||||
|
"для использования в условиях полной блокировки. Если полной блокировки нет — "
|
||||||
|
"рекомендуем пользоваться VPN"
|
||||||
|
"</blockquote>"
|
||||||
|
),
|
||||||
|
"",
|
||||||
"<i>Если ссылка не открывается автоматически, скопируйте её в клиент вручную.</i>",
|
"<i>Если ссылка не открывается автоматически, скопируйте её в клиент вручную.</i>",
|
||||||
]
|
]
|
||||||
return "\n".join(lines)
|
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,
|
language_code VARCHAR(16) NULL,
|
||||||
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
|
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
is_blocked 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,
|
last_seen_at DATETIME NOT NULL,
|
||||||
created_at DATETIME NOT NULL,
|
created_at DATETIME NOT NULL,
|
||||||
updated_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_telegram_id BIGINT NOT NULL UNIQUE,
|
||||||
invited_username VARCHAR(64) NULL,
|
invited_username VARCHAR(64) NULL,
|
||||||
invited_display_name VARCHAR(255) NOT NULL,
|
invited_display_name VARCHAR(255) NOT NULL,
|
||||||
|
discount_used BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
created_at DATETIME NOT NULL,
|
created_at DATETIME NOT NULL,
|
||||||
INDEX ix_referral_invites_inviter_telegram_id (inviter_telegram_id),
|
INDEX ix_referral_invites_inviter_telegram_id (inviter_telegram_id),
|
||||||
INDEX ix_referral_invites_invited_telegram_id (invited_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)
|
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 (
|
CREATE TABLE IF NOT EXISTS payment_orders (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
order_uuid CHAR(36) NOT NULL UNIQUE,
|
order_uuid CHAR(36) NOT NULL UNIQUE,
|
||||||
|
|||||||
@@ -19,11 +19,14 @@ def _make_settings() -> Settings:
|
|||||||
referral_discount_percent=5,
|
referral_discount_percent=5,
|
||||||
referral_bonus_days=7,
|
referral_bonus_days=7,
|
||||||
payment_plans_raw="30:250,180:600",
|
payment_plans_raw="30:250,180:600",
|
||||||
|
payment_product_plans_raw="",
|
||||||
payment_transfer_text="Реквизиты",
|
payment_transfer_text="Реквизиты",
|
||||||
payment_support_text="Напишите в поддержку",
|
payment_support_text="Напишите в поддержку",
|
||||||
payment_plan_traffic_limit_gb=0,
|
payment_plan_traffic_limit_gb=0,
|
||||||
payment_plan_traffic_reset_period="NO_RESET",
|
payment_plan_traffic_reset_period="NO_RESET",
|
||||||
payment_internal_squad_uuids_raw="uuid-1",
|
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_external_squad_uuid="",
|
||||||
payment_username_prefix="Oreol",
|
payment_username_prefix="Oreol",
|
||||||
payment_user_tag="BOT",
|
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",
|
raw_value="30:300,90:700",
|
||||||
updated_by_telegram_id=1,
|
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(
|
await service.update_setting(
|
||||||
key="referral_enabled",
|
key="referral_enabled",
|
||||||
raw_value="off",
|
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.bot_brand_name == "NEW BRAND"
|
||||||
assert snapshot.referral_enabled is False
|
assert snapshot.referral_enabled is False
|
||||||
assert [(plan.days, plan.amount_rub) for plan in snapshot.payment_plans] == [
|
assert [(plan.code, plan.days, plan.amount_rub) for plan in snapshot.payment_plans] == [
|
||||||
(30, 300),
|
("vpn_white", 30, 450),
|
||||||
(90, 700),
|
("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="bot_public_username") == "@oreol_vpn_bot"
|
||||||
assert BotConfigService.format_value(snapshot=snapshot, key="referral_enabled") == "on"
|
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_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.code for plan in plans] == ["30d", "180d", "365d"]
|
||||||
assert [plan.days for plan in plans] == [30, 180, 365]
|
assert [plan.days for plan in plans] == [30, 180, 365]
|
||||||
assert [plan.amount_rub for plan in plans] == [250, 600, 1000]
|
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
|
||||||
|
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:
|
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.refreshed: list[int] = []
|
||||||
self.cached_users_by_telegram = cached_users_by_telegram or {}
|
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):
|
async def get_referral_summary(self, telegram_id: int):
|
||||||
return type(
|
return type(
|
||||||
@@ -44,6 +50,9 @@ class _StubSyncService:
|
|||||||
async def get_cached_users_for_telegram(self, telegram_id: int):
|
async def get_cached_users_for_telegram(self, telegram_id: int):
|
||||||
return list(self.cached_users_by_telegram.get(telegram_id, []))
|
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:
|
class _StubRemnawaveClient:
|
||||||
def __init__(self, *, created_user, users_by_telegram_id: dict[int, list] | None = None) -> None:
|
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,
|
telegram_id: int,
|
||||||
status: str,
|
status: str,
|
||||||
order_uuid: str | None = None,
|
order_uuid: str | None = None,
|
||||||
|
plan_code: str = "30d",
|
||||||
|
plan_title: str = "OREOL VPN на 30 дней",
|
||||||
|
amount_stars: int = 250,
|
||||||
) -> str:
|
) -> str:
|
||||||
resolved_order_uuid = order_uuid or str(uuid4())
|
resolved_order_uuid = order_uuid or str(uuid4())
|
||||||
|
|
||||||
@@ -116,12 +128,12 @@ async def _seed_order(
|
|||||||
order_uuid=resolved_order_uuid,
|
order_uuid=resolved_order_uuid,
|
||||||
telegram_user_id=user.id,
|
telegram_user_id=user.id,
|
||||||
telegram_id=telegram_id,
|
telegram_id=telegram_id,
|
||||||
plan_code="30d",
|
plan_code=plan_code,
|
||||||
plan_title="OREOL VPN на 30 дней",
|
plan_title=plan_title,
|
||||||
plan_duration_days=30,
|
plan_duration_days=30,
|
||||||
traffic_limit_bytes=0,
|
traffic_limit_bytes=0,
|
||||||
traffic_limit_strategy="NO_RESET",
|
traffic_limit_strategy="NO_RESET",
|
||||||
amount_stars=250,
|
amount_stars=amount_stars,
|
||||||
currency="RUB",
|
currency="RUB",
|
||||||
status=status,
|
status=status,
|
||||||
invoice_payload=f"payload-{resolved_order_uuid}",
|
invoice_payload=f"payload-{resolved_order_uuid}",
|
||||||
@@ -155,7 +167,29 @@ async def _seed_referral_invite(
|
|||||||
def _make_settings() -> Settings:
|
def _make_settings() -> Settings:
|
||||||
return Settings.model_construct(
|
return Settings.model_construct(
|
||||||
bot_admin_ids_raw="",
|
bot_admin_ids_raw="",
|
||||||
|
payment_product_plans_raw="",
|
||||||
payment_internal_squad_uuids_raw="11111111-1111-1111-1111-111111111111",
|
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_user_tag="BOT",
|
||||||
payment_external_squad_uuid="",
|
payment_external_squad_uuid="",
|
||||||
payment_username_prefix="Oreol",
|
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)
|
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:
|
async def test_approve_order_grants_and_applies_referral_bonus(session_factory) -> None:
|
||||||
buyer_remote_user = make_remote_user(
|
buyer_remote_user = make_remote_user(
|
||||||
user_uuid="44444444-4444-4444-4444-444444444444",
|
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
|
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:
|
async def test_create_order_marks_existing_access_as_renewal(session_factory) -> None:
|
||||||
service = PaymentService(
|
service = PaymentService(
|
||||||
settings=Settings.model_construct(
|
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 __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.config import Settings
|
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 app.services.sync_service import SyncService
|
||||||
from tests.helpers import make_remote_user
|
from tests.helpers import make_remote_user
|
||||||
|
|
||||||
@@ -17,6 +26,10 @@ class _StubRemnawaveClient:
|
|||||||
async def get_users_by_telegram_id(self, telegram_id: int):
|
async def get_users_by_telegram_id(self, telegram_id: int):
|
||||||
return list(self.users)
|
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(
|
async def test_refresh_cached_users_clears_stale_links_and_keeps_profile(
|
||||||
session_factory,
|
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 profile.recent_invited_names == ["Child User"]
|
||||||
assert len(profile.accesses) == 1
|
assert len(profile.accesses) == 1
|
||||||
assert profile.accesses[0].record.username == "oreol-profile"
|
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