From 0701af0f3523b708c0e1d5d3a554def28f14ea6c Mon Sep 17 00:00:00 2001 From: VAQYBIN Date: Mon, 19 Jan 2026 01:06:44 +0500 Subject: [PATCH 01/14] =?UTF-8?q?feat(docs):=20=D0=94=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=BE=D1=88=D0=B0=D0=B3?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=8F=20=D0=B8=D0=BD=D1=81=D1=82=D1=80=D1=83?= =?UTF-8?q?=D0=BA=D1=86=D0=B8=D1=8F=20=D0=B2=20README.md=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=83=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA=D0=B5=20=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 148 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5312e02..2368874 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ 1. **Клонируйте репозиторий:** ```bash - git clone https://github.com/machka-pasla/remnawave-tg-shop + git clone https://github.com/kavore/remnawave-tg-shop cd remnawave-tg-shop ``` @@ -177,6 +177,152 @@ > 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут. +## Подробная инструкция для развертывания на сервере с панелью Remnawave + +### 1. Клонирование репозитория + +```bash +git clone https://github.com/kavore/remnawave-tg-shop && cd remnawave-tg-shop +``` + +### 2. Настройка переменных окружения + +```bash +cp .env.example .env && nano .env +``` + +**Обязательные поля для заполнения:** +- `BOT_TOKEN` - токен телеграмм бота, например, `234567890:ABC-DEF1234ghIkl-zyx57W2v1u123ew11` +- `ADMIN_IDS` - TG ID администраторов, например, `12345678,98765432` и т.д. (через запятую без пробелов) +- `WEBHOOK_BASE_URL` - Обязательно. Базовый URL для вебхуков, например `https://webhook.domain.com` +- `PANEL_API_URL` - URL API вашей панели Remnawave (например, `http://remnawave:3000/api` или `https://panel.domain.com/api`) +- `PANEL_API_KEY` - API ключ для доступа к панели (генерируется из UI-интерфейса панели) +- `PANEL_WEBHOOK_SECRET` - Секретный ключ для проверки вебхуков от панели (берётся из `.env` самой панели) +- `USER_SQUAD_UUIDS` - ID отрядов для новых пользователей + +### 3. Настройка Reverse Proxy (Nginx) + +Перейдите в директорию конфигурации Nginx панели Remnawave: + +```bash +cd /opt/remnawave/nginx && nano nginx.conf +``` + +Добавьте в `nginx.conf` следующую конфигурацию: + +```nginx +upstream remnawave-tg-shop { + server remnawave-tg-shop:8080; +} + +map $http_upgrade $connection_upgrade { + default upgrade; + "" close; +} + +server { + server_name webhook.domain.com; # Домен для отправки Webhook'ов + listen 443 ssl; + http2 on; + + ssl_certificate "/etc/nginx/ssl/webhook_fullchain.pem"; + ssl_certificate_key "/etc/nginx/ssl/webhook_privkey.key"; + ssl_trusted_certificate "/etc/nginx/ssl/webhook_fullchain.pem"; + + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + proxy_intercept_errors on; + error_page 400 404 500 502 @redirect; + + location / { + proxy_pass http://remnawave-tg-shop$request_uri; + } + + location @redirect { + return 404; + } +} +``` + +### 4. Выпуск SSL-сертификата для домена webhook + +Убедитесь, что установлены необходимые компоненты, а также откройте 80 порт: + +```bash +sudo apt-get install cron socat +curl https://get.acme.sh | sh -s email=EMAIL && source ~/.bashrc +ufw allow 80/tcp && ufw reload +``` + +Выпустите сертификат: + +```bash +acme.sh --set-default-ca --server letsencrypt +acme.sh --issue --standalone -d 'webhook.domain.com' \ + --key-file /opt/remnawave/nginx/webhook_privkey.key \ + --fullchain-file /opt/remnawave/nginx/webhook_fullchain.pem +``` + +### 5. Добавление сертификатов в Docker Compose Nginx + +Отредактируйте `docker-compose.yml` панели Nginx: + +```bash +cd /opt/remnawave/nginx && nano docker-compose.yml +``` + +Добавьте две строки в секцию `volumes`: + +```yaml +services: + remnawave-nginx: + image: nginx:1.26 + container_name: remnawave-nginx + hostname: remnawave-nginx + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./fullchain.pem:/etc/nginx/ssl/fullchain.pem:ro + - ./privkey.key:/etc/nginx/ssl/privkey.key:ro + - ./subdomain_fullchain.pem:/etc/nginx/ssl/subdomain_fullchain.pem:ro + - ./subdomain_privkey.key:/etc/nginx/ssl/subdomain_privkey.key:ro + - ./webhook_fullchain.pem:/etc/nginx/ssl/webhook_fullchain.pem:ro # Добавьте эту строку + - ./webhook_privkey.key:/etc/nginx/ssl/webhook_privkey.key:ro # Добавьте эту строку + restart: always + ports: + - '0.0.0.0:443:443' + networks: + - remnawave-network + +networks: + remnawave-network: + name: remnawave-network + driver: bridge + external: true +``` + +### 6. Запуск бота и перезапуск Nginx + +Запустите бота: + +```bash +cd /root/remnawave-tg-shop && docker compose up -d && docker compose logs -f -t +``` + +Перезапустите Nginx: + +```bash +cd /opt/remnawave/nginx && docker compose down && docker compose up -d && docker compose logs -f -t +``` + ## 🐳 Docker Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки. From 3bf9acf4d40cc90165ea8bcb8106d0d61b35747b Mon Sep 17 00:00:00 2001 From: VAQYBIN Date: Mon, 19 Jan 2026 03:50:33 +0500 Subject: [PATCH 02/14] =?UTF-8?q?feat(promo):=20=D0=94=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BF=D1=80=D0=BE=D0=BC=D0=BE?= =?UTF-8?q?=D0=BA=D0=BE=D0=B4=D1=8B=20=D0=BD=D0=B0=20=D1=81=D0=BA=D0=B8?= =?UTF-8?q?=D0=B4=D0=BA=D1=83=20=D0=B2=20=D0=BF=D1=80=D0=BE=D1=86=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/handlers/admin/promo/create.py | 188 +++++++++++++++--- bot/handlers/admin/promo/manage.py | 90 +++++++-- bot/handlers/user/promo_user.py | 40 +++- .../subscription/payment_discount_helper.py | 40 ++++ .../subscription/payments_subscription.py | 27 +++ .../user/subscription/payments_yookassa.py | 35 +++- bot/services/promo_code_service.py | 152 +++++++++++++- bot/services/subscription_service.py | 31 ++- bot/states/admin_states.py | 2 + db/dal/active_discount_dal.py | 71 +++++++ db/dal/promo_code_dal.py | 14 ++ db/models.py | 32 ++- locales/en.json | 10 + locales/ru.json | 12 ++ 14 files changed, 682 insertions(+), 62 deletions(-) create mode 100644 bot/handlers/user/subscription/payment_discount_helper.py create mode 100644 db/dal/active_discount_dal.py diff --git a/bot/handlers/admin/promo/create.py b/bot/handlers/admin/promo/create.py index e26cea6..ead38b0 100644 --- a/bot/handlers/admin/promo/create.py +++ b/bot/handlers/admin/promo/create.py @@ -28,25 +28,82 @@ async def create_promo_prompt_handler(callback: types.CallbackQuery, return _ = 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 = _( - "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: await callback.message.edit_text( prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + reply_markup=builder.as_markup(), parse_mode="HTML") except Exception as e: 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( prompt_text, - reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), + reply_markup=builder.as_markup(), parse_mode="HTML") 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 @@ -80,19 +137,31 @@ async def process_promo_code_handler(message: types.Message, return await state.update_data(promo_code=code_str) - - # Step 2: Ask for bonus days - prompt_text = _( - "admin_promo_step2_bonus_days", - code=code_str - ) - + + # Get promo type from state + data = await state.get_data() + promo_type = data.get("promo_type", "bonus_days") + + # 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( prompt_text, reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML" ) - await state.set_state(AdminStates.waiting_for_promo_bonus_days) + await state.set_state(next_state) except Exception as 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" )) return - + await state.update_data(bonus_days=bonus_days) - + # Step 3: Ask for max activations data = await state.get_data() prompt_text = _( @@ -129,14 +198,14 @@ async def process_promo_bonus_days_handler(message: types.Message, code=data.get("promo_code"), bonus_days=bonus_days ) - + 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" @@ -146,6 +215,53 @@ async def process_promo_bonus_days_handler(message: types.Message, 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", + code=data.get("promo_code"), + bonus_days=f"{discount_percentage}%" # Display as percentage in place of bonus_days + ) + + 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 @router.message(AdminStates.waiting_for_promo_max_activations, F.text) async def process_promo_max_activations_handler(message: types.Message, @@ -310,37 +426,53 @@ async def create_promo_code_final(callback_or_message, try: data = await state.get_data() - + promo_type = data.get("promo_type", "bonus_days") + # Prepare promo code data promo_data = { "code": data["promo_code"], - "bonus_days": data["bonus_days"], + "promo_type": promo_type, "max_activations": data["max_activations"], "current_activations": 0, "is_active": True, "created_by_admin_id": callback_or_message.from_user.id, "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 if data.get("validity_days"): promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=data["validity_days"]) else: promo_data["valid_until"] = None - + # Create promo code created_promo = await promo_code_dal.create_promo_code(session, promo_data) await session.commit() - + # 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 - 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']} days" + + # Format success message based on type + if promo_type == "discount": + value_display = f"{data['discount_percentage']}%" + else: + value_display = f"{data['bonus_days']} days" + success_text = _( "admin_promo_created_success", code=data["promo_code"], - bonus_days=data["bonus_days"], + bonus_days=value_display, # Reusing bonus_days placeholder for display max_activations=data["max_activations"], valid_until_str=valid_until_str ) @@ -385,8 +517,10 @@ async def create_promo_code_final(callback_or_message, @router.callback_query( F.data == "admin_action:main", StateFilter( + AdminStates.waiting_for_promo_type_selection, AdminStates.waiting_for_promo_code, AdminStates.waiting_for_promo_bonus_days, + AdminStates.waiting_for_promo_discount_percentage, AdminStates.waiting_for_promo_max_activations, AdminStates.waiting_for_promo_validity_days, ), diff --git a/bot/handlers/admin/promo/manage.py b/bot/handlers/admin/promo/manage.py index 9b60921..cefd1a2 100644 --- a/bot/handlers/admin/promo/manage.py +++ b/bot/handlers/admin/promo/manage.py @@ -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" + # 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([ _("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_validity", validity=validity), _("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) 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( - [_("admin_active_promos_list_header"), ""] + [ - f"{get_promo_status_emoji_and_text(p, i18n, current_lang)[0]} {p.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')}" - for p in promo_models - ] - ) + if not promo_models: + text = f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}" + else: + 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} {p.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.answer() @@ -300,7 +320,9 @@ async def promo_export_all_handler(callback: types.CallbackQuery, i18n_data: dic # CSV headers (forced to English) writer.writerow([ i18n.gettext(export_lang, "admin_promo_csv_code"), + "Type", 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_current_activations"), 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_by_admin_id"), ]) - + for promo in all_promos: # Определяем статус 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 row = [ promo.code, - promo.bonus_days, + promo_type, + bonus_days_val, + discount_val, promo.max_activations, promo.current_activations, status_text, @@ -374,13 +403,26 @@ async def promo_edit_select_handler(callback: types.CallbackQuery, i18n_data: di return _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) 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.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="💰 Edit Discount %", 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_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}")) - + await callback.message.edit_text(_("admin_promo_edit_select_field"), reply_markup=builder.as_markup()) await callback.answer() @@ -394,14 +436,22 @@ async def promo_edit_field_handler(callback: types.CallbackQuery, state: FSMCont action, field, promo_id_str = callback.data.split(":") await state.update_data(promo_id=int(promo_id_str), field_to_edit=field) - + prompts = { "bonus_days": "admin_promo_prompt_bonus_days", + "discount_percentage": "admin_promo_prompt_discount_percentage", "max_activations": "admin_promo_prompt_max_activations", "valid_until": "admin_promo_prompt_validity_days" } + + prompt_key = prompts.get(field, "error_occurred_try_again") + if field == "discount_percentage": + prompt_text = "Enter the new discount percentage (1-100):" + else: + prompt_text = _(prompt_key) + 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() @router.message(StateFilter(AdminStates.waiting_for_promo_edit_details)) @@ -418,9 +468,15 @@ async def process_promo_edit_details(message: types.Message, state: FSMContext, try: value = message.text update_data = {} - + if field == "bonus_days": 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": update_data["max_activations"] = int(value) elif field == "valid_until": @@ -433,7 +489,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): await session.commit() await message.answer(_("admin_promo_edit_success")) - + # Reset state and show updated details await state.clear() text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang) diff --git a/bot/handlers/user/promo_user.py b/bot/handlers/user/promo_user.py index 2fc02df..d68c35f 100644 --- a/bot/handlers/user/promo_user.py +++ b/bot/handlers/user/promo_user.py @@ -123,13 +123,15 @@ async def process_promo_code_input(message: types.Message, state: FSMContext, code=hcode(code_input.upper())) reply_markup = get_back_to_main_menu_markup(current_lang, i18n) else: - + # Try as BONUS code first (existing behavior) success, result = await promo_code_service.apply_promo_code( session, user.id, code_input, current_lang) + if success: + # Bonus code success await session.commit() 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 @@ -151,15 +153,35 @@ async def process_promo_code_input(message: types.Message, state: FSMContext, connect_button_url=connect_button_url, ) else: - await session.rollback() - logging.info( - f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}" - ) - response_to_user_text = result - reply_markup = get_back_to_main_menu_markup( - current_lang, i18n + # Bonus code failed, try as DISCOUNT code + success_discount, result_discount = await promo_code_service.apply_discount_promo_code( + session, user.id, code_input, current_lang ) + 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 + 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( response_to_user_text, reply_markup=reply_markup, diff --git a/bot/handlers/user/subscription/payment_discount_helper.py b/bot/handlers/user/subscription/payment_discount_helper.py new file mode 100644 index 0000000..14f5f58 --- /dev/null +++ b/bot/handlers/user/subscription/payment_discount_helper.py @@ -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 diff --git a/bot/handlers/user/subscription/payments_subscription.py b/bot/handlers/user/subscription/payments_subscription.py index 7a63e31..5cda93d 100644 --- a/bot/handlers/user/subscription/payments_subscription.py +++ b/bot/handlers/user/subscription/payments_subscription.py @@ -17,6 +17,7 @@ async def select_subscription_period_callback_handler( settings: Settings, i18n_data: dict, session: AsyncSession, + promo_code_service=None, # Injected from dispatcher ): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) 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) 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 traffic_mode and not price_source and stars_price is not None: currency_methods_enabled = any( @@ -83,6 +107,9 @@ async def select_subscription_period_callback_handler( return 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( months, price_rub, diff --git a/bot/handlers/user/subscription/payments_yookassa.py b/bot/handlers/user/subscription/payments_yookassa.py index 6566551..97475f1 100644 --- a/bot/handlers/user/subscription/payments_yookassa.py +++ b/bot/handlers/user/subscription/payments_yookassa.py @@ -13,7 +13,7 @@ from bot.keyboards.inline.user_keyboards import ( from bot.middlewares.i18n import JsonI18n from bot.services.yookassa_service import YooKassaService 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") @@ -60,6 +60,7 @@ async def _initiate_yk_payment( settings: Settings, session: AsyncSession, yookassa_service: YooKassaService, + promo_code_service, # NEW: Added promo_code_service i18n: Optional[JsonI18n], current_lang: str, get_text, @@ -77,6 +78,24 @@ async def _initiate_yk_payment( if not callback.message: return False + # NEW: Check for active discount and apply if exists + original_price = price_rub + 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: + final_price, discount_amount = promo_code_service.calculate_discounted_price( + price_rub, active_discount.discount_percentage + ) + price_rub = final_price + active_promo_code_id = active_discount.promo_code_id + logging.info( + f"Applying {active_discount.discount_percentage}% discount to YooKassa payment: " + f"{original_price} -> {price_rub}" + ) + payment_description = ( get_text("payment_description_traffic", traffic_gb=_format_value(months)) if sale_mode == "traffic" @@ -84,11 +103,14 @@ async def _initiate_yk_payment( ) payment_record_data = { "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, "status": "pending_yookassa", "description": payment_description, "subscription_duration_months": int(months), + "promo_code_id": active_promo_code_id, # NEW: Link to promo code } db_payment_record = None @@ -319,7 +341,7 @@ async def _initiate_yk_payment( @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) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") 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, session=session, yookassa_service=yookassa_service, + promo_code_service=promo_code_service, i18n=i18n, current_lang=current_lang, 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:")) -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) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") 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, session=session, yookassa_service=yookassa_service, + promo_code_service=promo_code_service, i18n=i18n, current_lang=current_lang, 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:")) -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) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") 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, session=session, yookassa_service=yookassa_service, + promo_code_service=promo_code_service, i18n=i18n, current_lang=current_lang, get_text=get_text, diff --git a/bot/services/promo_code_service.py b/bot/services/promo_code_service.py index 77ec565..6ad7ede 100644 --- a/bot/services/promo_code_service.py +++ b/bot/services/promo_code_service.py @@ -6,7 +6,7 @@ from aiogram import Bot 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 .subscription_service import SubscriptionService @@ -83,3 +83,153 @@ class PromoCodeService: return False, _("error_applying_promo_bonus") else: 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 + + 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 diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 6069228..847df40 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -5,7 +5,7 @@ from typing import Optional, Dict, Any, List, Tuple from aiogram import Bot 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.config_link import prepare_config_links from db.models import User, Subscription @@ -691,6 +691,35 @@ class SubscriptionService: final_subscription_url = updated_panel_user.get("subscriptionUrl") 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 { "subscription_id": new_or_updated_sub.subscription_id, "end_date": final_end_date, diff --git a/bot/states/admin_states.py b/bot/states/admin_states.py index 68b0c4a..aa11270 100644 --- a/bot/states/admin_states.py +++ b/bot/states/admin_states.py @@ -6,8 +6,10 @@ class AdminStates(StatesGroup): waiting_for_broadcast_message = State() confirming_broadcast = State() waiting_for_promo_details = State() + waiting_for_promo_type_selection = State() waiting_for_promo_code = State() waiting_for_promo_bonus_days = State() + waiting_for_promo_discount_percentage = State() waiting_for_promo_max_activations = State() waiting_for_promo_validity_days = State() waiting_for_promo_edit_details = State() diff --git a/db/dal/active_discount_dal.py b/db/dal/active_discount_dal.py new file mode 100644 index 0000000..846bd80 --- /dev/null +++ b/db/dal/active_discount_dal.py @@ -0,0 +1,71 @@ +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 diff --git a/db/dal/promo_code_dal.py b/db/dal/promo_code_dal.py index 00adb21..e4025d6 100644 --- a/db/dal/promo_code_dal.py +++ b/db/dal/promo_code_dal.py @@ -43,6 +43,20 @@ async def get_active_promo_code_by_code_str( 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, limit: int = 20, offset: int = 0) -> List[PromoCode]: diff --git a/db/models.py b/db/models.py index 843300f..5547f20 100644 --- a/db/models.py +++ b/db/models.py @@ -100,7 +100,12 @@ class Payment(Base): provider_payment_id = Column(String, unique=True, nullable=True) provider = Column(String, nullable=False, default="yookassa", index=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) status = Column(String, nullable=False, index=True) description = Column(String, nullable=True) @@ -154,7 +159,17 @@ class PromoCode(Base): promo_code_id = Column(Integer, primary_key=True, autoincrement=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) current_activations = Column(Integer, default=0) is_active = Column(Boolean, default=True) @@ -191,6 +206,19 @@ class PromoCodeActivation(Base): 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): __tablename__ = "message_logs" diff --git a/locales/en.json b/locales/en.json index 7732f34..bcf391e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -76,6 +76,10 @@ "promo_code_not_found": "Promo 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}.", "promo_code_applied_success_full": "✅ Promo code applied successfully!\nSubscription active until {end_date}.\n\nConnection key:\n{config_link}\n\nTo connect, open the link and follow the instructions 👇", + "discount_promo_code_applied_success": "✅ Promo 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}, -{discount_pct}%). Use it first or wait until the payment is complete.", + "promo_code_not_found_or_not_discount": "❌ Promo code {code} not found or is invalid.", + "active_discount_notice": "🎁 Active discount: {code} (-{discount_pct}%)\n💵 Price: {original_price}{discounted_price}\n💰 Savings: {discount_amount}", "error_applying_promo_bonus": "Failed to apply promo bonus. Please try again later or contact support.", "promo_input_cancelled_short": "Promo code entry cancelled.", "trial_feature_disabled": "Free trial is currently unavailable.", @@ -335,8 +339,12 @@ "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_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": "🎟 Create Promo Code\n\nStep 1 of 4: Promo Code\n\nEnter promo code (3-30 characters, letters and numbers only):", "admin_promo_step2_bonus_days": "🎟 Create Promo Code\n\nStep 2 of 4: Bonus Days\n\nCode: {code}\n\nEnter the number of bonus days (1-365):", + "admin_promo_step2_discount_percentage": "🎟 Create Promo Code\n\nStep 2 of 4: Discount Percentage\n\nCode: {code}\n\nEnter the discount percentage for the promo code (1-100):", "admin_promo_step3_max_activations": "🎟 Create Promo Code\n\nStep 3 of 4: Max Activations\n\nCode: {code}\nBonus days: {bonus_days}\n\nEnter the maximum number of activations (1-10000):", "admin_promo_step4_validity": "🎟 Create Promo Code\n\nStep 4 of 4: Validity Period\n\nCode: {code}\nBonus days: {bonus_days}\nMax activations: {max_activations}\n\nChoose the validity period for the promo code:", "admin_promo_code_already_exists": "❌ A promo code with this code already exists", @@ -373,7 +381,9 @@ "admin_promo_management_title": "🎟 Promo Code Management\n\nSelect a promo code for detailed view:", "admin_promo_management_empty": "📭 No promo codes available", "admin_promo_card_title": "🎟 Promo Code: {code}", + "admin_promo_card_type": "📌 Type: {type}", "admin_promo_card_bonus_days": "🎁 Bonus days: {days}", + "admin_promo_card_discount_percentage": "💰 Discount: {percentage}%", "admin_promo_card_activations": "🔢 Activations: {current}/{max}", "admin_promo_card_validity": "⏰ Valid until: {validity}", "admin_promo_card_status": "📊 Status: {status}", diff --git a/locales/ru.json b/locales/ru.json index de5f44d..eed21ec 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -76,7 +76,12 @@ "promo_code_not_found": "Промокод {code} не найден, истек или уже использован максимальное количество раз.", "promo_code_already_used_by_user": "Вы уже активировали промокод {code}.", "promo_code_applied_success_full": "✅ Промокод успешно применен!\nПодписка активна до {end_date}.\n\nКлюч подключения:\n{config_link}\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇", + "discount_promo_code_applied_success": "✅ Промокод {code} активирован!\n\n💰 Скидка {discount}% будет применена к вашей следующей покупке.\n\nВыберите тариф для оплаты.", + "discount_promo_already_active": "❌ У вас уже есть активированный промокод на скидку ({code}, -{discount_pct}%). Используйте его сначала или дождитесь окончания платежа.", + "promo_code_not_found_or_not_discount": "❌ Промокод {code} не найден или недействителен.", + "active_discount_notice": "🎁 Активна скидка: {code} (-{discount_pct}%)\n💵 Цена: {original_price}{discounted_price}\n💰 Экономия: {discount_amount}", "error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.", + "error_applying_promo_discount": "❌ Ошибка при активации промокода. Попробуйте позже.", "promo_input_cancelled_short": "Ввод промокода отменен.", "trial_feature_disabled": "Пробный период в данный момент недоступен.", "trial_already_had_subscription_or_trial": "Вы уже использовали пробный период или у вас была платная подписка. Пробный период доступен только один раз для новых пользователей.", @@ -335,8 +340,13 @@ "admin_bulk_promo_invalid_quantity": "❌ Количество должно быть от 1 до 1000", "admin_bulk_promo_enter_validity_days": "⏰ Введите количество дней действия промокодов (1-365):", "admin_bulk_promo_creating": "⏳ Создаю {quantity} промокодов...", + "admin_promo_step0_type": "Выберите тип промокода:", + "admin_promo_type_bonus_days": "🎁 Бонусные дни (продление подписки)", + "admin_promo_type_discount": "💰 Скидка на покупку (%)", "admin_promo_step1_code": "🎟 Создание промокода\n\nШаг 1 из 4: Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):", "admin_promo_step2_bonus_days": "🎟 Создание промокода\n\nШаг 2 из 4: Бонусные дни\n\nКод: {code}\n\nВведите количество бонусных дней (1-365):", + "admin_promo_step2_discount_percentage": "🎟 Создание промокода\n\nШаг 2 из 4: Процент скидки\n\nКод: {code}\n\nВведите процент скидки для промокода {code} (от 1 до 100):", + "admin_promo_invalid_discount_percentage": "❌ Процент скидки должен быть от 1 до 100.", "admin_promo_step3_max_activations": "🎟 Создание промокода\n\nШаг 3 из 4: Максимальные активации\n\nКод: {code}\nБонусные дни: {bonus_days}\n\nВведите максимальное количество активаций (1-10000):", "admin_promo_step4_validity": "🎟 Создание промокода\n\nШаг 4 из 4: Срок действия\n\nКод: {code}\nБонусные дни: {bonus_days}\nМакс. активации: {max_activations}\n\nВыберите срок действия промокода:", "admin_promo_code_already_exists": "❌ Промокод с таким кодом уже существует", @@ -373,7 +383,9 @@ "admin_promo_management_title": "🎟 Управление промокодами\n\nВыберите промокод для детального просмотра:", "admin_promo_management_empty": "📭 Промокоды отсутствуют", "admin_promo_card_title": "🎟 Промокод: {code}", + "admin_promo_card_type": "📌 Тип: {type}", "admin_promo_card_bonus_days": "🎁 Бонусные дни: {days}", + "admin_promo_card_discount_percentage": "💰 Скидка: {percentage}%", "admin_promo_card_activations": "🔢 Активации: {current}/{max}", "admin_promo_card_validity": "⏰ Действует до: {validity}", "admin_promo_card_status": "📊 Статус: {status}", From d619afff29e90aef425817eb60e113011c7a6b89 Mon Sep 17 00:00:00 2001 From: VAQYBIN Date: Mon, 19 Jan 2026 04:00:10 +0500 Subject: [PATCH 03/14] test: fix ci/cd --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0a8d3fa..aa8f263 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,4 @@ __pycache__/ *.pid locales/ru_backup.json locales/en_backup.json -db/models_old.py +db/models_old.py \ No newline at end of file From 62bf5c35a82791a01e050664d4dab9184c66a04c Mon Sep 17 00:00:00 2001 From: VAQYBIN Date: Tue, 20 Jan 2026 00:16:24 +0500 Subject: [PATCH 04/14] =?UTF-8?q?fix(db):=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20?= =?UTF-8?q?=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D1=8B=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8?= =?UTF-8?q?=D0=BD=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BE?= =?UTF-8?q?=20=D0=BD=D0=BE=D0=B2=D1=8B=D1=85=20=D0=B8=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D1=8B=D1=85=20=D1=86=D0=B5=D0=BD=D0=B0=D1=85=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B8=20=D0=BF=D1=80=D0=BE=D0=BC=D0=BE?= =?UTF-8?q?=D0=BA=D0=BE=D0=B4=D0=BE=D0=B2=20=D0=BD=D0=B0=20=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=D0=B4=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 378 +++++++++++++++++++++++++++++++++++++++++++++++++ db/migrator.py | 61 ++++++++ 2 files changed, 439 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..32a46df --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,378 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Telegram bot for selling and managing Remnawave VPN subscriptions. Built with Aiogram 3.x (async), SQLAlchemy 2.x (async ORM), and PostgreSQL. Supports multiple payment providers (YooKassa, CryptoPay, FreeKassa, Platega, SeverPay, Telegram Stars) and integrates with the Remnawave panel API. + +## Common Development Commands + +### Running the Bot + +```bash +# Using Docker Compose (recommended) +docker compose up -d + +# View logs +docker compose logs -f remnawave-tg-shop + +# Stop +docker compose down + +# Local development (requires PostgreSQL running) +python main.py +``` + +### Environment Setup + +```bash +# Copy example environment file +cp .env.example .env + +# Edit .env with your configuration +# Required: BOT_TOKEN, ADMIN_IDS, WEBHOOK_BASE_URL, POSTGRES_*, PANEL_API_URL, PANEL_API_KEY +``` + +### Database Operations + +The bot auto-creates tables on startup via `init_db()` in `db/database_setup.py`. No manual migrations needed for initial setup. + +## Architecture Overview + +### Layered Architecture + +``` +Handlers (routing, input validation) + ↓ +Services (business logic, orchestration) + ↓ +DAL (data access layer - pure async functions) + ↓ +Models (SQLAlchemy ORM) + ↓ +PostgreSQL +``` + +### Core Components + +**Entry Point**: `main.py` → `bot/main_bot.py::run_bot()` + +**Initialization Flow**: +1. Load `.env` via `config/settings.py` (Pydantic) +2. Initialize database connection (`db/database_setup.py`) +3. Create dispatcher with middlewares (`bot/app/controllers/dispatcher_controller.py`) +4. Build all services via factory (`bot/app/factories/build_services.py`) +5. Register routers (`bot/routers.py`) +6. Start AIOHTTP web server for webhooks (`bot/app/web/web_server.py`) + +### Middleware Pipeline (Execution Order) + +All middlewares are outer middlewares applied at dispatcher level: + +1. **DBSessionMiddleware** - Provides `session` to handlers, auto-commits/rollbacks +2. **I18nMiddleware** - Sets `current_language` and `i18n_instance` in handler data +3. **ProfileSyncMiddleware** - Syncs Telegram user profile to local DB +4. **BanCheckMiddleware** - Blocks banned users +5. **ChannelSubscriptionMiddleware** - Enforces required channel subscription (if configured) +6. **ActionLoggerMiddleware** - Logs all user actions to `message_logs` table + +### Router Hierarchy + +``` +root_router (Private chat filter) +├── user_router_aggregate +│ ├── start_router (CommandStart) +│ ├── payment_router (payment flow callbacks) +│ ├── subscription_router (my_subscription callbacks) +│ ├── trial_router +│ ├── referral_router +│ ├── promo_user_router +│ └── payment method routers (yookassa, stars, crypto, etc.) +├── inline_mode.router +└── admin_main_filtered_router (AdminFilter) + ├── admin_router_aggregate + ├── admin_common_router + ├── admin_payments_router + ├── admin_promo_routers + ├── admin_stats_router + ├── admin_logs_router + ├── admin_broadcast_router + ├── admin_ads_router + ├── admin_sync_router + └── admin_user_management_router +``` + +## Key Subsystems + +### 1. Service Layer (`bot/services/`) + +All services are created once at startup in `build_core_services()` and injected into dispatcher. Handlers access them via `data["service_name"]`. + +**Core Services**: +- **PanelApiService** - REST client for Remnawave panel API (user CRUD, device management, stats) +- **SubscriptionService** - Subscription lifecycle management (trial, paid, renewals, traffic packages) +- **YooKassaService** - Primary payment provider with auto-renewal support +- **StarsService**, **CryptoPayService**, **FreeKassaService**, **PlategaService**, **SeverPayService** - Alternative payment providers +- **PanelWebhookService** - Handles panel subscription events (expiry notifications, auto-renew triggers) +- **ReferralService** - Referral tracking and bonus distribution +- **PromoCodeService** - Promo code validation and activation +- **LknpdService** - Tax receipt generation (nalog.ru integration) + +**Service Wiring Pattern**: +```python +# Services are cross-injected after creation +subscription_service.yookassa_service = yookassa_service +panel_webhook_service.subscription_service = subscription_service +``` + +### 2. Data Access Layer (`db/dal/`) + +Pure async functions (no classes). Each module provides CRUD operations for specific entities. + +**Key DAL Modules**: +- `user_dal.py` - User CRUD, statistics +- `subscription_dal.py` - Subscription upsert, status tracking, expiry queries +- `payment_dal.py` - Payment records, financial statistics +- `promo_code_dal.py` - Promo code management, activation tracking +- `user_billing_dal.py` - Saved payment methods for auto-renewal + +**Common Pattern**: +```python +async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]: + stmt = select(User).where(User.user_id == user_id) + result = await session.execute(stmt) + return result.scalar_one_or_none() +``` + +### 3. Database Models (`db/models.py`) + +**Key Entities**: +- **User** - Telegram user profile, panel linkage, referral tree +- **Subscription** - Panel-synced subscription state (active/inactive, expiry, traffic, auto-renew flag) +- **Payment** - Payment transaction records (status, provider, amount, promo code linkage) +- **PromoCode** / **PromoCodeActivation** - Promo codes with usage limits +- **UserBilling** / **UserPaymentMethod** - Saved payment methods for auto-renewal +- **MessageLog** - Audit trail of all user/admin actions +- **PanelSyncStatus** - Tracks automatic panel sync state +- **AdCampaign** / **AdAttribution** - Ad tracking + +**Important Relationships**: +- User → Subscription (1:Many) +- User → Payment (1:Many) +- User → User (self-referencing for referral tree via `referred_by_id`) +- Subscription has `panel_user_uuid` and `panel_subscription_uuid` for sync + +### 4. Webhook Handling (`bot/app/web/web_server.py`) + +Single AIOHTTP application hosts multiple webhook routes: + +| Path | Handler | Purpose | +|------|---------|---------| +| `/{BOT_TOKEN}` | SimpleRequestHandler | Telegram updates | +| `/webhook/yookassa` | yookassa_webhook_route | YooKassa payment status | +| `/webhook/cryptopay` | cryptopay_webhook_route | CryptoPay transactions | +| `/webhook/freekassa` | freekassa_webhook_route | FreeKassa payments | +| `/webhook/platega` | platega_webhook_route | Platega payments | +| `/webhook/severpay` | severpay_webhook_route | SeverPay payments | +| `/webhook/panel` | panel_webhook_route | Panel subscription events | + +**Security**: All webhooks verify signatures (HMAC-SHA256 for panel, provider-specific for payment systems). + +### 5. Panel Integration + +**Remnawave API** (`bot/services/panel_api_service.py`): +- REST API client with Bearer token auth +- User CRUD operations +- Device management (HWID disconnect) +- Subscription link generation +- System/bandwidth stats + +**User Linking Logic**: +When activating subscription, the bot ensures a panel user exists: +1. Check local DB for `panel_user_uuid` +2. If missing, search panel by `telegramId` +3. If not found, search by username pattern `tg_{telegram_id}` +4. If still missing, create new panel user with configured settings +5. Save returned UUID to local DB + +**Panel Webhook Events** (`bot/services/panel_webhook_service.py`): +- `user.expires_in_72_hours` → Send notification +- `user.expires_in_48_hours` → Send notification (special if auto-renew enabled) +- `user.expires_in_24_hours` → **Trigger auto-renewal** if YooKassa + saved card +- `user.expired` → Send expiration notice +- `user.expired_24_hours_ago` → Follow-up reminder + +### 6. Payment Flow + +**Standard Payment Flow**: +1. User selects subscription duration +2. Handler creates `Payment` record (status: `pending_{provider}`) +3. Service creates payment with provider API +4. User redirected to payment page +5. Provider sends webhook on completion +6. Webhook handler verifies signature, updates Payment status +7. If succeeded: call `subscription_service.activate_subscription()` + - Ensures panel user exists/linked + - Creates/updates Subscription record + - Updates panel user expiry and traffic + - Enables auto-renew if payment method saved +8. Send success notification with config link + +**Auto-Renewal Flow** (YooKassa only): +1. Panel webhook triggers 24h before expiry +2. `PanelWebhookService` checks if auto-renew enabled +3. Calls `subscription_service.charge_subscription_renewal()` +4. Uses saved `payment_method_id` for off-session charge +5. If successful: suppress 24h notification, create new Payment +6. If failed: send notification to user + +## Important Architectural Patterns + +### 1. Dependency Injection via Factory +All services created in `build_core_services()`, stored in dispatcher: +```python +dp["panel_service"] = panel_service +# Handlers access: +panel_service = data["panel_service"] +``` + +### 2. Upsert Pattern for Panel Sync +Uses `panel_subscription_uuid` as idempotency key to prevent duplicate subscriptions: +```python +async def upsert_subscription(session, payload): + existing = await get_subscription_by_panel_subscription_uuid(...) + if existing: + # Update fields + else: + # Create new +``` + +### 3. FSM for Multi-Step Flows +State machine for complex interactions (defined in `bot/states/`): +```python +# Set state +await state.set_state(UserPromoStates.waiting_for_promo_code) + +# Next handler checks state, processes, clears +if await state.get_state() == UserPromoStates.waiting_for_promo_code: + # Process promo code + await state.clear() +``` + +### 4. Notification Suppression +Track `last_notification_sent` per subscription to avoid spam: +```python +if last_notification_sent is None or date(last_notification_sent) < date(now): + # Send notification + await update_last_notification_sent(subscription) +``` + +## Configuration + +All settings loaded via Pydantic from `.env` (see `.env.example` for full reference). + +**Critical Settings**: +- `BOT_TOKEN` - Telegram bot token +- `ADMIN_IDS` - Comma-separated admin Telegram IDs +- `WEBHOOK_BASE_URL` - External URL for webhooks (HTTPS required) +- `PANEL_API_URL`, `PANEL_API_KEY` - Remnawave panel access +- `PANEL_WEBHOOK_SECRET` - HMAC signature verification +- Payment provider credentials (YooKassa, CryptoPay, etc.) +- Pricing: `RUB_PRICE_1_MONTH`, `STARS_PRICE_1_MONTH`, etc. + +**Sales Modes**: +- **Time-based** (default): User buys N months subscription +- **Traffic-based** (`traffic_sale_mode`): User buys X GB with far-future expiry + +## Development Guidelines + +### Adding New Payment Provider + +1. Create service in `bot/services/{provider}_service.py` implementing: + - `create_payment()` - Generate payment URL/data + - Webhook handler function +2. Add webhook route in `bot/app/web/web_server.py` +3. Add service to `build_core_services()` factory +4. Create handler router in `bot/handlers/user/subscription/payments_{provider}.py` +5. Register router in `bot/routers.py` +6. Add pricing settings to `config/settings.py` +7. Update payment method selection in `bot/handlers/user/subscription/payment_methods.py` + +### Adding New Admin Feature + +1. Create handler in `bot/handlers/admin/{feature}.py` +2. Add keyboard buttons in `bot/keyboards/inline/admin_keyboards.py` +3. Register router in `bot/routers.py` under `admin_main_filtered_router` +4. Use `AdminFilter()` to protect routes +5. Access services via `data["service_name"]` + +### Database Changes + +The project uses SQLAlchemy models without formal migrations. To add fields: +1. Update model in `db/models.py` +2. Add corresponding DAL functions in `db/dal/` +3. Drop and recreate tables in dev (bot auto-creates on startup) +4. For production, manually ALTER tables or use `db/migrator.py` as template + +### Localization + +Translations stored in `locales/{lang}/LC_MESSAGES/messages.json`. To add strings: +1. Add key-value to both `ru` and `en` files +2. Access in handlers via: `i18n.get("key_name")` +3. I18n instance available in handler data: `data["i18n_instance"]` + +## Testing Checklist + +When making changes, verify: +- [ ] Webhooks are reachable (use ngrok for local testing) +- [ ] Payment flow completes end-to-end +- [ ] Panel user creation/linking works +- [ ] Subscription activation updates both DB and panel +- [ ] Auto-renewal triggers correctly +- [ ] Notifications send at proper times +- [ ] Admin panel functions accessible only to admins +- [ ] Banned users cannot access bot +- [ ] Referral bonuses apply correctly +- [ ] Promo codes validate and activate + +## Deployment + +**Production Setup**: +1. Configure reverse proxy (Nginx/HAProxy) to route HTTPS webhooks to container +2. Set `WEBHOOK_BASE_URL` to external domain +3. Run `docker compose up -d` +4. Verify webhook registration in logs +5. Add bot as admin to required channel (if using `REQUIRED_CHANNEL_ID`) +6. Test payment flow with small amount + +**Webhook Paths**: +- Telegram: `https://yourdomain.com/{BOT_TOKEN}` +- YooKassa: `https://yourdomain.com/webhook/yookassa` +- Panel: `https://yourdomain.com/webhook/panel` +- Other providers: `https://yourdomain.com/webhook/{provider}` + +## Troubleshooting + +**Bot not responding**: +- Check webhook is set correctly (logs show webhook URL on startup) +- Verify HTTPS is working for `WEBHOOK_BASE_URL` +- Check `docker compose logs` for errors + +**Payment not completing**: +- Verify webhook route is accessible from provider +- Check signature verification is passing +- Look for errors in payment webhook handler logs +- Ensure `provider_payment_id` is unique (duplicate payments are rejected) + +**Panel sync issues**: +- Check `PANEL_API_KEY` is valid +- Verify panel user exists with matching UUID +- Check panel webhook secret matches +- Review `panel_sync_status` table for last sync time + +**Auto-renewal not working**: +- Only works with YooKassa +- User must have saved payment method (`user_billing` or `user_payment_methods` table) +- Subscription must have `auto_renew_enabled = true` +- Panel webhook must trigger 24h before expiry diff --git a/db/migrator.py b/db/migrator.py index 11c4ee0..e385f3b 100644 --- a/db/migrator.py +++ b/db/migrator.py @@ -112,6 +112,62 @@ def _migration_0003_normalize_referral_codes(connection: Connection) -> None: ) ) + +def _migration_0004_add_discount_promo_codes(connection: Connection) -> None: + inspector = inspect(connection) + + # 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( + text( + "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 + ) + """ + ) + ) + MIGRATIONS: List[Migration] = [ Migration( id="0001_add_channel_subscription_fields", @@ -128,6 +184,11 @@ MIGRATIONS: List[Migration] = [ description="Normalize referral codes to uppercase for consistent lookups", upgrade=_migration_0003_normalize_referral_codes, ), + Migration( + id="0004_add_discount_promo_codes", + description="Add support for percentage discount promo codes", + upgrade=_migration_0004_add_discount_promo_codes, + ), ] From baaf5c457f00089394b9e3baae84096c19e04975 Mon Sep 17 00:00:00 2001 From: VAQYBIN Date: Tue, 20 Jan 2026 00:34:17 +0500 Subject: [PATCH 05/14] =?UTF-8?q?fix(promo):=20=D0=98=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BE=D1=88=D0=B8=D0=B1?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D1=80=D0=B8=20=D0=BF=D1=80=D0=B8=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=BF=D1=80=D0=BE=D0=BC?= =?UTF-8?q?=D0=BE=D0=BA=D0=BE=D0=B4=D0=B0=20=D0=BD=D0=B0=20=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=D0=B4=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/services/promo_code_service.py | 2 +- db/dal/promo_code_dal.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/bot/services/promo_code_service.py b/bot/services/promo_code_service.py index 6ad7ede..5e477e8 100644 --- a/bot/services/promo_code_service.py +++ b/bot/services/promo_code_service.py @@ -34,7 +34,7 @@ class PromoCodeService: _ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw) 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) if not promo_data: diff --git a/db/dal/promo_code_dal.py b/db/dal/promo_code_dal.py index e4025d6..0f6b174 100644 --- a/db/dal/promo_code_dal.py +++ b/db/dal/promo_code_dal.py @@ -43,6 +43,20 @@ async def get_active_promo_code_by_code_str( 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""" From dcc7f9eb7279f189b97d67399414633d089b2fc1 Mon Sep 17 00:00:00 2001 From: VAQYBIN Date: Tue, 20 Jan 2026 00:51:05 +0500 Subject: [PATCH 06/14] =?UTF-8?q?fix(promo):=20=D0=98=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BE=D1=88=D0=B8=D0=B1?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=81=20=D0=B4=D1=83=D0=B1=D0=BB=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=D0=BC=20=D1=81=D0=BA=D0=B8?= =?UTF-8?q?=D0=B4=D0=BA=D0=B8=20=D0=BF=D1=80=D0=B8=20=D0=B8=D1=81=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=BC=D0=BE=D0=BA=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/subscription/payments_yookassa.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/bot/handlers/user/subscription/payments_yookassa.py b/bot/handlers/user/subscription/payments_yookassa.py index 97475f1..807bcfd 100644 --- a/bot/handlers/user/subscription/payments_yookassa.py +++ b/bot/handlers/user/subscription/payments_yookassa.py @@ -78,22 +78,22 @@ async def _initiate_yk_payment( if not callback.message: return False - # NEW: Check for active discount and apply if exists - original_price = price_rub + # 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: - final_price, discount_amount = promo_code_service.calculate_discounted_price( - price_rub, active_discount.discount_percentage - ) - price_rub = final_price + # 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"Applying {active_discount.discount_percentage}% discount to YooKassa payment: " - f"{original_price} -> {price_rub}" + f"Recording {discount_pct}% discount for YooKassa payment: " + f"original {original_price:.2f} -> final {price_rub}" ) payment_description = ( From e7df5e539c0f4c06e4f43deda3b0af7e99466e38 Mon Sep 17 00:00:00 2001 From: VAQYBIN Date: Tue, 20 Jan 2026 01:06:18 +0500 Subject: [PATCH 07/14] =?UTF-8?q?chore(promo):=20=D0=94=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B8=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=BC=D0=BE=D0=BA=D0=BE=D0=B4=D0=BE=D0=B2=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D1=81=D0=BA=D0=B8=D0=B4=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/handlers/admin/promo/create.py | 69 +++++++++++++++++++----------- locales/en.json | 3 ++ locales/ru.json | 3 ++ 3 files changed, 49 insertions(+), 26 deletions(-) diff --git a/bot/handlers/admin/promo/create.py b/bot/handlers/admin/promo/create.py index ead38b0..8c0517f 100644 --- a/bot/handlers/admin/promo/create.py +++ b/bot/handlers/admin/promo/create.py @@ -241,9 +241,9 @@ async def process_promo_discount_percentage_handler(message: types.Message, # Step 3: Ask for max activations data = await state.get_data() prompt_text = _( - "admin_promo_step3_max_activations", + "admin_promo_step3_max_activations_discount", code=data.get("promo_code"), - bonus_days=f"{discount_percentage}%" # Display as percentage in place of bonus_days + discount_percentage=discount_percentage ) await message.answer( @@ -284,15 +284,25 @@ async def process_promo_max_activations_handler(message: types.Message, return await state.update_data(max_activations=max_activations) - + # Step 4: Ask for validity data = await state.get_data() - prompt_text = _( - "admin_promo_step4_validity", - code=data.get("promo_code"), - bonus_days=data.get("bonus_days"), - max_activations=max_activations - ) + promo_type = data.get("promo_type", "bonus_days") + + if promo_type == "discount": + prompt_text = _( + "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 builder = InlineKeyboardBuilder() @@ -356,12 +366,15 @@ async def process_promo_set_validity(callback: types.CallbackQuery, _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) data = await state.get_data() - prompt_text = _( - "admin_promo_enter_validity_days", - code=data.get("promo_code"), - bonus_days=data.get("bonus_days"), - max_activations=data.get("max_activations") - ) + promo_type = data.get("promo_type", "bonus_days") + + # Display the correct text based on promo type + if promo_type == "discount": + value_info = f"{data.get('discount_percentage')}%" + else: + value_info = f"{data.get('bonus_days')} дней" + + prompt_text = f"⏰ Введите количество дней действия промокода (1-365):\n\nКод: {data.get('promo_code')}\n{'Скидка' if promo_type == 'discount' else 'Бонус'}: {value_info}\nМакс. активаций: {data.get('max_activations')}" try: await callback.message.edit_text( @@ -461,21 +474,25 @@ async def create_promo_code_final(callback_or_message, logging.info(f"Promo code '{data['promo_code']}' ({promo_type}) created with ID {created_promo.promo_code_id}") # Success message - valid_until_str = _("admin_promo_unlimited") if not data.get("validity_days") else f"{data['validity_days']} days" + valid_until_str = _("admin_promo_unlimited") if not data.get("validity_days") else f"{data['validity_days']} дней" # Format success message based on type if promo_type == "discount": - value_display = f"{data['discount_percentage']}%" + success_text = _( + "admin_promo_created_success_discount", + code=data["promo_code"], + discount_percentage=data['discount_percentage'], + max_activations=data["max_activations"], + valid_until_str=valid_until_str + ) else: - value_display = f"{data['bonus_days']} days" - - success_text = _( - "admin_promo_created_success", - code=data["promo_code"], - bonus_days=value_display, # Reusing bonus_days placeholder for display - max_activations=data["max_activations"], - valid_until_str=valid_until_str - ) + 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 try: diff --git a/locales/en.json b/locales/en.json index bcf391e..04c8771 100644 --- a/locales/en.json +++ b/locales/en.json @@ -173,6 +173,7 @@ "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_created_success": "✅ Promo 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} created successfully!\nDiscount: {discount_percentage}%\nMax uses: {max_activations}\nValid until: {valid_until_str}.", "admin_promo_set_validity_days": "⏰ Set validity (days)", "admin_back_to_panel": "⬅️ Back to panel", "admin_promo_unlimited": "♾️ Unlimited", @@ -346,7 +347,9 @@ "admin_promo_step2_bonus_days": "🎟 Create Promo Code\n\nStep 2 of 4: Bonus Days\n\nCode: {code}\n\nEnter the number of bonus days (1-365):", "admin_promo_step2_discount_percentage": "🎟 Create Promo Code\n\nStep 2 of 4: Discount Percentage\n\nCode: {code}\n\nEnter the discount percentage for the promo code (1-100):", "admin_promo_step3_max_activations": "🎟 Create Promo Code\n\nStep 3 of 4: Max Activations\n\nCode: {code}\nBonus days: {bonus_days}\n\nEnter the maximum number of activations (1-10000):", + "admin_promo_step3_max_activations_discount": "🎟 Create Promo Code\n\nStep 3 of 4: Max Activations\n\nCode: {code}\nDiscount: {discount_percentage}%\n\nEnter the maximum number of activations (1-10000):", "admin_promo_step4_validity": "🎟 Create Promo Code\n\nStep 4 of 4: Validity Period\n\nCode: {code}\nBonus days: {bonus_days}\nMax activations: {max_activations}\n\nChoose the validity period for the promo code:", + "admin_promo_step4_validity_discount": "🎟 Create Promo Code\n\nStep 4 of 4: Validity Period\n\nCode: {code}\nDiscount: {discount_percentage}%\nMax activations: {max_activations}\n\nChoose the validity period for the promo code:", "admin_promo_code_already_exists": "❌ A promo code with this code already exists", "admin_promo_unlimited_validity": "♾️ Unlimited", "admin_promo_enter_validity_days": "⏰ Enter the number of validity days for the promo code (1-365):", diff --git a/locales/ru.json b/locales/ru.json index eed21ec..2d826a9 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -174,6 +174,7 @@ "admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.", "admin_promo_invalid_validity_days": "Срок действия промокода (в днях) должен быть положительным числом.", "admin_promo_created_success": "✅ Промокод {code} успешно создан!\nБонус: {bonus_days} дней\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}", + "admin_promo_created_success_discount": "✅ Промокод {code} успешно создан!\nСкидка: {discount_percentage}%\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}", "subscription_72h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 3 дня — {end_date}.\n\nПродлите её по кнопке ниже.", "subscription_48h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 2 дня — {end_date}.\n\nПродлите её по кнопке ниже.", "subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.", @@ -348,7 +349,9 @@ "admin_promo_step2_discount_percentage": "🎟 Создание промокода\n\nШаг 2 из 4: Процент скидки\n\nКод: {code}\n\nВведите процент скидки для промокода {code} (от 1 до 100):", "admin_promo_invalid_discount_percentage": "❌ Процент скидки должен быть от 1 до 100.", "admin_promo_step3_max_activations": "🎟 Создание промокода\n\nШаг 3 из 4: Максимальные активации\n\nКод: {code}\nБонусные дни: {bonus_days}\n\nВведите максимальное количество активаций (1-10000):", + "admin_promo_step3_max_activations_discount": "🎟 Создание промокода\n\nШаг 3 из 4: Максимальные активации\n\nКод: {code}\nСкидка: {discount_percentage}%\n\nВведите максимальное количество активаций (1-10000):", "admin_promo_step4_validity": "🎟 Создание промокода\n\nШаг 4 из 4: Срок действия\n\nКод: {code}\nБонусные дни: {bonus_days}\nМакс. активации: {max_activations}\n\nВыберите срок действия промокода:", + "admin_promo_step4_validity_discount": "🎟 Создание промокода\n\nШаг 4 из 4: Срок действия\n\nКод: {code}\nСкидка: {discount_percentage}%\nМакс. активации: {max_activations}\n\nВыберите срок действия промокода:", "admin_promo_code_already_exists": "❌ Промокод с таким кодом уже существует", "admin_promo_unlimited_validity": "♾️ Неограниченно", "admin_promo_enter_validity_days": "⏰ Введите количество дней действия промокода (1-365):", From fed1c1c960f3624f4083ad2a0ac986dc09b944b9 Mon Sep 17 00:00:00 2001 From: VAQYBIN Date: Tue, 20 Jan 2026 01:26:14 +0500 Subject: [PATCH 08/14] =?UTF-8?q?feat(promo):=20=D0=94=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=86=D0=B5=D0=BD=D1=82=D0=B0=20=D1=81=D0=BA=D0=B8?= =?UTF-8?q?=D0=B4=D0=BA=D0=B8=20=D0=B8=20=D0=BB=D0=BE=D0=B3=D0=B8=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=BE=D0=BC=D0=BE=D0=BA=D0=BE=D0=B4=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D1=81=D0=BA=D0=B8=D0=B4=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/handlers/admin/promo/manage.py | 5 +--- bot/handlers/user/promo_user.py | 15 ++++++++++++ bot/services/notification_service.py | 34 ++++++++++++++++++++++++---- locales/en.json | 2 ++ locales/ru.json | 2 ++ 5 files changed, 50 insertions(+), 8 deletions(-) diff --git a/bot/handlers/admin/promo/manage.py b/bot/handlers/admin/promo/manage.py index cefd1a2..c90b432 100644 --- a/bot/handlers/admin/promo/manage.py +++ b/bot/handlers/admin/promo/manage.py @@ -445,10 +445,7 @@ async def promo_edit_field_handler(callback: types.CallbackQuery, state: FSMCont } prompt_key = prompts.get(field, "error_occurred_try_again") - if field == "discount_percentage": - prompt_text = "Enter the new discount percentage (1-100):" - else: - prompt_text = _(prompt_key) + prompt_text = _(prompt_key) await state.set_state(AdminStates.waiting_for_promo_edit_details) await callback.message.edit_text(prompt_text) diff --git a/bot/handlers/user/promo_user.py b/bot/handlers/user/promo_user.py index d68c35f..2da7d16 100644 --- a/bot/handlers/user/promo_user.py +++ b/bot/handlers/user/promo_user.py @@ -165,6 +165,21 @@ async def process_promo_code_input(message: types.Message, state: FSMContext, 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()), diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index 8c79903..5143bc8 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -271,15 +271,15 @@ class NotificationService: """Send notification about 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_activation", user_display=user_display, @@ -287,7 +287,33 @@ class NotificationService: bonus_days=bonus_days, 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 profile_keyboard = self._build_profile_keyboard(_, user_id) await self._send_to_log_channel(message, reply_markup=profile_keyboard) diff --git a/locales/en.json b/locales/en.json index 04c8771..85a0095 100644 --- a/locales/en.json +++ b/locales/en.json @@ -208,6 +208,7 @@ "csv_no": "No", "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_discount_percentage": "Enter the new discount percentage (1-100):", "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_edit_success": "Promo code updated successfully.", @@ -313,6 +314,7 @@ "log_payment_received": "{provider_emoji} Payment Received\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n📅 Period: {months} mo.\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}", "log_payment_received_traffic": "{provider_emoji} Payment Received\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n🗂 Traffic: {traffic_gb} GB\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}", "log_promo_activation": "🎁 Promo Code Activated\n\n👤 User: {user_display}\n🏷 Code: {promo_code}\n🎯 Bonus: +{bonus_days}d\n🕐 Time: {timestamp}", + "log_promo_discount_activation": "💰 Discount Promo Code Activated\n\n👤 User: {user_display}\n🏷 Code: {promo_code}\n💵 Discount: {discount_percentage}%\n🕐 Time: {timestamp}", "log_trial_activation": "🆓 Trial Activated\n\n👤 User: {user_display}\n⏰ Valid until: {end_date}\n🕐 Time: {timestamp}", "log_panel_sync": "{status_emoji} Panel Synchronization\n\n📊 Status: {status}\n👥 Users processed: {users_processed}\n📋 Subscriptions synced: {subs_synced}\n🕐 Time: {timestamp}\n\n📝 Details:\n{details}", "log_suspicious_promo": "⚠️ Suspicious Promo Code Attempt\n\n👤 User: {user_display}\n🆔 ID: {user_id}\n📝 Input:
{suspicious_input}
\n🕐 Time: {timestamp}", diff --git a/locales/ru.json b/locales/ru.json index 2d826a9..fc5aac1 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -218,6 +218,7 @@ "csv_no": "Нет", "admin_promo_edit_select_field": "Выберите поле для редактирования:", "admin_promo_prompt_bonus_days": "Введите новое количество бонусных дней:", + "admin_promo_prompt_discount_percentage": "Введите новый процент скидки (от 1 до 100):", "admin_promo_prompt_max_activations": "Введите новое максимальное количество активаций:", "admin_promo_prompt_validity_days": "Введите новый срок действия в днях (0 для бессрочного):", "admin_promo_edit_success": "Промокод успешно обновлен.", @@ -314,6 +315,7 @@ "log_payment_received": "{provider_emoji} Получен платеж\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n📅 Период: {months} мес.\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}", "log_payment_received_traffic": "{provider_emoji} Получен платеж\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n🗂 Трафик: {traffic_gb} ГБ\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}", "log_promo_activation": "🎁 Активирован промокод\n\n👤 Пользователь: {user_display}\n🏷 Код: {promo_code}\n🎯 Бонус: +{bonus_days} дн.\n🕐 Время: {timestamp}", + "log_promo_discount_activation": "💰 Активирован промокод на скидку\n\n👤 Пользователь: {user_display}\n🏷 Код: {promo_code}\n💵 Скидка: {discount_percentage}%\n🕐 Время: {timestamp}", "log_trial_activation": "🆓 Активирован триал\n\n👤 Пользователь: {user_display}\n⏰ Действует до: {end_date}\n🕐 Время: {timestamp}", "log_panel_sync": "{status_emoji} Синхронизация с панелью\n\n📊 Статус: {status}\n👥 Обработано пользователей: {users_processed}\n📋 Синхронизировано подписок: {subs_synced}\n🕐 Время: {timestamp}\n\n📝 Детали:\n{details}", "log_suspicious_promo": "⚠️ Подозрительная попытка ввода промокода\n\n👤 Пользователь: {user_display}\n🆔 ID: {user_id}\n📝 Ввод:
{suspicious_input}
\n🕐 Время: {timestamp}", From 39d5fd1856e280490a2bbdc8a3ae4be21b623da7 Mon Sep 17 00:00:00 2001 From: VAQYBIN Date: Tue, 20 Jan 2026 01:44:22 +0500 Subject: [PATCH 09/14] =?UTF-8?q?chore(promo):=20=D0=94=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BA=D0=BD=D0=BE=D0=BF?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BF=D1=80=D0=BE=D1=86=D0=B5=D0=BD=D1=82=D0=B0=20?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D0=B4=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/handlers/admin/promo/manage.py | 2 +- locales/en.json | 1 + locales/ru.json | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/handlers/admin/promo/manage.py b/bot/handlers/admin/promo/manage.py index c90b432..3a90c20 100644 --- a/bot/handlers/admin/promo/manage.py +++ b/bot/handlers/admin/promo/manage.py @@ -415,7 +415,7 @@ async def promo_edit_select_handler(callback: types.CallbackQuery, i18n_data: di builder = InlineKeyboardBuilder() # Show appropriate edit option based on type if promo_type == "discount": - builder.row(InlineKeyboardButton(text="💰 Edit Discount %", callback_data=f"promo_edit_field:discount_percentage:{promo_id}")) + 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}")) diff --git a/locales/en.json b/locales/en.json index 85a0095..d6a3b9f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -214,6 +214,7 @@ "admin_promo_edit_success": "Promo code updated successfully.", "admin_promo_invalid_input": "Invalid input, please try again.", "admin_promo_edit_bonus_days": "🎁 Bonus Days", + "admin_promo_edit_discount_percentage": "💰 Discount %", "admin_promo_edit_max_activations": "🔢 Max Activations", "admin_promo_edit_validity": "⏰ Validity", "admin_ban_user_prompt": "Enter user ID or @username to ban:", diff --git a/locales/ru.json b/locales/ru.json index fc5aac1..a9de522 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -224,6 +224,7 @@ "admin_promo_edit_success": "Промокод успешно обновлен.", "admin_promo_invalid_input": "Неверный ввод, попробуйте еще раз.", "admin_promo_edit_bonus_days": "🎁 Бонусные дни", + "admin_promo_edit_discount_percentage": "💰 Процент скидки", "admin_promo_edit_max_activations": "🔢 Макс. активации", "admin_promo_edit_validity": "⏰ Срок действия", "admin_ban_user_prompt": "Введите ID или @username пользователя для блокировки:", From 229ce7e1e081570efd913800288a72ee56d8cdfe Mon Sep 17 00:00:00 2001 From: VAQYBIN Date: Wed, 21 Jan 2026 01:09:49 +0500 Subject: [PATCH 10/14] =?UTF-8?q?feat(promo):=20=D0=94=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=BA=D0=B0=20=D0=BF=D1=80=D0=BE=D0=BC=D0=BE=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=D0=BE=D0=B2=20=D0=BD=D0=B0=20=D1=81=D0=BA=D0=B8?= =?UTF-8?q?=D0=B4=D0=BA=D1=83=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D1=81=D0=B5?= =?UTF-8?q?=D1=85=20=D0=BF=D0=BB=D0=B0=D1=82=D1=91=D0=B6=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/subscription/payments_crypto.py | 2 ++ .../user/subscription/payments_freekassa.py | 14 +++++++-- .../user/subscription/payments_platega.py | 14 +++++++-- .../user/subscription/payments_severpay.py | 14 +++++++-- .../user/subscription/payments_stars.py | 2 ++ bot/services/crypto_pay_service.py | 21 +++++++++++-- bot/services/freekassa_service.py | 1 + bot/services/platega_service.py | 1 + bot/services/severpay_service.py | 1 + bot/services/stars_service.py | 31 ++++++++++++++++++- 10 files changed, 92 insertions(+), 9 deletions(-) diff --git a/bot/handlers/user/subscription/payments_crypto.py b/bot/handlers/user/subscription/payments_crypto.py index ed03c8a..dba6338 100644 --- a/bot/handlers/user/subscription/payments_crypto.py +++ b/bot/handlers/user/subscription/payments_crypto.py @@ -18,6 +18,7 @@ async def pay_crypto_callback_handler( i18n_data: dict, session: AsyncSession, cryptopay_service: CryptoPayService, + promo_code_service=None, ): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") @@ -65,6 +66,7 @@ async def pay_crypto_callback_handler( amount=price_amount, description=payment_description, sale_mode=sale_mode, + promo_code_service=promo_code_service, ) if invoice_url: diff --git a/bot/handlers/user/subscription/payments_freekassa.py b/bot/handlers/user/subscription/payments_freekassa.py index 1671c98..e24bb88 100644 --- a/bot/handlers/user/subscription/payments_freekassa.py +++ b/bot/handlers/user/subscription/payments_freekassa.py @@ -10,6 +10,7 @@ from bot.middlewares.i18n import JsonI18n from bot.services.freekassa_service import FreeKassaService from config.settings import Settings from db.dal import payment_dal +from bot.handlers.user.subscription.payment_discount_helper import apply_discount_to_payment router = Router(name="user_subscription_payments_freekassa_router") @@ -21,6 +22,7 @@ async def pay_fk_callback_handler( i18n_data: dict, freekassa_service: FreeKassaService, session: AsyncSession, + promo_code_service=None, ): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") @@ -68,14 +70,22 @@ async def pay_fk_callback_handler( ) currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + # Apply active discount if exists + final_price_rub, discount_amount, promo_code_id = await apply_discount_to_payment( + session, user_id, price_rub, promo_code_service + ) + payment_record_payload = { "user_id": user_id, - "amount": price_rub, + "amount": final_price_rub, + "original_amount": price_rub if discount_amount else None, + "discount_applied": discount_amount, "currency": currency_code, "status": "pending_freekassa", "description": payment_description, "subscription_duration_months": int(months), "provider": "freekassa", + "promo_code_id": promo_code_id, } try: @@ -101,7 +111,7 @@ async def pay_fk_callback_handler( payment_db_id=payment_record.payment_id, user_id=payment_record.user_id, months=months, - amount=price_rub, + amount=final_price_rub, currency=freekassa_service.default_currency, payment_method_id=freekassa_service.payment_method_id, ip_address=freekassa_service.server_ip, diff --git a/bot/handlers/user/subscription/payments_platega.py b/bot/handlers/user/subscription/payments_platega.py index b83e418..7f42ce1 100644 --- a/bot/handlers/user/subscription/payments_platega.py +++ b/bot/handlers/user/subscription/payments_platega.py @@ -10,6 +10,7 @@ from bot.middlewares.i18n import JsonI18n from bot.services.platega_service import PlategaService from config.settings import Settings from db.dal import payment_dal +from bot.handlers.user.subscription.payment_discount_helper import apply_discount_to_payment router = Router(name="user_subscription_payments_platega_router") @@ -21,6 +22,7 @@ async def pay_platega_callback_handler( i18n_data: dict, platega_service: PlategaService, session: AsyncSession, + promo_code_service=None, ): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") @@ -68,14 +70,22 @@ async def pay_platega_callback_handler( ) currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + # Apply active discount if exists + final_price_rub, discount_amount, promo_code_id = await apply_discount_to_payment( + session, user_id, price_rub, promo_code_service + ) + payment_record_payload = { "user_id": user_id, - "amount": price_rub, + "amount": final_price_rub, + "original_amount": price_rub if discount_amount else None, + "discount_applied": discount_amount, "currency": currency_code, "status": "pending_platega", "description": payment_description, "subscription_duration_months": int(months), "provider": "platega", + "promo_code_id": promo_code_id, } try: @@ -110,7 +120,7 @@ async def pay_platega_callback_handler( payment_db_id=payment_record.payment_id, user_id=user_id, months=months, - amount=price_rub, + amount=final_price_rub, currency=currency_code, description=payment_description, payload=payload_meta, diff --git a/bot/handlers/user/subscription/payments_severpay.py b/bot/handlers/user/subscription/payments_severpay.py index 2a3d100..f99431a 100644 --- a/bot/handlers/user/subscription/payments_severpay.py +++ b/bot/handlers/user/subscription/payments_severpay.py @@ -9,6 +9,7 @@ from bot.middlewares.i18n import JsonI18n from bot.services.severpay_service import SeverPayService from config.settings import Settings from db.dal import payment_dal +from bot.handlers.user.subscription.payment_discount_helper import apply_discount_to_payment router = Router(name="user_subscription_payments_severpay_router") @@ -20,6 +21,7 @@ async def pay_severpay_callback_handler( i18n_data: dict, severpay_service: SeverPayService, session: AsyncSession, + promo_code_service=None, ): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") @@ -67,14 +69,22 @@ async def pay_severpay_callback_handler( ) currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + # Apply active discount if exists + final_price_rub, discount_amount, promo_code_id = await apply_discount_to_payment( + session, user_id, price_rub, promo_code_service + ) + payment_record_payload = { "user_id": user_id, - "amount": price_rub, + "amount": final_price_rub, + "original_amount": price_rub if discount_amount else None, + "discount_applied": discount_amount, "currency": currency_code, "status": "pending_severpay", "description": payment_description, "subscription_duration_months": int(months), "provider": "severpay", + "promo_code_id": promo_code_id, } try: @@ -100,7 +110,7 @@ async def pay_severpay_callback_handler( payment_db_id=payment_record.payment_id, user_id=user_id, months=months, - amount=price_rub, + amount=final_price_rub, currency=currency_code, description=payment_description, ) diff --git a/bot/handlers/user/subscription/payments_stars.py b/bot/handlers/user/subscription/payments_stars.py index 8ee31cb..2cb53f2 100644 --- a/bot/handlers/user/subscription/payments_stars.py +++ b/bot/handlers/user/subscription/payments_stars.py @@ -19,6 +19,7 @@ async def pay_stars_callback_handler( i18n_data: dict, session: AsyncSession, stars_service: StarsService, + promo_code_service=None, ): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") @@ -66,6 +67,7 @@ async def pay_stars_callback_handler( stars_price=stars_price, description=payment_description, sale_mode=sale_mode, + promo_code_service=promo_code_service, ) if payment_db_id: diff --git a/bot/services/crypto_pay_service.py b/bot/services/crypto_pay_service.py index 895064f..babca27 100644 --- a/bot/services/crypto_pay_service.py +++ b/bot/services/crypto_pay_service.py @@ -18,6 +18,7 @@ from bot.services.notification_service import NotificationService from db.dal import payment_dal, user_dal from bot.utils.text_sanitizer import sanitize_display_name, username_for_display from bot.utils.config_link import prepare_config_links +from bot.handlers.user.subscription.payment_discount_helper import apply_discount_to_payment class CryptoPayService: @@ -65,23 +66,32 @@ class CryptoPayService: amount: float, description: str, sale_mode: str = "subscription", + promo_code_service=None, ) -> Optional[str]: if not self.configured or not self.client: logging.error("CryptoPayService not configured") return None + # Apply active discount if exists + final_amount, discount_amount, promo_code_id = await apply_discount_to_payment( + session, user_id, amount, promo_code_service + ) + # Create pending payment in DB and commit to persist try: payment_record = await payment_dal.create_payment_record( session, { "user_id": user_id, - "amount": float(amount), + "amount": final_amount, + "original_amount": amount if discount_amount else None, + "discount_applied": discount_amount, "currency": self.settings.CRYPTOPAY_ASSET, "status": "pending_cryptopay", "description": description, "subscription_duration_months": int(months), "provider": "cryptopay", + "promo_code_id": promo_code_id, }, ) await session.commit() @@ -101,7 +111,7 @@ class CryptoPayService: }) try: invoice = await self.client.create_invoice( - amount=amount, + amount=final_amount, currency_type=self.settings.CRYPTOPAY_CURRENCY_TYPE, fiat=self.settings.CRYPTOPAY_ASSET if self.settings.CRYPTOPAY_CURRENCY_TYPE == "fiat" else None, asset=self.settings.CRYPTOPAY_ASSET if self.settings.CRYPTOPAY_CURRENCY_TYPE == "crypto" else None, @@ -153,6 +163,12 @@ class CryptoPayService: async with async_session_factory() as session: 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( session, payment_db_id, @@ -165,6 +181,7 @@ class CryptoPayService: int(months) if sale_mode != "traffic" else 0, float(invoice.amount), payment_db_id, + promo_code_id_from_payment=payment_record.promo_code_id, provider="cryptopay", sale_mode=sale_mode, traffic_gb=traffic_gb if sale_mode == "traffic" else None, diff --git a/bot/services/freekassa_service.py b/bot/services/freekassa_service.py index 5ca9fe4..0484be3 100644 --- a/bot/services/freekassa_service.py +++ b/bot/services/freekassa_service.py @@ -293,6 +293,7 @@ class FreeKassaService: int(months) if sale_mode != "traffic" else 0, float(payment.amount), payment.payment_id, + promo_code_id_from_payment=payment.promo_code_id, provider="freekassa", sale_mode=sale_mode, traffic_gb=months if sale_mode == "traffic" else None, diff --git a/bot/services/platega_service.py b/bot/services/platega_service.py index a41651e..4aa3893 100644 --- a/bot/services/platega_service.py +++ b/bot/services/platega_service.py @@ -189,6 +189,7 @@ class PlategaService: int(payment_months) if sale_mode != "traffic" else 0, float(payment.amount), payment.payment_id, + promo_code_id_from_payment=payment.promo_code_id, provider="platega", sale_mode=sale_mode, traffic_gb=payment_months if sale_mode == "traffic" else None, diff --git a/bot/services/severpay_service.py b/bot/services/severpay_service.py index 35c6f49..75d79cc 100644 --- a/bot/services/severpay_service.py +++ b/bot/services/severpay_service.py @@ -207,6 +207,7 @@ class SeverPayService: int(payment_months) if sale_mode != "traffic" else 0, float(payment.amount), payment.payment_id, + promo_code_id_from_payment=payment.promo_code_id, provider="severpay", sale_mode=sale_mode, traffic_gb=payment_months if sale_mode == "traffic" else None, diff --git a/bot/services/stars_service.py b/bot/services/stars_service.py index 0dfbe2a..c6d0dad 100644 --- a/bot/services/stars_service.py +++ b/bot/services/stars_service.py @@ -1,4 +1,5 @@ import logging +import math from typing import Optional from aiogram import Bot, types @@ -14,6 +15,7 @@ from .notification_service import NotificationService from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard from bot.utils.text_sanitizer import sanitize_display_name, username_for_display from bot.utils.config_link import prepare_config_links +from bot.handlers.user.subscription.payment_discount_helper import apply_discount_to_payment class StarsService: @@ -27,15 +29,37 @@ class StarsService: self.referral_service = referral_service 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: + # 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 = { "user_id": user_id, "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", "status": "pending_stars", "description": description, "subscription_duration_months": int(months), "provider": "telegram_stars", + "promo_code_id": promo_code_id, } try: db_payment_record = await payment_dal.create_payment_record( @@ -72,6 +96,10 @@ class StarsService: stars_amount: int, i18n_data: dict, 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: await payment_dal.update_provider_payment_and_status( session, payment_db_id, @@ -91,6 +119,7 @@ class StarsService: int(months) if sale_mode != "traffic" else 0, float(stars_amount), payment_db_id, + promo_code_id_from_payment=promo_code_id_from_payment, provider="telegram_stars", sale_mode=sale_mode, traffic_gb=months if sale_mode == "traffic" else None, From 9ca2fe487c4ce4caf6350ce52d87bc5ffe04dfe0 Mon Sep 17 00:00:00 2001 From: VAQYBIN Date: Wed, 21 Jan 2026 01:13:26 +0500 Subject: [PATCH 11/14] =?UTF-8?q?fix(promo):=20=D0=98=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BE=D1=88=D0=B8=D0=B1?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=81=20=D0=B7=D0=B0=D1=86=D1=8B=D0=BA=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D0=BE=D1=81=D1=82=D1=8C=D1=8E=20=D0=B8=D0=BC?= =?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=82=D0=BE=D0=B2=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=BE=D0=BF=D0=BB=D0=B0=D1=82=20=D0=B7=D0=B2=D1=91=D0=B7=D0=B4?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8=20=D0=B8=20=D0=BA=D1=80=D0=B8=D0=BF=D1=82?= =?UTF-8?q?=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/services/crypto_pay_service.py | 12 ++++++++---- bot/services/stars_service.py | 4 +++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/bot/services/crypto_pay_service.py b/bot/services/crypto_pay_service.py index babca27..daa9118 100644 --- a/bot/services/crypto_pay_service.py +++ b/bot/services/crypto_pay_service.py @@ -18,7 +18,6 @@ from bot.services.notification_service import NotificationService from db.dal import payment_dal, user_dal from bot.utils.text_sanitizer import sanitize_display_name, username_for_display from bot.utils.config_link import prepare_config_links -from bot.handlers.user.subscription.payment_discount_helper import apply_discount_to_payment class CryptoPayService: @@ -73,9 +72,14 @@ class CryptoPayService: return None # Apply active discount if exists - final_amount, discount_amount, promo_code_id = await apply_discount_to_payment( - session, user_id, amount, promo_code_service - ) + if promo_code_service: + # Import here to avoid circular import + from bot.handlers.user.subscription.payment_discount_helper import apply_discount_to_payment + final_amount, discount_amount, promo_code_id = await apply_discount_to_payment( + session, user_id, amount, promo_code_service + ) + else: + final_amount, discount_amount, promo_code_id = amount, None, None # Create pending payment in DB and commit to persist try: diff --git a/bot/services/stars_service.py b/bot/services/stars_service.py index c6d0dad..4bd2252 100644 --- a/bot/services/stars_service.py +++ b/bot/services/stars_service.py @@ -15,7 +15,6 @@ from .notification_service import NotificationService from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard from bot.utils.text_sanitizer import sanitize_display_name, username_for_display from bot.utils.config_link import prepare_config_links -from bot.handlers.user.subscription.payment_discount_helper import apply_discount_to_payment class StarsService: @@ -37,6 +36,9 @@ class StarsService: 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 From 1e8b97888a67e456c63f1e658debb4ed43a050eb Mon Sep 17 00:00:00 2001 From: VAQYBIN Date: Wed, 21 Jan 2026 01:26:10 +0500 Subject: [PATCH 12/14] =?UTF-8?q?fix(promo):=20=D0=98=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=B1=D0=B0=D0=B3=20=D1=81=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=B4=D0=B2=D0=BE=D0=B9=D0=BD=D0=BE=D0=B9=20=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=D0=B4=D0=BA=D0=B8=20=D0=BF=D1=80=D0=B8=20=D0=BE?= =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D1=82=D0=B5=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=20CryptoBot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/services/crypto_pay_service.py | 31 +++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/bot/services/crypto_pay_service.py b/bot/services/crypto_pay_service.py index daa9118..679024b 100644 --- a/bot/services/crypto_pay_service.py +++ b/bot/services/crypto_pay_service.py @@ -71,15 +71,24 @@ class CryptoPayService: logging.error("CryptoPayService not configured") return None - # Apply active discount if exists + # 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: - # Import here to avoid circular import - from bot.handlers.user.subscription.payment_discount_helper import apply_discount_to_payment - final_amount, discount_amount, promo_code_id = await apply_discount_to_payment( - session, user_id, amount, promo_code_service - ) - else: - final_amount, discount_amount, promo_code_id = amount, None, None + 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 try: @@ -87,8 +96,8 @@ class CryptoPayService: session, { "user_id": user_id, - "amount": final_amount, - "original_amount": amount if discount_amount else None, + "amount": amount, + "original_amount": original_amount, "discount_applied": discount_amount, "currency": self.settings.CRYPTOPAY_ASSET, "status": "pending_cryptopay", @@ -115,7 +124,7 @@ class CryptoPayService: }) try: invoice = await self.client.create_invoice( - amount=final_amount, + amount=amount, currency_type=self.settings.CRYPTOPAY_CURRENCY_TYPE, fiat=self.settings.CRYPTOPAY_ASSET if self.settings.CRYPTOPAY_CURRENCY_TYPE == "fiat" else None, asset=self.settings.CRYPTOPAY_ASSET if self.settings.CRYPTOPAY_CURRENCY_TYPE == "crypto" else None, From a121d38fbb31574f923a559cd06870d41f7c1758 Mon Sep 17 00:00:00 2001 From: VAQYBIN Date: Fri, 23 Jan 2026 00:00:16 +0500 Subject: [PATCH 13/14] =?UTF-8?q?fix(payments):=20=D0=98=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BE=D1=88=D0=B8=D0=B1?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=81=20=D0=B4=D1=83=D0=B1=D0=BB=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=D0=BC=20=D1=81=D0=BA=D0=B8?= =?UTF-8?q?=D0=B4=D0=BA=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D1=81=D0=B5?= =?UTF-8?q?=D1=85=20=D0=BF=D0=BB=D0=B0=D1=82=D1=91=D0=B6=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/subscription/payments_freekassa.py | 20 +++++----- .../user/subscription/payments_platega.py | 20 +++++----- .../user/subscription/payments_severpay.py | 20 +++++----- bot/services/freekassa_service.py | 36 +++++++++++++++++ bot/services/platega_service.py | 40 ++++++++++++++++++- bot/services/severpay_service.py | 40 ++++++++++++++++++- db/dal/payment_dal.py | 26 ++++++++++++ 7 files changed, 165 insertions(+), 37 deletions(-) diff --git a/bot/handlers/user/subscription/payments_freekassa.py b/bot/handlers/user/subscription/payments_freekassa.py index e24bb88..1416cfa 100644 --- a/bot/handlers/user/subscription/payments_freekassa.py +++ b/bot/handlers/user/subscription/payments_freekassa.py @@ -10,7 +10,6 @@ from bot.middlewares.i18n import JsonI18n from bot.services.freekassa_service import FreeKassaService from config.settings import Settings from db.dal import payment_dal -from bot.handlers.user.subscription.payment_discount_helper import apply_discount_to_payment router = Router(name="user_subscription_payments_freekassa_router") @@ -70,22 +69,19 @@ async def pay_fk_callback_handler( ) currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB" - # Apply active discount if exists - final_price_rub, discount_amount, promo_code_id = await apply_discount_to_payment( - session, user_id, price_rub, promo_code_service - ) - + # Price is already discounted at payments_subscription.py stage + # Service will handle discount metadata if needed payment_record_payload = { "user_id": user_id, - "amount": final_price_rub, - "original_amount": price_rub if discount_amount else None, - "discount_applied": discount_amount, + "amount": price_rub, + "original_amount": None, + "discount_applied": None, "currency": currency_code, "status": "pending_freekassa", "description": payment_description, "subscription_duration_months": int(months), "provider": "freekassa", - "promo_code_id": promo_code_id, + "promo_code_id": None, } try: @@ -111,13 +107,15 @@ async def pay_fk_callback_handler( payment_db_id=payment_record.payment_id, user_id=payment_record.user_id, months=months, - amount=final_price_rub, + amount=price_rub, currency=freekassa_service.default_currency, payment_method_id=freekassa_service.payment_method_id, ip_address=freekassa_service.server_ip, extra_params={ "us_method": freekassa_service.payment_method_id, }, + promo_code_service=promo_code_service, + session=session, ) if success: diff --git a/bot/handlers/user/subscription/payments_platega.py b/bot/handlers/user/subscription/payments_platega.py index 7f42ce1..d7ff3e2 100644 --- a/bot/handlers/user/subscription/payments_platega.py +++ b/bot/handlers/user/subscription/payments_platega.py @@ -10,7 +10,6 @@ from bot.middlewares.i18n import JsonI18n from bot.services.platega_service import PlategaService from config.settings import Settings from db.dal import payment_dal -from bot.handlers.user.subscription.payment_discount_helper import apply_discount_to_payment router = Router(name="user_subscription_payments_platega_router") @@ -70,22 +69,19 @@ async def pay_platega_callback_handler( ) currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" - # Apply active discount if exists - final_price_rub, discount_amount, promo_code_id = await apply_discount_to_payment( - session, user_id, price_rub, promo_code_service - ) - + # Price is already discounted at payments_subscription.py stage + # Service will handle discount metadata if needed payment_record_payload = { "user_id": user_id, - "amount": final_price_rub, - "original_amount": price_rub if discount_amount else None, - "discount_applied": discount_amount, + "amount": price_rub, + "original_amount": None, + "discount_applied": None, "currency": currency_code, "status": "pending_platega", "description": payment_description, "subscription_duration_months": int(months), "provider": "platega", - "promo_code_id": promo_code_id, + "promo_code_id": None, } try: @@ -120,10 +116,12 @@ async def pay_platega_callback_handler( payment_db_id=payment_record.payment_id, user_id=user_id, months=months, - amount=final_price_rub, + amount=price_rub, currency=currency_code, description=payment_description, payload=payload_meta, + promo_code_service=promo_code_service, + session=session, ) if success: diff --git a/bot/handlers/user/subscription/payments_severpay.py b/bot/handlers/user/subscription/payments_severpay.py index f99431a..a316f7f 100644 --- a/bot/handlers/user/subscription/payments_severpay.py +++ b/bot/handlers/user/subscription/payments_severpay.py @@ -9,7 +9,6 @@ from bot.middlewares.i18n import JsonI18n from bot.services.severpay_service import SeverPayService from config.settings import Settings from db.dal import payment_dal -from bot.handlers.user.subscription.payment_discount_helper import apply_discount_to_payment router = Router(name="user_subscription_payments_severpay_router") @@ -69,22 +68,19 @@ async def pay_severpay_callback_handler( ) currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" - # Apply active discount if exists - final_price_rub, discount_amount, promo_code_id = await apply_discount_to_payment( - session, user_id, price_rub, promo_code_service - ) - + # Price is already discounted at payments_subscription.py stage + # Service will handle discount metadata if needed payment_record_payload = { "user_id": user_id, - "amount": final_price_rub, - "original_amount": price_rub if discount_amount else None, - "discount_applied": discount_amount, + "amount": price_rub, + "original_amount": None, + "discount_applied": None, "currency": currency_code, "status": "pending_severpay", "description": payment_description, "subscription_duration_months": int(months), "provider": "severpay", - "promo_code_id": promo_code_id, + "promo_code_id": None, } try: @@ -110,9 +106,11 @@ async def pay_severpay_callback_handler( payment_db_id=payment_record.payment_id, user_id=user_id, months=months, - amount=final_price_rub, + amount=price_rub, currency=currency_code, description=payment_description, + promo_code_service=promo_code_service, + session=session, ) if success: diff --git a/bot/services/freekassa_service.py b/bot/services/freekassa_service.py index 0484be3..b50b515 100644 --- a/bot/services/freekassa_service.py +++ b/bot/services/freekassa_service.py @@ -78,11 +78,47 @@ class FreeKassaService: ip_address: Optional[str] = None, payment_method_id: Optional[int] = None, extra_params: Optional[Dict[str, Any]] = None, + promo_code_service=None, + session=None, ) -> Tuple[bool, Dict[str, Any]]: if not self.configured: logging.error("FreeKassaService is not configured. Cannot create order.") 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 if not ip_address: logging.error("FreeKassaService: payment IP is required but not configured.") diff --git a/bot/services/platega_service.py b/bot/services/platega_service.py index 4aa3893..1fd8c8b 100644 --- a/bot/services/platega_service.py +++ b/bot/services/platega_service.py @@ -76,12 +76,48 @@ class PlategaService: currency: Optional[str], description: str, payload: Optional[str] = None, + promo_code_service=None, + session=None, ) -> Tuple[bool, Dict[str, Any]]: if not self.configured: logging.error("PlategaService is not configured. Cannot create transaction.") 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" currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() @@ -98,7 +134,7 @@ class PlategaService: clean_body = {k: v for k, v in body.items() if v not in (None, "")} 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() try: response_data = json.loads(response_text) if response_text else {} diff --git a/bot/services/severpay_service.py b/bot/services/severpay_service.py index 75d79cc..9efa1e5 100644 --- a/bot/services/severpay_service.py +++ b/bot/services/severpay_service.py @@ -99,12 +99,48 @@ class SeverPayService: amount: float, currency: Optional[str], description: str, + promo_code_service=None, + session=None, ) -> Tuple[bool, Dict[str, Any]]: if not self.configured: logging.error("SeverPayService is not configured. Cannot create payment.") 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" currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() amount_str = self._format_amount(amount) @@ -124,7 +160,7 @@ class SeverPayService: signed_body = self._build_signed_body(body) 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() try: response_data = json.loads(response_text) if response_text else {} diff --git a/db/dal/payment_dal.py b/db/dal/payment_dal.py index 0ae6ec3..74a3223 100644 --- a/db/dal/payment_dal.py +++ b/db/dal/payment_dal.py @@ -176,6 +176,32 @@ async def update_provider_payment_and_status( 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]: """Get comprehensive financial statistics.""" from datetime import datetime, timedelta From 4a0f7633078fb0932af7ecdd631a55e06d729e5f Mon Sep 17 00:00:00 2001 From: VAQYBIN <63801831+VAQYBIN@users.noreply.github.com> Date: Fri, 23 Jan 2026 12:42:40 +0500 Subject: [PATCH 14/14] Delete CLAUDE.md --- CLAUDE.md | 378 ------------------------------------------------------ 1 file changed, 378 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 32a46df..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,378 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -Telegram bot for selling and managing Remnawave VPN subscriptions. Built with Aiogram 3.x (async), SQLAlchemy 2.x (async ORM), and PostgreSQL. Supports multiple payment providers (YooKassa, CryptoPay, FreeKassa, Platega, SeverPay, Telegram Stars) and integrates with the Remnawave panel API. - -## Common Development Commands - -### Running the Bot - -```bash -# Using Docker Compose (recommended) -docker compose up -d - -# View logs -docker compose logs -f remnawave-tg-shop - -# Stop -docker compose down - -# Local development (requires PostgreSQL running) -python main.py -``` - -### Environment Setup - -```bash -# Copy example environment file -cp .env.example .env - -# Edit .env with your configuration -# Required: BOT_TOKEN, ADMIN_IDS, WEBHOOK_BASE_URL, POSTGRES_*, PANEL_API_URL, PANEL_API_KEY -``` - -### Database Operations - -The bot auto-creates tables on startup via `init_db()` in `db/database_setup.py`. No manual migrations needed for initial setup. - -## Architecture Overview - -### Layered Architecture - -``` -Handlers (routing, input validation) - ↓ -Services (business logic, orchestration) - ↓ -DAL (data access layer - pure async functions) - ↓ -Models (SQLAlchemy ORM) - ↓ -PostgreSQL -``` - -### Core Components - -**Entry Point**: `main.py` → `bot/main_bot.py::run_bot()` - -**Initialization Flow**: -1. Load `.env` via `config/settings.py` (Pydantic) -2. Initialize database connection (`db/database_setup.py`) -3. Create dispatcher with middlewares (`bot/app/controllers/dispatcher_controller.py`) -4. Build all services via factory (`bot/app/factories/build_services.py`) -5. Register routers (`bot/routers.py`) -6. Start AIOHTTP web server for webhooks (`bot/app/web/web_server.py`) - -### Middleware Pipeline (Execution Order) - -All middlewares are outer middlewares applied at dispatcher level: - -1. **DBSessionMiddleware** - Provides `session` to handlers, auto-commits/rollbacks -2. **I18nMiddleware** - Sets `current_language` and `i18n_instance` in handler data -3. **ProfileSyncMiddleware** - Syncs Telegram user profile to local DB -4. **BanCheckMiddleware** - Blocks banned users -5. **ChannelSubscriptionMiddleware** - Enforces required channel subscription (if configured) -6. **ActionLoggerMiddleware** - Logs all user actions to `message_logs` table - -### Router Hierarchy - -``` -root_router (Private chat filter) -├── user_router_aggregate -│ ├── start_router (CommandStart) -│ ├── payment_router (payment flow callbacks) -│ ├── subscription_router (my_subscription callbacks) -│ ├── trial_router -│ ├── referral_router -│ ├── promo_user_router -│ └── payment method routers (yookassa, stars, crypto, etc.) -├── inline_mode.router -└── admin_main_filtered_router (AdminFilter) - ├── admin_router_aggregate - ├── admin_common_router - ├── admin_payments_router - ├── admin_promo_routers - ├── admin_stats_router - ├── admin_logs_router - ├── admin_broadcast_router - ├── admin_ads_router - ├── admin_sync_router - └── admin_user_management_router -``` - -## Key Subsystems - -### 1. Service Layer (`bot/services/`) - -All services are created once at startup in `build_core_services()` and injected into dispatcher. Handlers access them via `data["service_name"]`. - -**Core Services**: -- **PanelApiService** - REST client for Remnawave panel API (user CRUD, device management, stats) -- **SubscriptionService** - Subscription lifecycle management (trial, paid, renewals, traffic packages) -- **YooKassaService** - Primary payment provider with auto-renewal support -- **StarsService**, **CryptoPayService**, **FreeKassaService**, **PlategaService**, **SeverPayService** - Alternative payment providers -- **PanelWebhookService** - Handles panel subscription events (expiry notifications, auto-renew triggers) -- **ReferralService** - Referral tracking and bonus distribution -- **PromoCodeService** - Promo code validation and activation -- **LknpdService** - Tax receipt generation (nalog.ru integration) - -**Service Wiring Pattern**: -```python -# Services are cross-injected after creation -subscription_service.yookassa_service = yookassa_service -panel_webhook_service.subscription_service = subscription_service -``` - -### 2. Data Access Layer (`db/dal/`) - -Pure async functions (no classes). Each module provides CRUD operations for specific entities. - -**Key DAL Modules**: -- `user_dal.py` - User CRUD, statistics -- `subscription_dal.py` - Subscription upsert, status tracking, expiry queries -- `payment_dal.py` - Payment records, financial statistics -- `promo_code_dal.py` - Promo code management, activation tracking -- `user_billing_dal.py` - Saved payment methods for auto-renewal - -**Common Pattern**: -```python -async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]: - stmt = select(User).where(User.user_id == user_id) - result = await session.execute(stmt) - return result.scalar_one_or_none() -``` - -### 3. Database Models (`db/models.py`) - -**Key Entities**: -- **User** - Telegram user profile, panel linkage, referral tree -- **Subscription** - Panel-synced subscription state (active/inactive, expiry, traffic, auto-renew flag) -- **Payment** - Payment transaction records (status, provider, amount, promo code linkage) -- **PromoCode** / **PromoCodeActivation** - Promo codes with usage limits -- **UserBilling** / **UserPaymentMethod** - Saved payment methods for auto-renewal -- **MessageLog** - Audit trail of all user/admin actions -- **PanelSyncStatus** - Tracks automatic panel sync state -- **AdCampaign** / **AdAttribution** - Ad tracking - -**Important Relationships**: -- User → Subscription (1:Many) -- User → Payment (1:Many) -- User → User (self-referencing for referral tree via `referred_by_id`) -- Subscription has `panel_user_uuid` and `panel_subscription_uuid` for sync - -### 4. Webhook Handling (`bot/app/web/web_server.py`) - -Single AIOHTTP application hosts multiple webhook routes: - -| Path | Handler | Purpose | -|------|---------|---------| -| `/{BOT_TOKEN}` | SimpleRequestHandler | Telegram updates | -| `/webhook/yookassa` | yookassa_webhook_route | YooKassa payment status | -| `/webhook/cryptopay` | cryptopay_webhook_route | CryptoPay transactions | -| `/webhook/freekassa` | freekassa_webhook_route | FreeKassa payments | -| `/webhook/platega` | platega_webhook_route | Platega payments | -| `/webhook/severpay` | severpay_webhook_route | SeverPay payments | -| `/webhook/panel` | panel_webhook_route | Panel subscription events | - -**Security**: All webhooks verify signatures (HMAC-SHA256 for panel, provider-specific for payment systems). - -### 5. Panel Integration - -**Remnawave API** (`bot/services/panel_api_service.py`): -- REST API client with Bearer token auth -- User CRUD operations -- Device management (HWID disconnect) -- Subscription link generation -- System/bandwidth stats - -**User Linking Logic**: -When activating subscription, the bot ensures a panel user exists: -1. Check local DB for `panel_user_uuid` -2. If missing, search panel by `telegramId` -3. If not found, search by username pattern `tg_{telegram_id}` -4. If still missing, create new panel user with configured settings -5. Save returned UUID to local DB - -**Panel Webhook Events** (`bot/services/panel_webhook_service.py`): -- `user.expires_in_72_hours` → Send notification -- `user.expires_in_48_hours` → Send notification (special if auto-renew enabled) -- `user.expires_in_24_hours` → **Trigger auto-renewal** if YooKassa + saved card -- `user.expired` → Send expiration notice -- `user.expired_24_hours_ago` → Follow-up reminder - -### 6. Payment Flow - -**Standard Payment Flow**: -1. User selects subscription duration -2. Handler creates `Payment` record (status: `pending_{provider}`) -3. Service creates payment with provider API -4. User redirected to payment page -5. Provider sends webhook on completion -6. Webhook handler verifies signature, updates Payment status -7. If succeeded: call `subscription_service.activate_subscription()` - - Ensures panel user exists/linked - - Creates/updates Subscription record - - Updates panel user expiry and traffic - - Enables auto-renew if payment method saved -8. Send success notification with config link - -**Auto-Renewal Flow** (YooKassa only): -1. Panel webhook triggers 24h before expiry -2. `PanelWebhookService` checks if auto-renew enabled -3. Calls `subscription_service.charge_subscription_renewal()` -4. Uses saved `payment_method_id` for off-session charge -5. If successful: suppress 24h notification, create new Payment -6. If failed: send notification to user - -## Important Architectural Patterns - -### 1. Dependency Injection via Factory -All services created in `build_core_services()`, stored in dispatcher: -```python -dp["panel_service"] = panel_service -# Handlers access: -panel_service = data["panel_service"] -``` - -### 2. Upsert Pattern for Panel Sync -Uses `panel_subscription_uuid` as idempotency key to prevent duplicate subscriptions: -```python -async def upsert_subscription(session, payload): - existing = await get_subscription_by_panel_subscription_uuid(...) - if existing: - # Update fields - else: - # Create new -``` - -### 3. FSM for Multi-Step Flows -State machine for complex interactions (defined in `bot/states/`): -```python -# Set state -await state.set_state(UserPromoStates.waiting_for_promo_code) - -# Next handler checks state, processes, clears -if await state.get_state() == UserPromoStates.waiting_for_promo_code: - # Process promo code - await state.clear() -``` - -### 4. Notification Suppression -Track `last_notification_sent` per subscription to avoid spam: -```python -if last_notification_sent is None or date(last_notification_sent) < date(now): - # Send notification - await update_last_notification_sent(subscription) -``` - -## Configuration - -All settings loaded via Pydantic from `.env` (see `.env.example` for full reference). - -**Critical Settings**: -- `BOT_TOKEN` - Telegram bot token -- `ADMIN_IDS` - Comma-separated admin Telegram IDs -- `WEBHOOK_BASE_URL` - External URL for webhooks (HTTPS required) -- `PANEL_API_URL`, `PANEL_API_KEY` - Remnawave panel access -- `PANEL_WEBHOOK_SECRET` - HMAC signature verification -- Payment provider credentials (YooKassa, CryptoPay, etc.) -- Pricing: `RUB_PRICE_1_MONTH`, `STARS_PRICE_1_MONTH`, etc. - -**Sales Modes**: -- **Time-based** (default): User buys N months subscription -- **Traffic-based** (`traffic_sale_mode`): User buys X GB with far-future expiry - -## Development Guidelines - -### Adding New Payment Provider - -1. Create service in `bot/services/{provider}_service.py` implementing: - - `create_payment()` - Generate payment URL/data - - Webhook handler function -2. Add webhook route in `bot/app/web/web_server.py` -3. Add service to `build_core_services()` factory -4. Create handler router in `bot/handlers/user/subscription/payments_{provider}.py` -5. Register router in `bot/routers.py` -6. Add pricing settings to `config/settings.py` -7. Update payment method selection in `bot/handlers/user/subscription/payment_methods.py` - -### Adding New Admin Feature - -1. Create handler in `bot/handlers/admin/{feature}.py` -2. Add keyboard buttons in `bot/keyboards/inline/admin_keyboards.py` -3. Register router in `bot/routers.py` under `admin_main_filtered_router` -4. Use `AdminFilter()` to protect routes -5. Access services via `data["service_name"]` - -### Database Changes - -The project uses SQLAlchemy models without formal migrations. To add fields: -1. Update model in `db/models.py` -2. Add corresponding DAL functions in `db/dal/` -3. Drop and recreate tables in dev (bot auto-creates on startup) -4. For production, manually ALTER tables or use `db/migrator.py` as template - -### Localization - -Translations stored in `locales/{lang}/LC_MESSAGES/messages.json`. To add strings: -1. Add key-value to both `ru` and `en` files -2. Access in handlers via: `i18n.get("key_name")` -3. I18n instance available in handler data: `data["i18n_instance"]` - -## Testing Checklist - -When making changes, verify: -- [ ] Webhooks are reachable (use ngrok for local testing) -- [ ] Payment flow completes end-to-end -- [ ] Panel user creation/linking works -- [ ] Subscription activation updates both DB and panel -- [ ] Auto-renewal triggers correctly -- [ ] Notifications send at proper times -- [ ] Admin panel functions accessible only to admins -- [ ] Banned users cannot access bot -- [ ] Referral bonuses apply correctly -- [ ] Promo codes validate and activate - -## Deployment - -**Production Setup**: -1. Configure reverse proxy (Nginx/HAProxy) to route HTTPS webhooks to container -2. Set `WEBHOOK_BASE_URL` to external domain -3. Run `docker compose up -d` -4. Verify webhook registration in logs -5. Add bot as admin to required channel (if using `REQUIRED_CHANNEL_ID`) -6. Test payment flow with small amount - -**Webhook Paths**: -- Telegram: `https://yourdomain.com/{BOT_TOKEN}` -- YooKassa: `https://yourdomain.com/webhook/yookassa` -- Panel: `https://yourdomain.com/webhook/panel` -- Other providers: `https://yourdomain.com/webhook/{provider}` - -## Troubleshooting - -**Bot not responding**: -- Check webhook is set correctly (logs show webhook URL on startup) -- Verify HTTPS is working for `WEBHOOK_BASE_URL` -- Check `docker compose logs` for errors - -**Payment not completing**: -- Verify webhook route is accessible from provider -- Check signature verification is passing -- Look for errors in payment webhook handler logs -- Ensure `provider_payment_id` is unique (duplicate payments are rejected) - -**Panel sync issues**: -- Check `PANEL_API_KEY` is valid -- Verify panel user exists with matching UUID -- Check panel webhook secret matches -- Review `panel_sync_status` table for last sync time - -**Auto-renewal not working**: -- Only works with YooKassa -- User must have saved payment method (`user_billing` or `user_payment_methods` table) -- Subscription must have `auto_renew_enabled = true` -- Panel webhook must trigger 24h before expiry