Compare commits

..
18 Commits
Author SHA1 Message Date
kavoreandGitHub 9ea9f571bb Merge pull request #155 from kavore/dev 2026-01-31 07:13:59 +03:00
kavoreandGitHub 9b4a58f82f Merge pull request #154 from VAQYBIN/dev
Исправление бага при удалении промокода на скидку
2026-01-28 16:18:23 +03:00
VAQYBIN e6adbbece4 Merge branch 'dev' of https://github.com/VAQYBIN/remnawave-tg-shop into dev 2026-01-25 20:43:20 +05:00
VAQYBIN 1e79b15351 fix(promo): Исправлена ошибка с удалением промокода на скидку
Исправлен баг, когда промокод на скидку не мог быть удалён, если пользователь применил скидку, но не воспользовался ей.
Добавлено истечение времени действия промокода, даже если промокод на скидку уже был введён
2026-01-25 20:43:01 +05:00
kavoreandGitHub 6f3c123b3a Merge pull request #153 from VAQYBIN/dev
Промокоды на скидку
2026-01-24 22:15:06 +03:00
VAQYBINandGitHub 4a0f763307 Delete CLAUDE.md 2026-01-23 12:42:40 +05:00
VAQYBIN a121d38fbb fix(payments): Исправлена ошибка с дублированием скидки для всех платёжных систем 2026-01-23 00:00:16 +05:00
VAQYBIN 1e8b97888a fix(promo): Исправлен баг с применением двойной скидки при оплате через CryptoBot 2026-01-21 01:26:10 +05:00
VAQYBIN 9ca2fe487c fix(promo): Исправлена ошибка с зацыкленностью импортов для оплат звёздами и криптой 2026-01-21 01:13:26 +05:00
VAQYBIN 229ce7e1e0 feat(promo): Добавлена поддержка промокодов на скидку для всех платёжных систем 2026-01-21 01:09:49 +05:00
VAQYBIN 39d5fd1856 chore(promo): Добавлена локализация кнопки изменения процента скидки 2026-01-20 01:44:22 +05:00
VAQYBIN fed1c1c960 feat(promo): Добавлена локализация при изменении процента скидки и логи при активации промокода на скидку 2026-01-20 01:26:14 +05:00
VAQYBIN e7df5e539c chore(promo): Добавлены локализации при создании промокодов на скидку 2026-01-20 01:06:18 +05:00
VAQYBIN dcc7f9eb72 fix(promo): Исправлена ошибка с дублированием скидки при использование промокода 2026-01-20 00:51:05 +05:00
VAQYBIN baaf5c457f fix(promo): Исправлена ошибка при применении промокода на скидку 2026-01-20 00:34:17 +05:00
VAQYBIN 62bf5c35a8 fix(db): Добавлены новые таблицы для хранения информации о новых и старых ценах при использовании промокодов на скидку 2026-01-20 00:16:24 +05:00
VAQYBIN d619afff29 test: fix ci/cd 2026-01-19 04:00:10 +05:00
VAQYBIN 3bf9acf4d4 feat(promo): Добавлены промокоды на скидку в процентах 2026-01-19 03:50:33 +05:00
44 changed files with 1193 additions and 815 deletions
-3
View File
@@ -17,8 +17,6 @@ DEFAULT_CURRENCY_SYMBOL="RUB" #
SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page
TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
PRIVACY_POLICY_URL=https://example.com/privacy # Link to the privacy policy
USER_AGREEMENT_URL=https://example.com/user-agreement # Link to the user agreement
SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app
START_COMMAND_DESCRIPTION= # Description of the /start command START_COMMAND_DESCRIPTION= # Description of the /start command
DISABLE_WELCOME_MESSAGE= # Disable the welcome message DISABLE_WELCOME_MESSAGE= # Disable the welcome message
@@ -124,7 +122,6 @@ SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 #
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
REFERRAL_WELCOME_BONUS_DAYS=3 # Welcome bonus for newly registered user from referral link
LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links
# Referral Bonus Days # Referral Bonus Days
# Bonus for the inviting user # Bonus for the inviting user
+15 -6
View File
@@ -1,4 +1,4 @@
name: Build and Push Dev GHCR Image name: Build and Push Dev Docker Image
on: on:
push: push:
@@ -9,7 +9,9 @@ on:
- dev - dev
env: env:
GHCR_IMAGE: ghcr.io/3252a8/remnawave-tg-shop GHCR_REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
jobs: jobs:
build: build:
@@ -22,22 +24,29 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry - name: Log in to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx - name: Log in to Docker Hub
uses: docker/setup-buildx-action@v3 uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: | images: |
${{ env.GHCR_IMAGE }} ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
${{ env.DOCKERHUB_IMAGE }}
tags: | tags: |
type=ref,event=branch type=ref,event=branch
type=ref,event=pr type=ref,event=pr
+14 -14
View File
@@ -1,4 +1,4 @@
name: Build and Publish GHCR Docker Image name: Build and Publish multi-arch Docker Image
on: on:
push: push:
@@ -10,7 +10,8 @@ on:
- 'README.md' - 'README.md'
env: env:
GHCR_IMAGE: ghcr.io/3252a8/remnawave-tg-shop GHCR_IMAGE: ghcr.io/${{ github.repository }}
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
jobs: jobs:
build-and-push: build-and-push:
@@ -30,27 +31,26 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up QEMU (для эмуляции arm64 на x86) - name: Set up QEMU (для эмуляции arm64 на x86)
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.GHCR_IMAGE }}
tags: |
type=raw,value=latest
type=semver,pattern={{version}}
- name: Build and push multi-arch Docker image - name: Build and push multi-arch Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: |
labels: ${{ steps.meta.outputs.labels }} ${{ env.GHCR_IMAGE }}:latest
${{ env.GHCR_IMAGE }}:${{ github.ref_name }}
${{ env.DOCKERHUB_IMAGE }}:latest
${{ env.DOCKERHUB_IMAGE }}:${{ github.ref_name }}
+1 -1
View File
@@ -16,4 +16,4 @@ __pycache__/
*.pid *.pid
locales/ru_backup.json locales/ru_backup.json
locales/en_backup.json locales/en_backup.json
db/models_old.py db/models_old.py
+4 -9
View File
@@ -45,7 +45,7 @@
1. **Клонируйте репозиторий:** 1. **Клонируйте репозиторий:**
```bash ```bash
git clone https://github.com/3252a8/remnawave-tg-shop git clone https://github.com/kavore/remnawave-tg-shop
cd remnawave-tg-shop cd remnawave-tg-shop
``` ```
@@ -182,7 +182,7 @@
### 1. Клонирование репозитория ### 1. Клонирование репозитория
```bash ```bash
git clone https://github.com/3252a8/remnawave-tg-shop && cd remnawave-tg-shop git clone https://github.com/kavore/remnawave-tg-shop && cd remnawave-tg-shop
``` ```
### 2. Настройка переменных окружения ### 2. Настройка переменных окружения
@@ -325,14 +325,9 @@ cd /opt/remnawave/nginx && docker compose down && docker compose up -d && docker
## 🐳 Docker ## 🐳 Docker
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для локальной сборки и запуска проекта. Если нужен готовый образ из GHCR, используйте `docker-compose-remote-server.yml` или переключите `docker-compose.yml` на строку `image:` вместо `build: .`. Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
Образ публикуется в GitHub Container Registry по пути `ghcr.io/3252a8/remnawave-tg-shop`. GitHub Actions выкладывают теги `latest` и `0.1.0`: `latest` обновляется из `main`, а `0.1.0` появляется при сборке тега `v0.1.0`. Для автоматической публикации образов настроены GitHub Actions (`.github/workflows`). По умолчанию образы пушатся в GitHub Container Registry и Docker Hub. Добавьте в Secrets репозитория значения `DOCKERHUB_USERNAME` и `DOCKERHUB_TOKEN` (персональный access token или пароль для Docker Hub), чтобы загрузка в Docker Hub работала корректно.
Чтобы закрепить версию на сервере, можно запустить:
```bash
IMAGE_TAG=0.1.0 docker compose -f docker-compose-remote-server.yml up -d
```
## 📁 Структура проекта ## 📁 Структура проекта
-3
View File
@@ -141,9 +141,6 @@ async def admin_panel_actions_callback_handler(
from . import payments as admin_payments_handlers from . import payments as admin_payments_handlers
await admin_payments_handlers.view_payments_handler( await admin_payments_handlers.view_payments_handler(
callback, i18n_data, settings, session) callback, i18n_data, settings, session)
elif action == "user_ratings":
await admin_stats_handlers.show_user_ratings_handler(
callback, i18n_data, settings, session)
elif action == "ads": elif action == "ads":
from . import ads as admin_ads_handlers from . import ads as admin_ads_handlers
await admin_ads_handlers.show_ads_menu(callback, settings, i18n_data, session) await admin_ads_handlers.show_ads_menu(callback, settings, i18n_data, session)
+196 -45
View File
@@ -28,25 +28,82 @@ async def create_promo_prompt_handler(callback: types.CallbackQuery,
return return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
# Step 1: Ask for promo code # Step 0: Ask for promo type (bonus_days or discount)
prompt_text = _( prompt_text = _(
"admin_promo_step1_code" "admin_promo_step0_type"
)
# Create keyboard for type selection
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=_("admin_promo_type_bonus_days"),
callback_data="promo_type_select:bonus_days"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_type_discount"),
callback_data="promo_type_select:discount"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_back_to_panel"),
callback_data="admin_action:main"
)
) )
try: try:
await callback.message.edit_text( await callback.message.edit_text(
prompt_text, prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), reply_markup=builder.as_markup(),
parse_mode="HTML") parse_mode="HTML")
except Exception as e: except Exception as e:
logging.warning( logging.warning(
f"Could not edit message for promo prompt: {e}. Sending new.") f"Could not edit message for promo type prompt: {e}. Sending new.")
await callback.message.answer( await callback.message.answer(
prompt_text, prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), reply_markup=builder.as_markup(),
parse_mode="HTML") parse_mode="HTML")
await callback.answer() await callback.answer()
await state.set_state(AdminStates.waiting_for_promo_code) await state.set_state(AdminStates.waiting_for_promo_type_selection)
# Step 0: Process type selection
@router.callback_query(F.data.startswith("promo_type_select:"), StateFilter(AdminStates.waiting_for_promo_type_selection))
async def process_promo_type_selection(callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error processing type selection.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
promo_type = callback.data.split(":")[-1] # "bonus_days" or "discount"
await state.update_data(promo_type=promo_type)
# Step 1: Ask for promo code
prompt_text = _(
"admin_promo_step1_code"
)
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await callback.answer()
await state.set_state(AdminStates.waiting_for_promo_code)
except Exception as e:
logging.error(f"Error processing promo type selection: {e}")
await callback.message.answer(_("error_occurred_try_again"))
await callback.answer()
# Step 1: Process promo code # Step 1: Process promo code
@@ -80,19 +137,31 @@ async def process_promo_code_handler(message: types.Message,
return return
await state.update_data(promo_code=code_str) await state.update_data(promo_code=code_str)
# Step 2: Ask for bonus days # Get promo type from state
prompt_text = _( data = await state.get_data()
"admin_promo_step2_bonus_days", promo_type = data.get("promo_type", "bonus_days")
code=code_str
) # Step 2: Ask for bonus days OR discount percentage based on type
if promo_type == "discount":
prompt_text = _(
"admin_promo_step2_discount_percentage",
code=code_str
)
next_state = AdminStates.waiting_for_promo_discount_percentage
else:
prompt_text = _(
"admin_promo_step2_bonus_days",
code=code_str
)
next_state = AdminStates.waiting_for_promo_bonus_days
await message.answer( await message.answer(
prompt_text, prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML" parse_mode="HTML"
) )
await state.set_state(AdminStates.waiting_for_promo_bonus_days) await state.set_state(next_state)
except Exception as e: except Exception as e:
logging.error(f"Error processing promo code: {e}") logging.error(f"Error processing promo code: {e}")
@@ -119,9 +188,9 @@ async def process_promo_bonus_days_handler(message: types.Message,
"admin_promo_invalid_bonus_days" "admin_promo_invalid_bonus_days"
)) ))
return return
await state.update_data(bonus_days=bonus_days) await state.update_data(bonus_days=bonus_days)
# Step 3: Ask for max activations # Step 3: Ask for max activations
data = await state.get_data() data = await state.get_data()
prompt_text = _( prompt_text = _(
@@ -129,14 +198,14 @@ async def process_promo_bonus_days_handler(message: types.Message,
code=data.get("promo_code"), code=data.get("promo_code"),
bonus_days=bonus_days bonus_days=bonus_days
) )
await message.answer( await message.answer(
prompt_text, prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML" parse_mode="HTML"
) )
await state.set_state(AdminStates.waiting_for_promo_max_activations) await state.set_state(AdminStates.waiting_for_promo_max_activations)
except ValueError: except ValueError:
await message.answer(_( await message.answer(_(
"admin_promo_invalid_number" "admin_promo_invalid_number"
@@ -146,6 +215,53 @@ async def process_promo_bonus_days_handler(message: types.Message,
await message.answer(_("error_occurred_try_again")) await message.answer(_("error_occurred_try_again"))
# Step 2: Process discount percentage
@router.message(AdminStates.waiting_for_promo_discount_percentage, F.text)
async def process_promo_discount_percentage_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
discount_percentage = int(message.text.strip())
if not (1 <= discount_percentage <= 100):
await message.answer(
"❌ Discount percentage must be between 1 and 100."
)
return
await state.update_data(discount_percentage=discount_percentage)
# Step 3: Ask for max activations
data = await state.get_data()
prompt_text = _(
"admin_promo_step3_max_activations_discount",
code=data.get("promo_code"),
discount_percentage=discount_percentage
)
await message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await state.set_state(AdminStates.waiting_for_promo_max_activations)
except ValueError:
await message.answer(_(
"admin_promo_invalid_number"
))
except Exception as e:
logging.error(f"Error processing discount percentage: {e}")
await message.answer(_("error_occurred_try_again"))
# Step 3: Process max activations # Step 3: Process max activations
@router.message(AdminStates.waiting_for_promo_max_activations, F.text) @router.message(AdminStates.waiting_for_promo_max_activations, F.text)
async def process_promo_max_activations_handler(message: types.Message, async def process_promo_max_activations_handler(message: types.Message,
@@ -168,15 +284,25 @@ async def process_promo_max_activations_handler(message: types.Message,
return return
await state.update_data(max_activations=max_activations) await state.update_data(max_activations=max_activations)
# Step 4: Ask for validity # Step 4: Ask for validity
data = await state.get_data() data = await state.get_data()
prompt_text = _( promo_type = data.get("promo_type", "bonus_days")
"admin_promo_step4_validity",
code=data.get("promo_code"), if promo_type == "discount":
bonus_days=data.get("bonus_days"), prompt_text = _(
max_activations=max_activations "admin_promo_step4_validity_discount",
) code=data.get("promo_code"),
discount_percentage=data.get("discount_percentage"),
max_activations=max_activations
)
else:
prompt_text = _(
"admin_promo_step4_validity",
code=data.get("promo_code"),
bonus_days=data.get("bonus_days"),
max_activations=max_activations
)
# Create keyboard for validity options # Create keyboard for validity options
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
@@ -240,12 +366,15 @@ async def process_promo_set_validity(callback: types.CallbackQuery,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
data = await state.get_data() data = await state.get_data()
prompt_text = _( promo_type = data.get("promo_type", "bonus_days")
"admin_promo_enter_validity_days",
code=data.get("promo_code"), # Display the correct text based on promo type
bonus_days=data.get("bonus_days"), if promo_type == "discount":
max_activations=data.get("max_activations") value_info = f"{data.get('discount_percentage')}%"
) else:
value_info = f"{data.get('bonus_days')} дней"
prompt_text = f"⏰ Введите количество дней действия промокода (1-365):\n\nКод: <b>{data.get('promo_code')}</b>\n{'Скидка' if promo_type == 'discount' else 'Бонус'}: <b>{value_info}</b>\nМакс. активаций: <b>{data.get('max_activations')}</b>"
try: try:
await callback.message.edit_text( await callback.message.edit_text(
@@ -310,40 +439,60 @@ async def create_promo_code_final(callback_or_message,
try: try:
data = await state.get_data() data = await state.get_data()
promo_type = data.get("promo_type", "bonus_days")
# Prepare promo code data # Prepare promo code data
promo_data = { promo_data = {
"code": data["promo_code"], "code": data["promo_code"],
"bonus_days": data["bonus_days"], "promo_type": promo_type,
"max_activations": data["max_activations"], "max_activations": data["max_activations"],
"current_activations": 0, "current_activations": 0,
"is_active": True, "is_active": True,
"created_by_admin_id": callback_or_message.from_user.id, "created_by_admin_id": callback_or_message.from_user.id,
"created_at": datetime.now(timezone.utc) "created_at": datetime.now(timezone.utc)
} }
# Set type-specific fields
if promo_type == "discount":
promo_data["discount_percentage"] = data["discount_percentage"]
promo_data["bonus_days"] = None
else:
promo_data["bonus_days"] = data["bonus_days"]
promo_data["discount_percentage"] = None
# Set validity # Set validity
if data.get("validity_days"): if data.get("validity_days"):
promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=data["validity_days"]) promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=data["validity_days"])
else: else:
promo_data["valid_until"] = None promo_data["valid_until"] = None
# Create promo code # Create promo code
created_promo = await promo_code_dal.create_promo_code(session, promo_data) created_promo = await promo_code_dal.create_promo_code(session, promo_data)
await session.commit() await session.commit()
# Log successful creation # Log successful creation
logging.info(f"Promo code '{data['promo_code']}' created with ID {created_promo.promo_code_id}") logging.info(f"Promo code '{data['promo_code']}' ({promo_type}) created with ID {created_promo.promo_code_id}")
# Success message # Success message
valid_until_str = _("admin_promo_unlimited") if not data.get("validity_days") else f"{data['validity_days']} дней" valid_until_str = _("admin_promo_unlimited") if not data.get("validity_days") else f"{data['validity_days']} дней"
success_text = _(
"admin_promo_created_success", # Format success message based on type
code=data["promo_code"], if promo_type == "discount":
bonus_days=data["bonus_days"], success_text = _(
max_activations=data["max_activations"], "admin_promo_created_success_discount",
valid_until_str=valid_until_str code=data["promo_code"],
) discount_percentage=data['discount_percentage'],
max_activations=data["max_activations"],
valid_until_str=valid_until_str
)
else:
success_text = _(
"admin_promo_created_success",
code=data["promo_code"],
bonus_days=data['bonus_days'],
max_activations=data["max_activations"],
valid_until_str=valid_until_str
)
if hasattr(callback_or_message, 'message'): # CallbackQuery if hasattr(callback_or_message, 'message'): # CallbackQuery
try: try:
@@ -385,8 +534,10 @@ async def create_promo_code_final(callback_or_message,
@router.callback_query( @router.callback_query(
F.data == "admin_action:main", F.data == "admin_action:main",
StateFilter( StateFilter(
AdminStates.waiting_for_promo_type_selection,
AdminStates.waiting_for_promo_code, AdminStates.waiting_for_promo_code,
AdminStates.waiting_for_promo_bonus_days, AdminStates.waiting_for_promo_bonus_days,
AdminStates.waiting_for_promo_discount_percentage,
AdminStates.waiting_for_promo_max_activations, AdminStates.waiting_for_promo_max_activations,
AdminStates.waiting_for_promo_validity_days, AdminStates.waiting_for_promo_validity_days,
), ),
+70 -17
View File
@@ -47,9 +47,19 @@ async def get_promo_detail_text_and_keyboard(promo_id: int, session: AsyncSessio
created = promo.created_at.strftime("%d.%m.%Y %H:%M") if promo.created_at else "N/A" created = promo.created_at.strftime("%d.%m.%Y %H:%M") if promo.created_at else "N/A"
# Determine promo type and value to display
promo_type = getattr(promo, "promo_type", "bonus_days")
if promo_type == "discount":
type_name = _("admin_promo_type_discount")
value_line = _("admin_promo_card_discount_percentage", percentage=promo.discount_percentage)
else:
type_name = _("admin_promo_type_bonus_days")
value_line = _("admin_promo_card_bonus_days", days=promo.bonus_days)
text = "\n".join([ text = "\n".join([
_("admin_promo_card_title", code=promo.code), _("admin_promo_card_title", code=promo.code),
_("admin_promo_card_bonus_days", days=promo.bonus_days), _("admin_promo_card_type", type=type_name),
value_line,
_("admin_promo_card_activations", current=promo.current_activations, max=promo.max_activations), _("admin_promo_card_activations", current=promo.current_activations, max=promo.max_activations),
_("admin_promo_card_validity", validity=validity), _("admin_promo_card_validity", validity=validity),
_("admin_promo_card_status", status=status), _("admin_promo_card_status", status=status),
@@ -76,12 +86,22 @@ async def view_promo_codes_handler(callback: types.CallbackQuery, i18n_data: dic
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
promo_models = await promo_code_dal.get_all_active_promo_codes(session, limit=20, offset=0) promo_models = await promo_code_dal.get_all_active_promo_codes(session, limit=20, offset=0)
text = f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}" if not promo_models else "\n".join( if not promo_models:
[_("admin_active_promos_list_header"), ""] + [ text = f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}"
f"{get_promo_status_emoji_and_text(p, i18n, current_lang)[0]} <code>{p.code}</code> | 🎁 {p.bonus_days}д | 📊 {p.current_activations}/{p.max_activations} | ⏰ {p.valid_until.strftime('%d.%m.%Y') if p.valid_until else _('admin_promo_valid_indefinitely')}" else:
for p in promo_models promo_lines = [_("admin_active_promos_list_header"), ""]
] for p in promo_models:
) status_emoji = get_promo_status_emoji_and_text(p, i18n, current_lang)[0]
promo_type = getattr(p, "promo_type", "bonus_days")
if promo_type == "discount":
value_display = f"💰 {p.discount_percentage}%"
else:
value_display = f"🎁 {p.bonus_days}д"
validity_display = p.valid_until.strftime('%d.%m.%Y') if p.valid_until else _('admin_promo_valid_indefinitely')
promo_lines.append(
f"{status_emoji} <code>{p.code}</code> | {value_display} | 📊 {p.current_activations}/{p.max_activations} | ⏰ {validity_display}"
)
text = "\n".join(promo_lines)
await callback.message.edit_text(text, reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML") await callback.message.edit_text(text, reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML")
await callback.answer() await callback.answer()
@@ -300,7 +320,9 @@ async def promo_export_all_handler(callback: types.CallbackQuery, i18n_data: dic
# CSV headers (forced to English) # CSV headers (forced to English)
writer.writerow([ writer.writerow([
i18n.gettext(export_lang, "admin_promo_csv_code"), i18n.gettext(export_lang, "admin_promo_csv_code"),
"Type",
i18n.gettext(export_lang, "admin_promo_csv_bonus_days"), i18n.gettext(export_lang, "admin_promo_csv_bonus_days"),
"Discount %",
i18n.gettext(export_lang, "admin_promo_csv_max_activations"), i18n.gettext(export_lang, "admin_promo_csv_max_activations"),
i18n.gettext(export_lang, "admin_promo_csv_current_activations"), i18n.gettext(export_lang, "admin_promo_csv_current_activations"),
i18n.gettext(export_lang, "admin_promo_csv_status"), i18n.gettext(export_lang, "admin_promo_csv_status"),
@@ -309,15 +331,22 @@ async def promo_export_all_handler(callback: types.CallbackQuery, i18n_data: dic
i18n.gettext(export_lang, "admin_promo_csv_created_at"), i18n.gettext(export_lang, "admin_promo_csv_created_at"),
i18n.gettext(export_lang, "admin_promo_csv_created_by_admin_id"), i18n.gettext(export_lang, "admin_promo_csv_created_by_admin_id"),
]) ])
for promo in all_promos: for promo in all_promos:
# Определяем статус # Определяем статус
status_emoji, status_text = get_promo_status_emoji_and_text(promo, i18n, export_lang) status_emoji, status_text = get_promo_status_emoji_and_text(promo, i18n, export_lang)
# Determine promo type and values
promo_type = getattr(promo, "promo_type", "bonus_days")
bonus_days_val = promo.bonus_days if promo_type == "bonus_days" else "N/A"
discount_val = promo.discount_percentage if promo_type == "discount" else "N/A"
# Формируем данные для CSV # Формируем данные для CSV
row = [ row = [
promo.code, promo.code,
promo.bonus_days, promo_type,
bonus_days_val,
discount_val,
promo.max_activations, promo.max_activations,
promo.current_activations, promo.current_activations,
status_text, status_text,
@@ -374,13 +403,26 @@ async def promo_edit_select_handler(callback: types.CallbackQuery, i18n_data: di
return return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
promo_id = int(callback.data.split(":")[1]) promo_id = int(callback.data.split(":")[1])
# Get promo to check type
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
if not promo:
await callback.answer(_("admin_promo_not_found"), show_alert=True)
return
promo_type = getattr(promo, "promo_type", "bonus_days")
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_bonus_days"), callback_data=f"promo_edit_field:bonus_days:{promo_id}")) # Show appropriate edit option based on type
if promo_type == "discount":
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_discount_percentage"), callback_data=f"promo_edit_field:discount_percentage:{promo_id}"))
else:
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_bonus_days"), callback_data=f"promo_edit_field:bonus_days:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_max_activations"), callback_data=f"promo_edit_field:max_activations:{promo_id}")) builder.row(InlineKeyboardButton(text=_("admin_promo_edit_max_activations"), callback_data=f"promo_edit_field:max_activations:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_validity"), callback_data=f"promo_edit_field:valid_until:{promo_id}")) builder.row(InlineKeyboardButton(text=_("admin_promo_edit_validity"), callback_data=f"promo_edit_field:valid_until:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_back_to_detail_button"), callback_data=f"promo_detail:{promo_id}")) builder.row(InlineKeyboardButton(text=_("admin_promo_back_to_detail_button"), callback_data=f"promo_detail:{promo_id}"))
await callback.message.edit_text(_("admin_promo_edit_select_field"), reply_markup=builder.as_markup()) await callback.message.edit_text(_("admin_promo_edit_select_field"), reply_markup=builder.as_markup())
await callback.answer() await callback.answer()
@@ -394,14 +436,19 @@ async def promo_edit_field_handler(callback: types.CallbackQuery, state: FSMCont
action, field, promo_id_str = callback.data.split(":") action, field, promo_id_str = callback.data.split(":")
await state.update_data(promo_id=int(promo_id_str), field_to_edit=field) await state.update_data(promo_id=int(promo_id_str), field_to_edit=field)
prompts = { prompts = {
"bonus_days": "admin_promo_prompt_bonus_days", "bonus_days": "admin_promo_prompt_bonus_days",
"discount_percentage": "admin_promo_prompt_discount_percentage",
"max_activations": "admin_promo_prompt_max_activations", "max_activations": "admin_promo_prompt_max_activations",
"valid_until": "admin_promo_prompt_validity_days" "valid_until": "admin_promo_prompt_validity_days"
} }
prompt_key = prompts.get(field, "error_occurred_try_again")
prompt_text = _(prompt_key)
await state.set_state(AdminStates.waiting_for_promo_edit_details) await state.set_state(AdminStates.waiting_for_promo_edit_details)
await callback.message.edit_text(_(prompts.get(field, "error_occurred_try_again"))) await callback.message.edit_text(prompt_text)
await callback.answer() await callback.answer()
@router.message(StateFilter(AdminStates.waiting_for_promo_edit_details)) @router.message(StateFilter(AdminStates.waiting_for_promo_edit_details))
@@ -418,9 +465,15 @@ async def process_promo_edit_details(message: types.Message, state: FSMContext,
try: try:
value = message.text value = message.text
update_data = {} update_data = {}
if field == "bonus_days": if field == "bonus_days":
update_data["bonus_days"] = int(value) update_data["bonus_days"] = int(value)
elif field == "discount_percentage":
discount_pct = int(value)
if not (1 <= discount_pct <= 100):
await message.answer("❌ Discount percentage must be between 1 and 100.")
return
update_data["discount_percentage"] = discount_pct
elif field == "max_activations": elif field == "max_activations":
update_data["max_activations"] = int(value) update_data["max_activations"] = int(value)
elif field == "valid_until": elif field == "valid_until":
@@ -433,7 +486,7 @@ async def process_promo_edit_details(message: types.Message, state: FSMContext,
if await promo_code_dal.update_promo_code(session, promo_id, update_data): if await promo_code_dal.update_promo_code(session, promo_id, update_data):
await session.commit() await session.commit()
await message.answer(_("admin_promo_edit_success")) await message.answer(_("admin_promo_edit_success"))
# Reset state and show updated details # Reset state and show updated details
await state.clear() await state.clear()
text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang) text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang)
+1 -135
View File
@@ -2,7 +2,6 @@ import logging
from aiogram import Router, F, types from aiogram import Router, F, types
from typing import Optional, Dict, List from typing import Optional, Dict, List
from datetime import datetime from datetime import datetime
import html
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings from config.settings import Settings
@@ -11,44 +10,12 @@ from db.dal import user_dal, payment_dal, panel_sync_dal
from db.models import Payment, PanelSyncStatus from db.models import Payment, PanelSyncStatus
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
from bot.keyboards.inline.admin_keyboards import ( from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
get_back_to_admin_panel_keyboard,
get_back_to_user_management_keyboard,
)
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_statistics_router") router = Router(name="admin_statistics_router")
def _format_rating_user_label(user_row: Dict[str, object],
bot_username: Optional[str] = None) -> str:
user_id = int(user_row.get("user_id", 0) or 0)
username = user_row.get("username")
first_name = user_row.get("first_name")
user_id_text = str(user_id)
user_id_html = html.escape(user_id_text)
if bot_username:
safe_bot_username = html.escape(bot_username)
user_id_html = (
f'<a href="https://t.me/{safe_bot_username}?start=admin_user_{user_id_text}">'
f"{user_id_html}</a>"
)
parts: List[str] = []
if username:
parts.append(f"@{html.escape(str(username))}")
elif first_name:
parts.append(html.escape(str(first_name)))
if not parts:
parts.append(f"ID {user_id_html}")
else:
parts.append(f"(ID {user_id_html})")
return " ".join(parts)
async def show_statistics_handler(callback: types.CallbackQuery, async def show_statistics_handler(callback: types.CallbackQuery,
i18n_data: dict, settings: Settings, i18n_data: dict, settings: Settings,
session: AsyncSession): session: AsyncSession):
@@ -288,104 +255,3 @@ async def show_statistics_handler(callback: types.CallbackQuery,
reply_markup=get_back_to_admin_panel_keyboard( reply_markup=get_back_to_admin_panel_keyboard(
current_lang, i18n)) current_lang, i18n))
break break
async def show_user_ratings_handler(
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error displaying ratings.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await callback.answer()
top_limit = 10
bot_username: Optional[str] = None
try:
me = await callback.bot.get_me()
bot_username = me.username
except Exception as e_get_me:
logging.warning("Failed to resolve bot username for ratings links: %s",
e_get_me)
traffic_top = await user_dal.get_top_users_by_traffic_used(session, limit=top_limit)
lifetime_traffic_top = await user_dal.get_top_users_by_lifetime_traffic_used(
session, limit=top_limit
)
invited_top = await user_dal.get_top_users_by_referrals_count(session, limit=top_limit)
revenue_top = await user_dal.get_top_users_by_referral_revenue(session, limit=top_limit)
text_parts: List[str] = [
_("admin_user_ratings_header", top_limit=top_limit),
"",
f"<b>{_('admin_user_ratings_traffic_month_title')}</b>",
]
if traffic_top:
for idx, row in enumerate(traffic_top, start=1):
traffic_gb = float(row.get("traffic_used_bytes") or 0) / (1024**3)
text_parts.append(
_(
"admin_user_ratings_traffic_item",
rank=idx,
user=_format_rating_user_label(row, bot_username),
traffic_gb=f"{traffic_gb:.2f}",
)
)
else:
text_parts.append(_("admin_user_ratings_empty"))
text_parts.extend(["", f"<b>{_('admin_user_ratings_traffic_lifetime_title')}</b>"])
if lifetime_traffic_top:
for idx, row in enumerate(lifetime_traffic_top, start=1):
traffic_gb = float(row.get("lifetime_used_traffic_bytes") or 0) / (1024**3)
text_parts.append(
_(
"admin_user_ratings_traffic_item",
rank=idx,
user=_format_rating_user_label(row, bot_username),
traffic_gb=f"{traffic_gb:.2f}",
)
)
else:
text_parts.append(_("admin_user_ratings_empty"))
text_parts.extend(["", f"<b>{_('admin_user_ratings_invited_title')}</b>"])
if invited_top:
for idx, row in enumerate(invited_top, start=1):
text_parts.append(
_(
"admin_user_ratings_invited_item",
rank=idx,
user=_format_rating_user_label(row, bot_username),
invited_count=int(row.get("invited_count") or 0),
)
)
else:
text_parts.append(_("admin_user_ratings_empty"))
text_parts.extend(["", f"<b>{_('admin_user_ratings_revenue_title')}</b>"])
if revenue_top:
for idx, row in enumerate(revenue_top, start=1):
text_parts.append(
_(
"admin_user_ratings_revenue_item",
rank=idx,
user=_format_rating_user_label(row, bot_username),
revenue=f"{float(row.get('referral_revenue') or 0):.2f}",
)
)
else:
text_parts.append(_("admin_user_ratings_empty"))
await callback.message.edit_text(
"\n".join(text_parts),
reply_markup=get_back_to_user_management_keyboard(current_lang, i18n),
parse_mode="HTML",
)
-26
View File
@@ -18,24 +18,6 @@ from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_sync_router") router = Router(name="admin_sync_router")
def _extract_lifetime_used_traffic_bytes(panel_user_data: dict) -> Optional[int]:
user_traffic = panel_user_data.get("userTraffic") or {}
raw_value = (
user_traffic.get("lifetimeUsedTrafficBytes")
if isinstance(user_traffic, dict)
else None
)
if raw_value is None:
raw_value = panel_user_data.get("lifetimeUsedTrafficBytes")
try:
if raw_value is None:
return None
return int(raw_value)
except (TypeError, ValueError):
return None
async def perform_sync( async def perform_sync(
panel_service: PanelApiService, panel_service: PanelApiService,
session: AsyncSession, session: AsyncSession,
@@ -194,14 +176,6 @@ async def perform_sync(
f"Updated panel UUID for user {actual_user_id}: {panel_uuid}" f"Updated panel UUID for user {actual_user_id}: {panel_uuid}"
) )
lifetime_used = _extract_lifetime_used_traffic_bytes(panel_user_dict)
if (
lifetime_used is not None
and existing_user.lifetime_used_traffic_bytes != lifetime_used
):
existing_user.lifetime_used_traffic_bytes = lifetime_used
user_was_updated = True
# Ensure panel description contains Telegram fields # Ensure panel description contains Telegram fields
try: try:
if panel_uuid and existing_user: if panel_uuid and existing_user:
+4 -36
View File
@@ -33,26 +33,6 @@ router = Router(name="admin_user_management_router")
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$") USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
def _format_traffic_period(strategy: Optional[str], get_text: Callable[..., str]) -> Optional[str]:
if not strategy:
return None
strategy_upper = str(strategy).upper()
key_map = {
"MONTH": "traffic_period_month",
"WEEK": "traffic_period_week",
"DAY": "traffic_period_day",
"NO_RESET": "traffic_period_no_reset",
}
label_key = key_map.get(strategy_upper)
return get_text(label_key) if label_key else strategy_upper
def _format_used_with_period(get_text: Callable[..., str], used_display: str, period_label: Optional[str]) -> str:
if not period_label:
return used_display
return get_text("traffic_used_with_period", traffic_used=used_display, traffic_period=period_label)
async def users_list_handler(callback: types.CallbackQuery, async def users_list_handler(callback: types.CallbackQuery,
i18n_data: dict, settings: Settings, i18n_data: dict, settings: Settings,
session: AsyncSession, page: int = 0): session: AsyncSession, page: int = 0):
@@ -283,22 +263,10 @@ async def format_user_card(user: User, session: AsyncSession,
traffic_limit = subscription_details.get('traffic_limit_bytes') traffic_limit = subscription_details.get('traffic_limit_bytes')
traffic_used = subscription_details.get('traffic_used_bytes') traffic_used = subscription_details.get('traffic_used_bytes')
traffic_strategy = subscription_details.get('traffic_limit_strategy') if traffic_limit and traffic_used is not None:
period_label = _format_traffic_period(traffic_strategy, _) traffic_limit_gb = traffic_limit / (1024**3)
if traffic_used is not None or traffic_limit is not None: traffic_used_gb = traffic_used / (1024**3)
used_display = _("traffic_na") card_parts.append(f"{_('admin_user_traffic_label')} {hcode(f'{traffic_used_gb:.2f}GB / {traffic_limit_gb:.2f}GB')}")
if traffic_used is not None:
traffic_used_gb = traffic_used / (1024**3)
used_display = f"{traffic_used_gb:.2f}GB"
used_display = _format_used_with_period(_, used_display, period_label)
if traffic_limit:
traffic_limit_gb = traffic_limit / (1024**3)
limit_display = f"{traffic_limit_gb:.2f}GB"
else:
limit_display = _("traffic_unlimited")
card_parts.append(f"{_('admin_user_traffic_label')} {hcode(f'{used_display} / {limit_display}')}")
else: else:
card_parts.append(f"{_('admin_user_subscription_label')} {hcode(_('admin_user_subscription_none'))}") card_parts.append(f"{_('admin_user_subscription_label')} {hcode(_('admin_user_subscription_none'))}")
except Exception as e: except Exception as e:
+46 -9
View File
@@ -123,13 +123,15 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
code=hcode(code_input.upper())) code=hcode(code_input.upper()))
reply_markup = get_back_to_main_menu_markup(current_lang, i18n) reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
else: else:
# Try as BONUS code first (existing behavior)
success, result = await promo_code_service.apply_promo_code( success, result = await promo_code_service.apply_promo_code(
session, user.id, code_input, current_lang) session, user.id, code_input, current_lang)
if success: if success:
# Bonus code success
await session.commit() await session.commit()
logging.info( logging.info(
f"Promo code '{code_input}' successfully applied for user {user.id}." f"Bonus promo code '{code_input}' successfully applied for user {user.id}."
) )
new_end_date = result if isinstance(result, datetime) else None new_end_date = result if isinstance(result, datetime) else None
@@ -151,15 +153,50 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
connect_button_url=connect_button_url, connect_button_url=connect_button_url,
) )
else: else:
await session.rollback() # Bonus code failed, try as DISCOUNT code
logging.info( success_discount, result_discount = await promo_code_service.apply_discount_promo_code(
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}" session, user.id, code_input, current_lang
)
response_to_user_text = result
reply_markup = get_back_to_main_menu_markup(
current_lang, i18n
) )
if success_discount:
# Discount code success
await session.commit()
logging.info(
f"Discount promo code '{code_input}' successfully applied for user {user.id}."
)
discount_pct = result_discount # Returns percentage
# Send notification about discount promo activation
if settings.LOG_PROMO_ACTIVATIONS:
try:
from bot.services.notification_service import NotificationService
notification_service = NotificationService(bot, settings, i18n)
await notification_service.notify_discount_promo_activation(
user_id=user.id,
promo_code=code_input.upper(),
discount_percentage=discount_pct,
username=user.username
)
except Exception as e:
logging.error(f"Failed to send discount promo activation notification: {e}")
response_to_user_text = _(
"discount_promo_code_applied_success",
code=hcode(code_input.upper()),
discount=discount_pct
)
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
else:
# Both failed
await session.rollback()
logging.info(
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
)
response_to_user_text = result # Original error message from bonus code attempt
reply_markup = get_back_to_main_menu_markup(
current_lang, i18n
)
await message.answer( await message.answer(
response_to_user_text, response_to_user_text,
reply_markup=reply_markup, reply_markup=reply_markup,
+3 -154
View File
@@ -16,7 +16,6 @@ from bot.keyboards.inline.user_keyboards import (
get_main_menu_inline_keyboard, get_main_menu_inline_keyboard,
get_language_selection_keyboard, get_language_selection_keyboard,
get_channel_subscription_keyboard, get_channel_subscription_keyboard,
get_information_links_keyboard,
) )
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
@@ -305,21 +304,16 @@ async def ensure_required_channel_subscription(
@router.message(CommandStart()) @router.message(CommandStart())
@router.message(CommandStart(magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_("ref_match"))) @router.message(CommandStart(magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_("ref_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match"))) @router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match"))) @router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_|admin_user_|page_ref$)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
async def start_command_handler(message: types.Message, async def start_command_handler(message: types.Message,
state: FSMContext, state: FSMContext,
settings: Settings, settings: Settings,
i18n_data: dict, i18n_data: dict,
subscription_service: SubscriptionService, subscription_service: SubscriptionService,
referral_service: ReferralService,
session: AsyncSession, session: AsyncSession,
ref_match: Optional[re.Match] = None, ref_match: Optional[re.Match] = None,
promo_match: Optional[re.Match] = None, promo_match: Optional[re.Match] = None,
page_ref_match: Optional[re.Match] = None, ad_param_match: Optional[re.Match] = None):
ad_param_match: Optional[re.Match] = None,
admin_user_match: Optional[re.Match] = None):
await state.clear() await state.clear()
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -329,61 +323,8 @@ async def start_command_handler(message: types.Message,
user = message.from_user user = message.from_user
user_id = user.id user_id = user.id
if admin_user_match and user_id in settings.ADMIN_IDS:
target_user_id = int(admin_user_match.group(1))
target_user = await user_dal.get_user_by_id(session, target_user_id)
if not target_user:
await message.answer(
_("admin_user_not_found", input=hd.quote(str(target_user_id)))
)
return
try:
from bot.handlers.admin.user_management import (
format_user_card,
get_user_card_keyboard,
_send_with_profile_link_fallback,
)
referral_service = ReferralService(
settings, subscription_service, message.bot, i18n
)
user_card_text = await format_user_card(
target_user,
session,
subscription_service,
i18n,
current_lang,
referral_service,
)
keyboard = get_user_card_keyboard(
target_user.user_id,
i18n,
current_lang,
target_user.referred_by_id,
)
await _send_with_profile_link_fallback(
message.answer,
text=user_card_text,
markup=keyboard.as_markup(),
user_id=target_user.user_id,
parse_mode="HTML",
)
return
except Exception as e_admin_card:
logging.error(
"Failed to open admin user card via deep-link for %s: %s",
target_user_id,
e_admin_card,
exc_info=True,
)
await message.answer(_("admin_user_card_error"))
return
referred_by_user_id: Optional[int] = None referred_by_user_id: Optional[int] = None
promo_code_to_apply: Optional[str] = None promo_code_to_apply: Optional[str] = None
should_open_referral_from_start = False
ad_start_param: Optional[str] = None ad_start_param: Optional[str] = None
if ref_match: if ref_match:
@@ -407,9 +348,6 @@ async def start_command_handler(message: types.Message,
elif promo_match: elif promo_match:
promo_code_to_apply = promo_match.group(1) promo_code_to_apply = promo_match.group(1)
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}") logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
elif page_ref_match:
should_open_referral_from_start = True
logging.info(f"User {user_id} started with page_ref deep-link.")
elif ad_param_match: elif ad_param_match:
ad_start_param = ad_param_match.group(1) ad_start_param = ad_param_match.group(1)
logging.info(f"User {user_id} started with ad start param: {ad_start_param}") logging.info(f"User {user_id} started with ad start param: {ad_start_param}")
@@ -419,7 +357,6 @@ async def start_command_handler(message: types.Message,
sanitized_last_name = sanitize_display_name(user.last_name) sanitized_last_name = sanitize_display_name(user.last_name)
db_user = await user_dal.get_user_by_id(session, user_id) db_user = await user_dal.get_user_by_id(session, user_id)
is_existing_user = db_user is not None
if not db_user: if not db_user:
user_data_to_create = { user_data_to_create = {
"user_id": user_id, "user_id": user_id,
@@ -449,50 +386,6 @@ async def start_command_handler(message: types.Message,
f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}." f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}."
) )
# Auto-grant referral welcome bonus to newly registered referred users.
referral_welcome_days = max(
0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0)
)
if referred_by_user_id and referral_welcome_days > 0:
try:
referral_bonus_end_date = await subscription_service.extend_active_subscription_days(
session,
user_id,
referral_welcome_days,
reason="referral_welcome_bonus",
)
if referral_bonus_end_date:
await session.commit()
logging.info(
"Referral welcome bonus applied: user %s got %s days, new end date %s.",
user_id,
referral_welcome_days,
referral_bonus_end_date.isoformat(),
)
await message.answer(
_(
"referral_welcome_bonus_applied",
days=referral_welcome_days,
end_date=referral_bonus_end_date.strftime("%d.%m.%Y %H:%M:%S"),
),
parse_mode="HTML",
)
else:
await session.rollback()
logging.warning(
"Referral welcome bonus was not applied for user %s (referred by %s).",
user_id,
referred_by_user_id,
)
except Exception as referral_bonus_error:
await session.rollback()
logging.error(
"Failed to apply referral welcome bonus for user %s: %s",
user_id,
referral_bonus_error,
exc_info=True,
)
# Send notification about new user registration # Send notification about new user registration
try: try:
from bot.services.notification_service import NotificationService from bot.services.notification_service import NotificationService
@@ -565,12 +458,8 @@ async def start_command_handler(message: types.Message,
db_user): db_user):
return return
open_referral_page_for_existing_user = (
should_open_referral_from_start and is_existing_user
)
# Send welcome message if not disabled # Send welcome message if not disabled
if not settings.DISABLE_WELCOME_MESSAGE and not open_referral_page_for_existing_user: if not settings.DISABLE_WELCOME_MESSAGE:
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name))) await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
# Auto-apply promo code if provided via start parameter # Auto-apply promo code if provided via start parameter
@@ -619,20 +508,12 @@ async def start_command_handler(message: types.Message,
else: else:
await session.rollback() await session.rollback()
logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}") logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}")
await message.answer(str(result), parse_mode="HTML")
# Continue to show main menu if promo failed # Continue to show main menu if promo failed
except Exception as e: except Exception as e:
logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}") logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}")
await session.rollback() await session.rollback()
if open_referral_page_for_existing_user:
from . import referral as user_referral_handlers
await user_referral_handlers.referral_command_handler(
message, settings, i18n_data, referral_service, message.bot, session
)
return
await send_main_menu(message, await send_main_menu(message,
settings, settings,
i18n_data, i18n_data,
@@ -815,38 +696,6 @@ async def main_action_callback_handler(
elif action == "language": elif action == "language":
await language_command_handler(callback, i18n_data, settings) await language_command_handler(callback, i18n_data, settings)
elif action == "info":
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language",
settings.DEFAULT_LANGUAGE)
if not i18n:
await callback.answer("Language service error.",
show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(
current_lang, key, **kwargs) if i18n else key
privacy_url = settings.PRIVACY_POLICY_URL
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
if not privacy_url and not user_agreement_url:
await callback.answer(_("error_occurred_try_again"),
show_alert=True)
return
reply_markup = get_information_links_keyboard(
current_lang,
i18n,
privacy_url,
user_agreement_url,
)
try:
await callback.message.edit_text(_(key="info_links_message"),
reply_markup=reply_markup)
except Exception:
await callback.message.answer(_(key="info_links_message"),
reply_markup=reply_markup)
await callback.answer()
elif action == "back_to_main": elif action == "back_to_main":
await send_main_menu(callback, await send_main_menu(callback,
settings, settings,
+2 -27
View File
@@ -172,30 +172,10 @@ async def my_subscription_command_handler(
except Exception: except Exception:
pass pass
return str(val) return str(val)
def _format_traffic_period(strategy: Optional[str]) -> Optional[str]:
if not strategy:
return None
strategy_upper = str(strategy).upper()
key_map = {
"MONTH": "traffic_period_month",
"WEEK": "traffic_period_week",
"DAY": "traffic_period_day",
"NO_RESET": "traffic_period_no_reset",
}
label_key = key_map.get(strategy_upper)
return get_text(label_key) if label_key else strategy_upper
def _format_used_with_period(used_display: str, period_label: Optional[str]) -> str:
if not period_label:
return used_display
return get_text("traffic_used_with_period", traffic_used=used_display, traffic_period=period_label)
period_label = _format_traffic_period(active.get("traffic_limit_strategy"))
period_label = period_label or get_text("traffic_period_unknown")
if traffic_mode: if traffic_mode:
limit_display = _fmt_gb(active.get("traffic_limit_bytes")) limit_display = _fmt_gb(active.get("traffic_limit_bytes"))
used_display = _format_used_with_period(_fmt_gb(active.get("traffic_used_bytes")), period_label) used_display = _fmt_gb(active.get("traffic_used_bytes"))
remaining_display = get_text("traffic_na") remaining_display = get_text("traffic_na")
try: try:
limit_val = active.get("traffic_limit_bytes") or 0 limit_val = active.get("traffic_limit_bytes") or 0
@@ -211,7 +191,6 @@ async def my_subscription_command_handler(
traffic_limit=limit_display, traffic_limit=limit_display,
traffic_used=used_display, traffic_used=used_display,
traffic_left=remaining_display, traffic_left=remaining_display,
traffic_period=period_label,
config_link=config_link_value, config_link=config_link_value,
) )
else: else:
@@ -223,12 +202,8 @@ async def my_subscription_command_handler(
config_link=config_link_value, config_link=config_link_value,
traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")), traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")),
traffic_used=( traffic_used=(
_format_used_with_period( f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na")
f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na"),
period_label,
)
), ),
traffic_period=period_label,
) )
base_markup = get_back_to_main_menu_markup(current_lang, i18n) base_markup = get_back_to_main_menu_markup(current_lang, i18n)
@@ -0,0 +1,40 @@
"""
Helper функция для применения скидок к платежам
Используется всеми платежными обработчиками
"""
import logging
from typing import Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from db.dal import active_discount_dal
async def apply_discount_to_payment(
session: AsyncSession,
user_id: int,
original_price: float,
promo_code_service=None
) -> Tuple[float, Optional[float], Optional[int]]:
"""
Apply active discount to payment if exists.
Returns:
(final_price, discount_amount, promo_code_id)
"""
if not promo_code_service:
return original_price, None, None
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if not active_discount:
return original_price, None, None
# Calculate discounted price
final_price, discount_amount = promo_code_service.calculate_discounted_price(
original_price, active_discount.discount_percentage
)
logging.info(
f"Applying {active_discount.discount_percentage}% discount to payment for user {user_id}: "
f"{original_price} -> {final_price}"
)
return final_price, discount_amount, active_discount.promo_code_id
@@ -18,6 +18,7 @@ async def pay_crypto_callback_handler(
i18n_data: dict, i18n_data: dict,
session: AsyncSession, session: AsyncSession,
cryptopay_service: CryptoPayService, cryptopay_service: CryptoPayService,
promo_code_service=None,
): ):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -65,6 +66,7 @@ async def pay_crypto_callback_handler(
amount=price_amount, amount=price_amount,
description=payment_description, description=payment_description,
sale_mode=sale_mode, sale_mode=sale_mode,
promo_code_service=promo_code_service,
) )
if invoice_url: if invoice_url:
@@ -21,6 +21,7 @@ async def pay_fk_callback_handler(
i18n_data: dict, i18n_data: dict,
freekassa_service: FreeKassaService, freekassa_service: FreeKassaService,
session: AsyncSession, session: AsyncSession,
promo_code_service=None,
): ):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -68,14 +69,19 @@ async def pay_fk_callback_handler(
) )
currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB" currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
# Price is already discounted at payments_subscription.py stage
# Service will handle discount metadata if needed
payment_record_payload = { payment_record_payload = {
"user_id": user_id, "user_id": user_id,
"amount": price_rub, "amount": price_rub,
"original_amount": None,
"discount_applied": None,
"currency": currency_code, "currency": currency_code,
"status": "pending_freekassa", "status": "pending_freekassa",
"description": payment_description, "description": payment_description,
"subscription_duration_months": int(months), "subscription_duration_months": int(months),
"provider": "freekassa", "provider": "freekassa",
"promo_code_id": None,
} }
try: try:
@@ -108,6 +114,8 @@ async def pay_fk_callback_handler(
extra_params={ extra_params={
"us_method": freekassa_service.payment_method_id, "us_method": freekassa_service.payment_method_id,
}, },
promo_code_service=promo_code_service,
session=session,
) )
if success: if success:
@@ -21,6 +21,7 @@ async def pay_platega_callback_handler(
i18n_data: dict, i18n_data: dict,
platega_service: PlategaService, platega_service: PlategaService,
session: AsyncSession, session: AsyncSession,
promo_code_service=None,
): ):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -68,14 +69,19 @@ async def pay_platega_callback_handler(
) )
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
# Price is already discounted at payments_subscription.py stage
# Service will handle discount metadata if needed
payment_record_payload = { payment_record_payload = {
"user_id": user_id, "user_id": user_id,
"amount": price_rub, "amount": price_rub,
"original_amount": None,
"discount_applied": None,
"currency": currency_code, "currency": currency_code,
"status": "pending_platega", "status": "pending_platega",
"description": payment_description, "description": payment_description,
"subscription_duration_months": int(months), "subscription_duration_months": int(months),
"provider": "platega", "provider": "platega",
"promo_code_id": None,
} }
try: try:
@@ -114,6 +120,8 @@ async def pay_platega_callback_handler(
currency=currency_code, currency=currency_code,
description=payment_description, description=payment_description,
payload=payload_meta, payload=payload_meta,
promo_code_service=promo_code_service,
session=session,
) )
if success: if success:
@@ -20,6 +20,7 @@ async def pay_severpay_callback_handler(
i18n_data: dict, i18n_data: dict,
severpay_service: SeverPayService, severpay_service: SeverPayService,
session: AsyncSession, session: AsyncSession,
promo_code_service=None,
): ):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -67,14 +68,19 @@ async def pay_severpay_callback_handler(
) )
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
# Price is already discounted at payments_subscription.py stage
# Service will handle discount metadata if needed
payment_record_payload = { payment_record_payload = {
"user_id": user_id, "user_id": user_id,
"amount": price_rub, "amount": price_rub,
"original_amount": None,
"discount_applied": None,
"currency": currency_code, "currency": currency_code,
"status": "pending_severpay", "status": "pending_severpay",
"description": payment_description, "description": payment_description,
"subscription_duration_months": int(months), "subscription_duration_months": int(months),
"provider": "severpay", "provider": "severpay",
"promo_code_id": None,
} }
try: try:
@@ -103,6 +109,8 @@ async def pay_severpay_callback_handler(
amount=price_rub, amount=price_rub,
currency=currency_code, currency=currency_code,
description=payment_description, description=payment_description,
promo_code_service=promo_code_service,
session=session,
) )
if success: if success:
@@ -19,6 +19,7 @@ async def pay_stars_callback_handler(
i18n_data: dict, i18n_data: dict,
session: AsyncSession, session: AsyncSession,
stars_service: StarsService, stars_service: StarsService,
promo_code_service=None,
): ):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -66,6 +67,7 @@ async def pay_stars_callback_handler(
stars_price=stars_price, stars_price=stars_price,
description=payment_description, description=payment_description,
sale_mode=sale_mode, sale_mode=sale_mode,
promo_code_service=promo_code_service,
) )
if payment_db_id: if payment_db_id:
@@ -17,6 +17,7 @@ async def select_subscription_period_callback_handler(
settings: Settings, settings: Settings,
i18n_data: dict, i18n_data: dict,
session: AsyncSession, session: AsyncSession,
promo_code_service=None, # Injected from dispatcher
): ):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -49,6 +50,29 @@ async def select_subscription_period_callback_handler(
stars_price = stars_price_source.get(months) stars_price = stars_price_source.get(months)
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
# Check for active discount and apply if exists
discount_text = ""
if promo_code_service and price_rub:
active_discount_info = await promo_code_service.get_user_active_discount(
session, callback.from_user.id
)
if active_discount_info:
discount_pct, promo_code = active_discount_info
original_price_rub = price_rub
price_rub, discount_amt = promo_code_service.calculate_discounted_price(
price_rub, discount_pct
)
discount_text = get_text(
"active_discount_notice",
code=promo_code,
discount_pct=discount_pct,
original_price=original_price_rub,
discounted_price=price_rub,
discount_amount=discount_amt
)
# Note: Stars prices typically don't get discounts (can be added if needed)
if price_rub is None: if price_rub is None:
if traffic_mode and not price_source and stars_price is not None: if traffic_mode and not price_source and stars_price is not None:
currency_methods_enabled = any( currency_methods_enabled = any(
@@ -83,6 +107,9 @@ async def select_subscription_period_callback_handler(
return return
text_content = get_text("choose_payment_method_traffic") if traffic_mode else get_text("choose_payment_method") text_content = get_text("choose_payment_method_traffic") if traffic_mode else get_text("choose_payment_method")
if discount_text:
text_content = f"{discount_text}\n\n{text_content}"
reply_markup = get_payment_method_keyboard( reply_markup = get_payment_method_keyboard(
months, months,
price_rub, price_rub,
@@ -13,7 +13,7 @@ from bot.keyboards.inline.user_keyboards import (
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.services.yookassa_service import YooKassaService from bot.services.yookassa_service import YooKassaService
from config.settings import Settings from config.settings import Settings
from db.dal import payment_dal, user_billing_dal from db.dal import payment_dal, user_billing_dal, active_discount_dal
router = Router(name="user_subscription_payments_yookassa_router") router = Router(name="user_subscription_payments_yookassa_router")
@@ -60,6 +60,7 @@ async def _initiate_yk_payment(
settings: Settings, settings: Settings,
session: AsyncSession, session: AsyncSession,
yookassa_service: YooKassaService, yookassa_service: YooKassaService,
promo_code_service, # NEW: Added promo_code_service
i18n: Optional[JsonI18n], i18n: Optional[JsonI18n],
current_lang: str, current_lang: str,
get_text, get_text,
@@ -77,6 +78,24 @@ async def _initiate_yk_payment(
if not callback.message: if not callback.message:
return False return False
# Check for active discount to save metadata (price already discounted from previous step)
original_price = None
discount_amount = None
active_promo_code_id = None
if promo_code_service:
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount:
# Price is already discounted, calculate original price backwards
discount_pct = active_discount.discount_percentage
original_price = price_rub / (1 - discount_pct / 100)
discount_amount = original_price - price_rub
active_promo_code_id = active_discount.promo_code_id
logging.info(
f"Recording {discount_pct}% discount for YooKassa payment: "
f"original {original_price:.2f} -> final {price_rub}"
)
payment_description = ( payment_description = (
get_text("payment_description_traffic", traffic_gb=_format_value(months)) get_text("payment_description_traffic", traffic_gb=_format_value(months))
if sale_mode == "traffic" if sale_mode == "traffic"
@@ -84,11 +103,14 @@ async def _initiate_yk_payment(
) )
payment_record_data = { payment_record_data = {
"user_id": user_id, "user_id": user_id,
"amount": price_rub, "amount": price_rub, # Discounted amount
"original_amount": original_price if discount_amount else None, # NEW
"discount_applied": discount_amount, # NEW
"currency": currency_code_for_yk, "currency": currency_code_for_yk,
"status": "pending_yookassa", "status": "pending_yookassa",
"description": payment_description, "description": payment_description,
"subscription_duration_months": int(months), "subscription_duration_months": int(months),
"promo_code_id": active_promo_code_id, # NEW: Link to promo code
} }
db_payment_record = None db_payment_record = None
@@ -319,7 +341,7 @@ async def _initiate_yk_payment(
@router.callback_query(F.data.startswith("pay_yk:")) @router.callback_query(F.data.startswith("pay_yk:"))
async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession): async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession, promo_code_service=None):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -417,6 +439,7 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
settings=settings, settings=settings,
session=session, session=session,
yookassa_service=yookassa_service, yookassa_service=yookassa_service,
promo_code_service=promo_code_service,
i18n=i18n, i18n=i18n,
current_lang=current_lang, current_lang=current_lang,
get_text=get_text, get_text=get_text,
@@ -435,7 +458,7 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
@router.callback_query(F.data.startswith("pay_yk_new:")) @router.callback_query(F.data.startswith("pay_yk_new:"))
async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession): async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession, promo_code_service=None):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -491,6 +514,7 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti
settings=settings, settings=settings,
session=session, session=session,
yookassa_service=yookassa_service, yookassa_service=yookassa_service,
promo_code_service=promo_code_service,
i18n=i18n, i18n=i18n,
current_lang=current_lang, current_lang=current_lang,
get_text=get_text, get_text=get_text,
@@ -653,7 +677,7 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set
@router.callback_query(F.data.startswith("pay_yk_use_saved:")) @router.callback_query(F.data.startswith("pay_yk_use_saved:"))
async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession): async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession, promo_code_service=None):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -752,6 +776,7 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett
settings=settings, settings=settings,
session=session, session=session,
yookassa_service=yookassa_service, yookassa_service=yookassa_service,
promo_code_service=promo_code_service,
i18n=i18n, i18n=i18n,
current_lang=current_lang, current_lang=current_lang,
get_text=get_text, get_text=get_text,
+1 -21
View File
@@ -64,12 +64,10 @@ def get_user_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMark
callback_data="admin_action:users_search_prompt") callback_data="admin_action:users_search_prompt")
builder.button(text=_(key="admin_ban_management_section"), builder.button(text=_(key="admin_ban_management_section"),
callback_data="admin_section:ban_management") callback_data="admin_section:ban_management")
builder.button(text=_(key="admin_user_ratings_button"),
callback_data="admin_action:user_ratings")
builder.button(text=_(key="back_to_admin_panel_button"), builder.button(text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main") callback_data="admin_action:main")
builder.adjust(2, 2, 1) builder.adjust(2, 1, 1)
return builder.as_markup() return builder.as_markup()
@@ -462,21 +460,3 @@ def get_back_to_admin_panel_keyboard(lang: str,
builder.button(text=_(key="back_to_admin_panel_button"), builder.button(text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main") callback_data="admin_action:main")
return builder.as_markup() return builder.as_markup()
def get_back_to_stats_monitoring_keyboard(lang: str,
i18n_instance) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="back_to_stats_monitoring_button"),
callback_data="admin_section:stats_monitoring")
return builder.as_markup()
def get_back_to_user_management_keyboard(lang: str,
i18n_instance) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="back_to_user_management_button"),
callback_data="admin_section:user_management")
return builder.as_markup()
+4 -27
View File
@@ -34,8 +34,7 @@ def get_main_menu_inline_keyboard(
promo_button = InlineKeyboardButton( promo_button = InlineKeyboardButton(
text=_(key="menu_apply_promo_button"), text=_(key="menu_apply_promo_button"),
callback_data="main_action:apply_promo") callback_data="main_action:apply_promo")
builder.row(referral_button) builder.row(referral_button, promo_button)
builder.row(promo_button)
language_button = InlineKeyboardButton( language_button = InlineKeyboardButton(
text=_(key="menu_language_settings_inline"), text=_(key="menu_language_settings_inline"),
@@ -56,36 +55,14 @@ def get_main_menu_inline_keyboard(
InlineKeyboardButton(text=_(key="menu_support_button"), InlineKeyboardButton(text=_(key="menu_support_button"),
url=settings.SUPPORT_LINK)) url=settings.SUPPORT_LINK))
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL if settings.TERMS_OF_SERVICE_URL:
if settings.PRIVACY_POLICY_URL or user_agreement_url:
builder.row( builder.row(
InlineKeyboardButton(text=_(key="menu_info_button"), InlineKeyboardButton(text=_(key="menu_terms_button"),
callback_data="main_action:info")) url=settings.TERMS_OF_SERVICE_URL))
return builder.as_markup() return builder.as_markup()
def get_information_links_keyboard(
lang: str,
i18n_instance,
privacy_policy_url: Optional[str],
user_agreement_url: Optional[str]) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
if privacy_policy_url:
builder.row(
InlineKeyboardButton(text=_(key="privacy_policy_button"),
url=privacy_policy_url))
if user_agreement_url:
builder.row(
InlineKeyboardButton(text=_(key="user_agreement_button"),
url=user_agreement_url))
builder.row(
InlineKeyboardButton(text=_(key="back_to_main_menu_button"),
callback_data="main_action:back_to_main"))
return builder.as_markup()
def get_language_selection_keyboard(i18n_instance, def get_language_selection_keyboard(i18n_instance,
current_lang: str) -> InlineKeyboardMarkup: current_lang: str) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs _ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs
+31 -1
View File
@@ -65,23 +65,46 @@ class CryptoPayService:
amount: float, amount: float,
description: str, description: str,
sale_mode: str = "subscription", sale_mode: str = "subscription",
promo_code_service=None,
) -> Optional[str]: ) -> Optional[str]:
if not self.configured or not self.client: if not self.configured or not self.client:
logging.error("CryptoPayService not configured") logging.error("CryptoPayService not configured")
return None return None
# Check for active discount to save metadata (price already discounted from previous step)
original_amount = None
discount_amount = None
promo_code_id = None
if promo_code_service:
from db.dal import active_discount_dal
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount:
# Price is already discounted, calculate original price backwards
discount_pct = active_discount.discount_percentage
original_amount = amount / (1 - discount_pct / 100)
discount_amount = original_amount - amount
promo_code_id = active_discount.promo_code_id
logging.info(
f"Recording {discount_pct}% discount for CryptoPay payment: "
f"original {original_amount:.2f} -> final {amount}"
)
# Create pending payment in DB and commit to persist # Create pending payment in DB and commit to persist
try: try:
payment_record = await payment_dal.create_payment_record( payment_record = await payment_dal.create_payment_record(
session, session,
{ {
"user_id": user_id, "user_id": user_id,
"amount": float(amount), "amount": amount,
"original_amount": original_amount,
"discount_applied": discount_amount,
"currency": self.settings.CRYPTOPAY_ASSET, "currency": self.settings.CRYPTOPAY_ASSET,
"status": "pending_cryptopay", "status": "pending_cryptopay",
"description": description, "description": description,
"subscription_duration_months": int(months), "subscription_duration_months": int(months),
"provider": "cryptopay", "provider": "cryptopay",
"promo_code_id": promo_code_id,
}, },
) )
await session.commit() await session.commit()
@@ -153,6 +176,12 @@ class CryptoPayService:
async with async_session_factory() as session: async with async_session_factory() as session:
try: try:
# Fetch payment record to get promo_code_id
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment_record:
logging.error(f"CryptoPay: Payment record {payment_db_id} not found")
return
await payment_dal.update_provider_payment_and_status( await payment_dal.update_provider_payment_and_status(
session, session,
payment_db_id, payment_db_id,
@@ -165,6 +194,7 @@ class CryptoPayService:
int(months) if sale_mode != "traffic" else 0, int(months) if sale_mode != "traffic" else 0,
float(invoice.amount), float(invoice.amount),
payment_db_id, payment_db_id,
promo_code_id_from_payment=payment_record.promo_code_id,
provider="cryptopay", provider="cryptopay",
sale_mode=sale_mode, sale_mode=sale_mode,
traffic_gb=traffic_gb if sale_mode == "traffic" else None, traffic_gb=traffic_gb if sale_mode == "traffic" else None,
+37
View File
@@ -78,11 +78,47 @@ class FreeKassaService:
ip_address: Optional[str] = None, ip_address: Optional[str] = None,
payment_method_id: Optional[int] = None, payment_method_id: Optional[int] = None,
extra_params: Optional[Dict[str, Any]] = None, extra_params: Optional[Dict[str, Any]] = None,
promo_code_service=None,
session=None,
) -> Tuple[bool, Dict[str, Any]]: ) -> Tuple[bool, Dict[str, Any]]:
if not self.configured: if not self.configured:
logging.error("FreeKassaService is not configured. Cannot create order.") logging.error("FreeKassaService is not configured. Cannot create order.")
return False, {"message": "service_not_configured"} return False, {"message": "service_not_configured"}
# Check for active discount to save metadata (price already discounted from previous step)
original_amount = None
discount_amount = None
promo_code_id = None
if promo_code_service and session:
from db.dal import active_discount_dal
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount:
# Price is already discounted, calculate original price backwards
discount_pct = active_discount.discount_percentage
original_amount = amount / (1 - discount_pct / 100)
discount_amount = original_amount - amount
promo_code_id = active_discount.promo_code_id
logging.info(
f"Recording {discount_pct}% discount for FreeKassa payment: "
f"original {original_amount:.2f} -> final {amount}"
)
# Update payment record with discount metadata
try:
await payment_dal.update_payment_discount_info(
session,
payment_db_id,
original_amount,
discount_amount,
promo_code_id,
)
await session.commit()
except Exception as e_update:
logging.warning(
f"FreeKassa: failed to update discount metadata for payment {payment_db_id}: {e_update}"
)
ip_address = ip_address or self.server_ip ip_address = ip_address or self.server_ip
if not ip_address: if not ip_address:
logging.error("FreeKassaService: payment IP is required but not configured.") logging.error("FreeKassaService: payment IP is required but not configured.")
@@ -293,6 +329,7 @@ class FreeKassaService:
int(months) if sale_mode != "traffic" else 0, int(months) if sale_mode != "traffic" else 0,
float(payment.amount), float(payment.amount),
payment.payment_id, payment.payment_id,
promo_code_id_from_payment=payment.promo_code_id,
provider="freekassa", provider="freekassa",
sale_mode=sale_mode, sale_mode=sale_mode,
traffic_gb=months if sale_mode == "traffic" else None, traffic_gb=months if sale_mode == "traffic" else None,
+30 -4
View File
@@ -271,15 +271,15 @@ class NotificationService:
"""Send notification about promo code activation""" """Send notification about promo code activation"""
if not self.settings.LOG_PROMO_ACTIVATIONS: if not self.settings.LOG_PROMO_ACTIVATIONS:
return return
admin_lang = self.settings.DEFAULT_LANGUAGE admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k _ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
user_display = self._format_user_display( user_display = self._format_user_display(
user_id=user_id, user_id=user_id,
username=username, username=username,
) )
message = _( message = _(
"log_promo_activation", "log_promo_activation",
user_display=user_display, user_display=user_display,
@@ -287,7 +287,33 @@ class NotificationService:
bonus_days=bonus_days, bonus_days=bonus_days,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
) )
# Send to log channel
profile_keyboard = self._build_profile_keyboard(_, user_id)
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
async def notify_discount_promo_activation(self, user_id: int, promo_code: str, discount_percentage: int,
username: Optional[str] = None):
"""Send notification about discount promo code activation"""
if not self.settings.LOG_PROMO_ACTIVATIONS:
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
user_display = self._format_user_display(
user_id=user_id,
username=username,
)
message = _(
"log_promo_discount_activation",
user_display=user_display,
promo_code=promo_code,
discount_percentage=discount_percentage,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
)
# Send to log channel # Send to log channel
profile_keyboard = self._build_profile_keyboard(_, user_id) profile_keyboard = self._build_profile_keyboard(_, user_id)
await self._send_to_log_channel(message, reply_markup=profile_keyboard) await self._send_to_log_channel(message, reply_markup=profile_keyboard)
+39 -8
View File
@@ -76,12 +76,48 @@ class PlategaService:
currency: Optional[str], currency: Optional[str],
description: str, description: str,
payload: Optional[str] = None, payload: Optional[str] = None,
promo_code_service=None,
session=None,
) -> Tuple[bool, Dict[str, Any]]: ) -> Tuple[bool, Dict[str, Any]]:
if not self.configured: if not self.configured:
logging.error("PlategaService is not configured. Cannot create transaction.") logging.error("PlategaService is not configured. Cannot create transaction.")
return False, {"message": "service_not_configured"} return False, {"message": "service_not_configured"}
session = await self._get_session() # Check for active discount to save metadata (price already discounted from previous step)
original_amount = None
discount_amount = None
promo_code_id = None
if promo_code_service and session:
from db.dal import active_discount_dal
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount:
# Price is already discounted, calculate original price backwards
discount_pct = active_discount.discount_percentage
original_amount = amount / (1 - discount_pct / 100)
discount_amount = original_amount - amount
promo_code_id = active_discount.promo_code_id
logging.info(
f"Recording {discount_pct}% discount for Platega payment: "
f"original {original_amount:.2f} -> final {amount}"
)
# Update payment record with discount metadata
try:
await payment_dal.update_payment_discount_info(
session,
payment_db_id,
original_amount,
discount_amount,
promo_code_id,
)
await session.commit()
except Exception as e_update:
logging.warning(
f"Platega: failed to update discount metadata for payment {payment_db_id}: {e_update}"
)
http_session = await self._get_session()
url = f"{self.base_url}/transaction/process" url = f"{self.base_url}/transaction/process"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
@@ -96,15 +132,9 @@ class PlategaService:
# Remove optional keys with falsy values to avoid validation errors # Remove optional keys with falsy values to avoid validation errors
clean_body = {k: v for k, v in body.items() if v not in (None, "")} clean_body = {k: v for k, v in body.items() if v not in (None, "")}
safe_headers = {
"X-MerchantId": self._auth_headers.get("X-MerchantId"),
"X-Secret": "***" if self._auth_headers.get("X-Secret") else "",
"Content-Type": self._auth_headers.get("Content-Type"),
}
logging.info("Platega create_transaction request: url=%s headers=%s body=%s", url, safe_headers, clean_body)
try: try:
async with session.post(url, json=clean_body, headers=self._auth_headers) as response: async with http_session.post(url, json=clean_body, headers=self._auth_headers) as response:
response_text = await response.text() response_text = await response.text()
try: try:
response_data = json.loads(response_text) if response_text else {} response_data = json.loads(response_text) if response_text else {}
@@ -195,6 +225,7 @@ class PlategaService:
int(payment_months) if sale_mode != "traffic" else 0, int(payment_months) if sale_mode != "traffic" else 0,
float(payment.amount), float(payment.amount),
payment.payment_id, payment.payment_id,
promo_code_id_from_payment=payment.promo_code_id,
provider="platega", provider="platega",
sale_mode=sale_mode, sale_mode=sale_mode,
traffic_gb=payment_months if sale_mode == "traffic" else None, traffic_gb=payment_months if sale_mode == "traffic" else None,
+163 -3
View File
@@ -1,12 +1,12 @@
import logging import logging
from datetime import datetime from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional, Tuple, Dict from typing import Optional, Tuple, Dict
from aiogram import Bot from aiogram import Bot
from config.settings import Settings from config.settings import Settings
from db.dal import promo_code_dal, user_dal from db.dal import promo_code_dal, user_dal, active_discount_dal
from db.models import PromoCode, User from db.models import PromoCode, User
from .subscription_service import SubscriptionService from .subscription_service import SubscriptionService
@@ -34,7 +34,7 @@ class PromoCodeService:
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw) _ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
code_input_upper = code_input.strip().upper() code_input_upper = code_input.strip().upper()
promo_data = await promo_code_dal.get_active_promo_code_by_code_str( promo_data = await promo_code_dal.get_active_bonus_promo_code_by_code_str(
session, code_input_upper) session, code_input_upper)
if not promo_data: if not promo_data:
@@ -83,3 +83,163 @@ class PromoCodeService:
return False, _("error_applying_promo_bonus") return False, _("error_applying_promo_bonus")
else: else:
return False, _("error_applying_promo_bonus") return False, _("error_applying_promo_bonus")
async def apply_discount_promo_code(
self,
session: AsyncSession,
user_id: int,
code_input: str,
user_lang: str,
) -> Tuple[bool, int | str]:
"""
Apply a discount promo code (sets active discount for user).
Returns: (success: bool, discount_percentage or error_message)
"""
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
code_input_upper = code_input.strip().upper()
# Check if user already has an active discount
existing_discount = await active_discount_dal.get_active_discount(session, user_id)
if existing_discount:
# Get the promo code for the existing discount
existing_promo = await promo_code_dal.get_promo_code_by_id(
session, existing_discount.promo_code_id
)
if existing_promo:
return False, _("discount_promo_already_active",
code=existing_promo.code,
discount_pct=existing_discount.discount_percentage)
else:
# Existing discount but promo not found - clear it and continue
await active_discount_dal.clear_active_discount(session, user_id)
# Get discount promo code
promo_data = await promo_code_dal.get_active_discount_promo_code_by_code_str(
session, code_input_upper
)
if not promo_data:
return False, _("promo_code_not_found_or_not_discount", code=code_input_upper)
# Check if user already used this code
existing_activation = await promo_code_dal.get_user_activation_for_promo(
session, promo_data.promo_code_id, user_id
)
if existing_activation:
return False, _("promo_code_already_used_by_user", code=code_input_upper)
# Set active discount
active_discount = await active_discount_dal.set_active_discount(
session,
user_id=user_id,
promo_code_id=promo_data.promo_code_id,
discount_percentage=promo_data.discount_percentage
)
if not active_discount:
# This shouldn't happen since we checked above, but just in case
return False, _("error_applying_promo_discount")
logging.info(
f"Discount promo code {code_input_upper} activated for user {user_id}: "
f"{promo_data.discount_percentage}% off"
)
return True, promo_data.discount_percentage
async def get_user_active_discount(
self,
session: AsyncSession,
user_id: int
) -> Optional[Tuple[int, str]]:
"""
Get user's active discount if any.
Returns: (discount_percentage, promo_code) or None
"""
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if not active_discount:
return None
# Fetch promo code for code string
promo = await promo_code_dal.get_promo_code_by_id(
session, active_discount.promo_code_id
)
if not promo:
# Discount exists but promo not found - clear it
await active_discount_dal.clear_active_discount(session, user_id)
return None
# Check if promo code has expired
if promo.valid_until and promo.valid_until <= datetime.now(timezone.utc):
# Promo code expired - clear the discount
logging.info(
f"Promo code {promo.code} expired (valid_until: {promo.valid_until}). "
f"Clearing active discount for user {user_id}"
)
await active_discount_dal.clear_active_discount(session, user_id)
return None
return (active_discount.discount_percentage, promo.code)
def calculate_discounted_price(
self,
original_price: float,
discount_percentage: int
) -> Tuple[float, float]:
"""
Calculate discounted price and discount amount.
Returns: (final_price, discount_amount)
"""
discount_amount = round(original_price * (discount_percentage / 100), 2)
final_price = round(original_price - discount_amount, 2)
# Ensure price doesn't go negative
if final_price < 0:
final_price = 0
discount_amount = original_price
return final_price, discount_amount
async def consume_discount(
self,
session: AsyncSession,
user_id: int,
payment_id: int
) -> bool:
"""
Consume active discount: record activation, increment usage, clear active discount.
Call this AFTER successful payment.
"""
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if not active_discount:
return False
# Record activation
activation_recorded = await promo_code_dal.record_promo_activation(
session,
active_discount.promo_code_id,
user_id,
payment_id=payment_id
)
# Increment usage
promo_incremented = await promo_code_dal.increment_promo_code_usage(
session,
active_discount.promo_code_id
)
# Clear active discount
await active_discount_dal.clear_active_discount(session, user_id)
if activation_recorded and promo_incremented:
await session.flush()
logging.info(
f"Discount consumed for user {user_id}, promo {active_discount.promo_code_id}, "
f"payment {payment_id}"
)
return True
else:
logging.error(
f"Failed to consume discount for user {user_id}, "
f"promo {active_discount.promo_code_id}"
)
return False
+39 -2
View File
@@ -99,12 +99,48 @@ class SeverPayService:
amount: float, amount: float,
currency: Optional[str], currency: Optional[str],
description: str, description: str,
promo_code_service=None,
session=None,
) -> Tuple[bool, Dict[str, Any]]: ) -> Tuple[bool, Dict[str, Any]]:
if not self.configured: if not self.configured:
logging.error("SeverPayService is not configured. Cannot create payment.") logging.error("SeverPayService is not configured. Cannot create payment.")
return False, {"message": "service_not_configured"} return False, {"message": "service_not_configured"}
session = await self._get_session() # Check for active discount to save metadata (price already discounted from previous step)
original_amount = None
discount_amount = None
promo_code_id = None
if promo_code_service and session:
from db.dal import active_discount_dal
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount:
# Price is already discounted, calculate original price backwards
discount_pct = active_discount.discount_percentage
original_amount = amount / (1 - discount_pct / 100)
discount_amount = original_amount - amount
promo_code_id = active_discount.promo_code_id
logging.info(
f"Recording {discount_pct}% discount for SeverPay payment: "
f"original {original_amount:.2f} -> final {amount}"
)
# Update payment record with discount metadata
try:
await payment_dal.update_payment_discount_info(
session,
payment_db_id,
original_amount,
discount_amount,
promo_code_id,
)
await session.commit()
except Exception as e_update:
logging.warning(
f"SeverPay: failed to update discount metadata for payment {payment_db_id}: {e_update}"
)
http_session = await self._get_session()
url = f"{self.base_url}/payin/create" url = f"{self.base_url}/payin/create"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
amount_str = self._format_amount(amount) amount_str = self._format_amount(amount)
@@ -124,7 +160,7 @@ class SeverPayService:
signed_body = self._build_signed_body(body) signed_body = self._build_signed_body(body)
try: try:
async with session.post(url, json=signed_body) as response: async with http_session.post(url, json=signed_body) as response:
response_text = await response.text() response_text = await response.text()
try: try:
response_data = json.loads(response_text) if response_text else {} response_data = json.loads(response_text) if response_text else {}
@@ -207,6 +243,7 @@ class SeverPayService:
int(payment_months) if sale_mode != "traffic" else 0, int(payment_months) if sale_mode != "traffic" else 0,
float(payment.amount), float(payment.amount),
payment.payment_id, payment.payment_id,
promo_code_id_from_payment=payment.promo_code_id,
provider="severpay", provider="severpay",
sale_mode=sale_mode, sale_mode=sale_mode,
traffic_gb=payment_months if sale_mode == "traffic" else None, traffic_gb=payment_months if sale_mode == "traffic" else None,
+32 -1
View File
@@ -1,4 +1,5 @@
import logging import logging
import math
from typing import Optional from typing import Optional
from aiogram import Bot, types from aiogram import Bot, types
@@ -27,15 +28,40 @@ class StarsService:
self.referral_service = referral_service self.referral_service = referral_service
async def create_invoice(self, session: AsyncSession, user_id: int, months: int, async def create_invoice(self, session: AsyncSession, user_id: int, months: int,
stars_price: int, description: str, sale_mode: str = "subscription") -> Optional[int]: stars_price: int, description: str, sale_mode: str = "subscription",
promo_code_service=None) -> Optional[int]:
# Apply active discount if exists (Stars use ceiling rounding)
original_stars_price = stars_price
discount_amount_stars = None
promo_code_id = None
if promo_code_service:
# Import here to avoid circular import
from bot.handlers.user.subscription.payment_discount_helper import apply_discount_to_payment
# Apply discount and round up using ceiling
final_price_float, discount_float, promo_code_id = await apply_discount_to_payment(
session, user_id, float(stars_price), promo_code_service
)
if discount_float:
# Apply ceiling rounding for fractional Stars amounts
stars_price = math.ceil(final_price_float)
discount_amount_stars = original_stars_price - stars_price
logging.info(
f"Stars discount applied: {original_stars_price} -> {final_price_float:.2f} -> {stars_price} (ceiling)"
)
payment_record_data = { payment_record_data = {
"user_id": user_id, "user_id": user_id,
"amount": float(stars_price), "amount": float(stars_price),
"original_amount": float(original_stars_price) if discount_amount_stars else None,
"discount_applied": float(discount_amount_stars) if discount_amount_stars else None,
"currency": "XTR", "currency": "XTR",
"status": "pending_stars", "status": "pending_stars",
"description": description, "description": description,
"subscription_duration_months": int(months), "subscription_duration_months": int(months),
"provider": "telegram_stars", "provider": "telegram_stars",
"promo_code_id": promo_code_id,
} }
try: try:
db_payment_record = await payment_dal.create_payment_record( db_payment_record = await payment_dal.create_payment_record(
@@ -72,6 +98,10 @@ class StarsService:
stars_amount: int, stars_amount: int,
i18n_data: dict, i18n_data: dict,
sale_mode: str = "subscription") -> None: sale_mode: str = "subscription") -> None:
# Fetch payment record to get promo_code_id
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
promo_code_id_from_payment = payment_record.promo_code_id if payment_record else None
try: try:
await payment_dal.update_provider_payment_and_status( await payment_dal.update_provider_payment_and_status(
session, payment_db_id, session, payment_db_id,
@@ -91,6 +121,7 @@ class StarsService:
int(months) if sale_mode != "traffic" else 0, int(months) if sale_mode != "traffic" else 0,
float(stars_amount), float(stars_amount),
payment_db_id, payment_db_id,
promo_code_id_from_payment=promo_code_id_from_payment,
provider="telegram_stars", provider="telegram_stars",
sale_mode=sale_mode, sale_mode=sale_mode,
traffic_gb=months if sale_mode == "traffic" else None, traffic_gb=months if sale_mode == "traffic" else None,
+38 -45
View File
@@ -5,7 +5,7 @@ from typing import Optional, Dict, Any, List, Tuple
from aiogram import Bot from aiogram import Bot
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal, active_discount_dal
from bot.utils.date_utils import add_months from bot.utils.date_utils import add_months
from bot.utils.config_link import prepare_config_links from bot.utils.config_link import prepare_config_links
from db.models import User, Subscription from db.models import User, Subscription
@@ -55,33 +55,6 @@ class SubscriptionService:
except Exception: except Exception:
return False return False
def _extract_panel_traffic_details(
self, panel_user_data: Dict[str, Any]
) -> Tuple[Optional[int], Optional[int], Optional[str]]:
traffic_stats = panel_user_data.get("userTraffic") or {}
used = traffic_stats.get("usedTrafficBytes")
if used is None:
used = panel_user_data.get("usedTrafficBytes")
limit = panel_user_data.get("trafficLimitBytes")
strategy = panel_user_data.get("trafficLimitStrategy")
if strategy is None:
strategy = traffic_stats.get("trafficLimitStrategy")
return used, limit, strategy
def _extract_lifetime_used_traffic(
self, panel_user_data: Dict[str, Any]
) -> Optional[int]:
traffic_stats = panel_user_data.get("userTraffic") or {}
lifetime = traffic_stats.get("lifetimeUsedTrafficBytes")
if lifetime is None:
lifetime = panel_user_data.get("lifetimeUsedTrafficBytes")
try:
if lifetime is None:
return None
return int(lifetime)
except (TypeError, ValueError):
return None
async def _notify_admin_panel_user_creation_failed(self, user_id: int): async def _notify_admin_panel_user_creation_failed(self, user_id: int):
if not self.bot or not self.i18n or not self.settings.ADMIN_IDS: if not self.bot or not self.i18n or not self.settings.ADMIN_IDS:
return return
@@ -471,7 +444,9 @@ class SubscriptionService:
return None return None
panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid) or {} panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid) or {}
current_used, current_limit, _ = self._extract_panel_traffic_details(panel_user_data) traffic_info = panel_user_data.get("userTraffic") or {}
current_limit = panel_user_data.get("trafficLimitBytes")
current_used = traffic_info.get("usedTrafficBytes")
active_sub = await subscription_dal.get_active_subscription_by_user_id( active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, panel_user_uuid session, user_id, panel_user_uuid
@@ -716,6 +691,35 @@ class SubscriptionService:
final_subscription_url = updated_panel_user.get("subscriptionUrl") final_subscription_url = updated_panel_user.get("subscriptionUrl")
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid) final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
# NEW: Consume discount promo code if payment had one
try:
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if payment_record and payment_record.discount_applied:
# This payment had a discount applied - consume it
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount:
# Record promo activation
await promo_code_dal.record_promo_activation(
session,
active_discount.promo_code_id,
user_id,
payment_id=payment_db_id
)
# Increment usage
await promo_code_dal.increment_promo_code_usage(
session,
active_discount.promo_code_id
)
# Clear active discount
await active_discount_dal.clear_active_discount(session, user_id)
logging.info(
f"Discount consumed for user {user_id}, promo {active_discount.promo_code_id}, "
f"payment {payment_db_id}"
)
except Exception as e:
logging.error(f"Failed to consume discount for user {user_id}, payment {payment_db_id}: {e}")
# Don't fail the subscription activation if discount consumption fails
return { return {
"subscription_id": new_or_updated_sub.subscription_id, "subscription_id": new_or_updated_sub.subscription_id,
"end_date": final_end_date, "end_date": final_end_date,
@@ -867,22 +871,13 @@ class SubscriptionService:
await user_dal.update_user(session, user_id, {"panel_user_uuid": None}) await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
return None return None
panel_lifetime_used = self._extract_lifetime_used_traffic(panel_user_data)
if (
panel_lifetime_used is not None
and db_user.lifetime_used_traffic_bytes != panel_lifetime_used
):
await user_dal.update_user(
session,
user_id,
{"lifetime_used_traffic_bytes": panel_lifetime_used},
)
if local_active_sub: if local_active_sub:
update_payload_local = {} update_payload_local = {}
panel_status = panel_user_data.get("status", "UNKNOWN").upper() panel_status = panel_user_data.get("status", "UNKNOWN").upper()
panel_expire_at_str = panel_user_data.get("expireAt") panel_expire_at_str = panel_user_data.get("expireAt")
panel_traffic_used, panel_traffic_limit, _ = self._extract_panel_traffic_details(panel_user_data) traffic_stats = panel_user_data.get("userTraffic") or {}
panel_traffic_used = traffic_stats.get("usedTrafficBytes")
panel_traffic_limit = panel_user_data.get("trafficLimitBytes")
panel_sub_uuid_from_panel = panel_user_data.get( panel_sub_uuid_from_panel = panel_user_data.get(
"subscriptionUuid" "subscriptionUuid"
) or panel_user_data.get("shortUuid") ) or panel_user_data.get("shortUuid")
@@ -935,7 +930,6 @@ class SubscriptionService:
if panel_user_data.get("expireAt") if panel_user_data.get("expireAt")
else None else None
) )
panel_traffic_used, panel_traffic_limit, panel_traffic_strategy = self._extract_panel_traffic_details(panel_user_data)
config_link_raw = panel_user_data.get("subscriptionUrl") config_link_raw = panel_user_data.get("subscriptionUrl")
display_link, connect_button_url = await prepare_config_links(self.settings, config_link_raw) display_link, connect_button_url = await prepare_config_links(self.settings, config_link_raw)
hwid_limit = panel_user_data.get("hwidDeviceLimit") hwid_limit = panel_user_data.get("hwidDeviceLimit")
@@ -948,9 +942,8 @@ class SubscriptionService:
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(), "status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
"config_link": display_link, "config_link": display_link,
"connect_button_url": connect_button_url, "connect_button_url": connect_button_url,
"traffic_limit_bytes": panel_traffic_limit, "traffic_limit_bytes": panel_user_data.get("trafficLimitBytes"),
"traffic_used_bytes": panel_traffic_used, "traffic_used_bytes": (panel_user_data.get("userTraffic") or {}).get("usedTrafficBytes"),
"traffic_limit_strategy": panel_traffic_strategy,
"user_bot_username": db_user.username, "user_bot_username": db_user.username,
"is_panel_data": True, "is_panel_data": True,
"max_devices": hwid_limit, "max_devices": hwid_limit,
+2
View File
@@ -6,8 +6,10 @@ class AdminStates(StatesGroup):
waiting_for_broadcast_message = State() waiting_for_broadcast_message = State()
confirming_broadcast = State() confirming_broadcast = State()
waiting_for_promo_details = State() waiting_for_promo_details = State()
waiting_for_promo_type_selection = State()
waiting_for_promo_code = State() waiting_for_promo_code = State()
waiting_for_promo_bonus_days = State() waiting_for_promo_bonus_days = State()
waiting_for_promo_discount_percentage = State()
waiting_for_promo_max_activations = State() waiting_for_promo_max_activations = State()
waiting_for_promo_validity_days = State() waiting_for_promo_validity_days = State()
waiting_for_promo_edit_details = State() waiting_for_promo_edit_details = State()
-8
View File
@@ -23,8 +23,6 @@ class Settings(BaseSettings):
SUPPORT_LINK: Optional[str] = Field(default=None) SUPPORT_LINK: Optional[str] = Field(default=None)
SERVER_STATUS_URL: Optional[str] = Field(default=None) SERVER_STATUS_URL: Optional[str] = Field(default=None)
TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None) TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None)
PRIVACY_POLICY_URL: Optional[str] = Field(default=None)
USER_AGREEMENT_URL: Optional[str] = Field(default=None)
REQUIRED_CHANNEL_ID: Optional[int] = Field( REQUIRED_CHANNEL_ID: Optional[int] = Field(
default=None, default=None,
description="Telegram channel ID the user must join to access the bot") description="Telegram channel ID the user must join to access the bot")
@@ -170,10 +168,6 @@ class Settings(BaseSettings):
default=True, default=True,
description="When true, referral bonuses (for inviter and referee) are applied only once per invited user - on their first successful payment." description="When true, referral bonuses (for inviter and referee) are applied only once per invited user - on their first successful payment."
) )
REFERRAL_WELCOME_BONUS_DAYS: int = Field(
default=3,
description="Welcome bonus days granted to a newly registered user who joined via referral link.",
)
LEGACY_REFS: bool = Field( LEGACY_REFS: bool = Field(
default=True, default=True,
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset." description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset."
@@ -550,8 +544,6 @@ class Settings(BaseSettings):
'PLATEGA_FAILED_URL', 'PLATEGA_FAILED_URL',
'SEVERPAY_RETURN_URL', 'SEVERPAY_RETURN_URL',
'CRYPT4_REDIRECT_URL', 'CRYPT4_REDIRECT_URL',
'PRIVACY_POLICY_URL',
'USER_AGREEMENT_URL',
mode='before', mode='before',
) )
@classmethod @classmethod
+88
View File
@@ -0,0 +1,88 @@
import logging
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy import delete
from datetime import datetime, timezone
from db.models import ActiveDiscount, PromoCode
async def set_active_discount(
session: AsyncSession,
user_id: int,
promo_code_id: int,
discount_percentage: int
) -> Optional[ActiveDiscount]:
"""
Set active discount for user.
Returns None if user already has an active discount (enforce one-at-a-time rule).
"""
# Check if user already has an active discount
existing = await get_active_discount(session, user_id)
if existing:
logging.warning(
f"User {user_id} already has active discount (promo_code_id: {existing.promo_code_id}). "
f"Cannot activate new discount {promo_code_id}."
)
return None
# Create new active discount
new_discount = ActiveDiscount(
user_id=user_id,
promo_code_id=promo_code_id,
discount_percentage=discount_percentage,
activated_at=datetime.now(timezone.utc)
)
session.add(new_discount)
await session.flush()
await session.refresh(new_discount)
logging.info(
f"Active discount set for user {user_id}: promo_code_id={promo_code_id}, "
f"discount={discount_percentage}%"
)
return new_discount
async def get_active_discount(
session: AsyncSession,
user_id: int
) -> Optional[ActiveDiscount]:
"""Get active discount for user if exists."""
stmt = select(ActiveDiscount).where(ActiveDiscount.user_id == user_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def clear_active_discount(
session: AsyncSession,
user_id: int
) -> bool:
"""
Clear active discount for user.
Returns True if discount was cleared, False if no discount was found.
"""
stmt = delete(ActiveDiscount).where(ActiveDiscount.user_id == user_id)
result = await session.execute(stmt)
await session.flush()
cleared = result.rowcount > 0
if cleared:
logging.info(f"Active discount cleared for user {user_id}")
return cleared
async def clear_active_discounts_by_promo_code(
session: AsyncSession,
promo_code_id: int
) -> int:
"""
Clear all active discounts associated with a specific promo code.
Returns the number of discounts cleared.
"""
stmt = delete(ActiveDiscount).where(ActiveDiscount.promo_code_id == promo_code_id)
result = await session.execute(stmt)
await session.flush()
count = result.rowcount
if count > 0:
logging.info(f"Cleared {count} active discount(s) for promo_code_id={promo_code_id}")
return count
+26
View File
@@ -176,6 +176,32 @@ async def update_provider_payment_and_status(
return payment return payment
async def update_payment_discount_info(
session: AsyncSession,
payment_db_id: int,
original_amount: Optional[float],
discount_applied: Optional[float],
promo_code_id: Optional[int]) -> Optional[Payment]:
"""Update payment record with discount metadata."""
payment = await get_payment_by_db_id(session, payment_db_id)
if payment:
payment.original_amount = original_amount
payment.discount_applied = discount_applied
payment.promo_code_id = promo_code_id
payment.updated_at = func.now()
await session.flush()
await session.refresh(payment)
logging.info(
f"Payment record {payment.payment_id} updated with discount info: "
f"original {original_amount}, discount {discount_applied}, promo {promo_code_id}"
)
else:
logging.warning(
f"Payment record with DB ID {payment_db_id} not found for discount info update."
)
return payment
async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]: async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]:
"""Get comprehensive financial statistics.""" """Get comprehensive financial statistics."""
from datetime import datetime, timedelta from datetime import datetime, timedelta
+43 -2
View File
@@ -43,6 +43,34 @@ async def get_active_promo_code_by_code_str(
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_active_bonus_promo_code_by_code_str(
session: AsyncSession, code_str: str) -> Optional[PromoCode]:
"""Get active bonus_days-type promo code by code string"""
stmt = select(PromoCode).where(
PromoCode.code == code_str.upper(),
PromoCode.promo_type == "bonus_days",
PromoCode.is_active == True,
PromoCode.current_activations < PromoCode.max_activations,
or_(PromoCode.valid_until == None, PromoCode.valid_until
> datetime.now(timezone.utc)))
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_active_discount_promo_code_by_code_str(
session: AsyncSession, code_str: str) -> Optional[PromoCode]:
"""Get active discount-type promo code by code string"""
stmt = select(PromoCode).where(
PromoCode.code == code_str.upper(),
PromoCode.promo_type == "discount",
PromoCode.is_active == True,
PromoCode.current_activations < PromoCode.max_activations,
or_(PromoCode.valid_until == None, PromoCode.valid_until
> datetime.now(timezone.utc)))
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_all_active_promo_codes(session: AsyncSession, async def get_all_active_promo_codes(session: AsyncSession,
limit: int = 20, limit: int = 20,
offset: int = 0) -> List[PromoCode]: offset: int = 0) -> List[PromoCode]:
@@ -104,16 +132,29 @@ async def update_promo_code(session: AsyncSession, promo_id: int,
async def delete_promo_code(session: AsyncSession, promo_id: int) -> Optional[PromoCode]: async def delete_promo_code(session: AsyncSession, promo_id: int) -> Optional[PromoCode]:
from db.dal import active_discount_dal
promo = await get_promo_code_by_id(session, promo_id) promo = await get_promo_code_by_id(session, promo_id)
if not promo: if not promo:
return None return None
# First, delete related activations due to foreign key constraint
# 1. Clear all active discounts referencing this promo code
await active_discount_dal.clear_active_discounts_by_promo_code(session, promo_id)
# 2. Set promo_code_id to NULL in payments table to avoid FK violation
stmt = update(Payment).where(Payment.promo_code_id == promo_id).values(promo_code_id=None)
await session.execute(stmt)
# 3. Delete related activations
activations = await get_promo_activations_by_code_id(session, promo_id) activations = await get_promo_activations_by_code_id(session, promo_id)
for activation in activations: for activation in activations:
await session.delete(activation) await session.delete(activation)
# 4. Delete the promo code itself
await session.delete(promo) await session.delete(promo)
await session.flush() await session.flush()
logging.info(f"Promo code '{promo.code}' (ID: {promo_id}) deleted successfully")
return promo return promo
+1 -121
View File
@@ -5,8 +5,7 @@ from typing import Optional, List, Dict, Any, Tuple
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlalchemy import update, delete, func, and_, or_, desc from sqlalchemy import update, delete, func, and_, or_
from sqlalchemy.orm import aliased
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -361,122 +360,3 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool
await session.delete(user) await session.delete(user)
await session.flush() await session.flush()
return True return True
async def get_top_users_by_traffic_used(
session: AsyncSession,
*,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Return top users by total used traffic across all subscriptions."""
safe_limit = max(1, limit)
total_traffic_used = func.coalesce(func.sum(Subscription.traffic_used_bytes), 0)
stmt = (
select(
User.user_id,
User.username,
User.first_name,
total_traffic_used.label("traffic_used_bytes"),
)
.join(Subscription, Subscription.user_id == User.user_id, isouter=True)
.group_by(User.user_id, User.username, User.first_name)
.having(total_traffic_used > 0)
.order_by(desc("traffic_used_bytes"), User.user_id.asc())
.limit(safe_limit)
)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result]
async def get_top_users_by_lifetime_traffic_used(
session: AsyncSession,
*,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Return top users by lifetime used traffic from panel data."""
safe_limit = max(1, limit)
lifetime_used = func.coalesce(User.lifetime_used_traffic_bytes, 0)
stmt = (
select(
User.user_id,
User.username,
User.first_name,
lifetime_used.label("lifetime_used_traffic_bytes"),
)
.where(lifetime_used > 0)
.order_by(desc("lifetime_used_traffic_bytes"), User.user_id.asc())
.limit(safe_limit)
)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result]
async def get_top_users_by_referrals_count(
session: AsyncSession,
*,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Return top users by number of invited users."""
safe_limit = max(1, limit)
referred_user = aliased(User)
invited_count = func.count(referred_user.user_id)
stmt = (
select(
User.user_id,
User.username,
User.first_name,
invited_count.label("invited_count"),
)
.join(referred_user, referred_user.referred_by_id == User.user_id, isouter=True)
.group_by(User.user_id, User.username, User.first_name)
.having(invited_count > 0)
.order_by(desc("invited_count"), User.user_id.asc())
.limit(safe_limit)
)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result]
async def get_top_users_by_referral_revenue(
session: AsyncSession,
*,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Return top users by total revenue brought by all invited users."""
safe_limit = max(1, limit)
referred_user = aliased(User)
referral_revenue = func.coalesce(func.sum(Payment.amount), 0.0)
stmt = (
select(
User.user_id,
User.username,
User.first_name,
referral_revenue.label("referral_revenue"),
)
.join(referred_user, referred_user.referred_by_id == User.user_id, isouter=True)
.join(
Payment,
and_(
Payment.user_id == referred_user.user_id,
Payment.status == "succeeded",
),
isouter=True,
)
.group_by(User.user_id, User.username, User.first_name)
.having(referral_revenue > 0)
.order_by(desc("referral_revenue"), User.user_id.asc())
.limit(safe_limit)
)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result]
+51 -8
View File
@@ -113,15 +113,58 @@ def _migration_0003_normalize_referral_codes(connection: Connection) -> None:
) )
def _migration_0004_add_lifetime_used_traffic(connection: Connection) -> None: def _migration_0004_add_discount_promo_codes(connection: Connection) -> None:
inspector = inspect(connection) inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
if "lifetime_used_traffic_bytes" in columns:
return
# 1. Добавить поля в payments
payment_columns: Set[str] = {col["name"] for col in inspector.get_columns("payments")}
if "original_amount" not in payment_columns:
connection.execute(text("ALTER TABLE payments ADD COLUMN original_amount FLOAT"))
if "discount_applied" not in payment_columns:
connection.execute(text("ALTER TABLE payments ADD COLUMN discount_applied FLOAT"))
# 2. Модифицировать promo_codes
promo_columns: Set[str] = {col["name"] for col in inspector.get_columns("promo_codes")}
if "promo_type" not in promo_columns:
connection.execute(
text(
"ALTER TABLE promo_codes ADD COLUMN promo_type VARCHAR NOT NULL DEFAULT 'bonus_days'"
)
)
if "discount_percentage" not in promo_columns:
connection.execute(
text("ALTER TABLE promo_codes ADD COLUMN discount_percentage INTEGER")
)
# Изменить bonus_days на nullable (если еще не nullable)
connection.execute(
text("ALTER TABLE promo_codes ALTER COLUMN bonus_days DROP NOT NULL")
)
# Создать индекс на promo_type
connection.execute( connection.execute(
text( text(
"ALTER TABLE users ADD COLUMN lifetime_used_traffic_bytes BIGINT" "CREATE INDEX IF NOT EXISTS idx_promo_codes_promo_type ON promo_codes (promo_type)"
)
)
# 3. Создать таблицу active_discounts
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS active_discounts (
user_id BIGINT PRIMARY KEY,
promo_code_id INTEGER NOT NULL,
discount_percentage INTEGER NOT NULL,
activated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_active_discounts_user
FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE,
CONSTRAINT fk_active_discounts_promo_code
FOREIGN KEY (promo_code_id) REFERENCES promo_codes (promo_code_id) ON DELETE CASCADE
)
"""
) )
) )
@@ -142,9 +185,9 @@ MIGRATIONS: List[Migration] = [
upgrade=_migration_0003_normalize_referral_codes, upgrade=_migration_0003_normalize_referral_codes,
), ),
Migration( Migration(
id="0004_add_lifetime_used_traffic", id="0004_add_discount_promo_codes",
description="Store lifetime traffic usage for users", description="Add support for percentage discount promo codes",
upgrade=_migration_0004_add_lifetime_used_traffic, upgrade=_migration_0004_add_discount_promo_codes,
), ),
] ]
+30 -3
View File
@@ -25,7 +25,6 @@ class User(Base):
referred_by_id = Column(BigInteger, referred_by_id = Column(BigInteger,
ForeignKey("users.user_id"), ForeignKey("users.user_id"),
nullable=True) nullable=True)
lifetime_used_traffic_bytes = Column(BigInteger, nullable=True)
channel_subscription_verified = Column(Boolean, nullable=True) channel_subscription_verified = Column(Boolean, nullable=True)
channel_subscription_checked_at = Column(DateTime(timezone=True), channel_subscription_checked_at = Column(DateTime(timezone=True),
nullable=True) nullable=True)
@@ -101,7 +100,12 @@ class Payment(Base):
provider_payment_id = Column(String, unique=True, nullable=True) provider_payment_id = Column(String, unique=True, nullable=True)
provider = Column(String, nullable=False, default="yookassa", index=True) provider = Column(String, nullable=False, default="yookassa", index=True)
idempotence_key = Column(String, unique=True, nullable=True) idempotence_key = Column(String, unique=True, nullable=True)
amount = Column(Float, nullable=False) amount = Column(Float, nullable=False) # Final amount paid (after discount if any)
# Discount tracking fields
original_amount = Column(Float, nullable=True) # Amount before discount
discount_applied = Column(Float, nullable=True) # Discount amount (not percentage)
currency = Column(String, nullable=False) currency = Column(String, nullable=False)
status = Column(String, nullable=False, index=True) status = Column(String, nullable=False, index=True)
description = Column(String, nullable=True) description = Column(String, nullable=True)
@@ -155,7 +159,17 @@ class PromoCode(Base):
promo_code_id = Column(Integer, primary_key=True, autoincrement=True) promo_code_id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String, unique=True, nullable=False, index=True) code = Column(String, unique=True, nullable=False, index=True)
bonus_days = Column(Integer, nullable=False)
# Type field to distinguish promo code types
promo_type = Column(String, nullable=False, default="bonus_days", index=True)
# Values: "bonus_days" or "discount"
# For bonus_days type: number of days to add to subscription
bonus_days = Column(Integer, nullable=True)
# For discount type: percentage discount (1-100)
discount_percentage = Column(Integer, nullable=True)
max_activations = Column(Integer, nullable=False) max_activations = Column(Integer, nullable=False)
current_activations = Column(Integer, default=0) current_activations = Column(Integer, default=0)
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
@@ -192,6 +206,19 @@ class PromoCodeActivation(Base):
name='uq_promo_user_activation'), ) name='uq_promo_user_activation'), )
class ActiveDiscount(Base):
"""Tracks pending discount promo codes awaiting payment (permanent until used)"""
__tablename__ = "active_discounts"
user_id = Column(BigInteger, ForeignKey("users.user_id"), primary_key=True)
promo_code_id = Column(Integer, ForeignKey("promo_codes.promo_code_id"), nullable=False)
discount_percentage = Column(Integer, nullable=False)
activated_at = Column(DateTime(timezone=True), server_default=func.now())
promo_code = relationship("PromoCode")
user = relationship("User")
class MessageLog(Base): class MessageLog(Base):
__tablename__ = "message_logs" __tablename__ = "message_logs"
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
remnawave-tg-shop: remnawave-tg-shop:
image: ghcr.io/3252a8/remnawave-tg-shop:${IMAGE_TAG:-latest} image: kavore/remnawave-tg-shop:latest
container_name: remnawave-tg-shop container_name: remnawave-tg-shop
hostname: remnawave-tg-shop hostname: remnawave-tg-shop
env_file: env_file:
+2 -4
View File
@@ -1,7 +1,7 @@
services: services:
remnawave-tg-shop: remnawave-tg-shop:
# image: ghcr.io/3252a8/remnawave-tg-shop:${IMAGE_TAG:-latest} image: kavore/remnawave-tg-shop:latest
build: . # build: .
container_name: remnawave-tg-shop container_name: remnawave-tg-shop
hostname: remnawave-tg-shop hostname: remnawave-tg-shop
env_file: env_file:
@@ -14,8 +14,6 @@ services:
depends_on: depends_on:
remnawave-tg-shop-db: remnawave-tg-shop-db:
condition: service_healthy condition: service_healthy
ports:
- 127.0.0.1:8080:8080
remnawave-tg-shop-db: remnawave-tg-shop-db:
image: postgres:17 image: postgres:17
+26 -33
View File
@@ -5,22 +5,18 @@
"channel_subscription_verify_button": "Check subscription", "channel_subscription_verify_button": "Check subscription",
"channel_subscription_check_failed": "Couldn't verify the subscription. Please try again later or contact support.", "channel_subscription_check_failed": "Couldn't verify the subscription. Please try again later or contact support.",
"channel_subscription_verified_success": "✅ Subscription confirmed! You're good to go.", "channel_subscription_verified_success": "✅ Subscription confirmed! You're good to go.",
"main_menu_greeting": "Hi, {user_name}! 👋\nWhat would you like to do?\n\nTo set up the connection on your device, go to \"My subscription\" and click \"Connect\". If needed, add access days in the \"Buy\" section.", "main_menu_greeting": "Hi, {user_name}! 👋\nWhat would you like to do?",
"menu_activate_trial_button": "🆓 Free Trial", "menu_activate_trial_button": "🆓 Free Trial",
"menu_subscribe_inline": "🚀 Purchase", "menu_subscribe_inline": "🚀 Purchase",
"menu_my_subscription_inline": "🔐 My Subscription", "menu_my_subscription_inline": "🔐 My Subscription",
"no_subscription_options_available": "Subscription issuance is not configured by the bot administrator", "no_subscription_options_available": "Subscription issuance is not configured by the bot administrator",
"menu_referral_inline": "🎁 Invite friend", "menu_referral_inline": "🎁 Referrals",
"referral_no_bonuses_configured": "Sorry, the referral program is currently disabled", "referral_no_bonuses_configured": "Sorry, the referral program is currently disabled",
"menu_apply_promo_button": "🎟 Promo Code", "menu_apply_promo_button": "🎟 Promo Code",
"menu_language_settings_inline": "🌐 Language", "menu_language_settings_inline": "🌐 Language",
"menu_server_status_button": "📊 Status", "menu_server_status_button": "📊 Status",
"menu_support_button": "💬 Support", "menu_support_button": "💬 Support",
"menu_terms_button": "📄 Terms of Service", "menu_terms_button": "📄 Terms of Service",
"menu_info_button": "️ Information",
"info_links_message": "Choose a document:",
"privacy_policy_button": "🔒 Privacy Policy",
"user_agreement_button": "📄 User Agreement",
"back_to_main_menu_button": "⬅️ Back", "back_to_main_menu_button": "⬅️ Back",
"choose_language": "Choose language:", "choose_language": "Choose language:",
"language_set_alert": "Language changed!", "language_set_alert": "Language changed!",
@@ -76,15 +72,14 @@
"payment_failed": "❌ Payment failed or was cancelled. Please try again or contact support.", "payment_failed": "❌ Payment failed or was cancelled. Please try again or contact support.",
"config_link_not_available": "not available, contact support", "config_link_not_available": "not available, contact support",
"traffic_unlimited": "Unlimited", "traffic_unlimited": "Unlimited",
"traffic_period_day": "per day",
"traffic_period_week": "per week",
"traffic_period_month": "per month",
"traffic_period_no_reset": "no reset",
"traffic_used_with_period": "{traffic_used} ({traffic_period})",
"promo_code_prompt": "Please enter your promo code:", "promo_code_prompt": "Please enter your promo code:",
"promo_code_not_found": "Promo code <code>{code}</code> not found, expired, or already used the maximum number of times.", "promo_code_not_found": "Promo code <code>{code}</code> not found, expired, or already used the maximum number of times.",
"promo_code_already_used_by_user": "You have already used promo code <code>{code}</code>.", "promo_code_already_used_by_user": "You have already used promo code <code>{code}</code>.",
"promo_code_applied_success_full": "✅ Promo code applied successfully!\nSubscription active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇", "promo_code_applied_success_full": "✅ Promo code applied successfully!\nSubscription active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
"discount_promo_code_applied_success": "✅ Promo code <code>{code}</code> activated!\n\n💰 A {discount}% discount will be applied to your next purchase.\n\nSelect a plan for payment.",
"discount_promo_already_active": "❌ You already have an active discount promo code (<code>{code}</code>, -{discount_pct}%). Use it first or wait until the payment is complete.",
"promo_code_not_found_or_not_discount": "❌ Promo code <code>{code}</code> not found or is invalid.",
"active_discount_notice": "🎁 Active discount: <code>{code}</code> (-{discount_pct}%)\n💵 Price: <s>{original_price}</s> ➔ <b>{discounted_price}</b>\n💰 Savings: {discount_amount}",
"error_applying_promo_bonus": "Failed to apply promo bonus. Please try again later or contact support.", "error_applying_promo_bonus": "Failed to apply promo bonus. Please try again later or contact support.",
"promo_input_cancelled_short": "Promo code entry cancelled.", "promo_input_cancelled_short": "Promo code entry cancelled.",
"trial_feature_disabled": "Free trial is currently unavailable.", "trial_feature_disabled": "Free trial is currently unavailable.",
@@ -92,14 +87,13 @@
"trial_confirm_activate_button": "✅ Activate!", "trial_confirm_activate_button": "✅ Activate!",
"trial_activated_alert": "Trial activated!", "trial_activated_alert": "Trial activated!",
"trial_activated_details_message": "✅ Trial activated!\nYour {days}-day trial is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇", "trial_activated_details_message": "✅ Trial activated!\nYour {days}-day trial is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
"referral_welcome_bonus_applied": "🎁 You have received {days} bonus day(s) for registering via a referral link!\nYour subscription is active until {end_date}.",
"yes_button": "Yes", "yes_button": "Yes",
"no_button": "No", "no_button": "No",
"referral_program_info_new": "🎁 <b>Referral Program</b>\n\n📊 <b>Your stats:</b>\n👥 Friends invited: <b>{invited_count}</b>\n💳 Purchased subscription: <b>{purchased_count}</b>\n\n🔗 Your link:\n<code>{referral_link}</code>\n\n💰 <b>Invitation bonuses:</b>\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!", "referral_program_info_new": "🎁 <b>Referral Program</b>\n\n📊 <b>Your stats:</b>\n👥 Friends invited: <b>{invited_count}</b>\n💳 Purchased subscription: <b>{purchased_count}</b>\n\n🔗 Your link:\n<code>{referral_link}</code>\n\n💰 <b>Invitation bonuses:</b>\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!",
"referral_bonus_per_period": "\n\n🎁 For a friend's {months}-month subscription:\n ➢ You: <b>{inviter_bonus_days} days</b>\n ➢ Friend: <b>{referee_bonus_days} days</b>", "referral_bonus_per_period": "\n\n🎁 For a friend's {months}-month subscription:\n ➢ You: <b>{inviter_bonus_days} days</b>\n ➢ Friend: <b>{referee_bonus_days} days</b>",
"referral_not_available_for_traffic": "Referral bonuses are not available for traffic packages.", "referral_not_available_for_traffic": "Referral bonuses are not available for traffic packages.",
"referral_share_message_button": "📩 Message for friend", "referral_share_message_button": "📩 Message for friend",
"referral_friend_message": "🚀 Hey! Try this service - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}", "referral_friend_message": "🚀 Hey! Try this VPN - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}",
"friend_placeholder": "friend", "friend_placeholder": "friend",
"referral_bonus_inviter_notification_extended": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received {days} bonus days! Your subscription is now active until {new_end_date}.", "referral_bonus_inviter_notification_extended": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received {days} bonus days! Your subscription is now active until {new_end_date}.",
"referral_bonus_inviter_notification_new_sub": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received a {days}-day bonus subscription! It is active until {new_end_date}.", "referral_bonus_inviter_notification_new_sub": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received a {days}-day bonus subscription! It is active until {new_end_date}.",
@@ -133,18 +127,6 @@
"admin_stats_payment_item": "{status_emoji} {amount} {currency} from {user_info} ({p_status}) [{p_date}]", "admin_stats_payment_item": "{status_emoji} {amount} {currency} from {user_info} ({p_status}) [{p_date}]",
"admin_stats_no_payments_found": "No payments found yet.", "admin_stats_no_payments_found": "No payments found yet.",
"admin_view_payments_button": "💰 Payments", "admin_view_payments_button": "💰 Payments",
"admin_user_ratings_button": "🏆 User Ratings",
"back_to_stats_monitoring_button": "⬅️ To Statistics",
"admin_user_ratings_header": "🏆 <b>User Ratings (TOP {top_limit})</b>",
"admin_user_ratings_traffic_title": "📊 By used traffic",
"admin_user_ratings_traffic_month_title": "📊 By used traffic (month)",
"admin_user_ratings_traffic_lifetime_title": "📊 By used traffic (lifetime)",
"admin_user_ratings_invited_title": "👥 By invited users",
"admin_user_ratings_revenue_title": "💸 By referral revenue",
"admin_user_ratings_traffic_item": "{rank}. {user} — <b>{traffic_gb} GB</b>",
"admin_user_ratings_invited_item": "{rank}. {user} — <b>{invited_count}</b>",
"admin_user_ratings_revenue_item": "{rank}. {user} — <b>{revenue} RUB</b>",
"admin_user_ratings_empty": "No data yet.",
"admin_payments_header": "💰 <b>All Payments</b>", "admin_payments_header": "💰 <b>All Payments</b>",
"admin_no_payments_found": "No payments found.", "admin_no_payments_found": "No payments found.",
"admin_export_payments_csv": "📊 Export CSV", "admin_export_payments_csv": "📊 Export CSV",
@@ -191,6 +173,7 @@
"admin_promo_invalid_max_activations": "Max activations must be a positive number.", "admin_promo_invalid_max_activations": "Max activations must be a positive number.",
"admin_promo_invalid_validity_days": "Validity period (in days) must be a positive number.", "admin_promo_invalid_validity_days": "Validity period (in days) must be a positive number.",
"admin_promo_created_success": "✅ Promo code <code>{code}</code> created successfully!\nBonus: {bonus_days} days\nMax uses: {max_activations}\nValid until: {valid_until_str}.", "admin_promo_created_success": "✅ Promo code <code>{code}</code> created successfully!\nBonus: {bonus_days} days\nMax uses: {max_activations}\nValid until: {valid_until_str}.",
"admin_promo_created_success_discount": "✅ Promo code <code>{code}</code> created successfully!\nDiscount: {discount_percentage}%\nMax uses: {max_activations}\nValid until: {valid_until_str}.",
"admin_promo_set_validity_days": "⏰ Set validity (days)", "admin_promo_set_validity_days": "⏰ Set validity (days)",
"admin_back_to_panel": "⬅️ Back to panel", "admin_back_to_panel": "⬅️ Back to panel",
"admin_promo_unlimited": "♾️ Unlimited", "admin_promo_unlimited": "♾️ Unlimited",
@@ -225,11 +208,13 @@
"csv_no": "No", "csv_no": "No",
"admin_promo_edit_select_field": "Select a field to edit:", "admin_promo_edit_select_field": "Select a field to edit:",
"admin_promo_prompt_bonus_days": "Enter the new number of bonus days:", "admin_promo_prompt_bonus_days": "Enter the new number of bonus days:",
"admin_promo_prompt_discount_percentage": "Enter the new discount percentage (1-100):",
"admin_promo_prompt_max_activations": "Enter the new maximum number of activations:", "admin_promo_prompt_max_activations": "Enter the new maximum number of activations:",
"admin_promo_prompt_validity_days": "Enter the new validity period in days (0 for indefinite):", "admin_promo_prompt_validity_days": "Enter the new validity period in days (0 for indefinite):",
"admin_promo_edit_success": "Promo code updated successfully.", "admin_promo_edit_success": "Promo code updated successfully.",
"admin_promo_invalid_input": "Invalid input, please try again.", "admin_promo_invalid_input": "Invalid input, please try again.",
"admin_promo_edit_bonus_days": "🎁 Bonus Days", "admin_promo_edit_bonus_days": "🎁 Bonus Days",
"admin_promo_edit_discount_percentage": "💰 Discount %",
"admin_promo_edit_max_activations": "🔢 Max Activations", "admin_promo_edit_max_activations": "🔢 Max Activations",
"admin_promo_edit_validity": "⏰ Validity", "admin_promo_edit_validity": "⏰ Validity",
"admin_ban_user_prompt": "Enter user ID or @username to ban:", "admin_ban_user_prompt": "Enter user ID or @username to ban:",
@@ -262,11 +247,11 @@
"admin_broadcast_invalid_html": "❌ Invalid HTML in message. Please send valid HTML (Telegram-supported tags) or remove tags.", "admin_broadcast_invalid_html": "❌ Invalid HTML in message. Please send valid HTML (Telegram-supported tags) or remove tags.",
"error_displaying_logs_too_long": "Error: logs too long to display in one message. Try viewing logs for a specific user.", "error_displaying_logs_too_long": "Error: logs too long to display in one message. Try viewing logs for a specific user.",
"error_displaying_statistics": "Error displaying statistics.", "error_displaying_statistics": "Error displaying statistics.",
"subscription_72h_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expires in 3 days — {end_date}.\n\nPlease renew it using the button below.", "subscription_72h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 3 days — {end_date}.\n\nPlease renew it using the button below.",
"subscription_48h_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expires in 2 days — {end_date}.\n\nPlease renew it using the button below.", "subscription_48h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 2 days — {end_date}.\n\nPlease renew it using the button below.",
"subscription_24h_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expires in 1 day — {end_date}.\n\nPlease renew it using the button below.", "subscription_24h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 1 day — {end_date}.\n\nPlease renew it using the button below.",
"subscription_expired_notification": "👋 Hi, {user_name}!\n\n⛔ Your service subscription expired on {end_date}.\n\nPlease renew it using the button below.", "subscription_expired_notification": "👋 Hi, {user_name}!\n\n⛔ Your VPN subscription expired on {end_date}.\n\nPlease renew it using the button below.",
"subscription_expired_yesterday_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expired yesterday ({end_date}).\n\nPlease renew it using the button below.", "subscription_expired_yesterday_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expired yesterday ({end_date}).\n\nPlease renew it using the button below.",
"autorenew_48h_charge_tomorrow_notice": "🔔 Reminder\n\nTomorrow an automatic charge will occur to renew your subscription. If you don't want auto-renew, disable it using the button below.", "autorenew_48h_charge_tomorrow_notice": "🔔 Reminder\n\nTomorrow an automatic charge will occur to renew your subscription. If you don't want auto-renew, disable it using the button below.",
"autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.", "autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.",
"autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.", "autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.",
@@ -330,6 +315,7 @@
"log_payment_received": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n📅 Period: <b>{months} mo.</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}", "log_payment_received": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n📅 Period: <b>{months} mo.</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
"log_payment_received_traffic": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n🗂 Traffic: <b>{traffic_gb} GB</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}", "log_payment_received_traffic": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n🗂 Traffic: <b>{traffic_gb} GB</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
"log_promo_activation": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}", "log_promo_activation": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}",
"log_promo_discount_activation": "💰 <b>Discount Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n💵 Discount: <b>{discount_percentage}%</b>\n🕐 Time: {timestamp}",
"log_trial_activation": "🆓 <b>Trial Activated</b>\n\n👤 User: {user_display}\n⏰ Valid until: <b>{end_date}</b>\n🕐 Time: {timestamp}", "log_trial_activation": "🆓 <b>Trial Activated</b>\n\n👤 User: {user_display}\n⏰ Valid until: <b>{end_date}</b>\n🕐 Time: {timestamp}",
"log_panel_sync": "{status_emoji} <b>Panel Synchronization</b>\n\n📊 Status: <b>{status}</b>\n👥 Users processed: <b>{users_processed}</b>\n📋 Subscriptions synced: <b>{subs_synced}</b>\n🕐 Time: {timestamp}\n\n📝 Details:\n{details}", "log_panel_sync": "{status_emoji} <b>Panel Synchronization</b>\n\n📊 Status: <b>{status}</b>\n👥 Users processed: <b>{users_processed}</b>\n📋 Subscriptions synced: <b>{subs_synced}</b>\n🕐 Time: {timestamp}\n\n📝 Details:\n{details}",
"log_suspicious_promo": "⚠️ <b>Suspicious Promo Code Attempt</b>\n\n👤 User: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Input: <pre>{suspicious_input}</pre>\n🕐 Time: {timestamp}", "log_suspicious_promo": "⚠️ <b>Suspicious Promo Code Attempt</b>\n\n👤 User: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Input: <pre>{suspicious_input}</pre>\n🕐 Time: {timestamp}",
@@ -357,10 +343,16 @@
"admin_bulk_promo_invalid_quantity": "❌ Quantity must be between 1 and 1000", "admin_bulk_promo_invalid_quantity": "❌ Quantity must be between 1 and 1000",
"admin_bulk_promo_enter_validity_days": "⏰ Enter the number of validity days for promo codes (1-365):", "admin_bulk_promo_enter_validity_days": "⏰ Enter the number of validity days for promo codes (1-365):",
"admin_bulk_promo_creating": "⏳ Creating {quantity} promo codes...", "admin_bulk_promo_creating": "⏳ Creating {quantity} promo codes...",
"admin_promo_step0_type": "Choose promo code type:",
"admin_promo_type_bonus_days": "🎁 Bonus Days (subscription extension)",
"admin_promo_type_discount": "💰 Purchase Discount (%)",
"admin_promo_step1_code": "🎟 <b>Create Promo Code</b>\n\n<b>Step 1 of 4:</b> Promo Code\n\nEnter promo code (3-30 characters, letters and numbers only):", "admin_promo_step1_code": "🎟 <b>Create Promo Code</b>\n\n<b>Step 1 of 4:</b> Promo Code\n\nEnter promo code (3-30 characters, letters and numbers only):",
"admin_promo_step2_bonus_days": "🎟 <b>Create Promo Code</b>\n\n<b>Step 2 of 4:</b> Bonus Days\n\nCode: <b>{code}</b>\n\nEnter the number of bonus days (1-365):", "admin_promo_step2_bonus_days": "🎟 <b>Create Promo Code</b>\n\n<b>Step 2 of 4:</b> Bonus Days\n\nCode: <b>{code}</b>\n\nEnter the number of bonus days (1-365):",
"admin_promo_step2_discount_percentage": "🎟 <b>Create Promo Code</b>\n\n<b>Step 2 of 4:</b> Discount Percentage\n\nCode: <b>{code}</b>\n\nEnter the discount percentage for the promo code (1-100):",
"admin_promo_step3_max_activations": "🎟 <b>Create Promo Code</b>\n\n<b>Step 3 of 4:</b> Max Activations\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\n\nEnter the maximum number of activations (1-10000):", "admin_promo_step3_max_activations": "🎟 <b>Create Promo Code</b>\n\n<b>Step 3 of 4:</b> Max Activations\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\n\nEnter the maximum number of activations (1-10000):",
"admin_promo_step3_max_activations_discount": "🎟 <b>Create Promo Code</b>\n\n<b>Step 3 of 4:</b> Max Activations\n\nCode: <b>{code}</b>\nDiscount: <b>{discount_percentage}%</b>\n\nEnter the maximum number of activations (1-10000):",
"admin_promo_step4_validity": "🎟 <b>Create Promo Code</b>\n\n<b>Step 4 of 4:</b> Validity Period\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\nMax activations: <b>{max_activations}</b>\n\nChoose the validity period for the promo code:", "admin_promo_step4_validity": "🎟 <b>Create Promo Code</b>\n\n<b>Step 4 of 4:</b> Validity Period\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\nMax activations: <b>{max_activations}</b>\n\nChoose the validity period for the promo code:",
"admin_promo_step4_validity_discount": "🎟 <b>Create Promo Code</b>\n\n<b>Step 4 of 4:</b> Validity Period\n\nCode: <b>{code}</b>\nDiscount: <b>{discount_percentage}%</b>\nMax activations: <b>{max_activations}</b>\n\nChoose the validity period for the promo code:",
"admin_promo_code_already_exists": "❌ A promo code with this code already exists", "admin_promo_code_already_exists": "❌ A promo code with this code already exists",
"admin_promo_unlimited_validity": "♾️ Unlimited", "admin_promo_unlimited_validity": "♾️ Unlimited",
"admin_promo_enter_validity_days": "⏰ Enter the number of validity days for the promo code (1-365):", "admin_promo_enter_validity_days": "⏰ Enter the number of validity days for the promo code (1-365):",
@@ -395,7 +387,9 @@
"admin_promo_management_title": "🎟 <b>Promo Code Management</b>\n\nSelect a promo code for detailed view:", "admin_promo_management_title": "🎟 <b>Promo Code Management</b>\n\nSelect a promo code for detailed view:",
"admin_promo_management_empty": "📭 No promo codes available", "admin_promo_management_empty": "📭 No promo codes available",
"admin_promo_card_title": "🎟 <b>Promo Code: {code}</b>", "admin_promo_card_title": "🎟 <b>Promo Code: {code}</b>",
"admin_promo_card_type": "📌 Type: {type}",
"admin_promo_card_bonus_days": "🎁 Bonus days: <b>{days}</b>", "admin_promo_card_bonus_days": "🎁 Bonus days: <b>{days}</b>",
"admin_promo_card_discount_percentage": "💰 Discount: <b>{percentage}%</b>",
"admin_promo_card_activations": "🔢 Activations: <b>{current}/{max}</b>", "admin_promo_card_activations": "🔢 Activations: <b>{current}/{max}</b>",
"admin_promo_card_validity": "⏰ Valid until: <b>{validity}</b>", "admin_promo_card_validity": "⏰ Valid until: <b>{validity}</b>",
"admin_promo_card_status": "📊 Status: <b>{status}</b>", "admin_promo_card_status": "📊 Status: <b>{status}</b>",
@@ -450,10 +444,9 @@
"admin_payments_pagination_info": "📊 Showing {shown} of {total} payments (page {current_page}/{total_pages})", "admin_payments_pagination_info": "📊 Showing {shown} of {total} payments (page {current_page}/{total_pages})",
"admin_payment_traffic_label": "🗂 Traffic: <b>{traffic_gb} GB</b>", "admin_payment_traffic_label": "🗂 Traffic: <b>{traffic_gb} GB</b>",
"admin_payment_months_label": "📅 Period: <b>{months} mo.</b>", "admin_payment_months_label": "📅 Period: <b>{months} mo.</b>",
"my_subscription_details": "🔐 <b>My Subscription</b>\n\n⏰ Status: <b>{status}</b>\n📅 Active until: <b>{end_date}</b>\n📆 Days left: <b>{days_left}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic ({traffic_period}):\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>", "my_subscription_details": "🔐 <b>My Subscription</b>\n\n⏰ Status: <b>{status}</b>\n📅 Active until: <b>{end_date}</b>\n📆 Days left: <b>{days_left}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic:\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>",
"my_traffic_details": "🔐 <b>My Traffic</b>\n\n⏰ Status: <b>{status}</b>\n📅 Valid until: <b>{end_date}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic ({traffic_period}):\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>\nLeft: <b>{traffic_left}</b>", "my_traffic_details": "🔐 <b>My Traffic</b>\n\n⏰ Status: <b>{status}</b>\n📅 Valid until: <b>{end_date}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic:\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>\nLeft: <b>{traffic_left}</b>",
"traffic_no_expiry": "no limit", "traffic_no_expiry": "no limit",
"traffic_period_unknown": "unknown",
"autorenew_enable_button": "🔄 Enable auto-renew", "autorenew_enable_button": "🔄 Enable auto-renew",
"autorenew_disable_button": "🛑 Disable auto-renew", "autorenew_disable_button": "🛑 Disable auto-renew",
"subscription_autorenew_updated": "Auto-renew settings updated.", "subscription_autorenew_updated": "Auto-renew settings updated.",
+28 -33
View File
@@ -5,22 +5,18 @@
"channel_subscription_verify_button": "Проверить подписку", "channel_subscription_verify_button": "Проверить подписку",
"channel_subscription_check_failed": "Не удалось проверить подписку. Попробуйте позже или обратитесь в поддержку.", "channel_subscription_check_failed": "Не удалось проверить подписку. Попробуйте позже или обратитесь в поддержку.",
"channel_subscription_verified_success": "✅ Подписка подтверждена! Можно продолжать.", "channel_subscription_verified_success": "✅ Подписка подтверждена! Можно продолжать.",
"main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?\n\nДля настройки подключения на устройстве перейдите в раздел \"Моя подписка\" и нажмите кнопку \"Подключиться\". При необходимости добавьте дни доступа в разделе \"Купить\".", "main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?",
"menu_activate_trial_button": "🆓 Пробный период", "menu_activate_trial_button": "🆓 Пробный период",
"menu_subscribe_inline": "🚀 Купить", "menu_subscribe_inline": "🚀 Купить",
"menu_my_subscription_inline": "🔐 Моя подписка", "menu_my_subscription_inline": "🔐 Моя подписка",
"no_subscription_options_available": "Выдача подписки не настроена администратором бота", "no_subscription_options_available": "Выдача подписки не настроена администратором бота",
"menu_referral_inline": "🎁 Пригласить друга", "menu_referral_inline": "🎁 Рефералы",
"referral_no_bonuses_configured": "Извините, реферальная программа в данный момент отключена", "referral_no_bonuses_configured": "Извините, реферальная программа в данный момент отключена",
"menu_apply_promo_button": "🎟 Промокод", "menu_apply_promo_button": "🎟 Промокод",
"menu_language_settings_inline": "🌐 Язык", "menu_language_settings_inline": "🌐 Язык",
"menu_server_status_button": "📊 Статус", "menu_server_status_button": "📊 Статус",
"menu_support_button": "💬 Поддержка", "menu_support_button": "💬 Поддержка",
"menu_terms_button": "📄 Условия сервиса", "menu_terms_button": "📄 Условия сервиса",
"menu_info_button": "ℹ️ Информация",
"info_links_message": "Выберите документ:",
"privacy_policy_button": "🔒 Политика конфиденциальности",
"user_agreement_button": "📄 Пользовательское соглашение",
"back_to_main_menu_button": "⬅️ Назад", "back_to_main_menu_button": "⬅️ Назад",
"choose_language": "Выберите язык / Select language:", "choose_language": "Выберите язык / Select language:",
"language_set_alert": "Язык изменен!", "language_set_alert": "Язык изменен!",
@@ -76,30 +72,29 @@
"payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.", "payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.",
"config_link_not_available": "недоступна, обратитесь в поддержку", "config_link_not_available": "недоступна, обратитесь в поддержку",
"traffic_unlimited": "Безлимитный", "traffic_unlimited": "Безлимитный",
"traffic_period_day": "за день",
"traffic_period_week": "за неделю",
"traffic_period_month": "за месяц",
"traffic_period_no_reset": "без сброса",
"traffic_used_with_period": "{traffic_used} ({traffic_period})",
"promo_code_prompt": "Пожалуйста, введите ваш промокод:", "promo_code_prompt": "Пожалуйста, введите ваш промокод:",
"promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.", "promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.",
"promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.", "promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.",
"promo_code_applied_success_full": "✅ Промокод успешно применен!\nПодписка активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇", "promo_code_applied_success_full": "✅ Промокод успешно применен!\nПодписка активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"discount_promo_code_applied_success": "✅ Промокод <code>{code}</code> активирован!\n\n💰 Скидка {discount}% будет применена к вашей следующей покупке.\n\nВыберите тариф для оплаты.",
"discount_promo_already_active": "❌ У вас уже есть активированный промокод на скидку (<code>{code}</code>, -{discount_pct}%). Используйте его сначала или дождитесь окончания платежа.",
"promo_code_not_found_or_not_discount": "❌ Промокод <code>{code}</code> не найден или недействителен.",
"active_discount_notice": "🎁 Активна скидка: <code>{code}</code> (-{discount_pct}%)\n💵 Цена: <s>{original_price}</s> ➔ <b>{discounted_price}</b>\n💰 Экономия: {discount_amount}",
"error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.", "error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.",
"error_applying_promo_discount": "❌ Ошибка при активации промокода. Попробуйте позже.",
"promo_input_cancelled_short": "Ввод промокода отменен.", "promo_input_cancelled_short": "Ввод промокода отменен.",
"trial_feature_disabled": "Пробный период в данный момент недоступен.", "trial_feature_disabled": "Пробный период в данный момент недоступен.",
"trial_already_had_subscription_or_trial": "Вы уже использовали пробный период или у вас была платная подписка. Пробный период доступен только один раз для новых пользователей.", "trial_already_had_subscription_or_trial": "Вы уже использовали пробный период или у вас была платная подписка. Пробный период доступен только один раз для новых пользователей.",
"trial_confirm_activate_button": "✅ Активировать!", "trial_confirm_activate_button": "✅ Активировать!",
"trial_activated_alert": "Пробный период активирован!", "trial_activated_alert": "Пробный период активирован!",
"trial_activated_details_message": "✅ Пробный доступ активирован!\nВаш триал на {days} дн. действует до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇", "trial_activated_details_message": "✅ Пробный доступ активирован!\nВаш триал на {days} дн. действует до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"referral_welcome_bonus_applied": "🎁 Вам начислено {days} бонусных дн. за регистрацию по реферальной ссылке!\nПодписка активна до {end_date}.",
"yes_button": "Да", "yes_button": "Да",
"no_button": "Нет", "no_button": "Нет",
"referral_program_info_new": "🎁 <b>Реферальная программа</b>\n\n📊 <b>Твоя статистика:</b>\n👥 Приглашено друзей: <b>{invited_count}</b>\n💳 Купили подписку: <b>{purchased_count}</b>\n\n🔗 Твоя ссылка:\n<code>{referral_link}</code>\n\n💰 <b>Бонусы за приглашения:</b>\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!", "referral_program_info_new": "🎁 <b>Реферальная программа</b>\n\n📊 <b>Твоя статистика:</b>\n👥 Приглашено друзей: <b>{invited_count}</b>\n💳 Купили подписку: <b>{purchased_count}</b>\n\n🔗 Твоя ссылка:\n<code>{referral_link}</code>\n\n💰 <b>Бонусы за приглашения:</b>\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!",
"referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: <b>{inviter_bonus_days} дн.</b>\n ➢ Друг: <b>{referee_bonus_days} дн.</b>", "referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: <b>{inviter_bonus_days} дн.</b>\n ➢ Друг: <b>{referee_bonus_days} дн.</b>",
"referral_not_available_for_traffic": "Для пакетов трафика реферальные бонусы не начисляются.", "referral_not_available_for_traffic": "Для пакетов трафика реферальные бонусы не начисляются.",
"referral_share_message_button": "📩 Сообщение для друга", "referral_share_message_button": "📩 Сообщение для друга",
"referral_friend_message": "🚀 Привет! Попробуй этот сервис - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}", "referral_friend_message": "🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}",
"friend_placeholder": "друг", "friend_placeholder": "друг",
"referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.", "referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.",
"referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.", "referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.",
@@ -133,18 +128,6 @@
"admin_stats_payment_item": "{status_emoji} {amount} {currency} от {user_info} ({p_status}) [{p_date}]", "admin_stats_payment_item": "{status_emoji} {amount} {currency} от {user_info} ({p_status}) [{p_date}]",
"admin_stats_no_payments_found": "Платежей пока нет.", "admin_stats_no_payments_found": "Платежей пока нет.",
"admin_view_payments_button": "💰 Платежи", "admin_view_payments_button": "💰 Платежи",
"admin_user_ratings_button": "🏆 Рейтинг пользователей",
"back_to_stats_monitoring_button": "⬅️ К статистике",
"admin_user_ratings_header": "🏆 <b>Рейтинг пользователей (ТОП {top_limit})</b>",
"admin_user_ratings_traffic_title": "📊 По использованному трафику",
"admin_user_ratings_traffic_month_title": "📊 По использованному трафику за месяц",
"admin_user_ratings_traffic_lifetime_title": "📊 По использованному трафику за всё время",
"admin_user_ratings_invited_title": "👥 По количеству приглашенных",
"admin_user_ratings_revenue_title": "💸 По доходу с приглашенных",
"admin_user_ratings_traffic_item": "{rank}. {user} — <b>{traffic_gb} ГБ</b>",
"admin_user_ratings_invited_item": "{rank}. {user} — <b>{invited_count}</b>",
"admin_user_ratings_revenue_item": "{rank}. {user} — <b>{revenue} RUB</b>",
"admin_user_ratings_empty": "Пока нет данных.",
"admin_payments_header": "💰 <b>Все платежи</b>", "admin_payments_header": "💰 <b>Все платежи</b>",
"admin_no_payments_found": "Платежи не найдены.", "admin_no_payments_found": "Платежи не найдены.",
"admin_export_payments_csv": "📊 Экспорт CSV", "admin_export_payments_csv": "📊 Экспорт CSV",
@@ -191,11 +174,12 @@
"admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.", "admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.",
"admin_promo_invalid_validity_days": "Срок действия промокода (в днях) должен быть положительным числом.", "admin_promo_invalid_validity_days": "Срок действия промокода (в днях) должен быть положительным числом.",
"admin_promo_created_success": "✅ Промокод <code>{code}</code> успешно создан!\nБонус: {bonus_days} дней\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}", "admin_promo_created_success": "✅ Промокод <code>{code}</code> успешно создан!\nБонус: {bonus_days} дней\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}",
"subscription_72h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекает через 3 дня — {end_date}.\n\nПродлите её по кнопке ниже.", "admin_promo_created_success_discount": " Промокод <code>{code}</code> успешно создан!\nСкидка: {discount_percentage}%\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}",
"subscription_48h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекает через 2 дня — {end_date}.\n\nПродлите её по кнопке ниже.", "subscription_72h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 3 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
"subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.", "subscription_48h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 2 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
"subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на сервис истек ({end_date}).\n\nПродлите её по кнопке ниже.", "subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.",
"subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.", "subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на VPN истек ({end_date}).\n\nПродлите её по кнопке ниже.",
"subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.",
"autorenew_48h_charge_tomorrow_notice": "🔔 Напоминание\n\nЗавтра будет автоматическое списание за продление подписки. Если вы не хотите автопродление — отключите его кнопкой ниже.", "autorenew_48h_charge_tomorrow_notice": "🔔 Напоминание\n\nЗавтра будет автоматическое списание за продление подписки. Если вы не хотите автопродление — отключите его кнопкой ниже.",
"autorenew_confirm_enable": "🔄 Включить автопродление? Перед окончанием подписки будет выполняться автосписание.", "autorenew_confirm_enable": "🔄 Включить автопродление? Перед окончанием подписки будет выполняться автосписание.",
"autorenew_confirm_disable": "🛑 Отключить автопродление? Автосписаний больше не будет.", "autorenew_confirm_disable": "🛑 Отключить автопродление? Автосписаний больше не будет.",
@@ -234,11 +218,13 @@
"csv_no": "Нет", "csv_no": "Нет",
"admin_promo_edit_select_field": "Выберите поле для редактирования:", "admin_promo_edit_select_field": "Выберите поле для редактирования:",
"admin_promo_prompt_bonus_days": "Введите новое количество бонусных дней:", "admin_promo_prompt_bonus_days": "Введите новое количество бонусных дней:",
"admin_promo_prompt_discount_percentage": "Введите новый процент скидки (от 1 до 100):",
"admin_promo_prompt_max_activations": "Введите новое максимальное количество активаций:", "admin_promo_prompt_max_activations": "Введите новое максимальное количество активаций:",
"admin_promo_prompt_validity_days": "Введите новый срок действия в днях (0 для бессрочного):", "admin_promo_prompt_validity_days": "Введите новый срок действия в днях (0 для бессрочного):",
"admin_promo_edit_success": "Промокод успешно обновлен.", "admin_promo_edit_success": "Промокод успешно обновлен.",
"admin_promo_invalid_input": "Неверный ввод, попробуйте еще раз.", "admin_promo_invalid_input": "Неверный ввод, попробуйте еще раз.",
"admin_promo_edit_bonus_days": "🎁 Бонусные дни", "admin_promo_edit_bonus_days": "🎁 Бонусные дни",
"admin_promo_edit_discount_percentage": "💰 Процент скидки",
"admin_promo_edit_max_activations": "🔢 Макс. активации", "admin_promo_edit_max_activations": "🔢 Макс. активации",
"admin_promo_edit_validity": "⏰ Срок действия", "admin_promo_edit_validity": "⏰ Срок действия",
"admin_ban_user_prompt": "Введите ID или @username пользователя для блокировки:", "admin_ban_user_prompt": "Введите ID или @username пользователя для блокировки:",
@@ -330,6 +316,7 @@
"log_payment_received": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n📅 Период: <b>{months} мес.</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}", "log_payment_received": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n📅 Период: <b>{months} мес.</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
"log_payment_received_traffic": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n🗂 Трафик: <b>{traffic_gb} ГБ</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}", "log_payment_received_traffic": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n🗂 Трафик: <b>{traffic_gb} ГБ</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
"log_promo_activation": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}", "log_promo_activation": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}",
"log_promo_discount_activation": "💰 <b>Активирован промокод на скидку</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n💵 Скидка: <b>{discount_percentage}%</b>\n🕐 Время: {timestamp}",
"log_trial_activation": "🆓 <b>Активирован триал</b>\n\n👤 Пользователь: {user_display}\n⏰ Действует до: <b>{end_date}</b>\n🕐 Время: {timestamp}", "log_trial_activation": "🆓 <b>Активирован триал</b>\n\n👤 Пользователь: {user_display}\n⏰ Действует до: <b>{end_date}</b>\n🕐 Время: {timestamp}",
"log_panel_sync": "{status_emoji} <b>Синхронизация с панелью</b>\n\n📊 Статус: <b>{status}</b>\n👥 Обработано пользователей: <b>{users_processed}</b>\n📋 Синхронизировано подписок: <b>{subs_synced}</b>\n🕐 Время: {timestamp}\n\n📝 Детали:\n{details}", "log_panel_sync": "{status_emoji} <b>Синхронизация с панелью</b>\n\n📊 Статус: <b>{status}</b>\n👥 Обработано пользователей: <b>{users_processed}</b>\n📋 Синхронизировано подписок: <b>{subs_synced}</b>\n🕐 Время: {timestamp}\n\n📝 Детали:\n{details}",
"log_suspicious_promo": "⚠️ <b>Подозрительная попытка ввода промокода</b>\n\n👤 Пользователь: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Ввод: <pre>{suspicious_input}</pre>\n🕐 Время: {timestamp}", "log_suspicious_promo": "⚠️ <b>Подозрительная попытка ввода промокода</b>\n\n👤 Пользователь: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Ввод: <pre>{suspicious_input}</pre>\n🕐 Время: {timestamp}",
@@ -357,10 +344,17 @@
"admin_bulk_promo_invalid_quantity": "❌ Количество должно быть от 1 до 1000", "admin_bulk_promo_invalid_quantity": "❌ Количество должно быть от 1 до 1000",
"admin_bulk_promo_enter_validity_days": "⏰ Введите количество дней действия промокодов (1-365):", "admin_bulk_promo_enter_validity_days": "⏰ Введите количество дней действия промокодов (1-365):",
"admin_bulk_promo_creating": "⏳ Создаю {quantity} промокодов...", "admin_bulk_promo_creating": "⏳ Создаю {quantity} промокодов...",
"admin_promo_step0_type": "Выберите тип промокода:",
"admin_promo_type_bonus_days": "🎁 Бонусные дни (продление подписки)",
"admin_promo_type_discount": "💰 Скидка на покупку (%)",
"admin_promo_step1_code": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 1 из 4:</b> Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):", "admin_promo_step1_code": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 1 из 4:</b> Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):",
"admin_promo_step2_bonus_days": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКод: <b>{code}</b>\n\nВведите количество бонусных дней (1-365):", "admin_promo_step2_bonus_days": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКод: <b>{code}</b>\n\nВведите количество бонусных дней (1-365):",
"admin_promo_step2_discount_percentage": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 2 из 4:</b> Процент скидки\n\nКод: <b>{code}</b>\n\nВведите процент скидки для промокода <code>{code}</code> (от 1 до 100):",
"admin_promo_invalid_discount_percentage": "❌ Процент скидки должен быть от 1 до 100.",
"admin_promo_step3_max_activations": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Максимальные активации\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций (1-10000):", "admin_promo_step3_max_activations": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Максимальные активации\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций (1-10000):",
"admin_promo_step3_max_activations_discount": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Максимальные активации\n\nКод: <b>{code}</b>\nСкидка: <b>{discount_percentage}%</b>\n\nВведите максимальное количество активаций (1-10000):",
"admin_promo_step4_validity": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активации: <b>{max_activations}</b>\n\nВыберите срок действия промокода:", "admin_promo_step4_validity": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активации: <b>{max_activations}</b>\n\nВыберите срок действия промокода:",
"admin_promo_step4_validity_discount": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nСкидка: <b>{discount_percentage}%</b>\nМакс. активации: <b>{max_activations}</b>\n\nВыберите срок действия промокода:",
"admin_promo_code_already_exists": "❌ Промокод с таким кодом уже существует", "admin_promo_code_already_exists": "❌ Промокод с таким кодом уже существует",
"admin_promo_unlimited_validity": "♾️ Неограниченно", "admin_promo_unlimited_validity": "♾️ Неограниченно",
"admin_promo_enter_validity_days": "⏰ Введите количество дней действия промокода (1-365):", "admin_promo_enter_validity_days": "⏰ Введите количество дней действия промокода (1-365):",
@@ -395,7 +389,9 @@
"admin_promo_management_title": "🎟 <b>Управление промокодами</b>\n\nВыберите промокод для детального просмотра:", "admin_promo_management_title": "🎟 <b>Управление промокодами</b>\n\nВыберите промокод для детального просмотра:",
"admin_promo_management_empty": "📭 Промокоды отсутствуют", "admin_promo_management_empty": "📭 Промокоды отсутствуют",
"admin_promo_card_title": "🎟 <b>Промокод: {code}</b>", "admin_promo_card_title": "🎟 <b>Промокод: {code}</b>",
"admin_promo_card_type": "📌 Тип: {type}",
"admin_promo_card_bonus_days": "🎁 Бонусные дни: <b>{days}</b>", "admin_promo_card_bonus_days": "🎁 Бонусные дни: <b>{days}</b>",
"admin_promo_card_discount_percentage": "💰 Скидка: <b>{percentage}%</b>",
"admin_promo_card_activations": "🔢 Активации: <b>{current}/{max}</b>", "admin_promo_card_activations": "🔢 Активации: <b>{current}/{max}</b>",
"admin_promo_card_validity": "⏰ Действует до: <b>{validity}</b>", "admin_promo_card_validity": "⏰ Действует до: <b>{validity}</b>",
"admin_promo_card_status": "📊 Статус: <b>{status}</b>", "admin_promo_card_status": "📊 Статус: <b>{status}</b>",
@@ -450,10 +446,9 @@
"admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})", "admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})",
"admin_payment_traffic_label": "🗂 Трафик: <b>{traffic_gb} ГБ</b>", "admin_payment_traffic_label": "🗂 Трафик: <b>{traffic_gb} ГБ</b>",
"admin_payment_months_label": "📅 Период: <b>{months} мес.</b>", "admin_payment_months_label": "📅 Период: <b>{months} мес.</b>",
"my_subscription_details": "🔐 <b>Моя подписка</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n📆 Осталось дней: <b>{days_left}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик ({traffic_period}):\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>", "my_subscription_details": "🔐 <b>Моя подписка</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n📆 Осталось дней: <b>{days_left}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик:\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>",
"my_traffic_details": "🔐 <b>Мой трафик</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик ({traffic_period}):\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>\nОсталось: <b>{traffic_left}</b>", "my_traffic_details": "🔐 <b>Мой трафик</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик:\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>\nОсталось: <b>{traffic_left}</b>",
"traffic_no_expiry": "без ограничения", "traffic_no_expiry": "без ограничения",
"traffic_period_unknown": "неизвестно",
"autorenew_enable_button": "🔄 Включить автопродление", "autorenew_enable_button": "🔄 Включить автопродление",
"autorenew_disable_button": "🛑 Отключить автопродление", "autorenew_disable_button": "🛑 Отключить автопродление",
"subscription_autorenew_updated": "Настройки автопродления обновлены.", "subscription_autorenew_updated": "Настройки автопродления обновлены.",