diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index 8c7b2124..afc7f469 100644 --- a/.github/workflows/docker-hub.yml +++ b/.github/workflows/docker-hub.yml @@ -36,15 +36,15 @@ jobs: TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "🏷️ Π‘ΠΎΠ±ΠΈΡ€Π°Π΅ΠΌ Ρ€Π΅Π»ΠΈΠ·Π½ΡƒΡŽ Π²Π΅Ρ€ΡΠΈΡŽ: $VERSION" elif [[ $GITHUB_REF == refs/heads/main ]]; then - VERSION="v2.9.3-$(git rev-parse --short HEAD)" + VERSION="v2.9.4-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "πŸš€ Π‘ΠΎΠ±ΠΈΡ€Π°Π΅ΠΌ Π²Π΅Ρ€ΡΠΈΡŽ ΠΈΠ· main: $VERSION" elif [[ $GITHUB_REF == refs/heads/dev ]]; then - VERSION="v2.9.3-dev-$(git rev-parse --short HEAD)" + VERSION="v2.9.4-dev-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "πŸ§ͺ Π‘ΠΎΠ±ΠΈΡ€Π°Π΅ΠΌ dev Π²Π΅Ρ€ΡΠΈΡŽ: $VERSION" else - VERSION="v2.9.3-pr-$(git rev-parse --short HEAD)" + VERSION="v2.9.4-pr-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)" echo "πŸ”€ Π‘ΠΎΠ±ΠΈΡ€Π°Π΅ΠΌ PR Π²Π΅Ρ€ΡΠΈΡŽ: $VERSION" fi diff --git a/.github/workflows/docker-registry.yml b/.github/workflows/docker-registry.yml index b45fe175..aa0beb08 100644 --- a/.github/workflows/docker-registry.yml +++ b/.github/workflows/docker-registry.yml @@ -49,13 +49,13 @@ jobs: VERSION=${GITHUB_REF#refs/tags/} echo "🏷️ Building release version: $VERSION" elif [[ $GITHUB_REF == refs/heads/main ]]; then - VERSION="v2.9.3-$(git rev-parse --short HEAD)" + VERSION="v2.9.4-$(git rev-parse --short HEAD)" echo "πŸš€ Building main version: $VERSION" elif [[ $GITHUB_REF == refs/heads/dev ]]; then - VERSION="v2.9.3-dev-$(git rev-parse --short HEAD)" + VERSION="v2.9.4-dev-$(git rev-parse --short HEAD)" echo "πŸ§ͺ Building dev version: $VERSION" else - VERSION="v2.9.3-pr-$(git rev-parse --short HEAD)" + VERSION="v2.9.4-pr-$(git rev-parse --short HEAD)" echo "πŸ”€ Building PR version: $VERSION" fi echo "version=$VERSION" >> $GITHUB_OUTPUT diff --git a/Dockerfile b/Dockerfile index 4ad136a3..d8996e29 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \ FROM python:3.13-slim -ARG VERSION="v2.9.3" +ARG VERSION="v2.9.4" ARG BUILD_DATE ARG VCS_REF diff --git a/app/config.py b/app/config.py index d2407b15..899fe4a7 100644 --- a/app/config.py +++ b/app/config.py @@ -158,6 +158,10 @@ class Settings(BaseSettings): BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = "" + # Π Π΅ΠΆΠΈΠΌ Π²Ρ‹Π±ΠΎΡ€Π° Ρ‚Ρ€Π°Ρ„ΠΈΠΊΠ°: + # - selectable: ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒ Π²Ρ‹Π±ΠΈΡ€Π°Π΅Ρ‚ Ρ‚Ρ€Π°Ρ„ΠΈΠΊ ΠΏΡ€ΠΈ ΠΏΠΎΠΊΡƒΠΏΠΊΠ΅ ΠΈ ΠΌΠΎΠΆΠ΅Ρ‚ Π΄ΠΎΠΊΡƒΠΏΠ°Ρ‚ΡŒ + # - fixed: фиксированный Π»ΠΈΠΌΠΈΡ‚, Π±Π΅Π· Π²Ρ‹Π±ΠΎΡ€Π° ΠΈ Π±Π΅Π· Π΄ΠΎΠΊΡƒΠΏΠΊΠΈ + # - fixed_with_topup: фиксированный Π»ΠΈΠΌΠΈΡ‚ ΠΏΡ€ΠΈ ΠΏΠΎΠΊΡƒΠΏΠΊΠ΅, Π½ΠΎ Π΄ΠΎΠΊΡƒΠΏΠΊΠ° Ρ€Π°Π·Ρ€Π΅ΡˆΠ΅Π½Π° (ΠΏΡ€ΠΈ ΠΏΡ€ΠΎΠ΄Π»Π΅Π½ΠΈΠΈ сброс Π΄ΠΎ Π»ΠΈΠΌΠΈΡ‚Π°) TRAFFIC_SELECTION_MODE: str = "selectable" FIXED_TRAFFIC_LIMIT_GB: int = 100 BUY_TRAFFIC_BUTTON_VISIBLE: bool = True @@ -1058,6 +1062,11 @@ class Settings(BaseSettings): return self.TRAFFIC_SELECTION_MODE.lower() == "selectable" def is_traffic_fixed(self) -> bool: + """Π’ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ True Ссли Π²Ρ‹Π±ΠΎΡ€ Ρ‚Ρ€Π°Ρ„ΠΈΠΊΠ° ΠΎΡ‚ΠΊΠ»ΡŽΡ‡Ρ‘Π½ (fixed ΠΈΠ»ΠΈ fixed_with_topup)""" + return self.TRAFFIC_SELECTION_MODE.lower() in ("fixed", "fixed_with_topup") + + def is_traffic_topup_blocked(self) -> bool: + """Π’ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ True Ссли Π΄ΠΎΠΊΡƒΠΏΠΊΠ° Ρ‚Ρ€Π°Ρ„ΠΈΠΊΠ° ΠΏΠΎΠ»Π½ΠΎΡΡ‚ΡŒΡŽ Π·Π°Π±Π»ΠΎΠΊΠΈΡ€ΠΎΠ²Π°Π½Π° (Ρ‚ΠΎΠ»ΡŒΠΊΠΎ fixed)""" return self.TRAFFIC_SELECTION_MODE.lower() == "fixed" def get_fixed_traffic_limit(self) -> int: diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 847d73cb..5ea61978 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -344,6 +344,15 @@ async def extend_subscription( subscription.purchased_traffic_gb = 0 # БбрасываСм Π΄ΠΎΠΊΡƒΠΏΠ»Π΅Π½Π½Ρ‹ΠΉ Ρ‚Ρ€Π°Ρ„ΠΈΠΊ вмСстС с ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Π½Π½Ρ‹ΠΌ logger.info("πŸ”„ БбрасываСм ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Π½Π½Ρ‹ΠΉ ΠΈ Π΄ΠΎΠΊΡƒΠΏΠ»Π΅Π½Π½Ρ‹ΠΉ Ρ‚Ρ€Π°Ρ„ΠΈΠΊ согласно настройкС RESET_TRAFFIC_ON_PAYMENT") + # Π’ Ρ€Π΅ΠΆΠΈΠΌΠ΅ fixed_with_topup ΠΏΡ€ΠΈ ΠΏΡ€ΠΎΠ΄Π»Π΅Π½ΠΈΠΈ сбрасываСм Ρ‚Ρ€Π°Ρ„ΠΈΠΊ Π΄ΠΎ фиксированного Π»ΠΈΠΌΠΈΡ‚Π° + if settings.is_traffic_fixed() and days > 0: + fixed_limit = settings.get_fixed_traffic_limit() + old_limit = subscription.traffic_limit_gb + if subscription.traffic_limit_gb != fixed_limit or (subscription.purchased_traffic_gb or 0) > 0: + subscription.traffic_limit_gb = fixed_limit + subscription.purchased_traffic_gb = 0 + logger.info(f"πŸ”„ Бброс Ρ‚Ρ€Π°Ρ„ΠΈΠΊΠ° ΠΏΡ€ΠΈ ΠΏΡ€ΠΎΠ΄Π»Π΅Π½ΠΈΠΈ (fixed_with_topup): {old_limit} Π“Π‘ β†’ {fixed_limit} Π“Π‘") + subscription.updated_at = current_time await db.commit() @@ -1158,7 +1167,12 @@ async def get_subscription_renewal_cost( total_servers_cost = discounted_servers_per_month * months_in_period total_servers_discount = servers_discount_per_month * months_in_period - traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb) + # Π’ Ρ€Π΅ΠΆΠΈΠΌΠ΅ fixed_with_topup ΠΏΡ€ΠΈ ΠΏΡ€ΠΎΠ΄Π»Π΅Π½ΠΈΠΈ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ фиксированный Π»ΠΈΠΌΠΈΡ‚ + if settings.is_traffic_fixed(): + renewal_traffic_gb = settings.get_fixed_traffic_limit() + else: + renewal_traffic_gb = subscription.traffic_limit_gb + traffic_price_per_month = settings.get_traffic_price(renewal_traffic_gb) traffic_discount_percent = _get_discount_percent( user, promo_group, diff --git a/app/handlers/admin/pricing.py b/app/handlers/admin/pricing.py index a46f0a33..ab6a4592 100644 --- a/app/handlers/admin/pricing.py +++ b/app/handlers/admin/pricing.py @@ -198,6 +198,7 @@ CORE_PRICING_ENTRIES: Tuple[SettingEntry, ...] = ( choices=( ChoiceOption("selectable", "Π’Ρ‹Π±ΠΎΡ€ ΠΏΠ°ΠΊΠ΅Ρ‚ΠΎΠ²", "Selectable"), ChoiceOption("fixed", "Ѐиксированный Π»ΠΈΠΌΠΈΡ‚", "Fixed limit"), + ChoiceOption("fixed_with_topup", "Ѐикс. Π»ΠΈΠΌΠΈΡ‚ + Π΄ΠΎΠΊΡƒΠΏΠΊΠ°", "Fixed + topup"), ), description_ru="ΠžΠΏΡ€Π΅Π΄Π΅Π»ΡΠ΅Ρ‚, Π²Ρ‹Π±ΠΈΡ€Π°ΡŽΡ‚ Π»ΠΈ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΠΈ ΠΏΠ°ΠΊΠ΅Ρ‚Ρ‹ ΠΈΠ»ΠΈ ΠΏΠΎΠ»ΡƒΡ‡Π°ΡŽΡ‚ фиксированный Π»ΠΈΠΌΠΈΡ‚.", description_en="Defines whether users pick packages or use a fixed limit.", @@ -351,8 +352,11 @@ def _format_core_summary(lang_code: str) -> str: base_price = settings.format_price(settings.BASE_SUBSCRIPTION_PRICE) device_limit = settings.DEFAULT_DEVICE_LIMIT traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB - if settings.TRAFFIC_SELECTION_MODE == "fixed": + mode = settings.TRAFFIC_SELECTION_MODE.lower() + if mode == "fixed": traffic_mode = "βš™οΈ fixed" + elif mode == "fixed_with_topup": + traffic_mode = "βš™οΈ fixed+topup" else: traffic_mode = "βš™οΈ selectable" traffic_label = _format_traffic_label(traffic_limit, lang_code, short=True) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 3172ed85..581f15d4 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -1362,7 +1362,12 @@ async def handle_extend_subscription( devices_price_info = calculate_user_price(db_user, devices_total_base, days, "devices") # 4. Calculate traffic price with promo group discount - traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb) + # Π’ Ρ€Π΅ΠΆΠΈΠΌΠ΅ fixed_with_topup ΠΏΡ€ΠΈ ΠΏΡ€ΠΎΠ΄Π»Π΅Π½ΠΈΠΈ Ρ‚Ρ€Π°Ρ„ΠΈΠΊ сбрасываСтся Π΄ΠΎ фиксированного Π»ΠΈΠΌΠΈΡ‚Π° + if settings.is_traffic_fixed(): + renewal_traffic_gb = settings.get_fixed_traffic_limit() + else: + renewal_traffic_gb = subscription.traffic_limit_gb + traffic_price_per_month = settings.get_traffic_price(renewal_traffic_gb) traffic_total_base = traffic_price_per_month * months_in_period traffic_price_info = calculate_user_price(db_user, traffic_total_base, days, "traffic") @@ -1579,7 +1584,12 @@ async def confirm_extend_subscription( discounted_devices_price_per_month = devices_price_per_month - devices_discount_per_month total_devices_price = discounted_devices_price_per_month * months_in_period - traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb) + # Π’ Ρ€Π΅ΠΆΠΈΠΌΠ΅ fixed_with_topup ΠΏΡ€ΠΈ ΠΏΡ€ΠΎΠ΄Π»Π΅Π½ΠΈΠΈ Ρ‚Ρ€Π°Ρ„ΠΈΠΊ сбрасываСтся Π΄ΠΎ фиксированного Π»ΠΈΠΌΠΈΡ‚Π° + if settings.is_traffic_fixed(): + renewal_traffic_gb = settings.get_fixed_traffic_limit() + else: + renewal_traffic_gb = subscription.traffic_limit_gb + traffic_price_per_month = settings.get_traffic_price(renewal_traffic_gb) traffic_discount_percent = db_user.get_promo_discount( "traffic", days, @@ -1731,6 +1741,17 @@ async def confirm_extend_subscription( subscription.status = SubscriptionStatus.ACTIVE.value subscription.updated_at = current_time + # Π’ Ρ€Π΅ΠΆΠΈΠΌΠ΅ fixed_with_topup ΠΏΡ€ΠΈ ΠΏΡ€ΠΎΠ΄Π»Π΅Π½ΠΈΠΈ сбрасываСм Ρ‚Ρ€Π°Ρ„ΠΈΠΊ Π΄ΠΎ фиксированного Π»ΠΈΠΌΠΈΡ‚Π° + traffic_was_reset = False + old_traffic_limit = subscription.traffic_limit_gb + if settings.is_traffic_fixed(): + fixed_limit = settings.get_fixed_traffic_limit() + if subscription.traffic_limit_gb != fixed_limit or (subscription.purchased_traffic_gb or 0) > 0: + traffic_was_reset = True + subscription.traffic_limit_gb = fixed_limit + subscription.purchased_traffic_gb = 0 + logger.info(f"πŸ”„ Бброс Ρ‚Ρ€Π°Ρ„ΠΈΠΊΠ° ΠΏΡ€ΠΈ ΠΏΡ€ΠΎΠ΄Π»Π΅Π½ΠΈΠΈ: {old_traffic_limit} Π“Π‘ β†’ {fixed_limit} Π“Π‘") + await db.commit() await db.refresh(subscription) await db.refresh(db_user) @@ -1803,6 +1824,11 @@ async def confirm_extend_subscription( f"πŸ’° Бписано: {texts.format_price(price)}" ) + # ДобавляСм ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅ ΠΎ сбросС Ρ‚Ρ€Π°Ρ„ΠΈΠΊΠ° + if traffic_was_reset: + fixed_limit = settings.get_fixed_traffic_limit() + success_message += f"\n\nπŸ“Š Π’Ρ€Π°Ρ„ΠΈΠΊ ΡΠ±Ρ€ΠΎΡˆΠ΅Π½ Π΄ΠΎ {fixed_limit} Π“Π‘" + if promo_component["discount"] > 0: success_message += ( f" (Π²ΠΊΠ»ΡŽΡ‡Π°Ρ Π΄ΠΎΠΏ. скидку {promo_component['percent']}%:" diff --git a/app/handlers/subscription/traffic.py b/app/handlers/subscription/traffic.py index 0e7f1d92..b5fd0687 100644 --- a/app/handlers/subscription/traffic.py +++ b/app/handlers/subscription/traffic.py @@ -107,7 +107,7 @@ async def handle_add_traffic( ) return - if settings.is_traffic_fixed(): + if settings.is_traffic_topup_blocked(): await callback.answer( texts.t( "TRAFFIC_FIXED_MODE", @@ -206,7 +206,7 @@ async def handle_reset_traffic( ): from app.config import settings - if settings.is_traffic_fixed(): + if settings.is_traffic_topup_blocked(): await callback.answer("⚠️ Π’ Ρ‚Π΅ΠΊΡƒΡ‰Π΅ΠΌ Ρ€Π΅ΠΆΠΈΠΌΠ΅ Ρ‚Ρ€Π°Ρ„ΠΈΠΊ фиксированный ΠΈ Π½Π΅ ΠΌΠΎΠΆΠ΅Ρ‚ Π±Ρ‹Ρ‚ΡŒ ΡΠ±Ρ€ΠΎΡˆΠ΅Π½", show_alert=True) return @@ -266,7 +266,7 @@ async def confirm_reset_traffic( ): from app.config import settings - if settings.is_traffic_fixed(): + if settings.is_traffic_topup_blocked(): await callback.answer("⚠️ Π’ Ρ‚Π΅ΠΊΡƒΡ‰Π΅ΠΌ Ρ€Π΅ΠΆΠΈΠΌΠ΅ Ρ‚Ρ€Π°Ρ„ΠΈΠΊ фиксированный", show_alert=True) return @@ -461,7 +461,7 @@ async def add_traffic( db_user: User, db: AsyncSession ): - if settings.is_traffic_fixed(): + if settings.is_traffic_topup_blocked(): await callback.answer("⚠️ Π’ Ρ‚Π΅ΠΊΡƒΡ‰Π΅ΠΌ Ρ€Π΅ΠΆΠΈΠΌΠ΅ Ρ‚Ρ€Π°Ρ„ΠΈΠΊ фиксированный", show_alert=True) return @@ -609,7 +609,7 @@ async def handle_switch_traffic( ): from app.config import settings - if settings.is_traffic_fixed(): + if settings.is_traffic_topup_blocked(): await callback.answer("⚠️ Π’ Ρ‚Π΅ΠΊΡƒΡ‰Π΅ΠΌ Ρ€Π΅ΠΆΠΈΠΌΠ΅ Ρ‚Ρ€Π°Ρ„ΠΈΠΊ фиксированный", show_alert=True) return diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 84001a7f..d0976afc 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -511,6 +511,7 @@ def get_main_menu_keyboard( if ( settings.BUY_TRAFFIC_BUTTON_VISIBLE and settings.is_traffic_topup_enabled() + and not settings.is_traffic_topup_blocked() and subscription and not subscription.is_trial and (subscription.traffic_limit_gb or 0) > 0 @@ -992,7 +993,20 @@ def get_subscription_keyboard( callback_data="subscription_settings", ) ]) - + # Кнопка Π΄ΠΎΠΊΡƒΠΏΠΊΠΈ Ρ‚Ρ€Π°Ρ„ΠΈΠΊΠ° для ΠΏΠ»Π°Ρ‚Π½Ρ‹Ρ… подписок + if ( + settings.is_traffic_topup_enabled() + and not settings.is_traffic_topup_blocked() + and subscription + and (subscription.traffic_limit_gb or 0) > 0 + ): + keyboard.append([ + InlineKeyboardButton( + text=texts.t("BUY_TRAFFIC_BUTTON", "πŸ“ˆ Π”ΠΎΠΊΡƒΠΏΠΈΡ‚ΡŒ Ρ‚Ρ€Π°Ρ„ΠΈΠΊ"), + callback_data="buy_traffic" + ) + ]) + keyboard.append([ InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu") ]) @@ -1134,10 +1148,10 @@ def get_subscription_period_keyboard( def get_traffic_packages_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: import logging logger = logging.getLogger(__name__) - + from app.config import settings - - if settings.is_traffic_fixed(): + + if settings.is_traffic_topup_blocked(): return get_back_keyboard(language) logger.info(f"πŸ” RAW CONFIG: '{settings.TRAFFIC_PACKAGES_CONFIG}'") @@ -1863,8 +1877,8 @@ def get_reset_traffic_confirm_keyboard( missing_kopeks: int = 0, ) -> InlineKeyboardMarkup: from app.config import settings - - if settings.is_traffic_fixed(): + + if settings.is_traffic_topup_blocked(): return get_back_keyboard(language) texts = get_texts(language) diff --git a/app/services/cloudpayments_service.py b/app/services/cloudpayments_service.py index c8d15796..f1325a01 100644 --- a/app/services/cloudpayments_service.py +++ b/app/services/cloudpayments_service.py @@ -7,9 +7,7 @@ import hashlib import hmac import logging import time -from datetime import datetime from typing import Any, Dict, Optional -from urllib.parse import urlencode import httpx @@ -35,12 +33,10 @@ class CloudPaymentsService: public_id: Optional[str] = None, api_secret: Optional[str] = None, api_url: Optional[str] = None, - widget_url: Optional[str] = None, ) -> None: self.public_id = public_id or settings.CLOUDPAYMENTS_PUBLIC_ID self.api_secret = api_secret or settings.CLOUDPAYMENTS_API_SECRET self.api_url = (api_url or settings.CLOUDPAYMENTS_API_URL).rstrip("/") - self.widget_url = (widget_url or settings.CLOUDPAYMENTS_WIDGET_URL).rstrip("/") @property def is_configured(self) -> bool: @@ -114,16 +110,18 @@ class CloudPaymentsService: """Convert rubles to kopeks.""" return int(amount * 100) - def generate_payment_link( + async def generate_payment_link( self, telegram_id: int, amount_kopeks: int, invoice_id: str, description: Optional[str] = None, email: Optional[str] = None, + success_redirect_url: Optional[str] = None, + fail_redirect_url: Optional[str] = None, ) -> str: """ - Generate a payment widget URL for CloudPayments. + Create a payment order via CloudPayments API and return payment URL. Args: telegram_id: User's Telegram ID (will be used as AccountId) @@ -131,35 +129,65 @@ class CloudPaymentsService: invoice_id: Unique invoice ID for this payment description: Payment description email: User's email (optional) + success_redirect_url: Redirect URL after successful payment + fail_redirect_url: Redirect URL after failed payment Returns: - URL to CloudPayments payment widget + URL to CloudPayments payment page """ - if not self.public_id: - raise CloudPaymentsAPIError("CloudPayments public_id not configured") + if not self.is_configured: + raise CloudPaymentsAPIError("CloudPayments is not configured") amount = self._amount_from_kopeks(amount_kopeks) - params = { - "publicId": self.public_id, - "description": description or settings.CLOUDPAYMENTS_DESCRIPTION, - "amount": amount, - "currency": settings.CLOUDPAYMENTS_CURRENCY, - "accountId": str(telegram_id), - "invoiceId": invoice_id, - "skin": settings.CLOUDPAYMENTS_SKIN, + # Π€ΠΎΡ€ΠΌΠΈΡ€ΡƒΠ΅ΠΌ Π΄Π°Π½Π½Ρ‹Π΅ для создания Π·Π°ΠΊΠ°Π·Π° Ρ‡Π΅Ρ€Π΅Π· API /orders/create + payload: Dict[str, Any] = { + "Amount": amount, + "Currency": settings.CLOUDPAYMENTS_CURRENCY, + "Description": description or settings.CLOUDPAYMENTS_DESCRIPTION, + "AccountId": str(telegram_id), + "InvoiceId": invoice_id, + "JsonData": { + "telegram_id": telegram_id, + "invoice_id": invoice_id, + }, } - if settings.CLOUDPAYMENTS_REQUIRE_EMAIL: - params["requireEmail"] = "true" - if email: - params["email"] = email + payload["Email"] = email - # ДобавляСм JSON Π΄Π°Π½Π½Ρ‹Π΅ для webhook - params["data"] = f'{{"telegram_id": {telegram_id}, "invoice_id": "{invoice_id}"}}' + if settings.CLOUDPAYMENTS_REQUIRE_EMAIL: + payload["RequireConfirmation"] = False - return f"{self.widget_url}?{urlencode(params)}" + # URL для Ρ€Π΅Π΄ΠΈΡ€Π΅ΠΊΡ‚Π° послС ΠΎΠΏΠ»Π°Ρ‚Ρ‹ + if success_redirect_url or settings.CLOUDPAYMENTS_RETURN_URL: + payload["SuccessRedirectUrl"] = success_redirect_url or settings.CLOUDPAYMENTS_RETURN_URL + + if fail_redirect_url: + payload["FailRedirectUrl"] = fail_redirect_url + + # Π‘ΠΎΠ·Π΄Π°Ρ‘ΠΌ Π·Π°ΠΊΠ°Π· Ρ‡Π΅Ρ€Π΅Π· API + response = await self._request("POST", "/orders/create", json=payload) + + if not response.get("Success"): + error_message = response.get("Message", "Unknown error") + logger.error("CloudPayments orders/create failed: %s", error_message) + raise CloudPaymentsAPIError(f"Failed to create order: {error_message}") + + model = response.get("Model", {}) + payment_url = model.get("Url") + + if not payment_url: + logger.error("CloudPayments orders/create returned no URL: %s", response) + raise CloudPaymentsAPIError("CloudPayments API returned no payment URL") + + logger.info( + "CloudPayments order created: id=%s, url=%s", + model.get("Id"), + payment_url, + ) + + return payment_url def generate_invoice_id(self, telegram_id: int) -> str: """Generate unique invoice ID for a payment.""" diff --git a/app/services/payment/cloudpayments.py b/app/services/payment/cloudpayments.py index 2250a8a7..ed1dd843 100644 --- a/app/services/payment/cloudpayments.py +++ b/app/services/payment/cloudpayments.py @@ -75,8 +75,8 @@ class CloudPaymentsPaymentMixin: invoice_id = self.cloudpayments_service.generate_invoice_id(telegram_id) try: - # Generate payment widget URL - payment_url = self.cloudpayments_service.generate_payment_link( + # Create payment order via CloudPayments API + payment_url = await self.cloudpayments_service.generate_payment_link( telegram_id=telegram_id, amount_kopeks=amount_kopeks, invoice_id=invoice_id, diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index 3cccb877..4e11af5b 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -680,11 +680,19 @@ async def auto_activate_subscription_after_topup( # ΠžΠΏΡ€Π΅Π΄Π΅Π»ΡΠ΅ΠΌ ΠΏΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€Ρ‹ подписки if subscription: device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT - traffic_limit_gb = subscription.traffic_limit_gb or 0 + # Π’ Ρ€Π΅ΠΆΠΈΠΌΠ΅ fixed_with_topup ΠΏΡ€ΠΈ Π°Π²Ρ‚ΠΎΠ°ΠΊΡ‚ΠΈΠ²Π°Ρ†ΠΈΠΈ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ фиксированный Π»ΠΈΠΌΠΈΡ‚ + if settings.is_traffic_fixed(): + traffic_limit_gb = settings.get_fixed_traffic_limit() + else: + traffic_limit_gb = subscription.traffic_limit_gb or 0 connected_squads = subscription.connected_squads or [] else: device_limit = settings.DEFAULT_DEVICE_LIMIT - traffic_limit_gb = 0 + # Π’ Ρ€Π΅ΠΆΠΈΠΌΠ΅ fixed_with_topup ΠΏΡ€ΠΈ Π°Π²Ρ‚ΠΎΠ°ΠΊΡ‚ΠΈΠ²Π°Ρ†ΠΈΠΈ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ фиксированный Π»ΠΈΠΌΠΈΡ‚ + if settings.is_traffic_fixed(): + traffic_limit_gb = settings.get_fixed_traffic_limit() + else: + traffic_limit_gb = 0 connected_squads = [] # Если сСрвСры Π½Π΅ Π²Ρ‹Π±Ρ€Π°Π½Ρ‹ β€” Π±Π΅Ρ€Ρ‘ΠΌ бСсплатныС ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ diff --git a/app/services/subscription_purchase_service.py b/app/services/subscription_purchase_service.py index 163255da..5a9dcbc3 100644 --- a/app/services/subscription_purchase_service.py +++ b/app/services/subscription_purchase_service.py @@ -511,9 +511,11 @@ class MiniAppSubscriptionPurchaseService: ) -> PurchaseTrafficConfig: if settings.is_traffic_fixed(): value = fixed_traffic_value if fixed_traffic_value is not None else settings.get_fixed_traffic_limit() + # ΠŸΠ΅Ρ€Π΅Π΄Π°Ρ‘ΠΌ Π°ΠΊΡ‚ΡƒΠ°Π»ΡŒΠ½Ρ‹ΠΉ Ρ€Π΅ΠΆΠΈΠΌ (fixed ΠΈΠ»ΠΈ fixed_with_topup) + actual_mode = settings.TRAFFIC_SELECTION_MODE.lower() return PurchaseTrafficConfig( selectable=False, - mode="fixed", + mode=actual_mode, options=[], default_value=value, current_value=value, diff --git a/app/services/subscription_renewal_service.py b/app/services/subscription_renewal_service.py index 6912af23..3dc89dcd 100644 --- a/app/services/subscription_renewal_service.py +++ b/app/services/subscription_renewal_service.py @@ -319,9 +319,13 @@ class SubscriptionRenewalService: if connected_uuids: server_ids = await get_server_ids_by_uuids(db, connected_uuids) - traffic_limit = subscription.traffic_limit_gb - if traffic_limit is None: - traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB + # Π’ Ρ€Π΅ΠΆΠΈΠΌΠ΅ fixed_with_topup ΠΏΡ€ΠΈ ΠΏΡ€ΠΎΠ΄Π»Π΅Π½ΠΈΠΈ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ фиксированный Π»ΠΈΠΌΠΈΡ‚ + if settings.is_traffic_fixed(): + traffic_limit = settings.get_fixed_traffic_limit() + else: + traffic_limit = subscription.traffic_limit_gb + if traffic_limit is None: + traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB devices_limit = subscription.device_limit if devices_limit is None: diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 904382da..f5767f3a 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -730,7 +730,12 @@ class SubscriptionService: devices_discount = devices_price * devices_discount_percent // 100 discounted_devices_price = devices_price - devices_discount - traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb) + # Π’ Ρ€Π΅ΠΆΠΈΠΌΠ΅ fixed_with_topup ΠΏΡ€ΠΈ ΠΏΡ€ΠΎΠ΄Π»Π΅Π½ΠΈΠΈ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ фиксированный Π»ΠΈΠΌΠΈΡ‚ + if settings.is_traffic_fixed(): + renewal_traffic_gb = settings.get_fixed_traffic_limit() + else: + renewal_traffic_gb = subscription.traffic_limit_gb + traffic_price = settings.get_traffic_price(renewal_traffic_gb) traffic_discount_percent = _resolve_discount_percent( user, promo_group, @@ -1072,7 +1077,12 @@ class SubscriptionService: discounted_devices_per_month = devices_price_per_month - devices_discount_per_month total_devices_price = discounted_devices_per_month * months_in_period - traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb) + # Π’ Ρ€Π΅ΠΆΠΈΠΌΠ΅ fixed_with_topup ΠΏΡ€ΠΈ ΠΏΡ€ΠΎΠ΄Π»Π΅Π½ΠΈΠΈ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ фиксированный Π»ΠΈΠΌΠΈΡ‚ + if settings.is_traffic_fixed(): + renewal_traffic_gb = settings.get_fixed_traffic_limit() + else: + renewal_traffic_gb = subscription.traffic_limit_gb + traffic_price_per_month = settings.get_traffic_price(renewal_traffic_gb) traffic_discount_percent = _resolve_discount_percent( user, promo_group, diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index cb80e199..8d53ed75 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -355,6 +355,7 @@ class BotConfigurationService: "TRAFFIC_SELECTION_MODE": [ ChoiceOption("selectable", "πŸ“¦ Π’Ρ‹Π±ΠΎΡ€ ΠΏΠ°ΠΊΠ΅Ρ‚ΠΎΠ²"), ChoiceOption("fixed", "πŸ“ Ѐиксированный Π»ΠΈΠΌΠΈΡ‚"), + ChoiceOption("fixed_with_topup", "πŸ“ Ѐикс. Π»ΠΈΠΌΠΈΡ‚ + Π΄ΠΎΠΊΡƒΠΏΠΊΠ°"), ], "DEFAULT_TRAFFIC_RESET_STRATEGY": [ ChoiceOption("NO_RESET", "♾️ Π‘Π΅Π· сброса"), diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index c701fc88..4e4b733c 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -4690,7 +4690,8 @@ async def _build_subscription_settings( ) traffic_options: List[MiniAppSubscriptionTrafficOption] = [] - if settings.is_traffic_selectable(): + # Π’ Ρ€Π΅ΠΆΠΈΠΌΠ΅ fixed_with_topup ΠΏΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅ΠΌ ΠΎΠΏΡ†ΠΈΠΈ Ρ‚Ρ€Π°Ρ„ΠΈΠΊΠ° (для Π΄ΠΎΠΊΡƒΠΏΠΊΠΈ) + if not settings.is_traffic_topup_blocked(): for package in settings.get_traffic_packages(): is_enabled = bool(package.get("enabled", True)) if package.get("is_active") is False: @@ -4764,7 +4765,7 @@ async def _build_subscription_settings( ), traffic=MiniAppSubscriptionTrafficSettings( options=traffic_options, - can_update=settings.is_traffic_selectable(), + can_update=not settings.is_traffic_topup_blocked(), current_value=subscription.traffic_limit_gb, ), devices=MiniAppSubscriptionDevicesSettings( @@ -5512,7 +5513,9 @@ async def update_subscription_traffic_endpoint( if new_traffic == subscription.traffic_limit_gb: return MiniAppSubscriptionUpdateResponse(success=True, message="No changes") - if not settings.is_traffic_selectable(): + # Π’ Ρ€Π΅ΠΆΠΈΠΌΠ΅ fixed ΠΏΠΎΠ»Π½ΠΎΡΡ‚ΡŒΡŽ Π±Π»ΠΎΠΊΠΈΡ€ΡƒΠ΅ΠΌ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠ΅ Ρ‚Ρ€Π°Ρ„ΠΈΠΊΠ° + # Π’ Ρ€Π΅ΠΆΠΈΠΌΠ΅ fixed_with_topup Ρ€Π°Π·Ρ€Π΅ΡˆΠ°Π΅ΠΌ Π΄ΠΎΠΊΡƒΠΏΠΊΡƒ (is_traffic_topup_blocked = False) + if settings.is_traffic_topup_blocked(): raise HTTPException( status.HTTP_403_FORBIDDEN, detail={ diff --git a/miniapp/index.html b/miniapp/index.html index 0a544c88..11fdfe26 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -16166,7 +16166,8 @@ } function ensurePurchaseTrafficSelection(config) { - const selectable = config && config.selectable !== false && String(config.mode || '').toLowerCase() !== 'fixed'; + const mode = String(config?.mode || '').toLowerCase(); + const selectable = config && config.selectable !== false && !['fixed', 'fixed_with_topup'].includes(mode); const options = ensureArray(config?.options || config?.available || []); if (!selectable || !options.length) { if (config && (config.current !== undefined || config.default !== undefined)) { @@ -16937,7 +16938,8 @@ } const config = getSubscriptionPurchaseTrafficConfig(period); - const selectable = config && config.selectable !== false && String(config.mode || '').toLowerCase() !== 'fixed'; + const mode = String(config?.mode || '').toLowerCase(); + const selectable = config && config.selectable !== false && !['fixed', 'fixed_with_topup'].includes(mode); const options = ensureArray(config?.options || config?.available || []); optionsContainer.innerHTML = '';