Compare commits
302 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53988c319b | |||
| 777ea3b99b | |||
| 672ce18f38 | |||
| aafcec2716 | |||
| 4e5b607288 | |||
| aae85683a0 | |||
| 9971c43218 | |||
| b4370e9a34 | |||
| 101e27acd6 | |||
| 5b9002896e | |||
| e8ac14b0b7 | |||
| 7cd6c3acb7 | |||
| 7e7795fc4e | |||
| 1f75413abd | |||
| 3c6d674ce5 | |||
| 92efc52f7e | |||
| c558f69d62 | |||
| 168cb5ea38 | |||
| 6a48cc687c | |||
| 9a5b0553c6 | |||
| 7515964599 | |||
| bf5e0ecd4f | |||
| 6d058beade | |||
| 91f557e357 | |||
| 8e6c594a7b | |||
| 94cc04703b | |||
| 9ac621cebd | |||
| 5d9f6064d6 | |||
| 010be14379 | |||
| d864c80457 | |||
| ac04284d85 | |||
| e1377ad7a0 | |||
| 6c7bbba7ed | |||
| 7a70a90d63 | |||
| 58187cecdb | |||
| 649a358560 | |||
| e0f1f221df | |||
| 1342236b23 | |||
| 61681b393e | |||
| 20a36fe869 | |||
| a4fe68bf2a | |||
| 827cdd7805 | |||
| 2479167073 | |||
| 5cc8f7869f | |||
| 330ace039b | |||
| 36ad95dfef | |||
| a7d564b7d1 | |||
| 4582daa1d1 | |||
| c440274c32 | |||
| a02b2f163b | |||
| f5a6dbdeec | |||
| 282bf3aa11 | |||
| d302e02b0a | |||
| da554736ba | |||
| aefbc21033 | |||
| add0fb4fbe | |||
| f1bec83a6f | |||
| a9e15a7cc1 | |||
| 53c0f17200 | |||
| 0c5a28d1c3 | |||
| cedb6b4cc4 | |||
| 5c7ec8c4a2 | |||
| 3bb2a055e7 | |||
| b117df126a | |||
| 610a39e322 | |||
| 71fca0ccf8 | |||
| 2568cf086a | |||
| ae453279bf | |||
| bdb04ab91b | |||
| 3636cd27b6 | |||
| 04279542c5 | |||
| c7666274b8 | |||
| 5b27b8834d | |||
| 4b506cde0c | |||
| 121d254fc0 | |||
| 515c9dc8b1 | |||
| 4e03fa1b71 | |||
| 80752c3004 | |||
| a11d37ef96 | |||
| 14c89c7aab | |||
| b09b47fa49 | |||
| 49b8a96a84 | |||
| 6f800ac60b | |||
| 46dfb59f3d | |||
| d5bbb9f14a | |||
| 7c52811750 | |||
| 2d4b46afbd | |||
| 83093236fc | |||
| 3881e7d1d5 | |||
| 1c3dbd57c5 | |||
| 6f1275f291 | |||
| cfc48ea061 | |||
| 2679172ae4 | |||
| 6a0c276454 | |||
| 5872e3bd3c | |||
| f6041ab055 | |||
| b5c5da552d | |||
| c093ef95ed | |||
| d376a25e64 | |||
| 4ec926d072 | |||
| 65a7df4549 | |||
| 57c252dcbf | |||
| 9b74b40794 | |||
| 0b028e1f13 | |||
| 337b948fda | |||
| 5de187c6cd | |||
| 7150204c71 | |||
| 3d44403318 | |||
| 812f3e983c | |||
| 7549e571fe | |||
| 9d3f096be0 | |||
| fd40cd3ba6 | |||
| 1ba566f30f | |||
| 9b9d17b8e7 | |||
| 3fee600db0 | |||
| fbb1091f8b | |||
| 8513d67a78 | |||
| 45ce615fbe | |||
| 2ab4ca5b09 | |||
| b229dc22ba | |||
| bf49a5c1d5 | |||
| 27b6e974ab | |||
| 9f00b56be0 | |||
| 05177bff92 | |||
| f40f233562 | |||
| d3a351aeba | |||
| fb76d30457 | |||
| afba2137d1 | |||
| 9ef3ca1831 | |||
| 65ae2f5c48 | |||
| 2686dcd30a | |||
| 6bc125a2f4 | |||
| 29c6e296df | |||
| 3d1d064af7 | |||
| ccf1f8a71d | |||
| b83a8c9df0 | |||
| a7bd94f394 | |||
| 96787c27d3 | |||
| e99c731e27 | |||
| 4a4b5bd8ef | |||
| 9d27f1aa6b | |||
| fb010038f1 | |||
| 700f86fe38 | |||
| 582a8b9615 | |||
| 7c9dfc352c | |||
| b86d549695 | |||
| 577f6dfd8e | |||
| eab4cce251 | |||
| 7a099ab255 | |||
| 9fa67f61f3 | |||
| 86ebff4948 | |||
| 821ac4668f | |||
| d0280f0ab9 | |||
| ceb45b4c0b | |||
| 85ea3d97ef | |||
| 5640041e45 | |||
| 1357ddc1fe | |||
| 0765dae178 | |||
| 81f2e20c08 | |||
| b27d34ec92 | |||
| e7fbbd579c | |||
| 7a2a82fa87 | |||
| c3c105619b | |||
| 01e575676f | |||
| b53a43fcc6 | |||
| 6a8ff1c1b4 | |||
| 451ab514cc | |||
| c8b6830704 | |||
| 74ee7149c5 | |||
| 506b96dec4 | |||
| 927d4230a2 | |||
| 69ff737b72 | |||
| d0114a1f29 | |||
| f92a0fd88b | |||
| e7478cc1e1 | |||
| 5814f9757d | |||
| cb7b9a9e22 | |||
| 6dc525dd72 | |||
| 409c8c161b | |||
| 78db3a1e77 | |||
| d0c5ff19e1 | |||
| 6a29765ddf | |||
| 95147fbe03 | |||
| 0137321d24 | |||
| 0521306699 | |||
| 2a10e7d2c7 | |||
| e78e9e1a4d | |||
| 29d0b54b04 | |||
| 64fe3fdbb7 | |||
| 04edca175e | |||
| 840091cb05 | |||
| 473c3704cf | |||
| af630b22c9 | |||
| 29fe2f2016 | |||
| cd5691640e | |||
| 77c217f9ad | |||
| 9e963569bc | |||
| c7114ec359 | |||
| b9586ad72a | |||
| b6e333127c | |||
| 6ea2c2cceb | |||
| af42377c3b | |||
| 82881a4c45 | |||
| 70d50e9c47 | |||
| ddb5c01026 | |||
| e287adcb84 | |||
| 83d041c13b | |||
| 545c5fd749 | |||
| 7f0e2de7c4 | |||
| 1fdf1e49a3 | |||
| 08f82ee511 | |||
| fba217b87f | |||
| 6668a18ad3 | |||
| 11c239f46c | |||
| ac62dee279 | |||
| 29a17a7876 | |||
| 694c934612 | |||
| c01d2bfcd7 | |||
| b3708f99c0 | |||
| f651f921aa | |||
| ada6845036 | |||
| d33b259537 | |||
| 3c2ed147ca | |||
| d46e88c2b5 | |||
| 1477491e6e | |||
| 647dc0bd7c | |||
| f6a45993e9 | |||
| 0766320411 | |||
| 5ebf113fd6 | |||
| 2005d103f8 | |||
| 3935813336 | |||
| ec71fa78c0 | |||
| cd2b0a2d67 | |||
| 68d502804f | |||
| afbd343442 | |||
| 991e5a3112 | |||
| 7d9de6ca5d | |||
| 562bb69082 | |||
| a02416c78b | |||
| 81f56008d4 | |||
| 43a7039479 | |||
| a9f3904524 | |||
| 9136c7cfe3 | |||
| 5595846929 | |||
| 9ebe2971bf | |||
| fadea75314 | |||
| a2009cae39 | |||
| 0187adb6f9 | |||
| 302a028873 | |||
| 77c9c19806 | |||
| 724da2545e | |||
| 079af66d9f | |||
| e701d234f0 | |||
| 1b111975bb | |||
| c874425e0e | |||
| c6c112fd17 | |||
| 57d924e3ba | |||
| 9d5b9ebf82 | |||
| 354358e860 | |||
| 327c9d52d2 | |||
| c03051614d | |||
| ac9e5cd908 | |||
| 585bb56a99 | |||
| 8f33eb0cc6 | |||
| 71515a87de | |||
| 0a4ede8f79 | |||
| 040b33b541 | |||
| 5c3484ee28 | |||
| ef309c3101 | |||
| 51dad7936b | |||
| 097734b31d | |||
| 7185fde9bc | |||
| 75f2cdb04b | |||
| d3c445ed2d | |||
| 804f088435 | |||
| 6c1b70e796 | |||
| a3532e5878 | |||
| a8c1b32912 | |||
| 20b0c8a0e2 | |||
| bc18692d75 | |||
| c05ecf1ecd | |||
| 8381fc52f1 | |||
| a697ffa8f1 | |||
| 4eb36fab6d | |||
| 4b0780b549 | |||
| 1befc2982a | |||
| df1f225405 | |||
| df6feca53c | |||
| bd41efb974 | |||
| a1216bface | |||
| e0969b315a | |||
| 395bccf4e5 | |||
| f6e5cfce54 | |||
| a26e89147b | |||
| 4396020177 | |||
| e2c89ec19e | |||
| 68c125ba14 | |||
| 8c794fc91c | |||
| efbe8475df | |||
| 7835e02d5c | |||
| 21a5499af8 | |||
| dd85db071a |
+3
-1
@@ -253,6 +253,8 @@ YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
|
|||||||
|
|
||||||
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
|
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
|
||||||
DISABLE_TOPUP_BUTTONS=false
|
DISABLE_TOPUP_BUTTONS=false
|
||||||
|
# Отключить пополнение баланса через поддержку
|
||||||
|
SUPPORT_TOPUP_ENABLED=true
|
||||||
|
|
||||||
# Автоматическая проверка зависших пополнений и повторные обращения к провайдерам
|
# Автоматическая проверка зависших пополнений и повторные обращения к провайдерам
|
||||||
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED=false
|
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED=false
|
||||||
@@ -428,7 +430,7 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
|
|||||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||||
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
||||||
DEFAULT_LANGUAGE=ru
|
DEFAULT_LANGUAGE=ru
|
||||||
AVAILABLE_LANGUAGES=ru,en
|
AVAILABLE_LANGUAGES=ru,en,ua,zh
|
||||||
# Включить выбор языка при старте и отображение кнопки в меню
|
# Включить выбор языка при старте и отображение кнопки в меню
|
||||||
LANGUAGE_SELECTION_ENABLED=true
|
LANGUAGE_SELECTION_ENABLED=true
|
||||||
# Часовой пояс
|
# Часовой пояс
|
||||||
|
|||||||
@@ -36,15 +36,15 @@ jobs:
|
|||||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||||
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
|
VERSION="v2.8.0-$(git rev-parse --short HEAD)"
|
||||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||||
echo "🚀 Собираем версию из main: $VERSION"
|
echo "🚀 Собираем версию из main: $VERSION"
|
||||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||||
VERSION="v2.6.2-dev-$(git rev-parse --short HEAD)"
|
VERSION="v2.8.0-dev-$(git rev-parse --short HEAD)"
|
||||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||||
echo "🧪 Собираем dev версию: $VERSION"
|
echo "🧪 Собираем dev версию: $VERSION"
|
||||||
else
|
else
|
||||||
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
|
VERSION="v2.8.0-pr-$(git rev-parse --short HEAD)"
|
||||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||||
echo "🔀 Собираем PR версию: $VERSION"
|
echo "🔀 Собираем PR версию: $VERSION"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -49,13 +49,13 @@ jobs:
|
|||||||
VERSION=${GITHUB_REF#refs/tags/}
|
VERSION=${GITHUB_REF#refs/tags/}
|
||||||
echo "🏷️ Building release version: $VERSION"
|
echo "🏷️ Building release version: $VERSION"
|
||||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||||
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
|
VERSION="v2.8.0-$(git rev-parse --short HEAD)"
|
||||||
echo "🚀 Building main version: $VERSION"
|
echo "🚀 Building main version: $VERSION"
|
||||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||||
VERSION="v2.6.2-dev-$(git rev-parse --short HEAD)"
|
VERSION="v2.8.0-dev-$(git rev-parse --short HEAD)"
|
||||||
echo "🧪 Building dev version: $VERSION"
|
echo "🧪 Building dev version: $VERSION"
|
||||||
else
|
else
|
||||||
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
|
VERSION="v2.8.0-pr-$(git rev-parse --short HEAD)"
|
||||||
echo "🔀 Building PR version: $VERSION"
|
echo "🔀 Building PR version: $VERSION"
|
||||||
fi
|
fi
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ docker-compose.override.yml
|
|||||||
!requirements.txt
|
!requirements.txt
|
||||||
!docs/
|
!docs/
|
||||||
!docs/**
|
!docs/**
|
||||||
|
!migrations/
|
||||||
|
!migrations/**
|
||||||
|
|
||||||
# Разрешаем папку app/ и все её содержимое рекурсивно
|
# Разрешаем папку app/ и все её содержимое рекурсивно
|
||||||
!app/
|
!app/
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
|||||||
|
|
||||||
FROM python:3.13-slim
|
FROM python:3.13-slim
|
||||||
|
|
||||||
ARG VERSION="v2.6.2"
|
ARG VERSION="v2.8.0"
|
||||||
ARG BUILD_DATE
|
ARG BUILD_DATE
|
||||||
ARG VCS_REF
|
ARG VCS_REF
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ from app.handlers.admin import (
|
|||||||
public_offer as admin_public_offer,
|
public_offer as admin_public_offer,
|
||||||
faq as admin_faq,
|
faq as admin_faq,
|
||||||
payments as admin_payments,
|
payments as admin_payments,
|
||||||
|
trials as admin_trials,
|
||||||
)
|
)
|
||||||
from app.handlers.stars_payments import register_stars_handlers
|
from app.handlers.stars_payments import register_stars_handlers
|
||||||
|
|
||||||
@@ -174,6 +175,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
|||||||
admin_public_offer.register_handlers(dp)
|
admin_public_offer.register_handlers(dp)
|
||||||
admin_faq.register_handlers(dp)
|
admin_faq.register_handlers(dp)
|
||||||
admin_payments.register_handlers(dp)
|
admin_payments.register_handlers(dp)
|
||||||
|
admin_trials.register_handlers(dp)
|
||||||
common.register_handlers(dp)
|
common.register_handlers(dp)
|
||||||
register_stars_handlers(dp)
|
register_stars_handlers(dp)
|
||||||
user_polls.register_handlers(dp)
|
user_polls.register_handlers(dp)
|
||||||
|
|||||||
+60
-15
@@ -6,7 +6,8 @@ import re
|
|||||||
import html
|
import html
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import time
|
from datetime import time
|
||||||
from typing import List, Optional, Union, Dict
|
from typing import Dict, List, Optional, Union
|
||||||
|
from urllib.parse import urlparse
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
from pydantic import field_validator, Field
|
from pydantic import field_validator, Field
|
||||||
@@ -193,6 +194,7 @@ class Settings(BaseSettings):
|
|||||||
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
|
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
|
||||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
|
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
|
||||||
DISABLE_TOPUP_BUTTONS: bool = False
|
DISABLE_TOPUP_BUTTONS: bool = False
|
||||||
|
SUPPORT_TOPUP_ENABLED: bool = True
|
||||||
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
|
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
|
||||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
|
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
|
||||||
|
|
||||||
@@ -254,6 +256,7 @@ class Settings(BaseSettings):
|
|||||||
MULENPAY_PAYMENT_MODE: int = 4
|
MULENPAY_PAYMENT_MODE: int = 4
|
||||||
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
|
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
|
||||||
MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000
|
MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000
|
||||||
|
MULENPAY_IFRAME_EXPECTED_ORIGIN: Optional[str] = None
|
||||||
|
|
||||||
PAL24_ENABLED: bool = False
|
PAL24_ENABLED: bool = False
|
||||||
PAL24_API_TOKEN: Optional[str] = None
|
PAL24_API_TOKEN: Optional[str] = None
|
||||||
@@ -697,15 +700,40 @@ class Settings(BaseSettings):
|
|||||||
return bool(value)
|
return bool(value)
|
||||||
|
|
||||||
def get_available_languages(self) -> List[str]:
|
def get_available_languages(self) -> List[str]:
|
||||||
|
defaults = ["ru", "en", "ua", "zh"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
langs = self.AVAILABLE_LANGUAGES
|
langs = self.AVAILABLE_LANGUAGES
|
||||||
if isinstance(langs, str):
|
|
||||||
if not langs.strip():
|
|
||||||
return ["ru", "en"]
|
|
||||||
return [x.strip() for x in langs.split(',') if x.strip()]
|
|
||||||
return ["ru", "en"]
|
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
return ["ru", "en"]
|
return defaults
|
||||||
|
|
||||||
|
candidates: List[str]
|
||||||
|
|
||||||
|
if isinstance(langs, str):
|
||||||
|
if not langs.strip():
|
||||||
|
return defaults
|
||||||
|
candidates = [chunk.strip() for chunk in langs.split(',')]
|
||||||
|
elif isinstance(langs, (list, tuple, set)):
|
||||||
|
candidates = [str(item).strip() for item in langs]
|
||||||
|
else:
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
cleaned: List[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
for code in candidates:
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized = code.lower()
|
||||||
|
|
||||||
|
if normalized in seen:
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen.add(normalized)
|
||||||
|
cleaned.append(code)
|
||||||
|
|
||||||
|
return cleaned or defaults
|
||||||
|
|
||||||
def is_language_selection_enabled(self) -> bool:
|
def is_language_selection_enabled(self) -> bool:
|
||||||
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
|
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
|
||||||
@@ -904,9 +932,12 @@ class Settings(BaseSettings):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
def is_yookassa_enabled(self) -> bool:
|
def is_yookassa_enabled(self) -> bool:
|
||||||
return (self.YOOKASSA_ENABLED and
|
return (self.YOOKASSA_ENABLED and
|
||||||
self.YOOKASSA_SHOP_ID is not None and
|
self.YOOKASSA_SHOP_ID is not None and
|
||||||
self.YOOKASSA_SECRET_KEY is not None)
|
self.YOOKASSA_SECRET_KEY is not None)
|
||||||
|
|
||||||
|
def is_support_topup_enabled(self) -> bool:
|
||||||
|
return bool(self.SUPPORT_TOPUP_ENABLED)
|
||||||
|
|
||||||
def get_yookassa_return_url(self) -> str:
|
def get_yookassa_return_url(self) -> str:
|
||||||
if self.YOOKASSA_RETURN_URL:
|
if self.YOOKASSA_RETURN_URL:
|
||||||
@@ -943,6 +974,20 @@ class Settings(BaseSettings):
|
|||||||
def get_mulenpay_display_name_html(self) -> str:
|
def get_mulenpay_display_name_html(self) -> str:
|
||||||
return html.escape(self.get_mulenpay_display_name())
|
return html.escape(self.get_mulenpay_display_name())
|
||||||
|
|
||||||
|
def get_mulenpay_expected_origin(self) -> Optional[str]:
|
||||||
|
override = (self.MULENPAY_IFRAME_EXPECTED_ORIGIN or "").strip()
|
||||||
|
if override:
|
||||||
|
return override
|
||||||
|
|
||||||
|
base_url = (self.MULENPAY_BASE_URL or "").strip()
|
||||||
|
if not base_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
parsed = urlparse(base_url)
|
||||||
|
if parsed.scheme and parsed.netloc:
|
||||||
|
return f"{parsed.scheme}://{parsed.netloc}"
|
||||||
|
return None
|
||||||
|
|
||||||
def is_pal24_enabled(self) -> bool:
|
def is_pal24_enabled(self) -> bool:
|
||||||
return (
|
return (
|
||||||
self.PAL24_ENABLED
|
self.PAL24_ENABLED
|
||||||
@@ -1291,13 +1336,13 @@ class Settings(BaseSettings):
|
|||||||
packages = []
|
packages = []
|
||||||
config_str = self.TRAFFIC_PACKAGES_CONFIG.strip()
|
config_str = self.TRAFFIC_PACKAGES_CONFIG.strip()
|
||||||
|
|
||||||
logger.info(f"CONFIG STRING: '{config_str}'")
|
logger.debug(f"CONFIG STRING: '{config_str}'")
|
||||||
|
|
||||||
if not config_str:
|
if not config_str:
|
||||||
logger.info("CONFIG EMPTY, USING FALLBACK")
|
logger.debug("CONFIG EMPTY, USING FALLBACK")
|
||||||
return self._get_fallback_traffic_packages()
|
return self._get_fallback_traffic_packages()
|
||||||
|
|
||||||
logger.info("PARSING CONFIG...")
|
logger.debug("PARSING CONFIG...")
|
||||||
|
|
||||||
for package_config in config_str.split(','):
|
for package_config in config_str.split(','):
|
||||||
package_config = package_config.strip()
|
package_config = package_config.strip()
|
||||||
@@ -1321,7 +1366,7 @@ class Settings(BaseSettings):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(f"PARSED {len(packages)} packages from config")
|
logger.debug(f"PARSED {len(packages)} packages from config")
|
||||||
return packages if packages else self._get_fallback_traffic_packages()
|
return packages if packages else self._get_fallback_traffic_packages()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from datetime import datetime
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from datetime import datetime
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -90,6 +91,7 @@ async def update_mulenpay_payment_status(
|
|||||||
paid_at: Optional[datetime] = None,
|
paid_at: Optional[datetime] = None,
|
||||||
callback_payload: Optional[dict] = None,
|
callback_payload: Optional[dict] = None,
|
||||||
mulen_payment_id: Optional[int] = None,
|
mulen_payment_id: Optional[int] = None,
|
||||||
|
metadata: Optional[dict] = None,
|
||||||
) -> MulenPayPayment:
|
) -> MulenPayPayment:
|
||||||
payment.status = status
|
payment.status = status
|
||||||
if is_paid is not None:
|
if is_paid is not None:
|
||||||
@@ -100,6 +102,8 @@ async def update_mulenpay_payment_status(
|
|||||||
payment.callback_payload = callback_payload
|
payment.callback_payload = callback_payload
|
||||||
if mulen_payment_id is not None and not payment.mulen_payment_id:
|
if mulen_payment_id is not None and not payment.mulen_payment_id:
|
||||||
payment.mulen_payment_id = mulen_payment_id
|
payment.mulen_payment_id = mulen_payment_id
|
||||||
|
if metadata is not None:
|
||||||
|
payment.metadata_json = metadata
|
||||||
|
|
||||||
payment.updated_at = datetime.utcnow()
|
payment.updated_at = datetime.utcnow()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -107,6 +111,19 @@ async def update_mulenpay_payment_status(
|
|||||||
return payment
|
return payment
|
||||||
|
|
||||||
|
|
||||||
|
async def update_mulenpay_payment_metadata(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
payment: MulenPayPayment,
|
||||||
|
metadata: dict,
|
||||||
|
) -> MulenPayPayment:
|
||||||
|
payment.metadata_json = metadata
|
||||||
|
payment.updated_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(payment)
|
||||||
|
return payment
|
||||||
|
|
||||||
|
|
||||||
async def link_mulenpay_payment_to_transaction(
|
async def link_mulenpay_payment_to_transaction(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ async def update_pal24_payment_status(
|
|||||||
balance_currency: Optional[str] = None,
|
balance_currency: Optional[str] = None,
|
||||||
payer_account: Optional[str] = None,
|
payer_account: Optional[str] = None,
|
||||||
callback_payload: Optional[Dict[str, Any]] = None,
|
callback_payload: Optional[Dict[str, Any]] = None,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
) -> Pal24Payment:
|
) -> Pal24Payment:
|
||||||
update_values: Dict[str, Any] = {
|
update_values: Dict[str, Any] = {
|
||||||
"status": status,
|
"status": status,
|
||||||
@@ -121,6 +122,8 @@ async def update_pal24_payment_status(
|
|||||||
update_values["payer_account"] = payer_account
|
update_values["payer_account"] = payer_account
|
||||||
if callback_payload is not None:
|
if callback_payload is not None:
|
||||||
update_values["callback_payload"] = callback_payload
|
update_values["callback_payload"] = callback_payload
|
||||||
|
if metadata is not None:
|
||||||
|
update_values["metadata_json"] = metadata
|
||||||
|
|
||||||
update_values["last_status"] = status
|
update_values["last_status"] = status
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Iterable, Optional, List, Tuple
|
from typing import Iterable, Optional, List, Tuple
|
||||||
from sqlalchemy import select, and_, func
|
from sqlalchemy import select, and_, func, delete
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ async def create_paid_subscription(
|
|||||||
connected_squads: List[str] = None,
|
connected_squads: List[str] = None,
|
||||||
update_server_counters: bool = False,
|
update_server_counters: bool = False,
|
||||||
) -> Subscription:
|
) -> Subscription:
|
||||||
|
|
||||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||||
|
|
||||||
if device_limit is None:
|
if device_limit is None:
|
||||||
@@ -186,6 +186,91 @@ async def create_paid_subscription(
|
|||||||
return subscription
|
return subscription
|
||||||
|
|
||||||
|
|
||||||
|
async def replace_subscription(
|
||||||
|
db: AsyncSession,
|
||||||
|
subscription: Subscription,
|
||||||
|
*,
|
||||||
|
duration_days: int,
|
||||||
|
traffic_limit_gb: int,
|
||||||
|
device_limit: int,
|
||||||
|
connected_squads: List[str],
|
||||||
|
is_trial: bool,
|
||||||
|
autopay_enabled: Optional[bool] = None,
|
||||||
|
autopay_days_before: Optional[int] = None,
|
||||||
|
update_server_counters: bool = False,
|
||||||
|
) -> Subscription:
|
||||||
|
"""Перезаписывает параметры существующей подписки пользователя."""
|
||||||
|
|
||||||
|
current_time = datetime.utcnow()
|
||||||
|
old_squads = set(subscription.connected_squads or [])
|
||||||
|
new_squads = set(connected_squads or [])
|
||||||
|
|
||||||
|
new_autopay_enabled = (
|
||||||
|
subscription.autopay_enabled
|
||||||
|
if autopay_enabled is None
|
||||||
|
else autopay_enabled
|
||||||
|
)
|
||||||
|
new_autopay_days_before = (
|
||||||
|
subscription.autopay_days_before
|
||||||
|
if autopay_days_before is None
|
||||||
|
else autopay_days_before
|
||||||
|
)
|
||||||
|
|
||||||
|
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||||
|
subscription.is_trial = is_trial
|
||||||
|
subscription.start_date = current_time
|
||||||
|
subscription.end_date = current_time + timedelta(days=duration_days)
|
||||||
|
subscription.traffic_limit_gb = traffic_limit_gb
|
||||||
|
subscription.traffic_used_gb = 0.0
|
||||||
|
subscription.device_limit = device_limit
|
||||||
|
subscription.connected_squads = list(new_squads)
|
||||||
|
subscription.subscription_url = None
|
||||||
|
subscription.subscription_crypto_link = None
|
||||||
|
subscription.remnawave_short_uuid = None
|
||||||
|
subscription.autopay_enabled = new_autopay_enabled
|
||||||
|
subscription.autopay_days_before = new_autopay_days_before
|
||||||
|
subscription.updated_at = current_time
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(subscription)
|
||||||
|
|
||||||
|
if update_server_counters:
|
||||||
|
try:
|
||||||
|
from app.database.crud.server_squad import (
|
||||||
|
add_user_to_servers,
|
||||||
|
get_server_ids_by_uuids,
|
||||||
|
remove_user_from_servers,
|
||||||
|
)
|
||||||
|
|
||||||
|
squads_to_remove = old_squads - new_squads
|
||||||
|
squads_to_add = new_squads - old_squads
|
||||||
|
|
||||||
|
if squads_to_remove:
|
||||||
|
server_ids = await get_server_ids_by_uuids(db, list(squads_to_remove))
|
||||||
|
if server_ids:
|
||||||
|
await remove_user_from_servers(db, sorted(server_ids))
|
||||||
|
|
||||||
|
if squads_to_add:
|
||||||
|
server_ids = await get_server_ids_by_uuids(db, list(squads_to_add))
|
||||||
|
if server_ids:
|
||||||
|
await add_user_to_servers(db, sorted(server_ids))
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"♻️ Обновлены параметры подписки %s: удалено сквадов %s, добавлено %s",
|
||||||
|
subscription.id,
|
||||||
|
len(squads_to_remove),
|
||||||
|
len(squads_to_add),
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
logger.error(
|
||||||
|
"⚠️ Ошибка обновления счетчиков серверов при замене подписки %s: %s",
|
||||||
|
subscription.id,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
|
return subscription
|
||||||
|
|
||||||
|
|
||||||
async def extend_subscription(
|
async def extend_subscription(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
subscription: Subscription,
|
subscription: Subscription,
|
||||||
@@ -591,10 +676,113 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
|
|||||||
"purchased_today": purchased_today,
|
"purchased_today": purchased_today,
|
||||||
"purchased_week": purchased_week,
|
"purchased_week": purchased_week,
|
||||||
"purchased_month": purchased_month,
|
"purchased_month": purchased_month,
|
||||||
"trial_to_paid_conversion": trial_to_paid_conversion,
|
"trial_to_paid_conversion": trial_to_paid_conversion,
|
||||||
"renewals_count": renewals_count
|
"renewals_count": renewals_count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_trial_statistics(db: AsyncSession) -> dict:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
total_trials_result = await db.execute(
|
||||||
|
select(func.count(Subscription.id)).where(Subscription.is_trial.is_(True))
|
||||||
|
)
|
||||||
|
total_trials = total_trials_result.scalar() or 0
|
||||||
|
|
||||||
|
active_trials_result = await db.execute(
|
||||||
|
select(func.count(Subscription.id)).where(
|
||||||
|
Subscription.is_trial.is_(True),
|
||||||
|
Subscription.end_date > now,
|
||||||
|
Subscription.status.in_(
|
||||||
|
[SubscriptionStatus.TRIAL.value, SubscriptionStatus.ACTIVE.value]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
active_trials = active_trials_result.scalar() or 0
|
||||||
|
|
||||||
|
resettable_trials_result = await db.execute(
|
||||||
|
select(func.count(Subscription.id))
|
||||||
|
.join(User, Subscription.user_id == User.id)
|
||||||
|
.where(
|
||||||
|
Subscription.is_trial.is_(True),
|
||||||
|
Subscription.end_date <= now,
|
||||||
|
User.has_had_paid_subscription.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
resettable_trials = resettable_trials_result.scalar() or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"used_trials": total_trials,
|
||||||
|
"active_trials": active_trials,
|
||||||
|
"resettable_trials": resettable_trials,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) -> int:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Subscription)
|
||||||
|
.options(
|
||||||
|
selectinload(Subscription.user),
|
||||||
|
selectinload(Subscription.subscription_servers),
|
||||||
|
)
|
||||||
|
.join(User, Subscription.user_id == User.id)
|
||||||
|
.where(
|
||||||
|
Subscription.is_trial.is_(True),
|
||||||
|
Subscription.end_date <= now,
|
||||||
|
User.has_had_paid_subscription.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
subscriptions = result.scalars().unique().all()
|
||||||
|
if not subscriptions:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
reset_count = len(subscriptions)
|
||||||
|
for subscription in subscriptions:
|
||||||
|
try:
|
||||||
|
await decrement_subscription_server_counts(
|
||||||
|
db,
|
||||||
|
subscription,
|
||||||
|
subscription_servers=subscription.subscription_servers,
|
||||||
|
)
|
||||||
|
except Exception as error: # pragma: no cover - defensive logging
|
||||||
|
logger.error(
|
||||||
|
"Не удалось обновить счётчики серверов при сбросе триала %s: %s",
|
||||||
|
subscription.id,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
|
subscription_ids = [subscription.id for subscription in subscriptions]
|
||||||
|
|
||||||
|
if subscription_ids:
|
||||||
|
try:
|
||||||
|
await db.execute(
|
||||||
|
delete(SubscriptionServer).where(
|
||||||
|
SubscriptionServer.subscription_id.in_(subscription_ids)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as error: # pragma: no cover - defensive logging
|
||||||
|
logger.error(
|
||||||
|
"Ошибка удаления серверных связей триалов %s: %s",
|
||||||
|
subscription_ids,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
await db.execute(delete(Subscription).where(Subscription.id.in_(subscription_ids)))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await db.commit()
|
||||||
|
except Exception as error: # pragma: no cover - defensive logging
|
||||||
|
await db.rollback()
|
||||||
|
logger.error("Ошибка сохранения сброса триалов: %s", error)
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info("♻️ Сброшено триальных подписок: %s", reset_count)
|
||||||
|
return reset_count
|
||||||
|
|
||||||
async def update_subscription_usage(
|
async def update_subscription_usage(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
subscription: Subscription,
|
subscription: Subscription,
|
||||||
@@ -800,8 +988,8 @@ async def calculate_subscription_total_cost(
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
|
logger.debug(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
|
||||||
logger.info(f" Базовый период: {base_price/100}₽")
|
logger.debug(f" Базовый период: {base_price/100}₽")
|
||||||
if total_traffic_price > 0:
|
if total_traffic_price > 0:
|
||||||
message = (
|
message = (
|
||||||
f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽"
|
f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽"
|
||||||
@@ -810,7 +998,7 @@ async def calculate_subscription_total_cost(
|
|||||||
message += (
|
message += (
|
||||||
f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)"
|
f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)"
|
||||||
)
|
)
|
||||||
logger.info(message)
|
logger.debug(message)
|
||||||
if total_servers_price > 0:
|
if total_servers_price > 0:
|
||||||
message = (
|
message = (
|
||||||
f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽"
|
f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽"
|
||||||
@@ -819,7 +1007,7 @@ async def calculate_subscription_total_cost(
|
|||||||
message += (
|
message += (
|
||||||
f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)"
|
f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)"
|
||||||
)
|
)
|
||||||
logger.info(message)
|
logger.debug(message)
|
||||||
if total_devices_price > 0:
|
if total_devices_price > 0:
|
||||||
message = (
|
message = (
|
||||||
f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽"
|
f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽"
|
||||||
@@ -828,8 +1016,8 @@ async def calculate_subscription_total_cost(
|
|||||||
message += (
|
message += (
|
||||||
f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)"
|
f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)"
|
||||||
)
|
)
|
||||||
logger.info(message)
|
logger.debug(message)
|
||||||
logger.info(f" ИТОГО: {total_cost/100}₽")
|
logger.debug(f" ИТОГО: {total_cost/100}₽")
|
||||||
|
|
||||||
return total_cost, details
|
return total_cost, details
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, Iterable, Optional, Tuple
|
||||||
|
|
||||||
|
from sqlalchemy import and_, func, select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database.models import SubscriptionEvent
|
||||||
|
|
||||||
|
|
||||||
|
async def create_subscription_event(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
event_type: str,
|
||||||
|
subscription_id: Optional[int] = None,
|
||||||
|
transaction_id: Optional[int] = None,
|
||||||
|
amount_kopeks: Optional[int] = None,
|
||||||
|
currency: Optional[str] = None,
|
||||||
|
message: Optional[str] = None,
|
||||||
|
occurred_at: Optional[datetime] = None,
|
||||||
|
extra: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> SubscriptionEvent:
|
||||||
|
event = SubscriptionEvent(
|
||||||
|
user_id=user_id,
|
||||||
|
event_type=event_type,
|
||||||
|
subscription_id=subscription_id,
|
||||||
|
transaction_id=transaction_id,
|
||||||
|
amount_kopeks=amount_kopeks,
|
||||||
|
currency=currency,
|
||||||
|
message=message,
|
||||||
|
occurred_at=occurred_at or datetime.utcnow(),
|
||||||
|
extra=extra or None,
|
||||||
|
)
|
||||||
|
db.add(event)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
async def list_subscription_events(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
event_types: Optional[Iterable[str]] = None,
|
||||||
|
user_id: Optional[int] = None,
|
||||||
|
) -> Tuple[list[SubscriptionEvent], int]:
|
||||||
|
base_query = select(SubscriptionEvent)
|
||||||
|
filters = []
|
||||||
|
|
||||||
|
if event_types:
|
||||||
|
filters.append(SubscriptionEvent.event_type.in_(set(event_types)))
|
||||||
|
if user_id:
|
||||||
|
filters.append(SubscriptionEvent.user_id == user_id)
|
||||||
|
|
||||||
|
if filters:
|
||||||
|
base_query = base_query.where(and_(*filters))
|
||||||
|
|
||||||
|
total_query = base_query.with_only_columns(func.count()).order_by(None)
|
||||||
|
total = await db.scalar(total_query) or 0
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
base_query.options(selectinload(SubscriptionEvent.user))
|
||||||
|
.order_by(SubscriptionEvent.occurred_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.scalars().all(), int(total)
|
||||||
@@ -379,11 +379,11 @@ async def subtract_user_balance(
|
|||||||
*,
|
*,
|
||||||
consume_promo_offer: bool = False,
|
consume_promo_offer: bool = False,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
|
logger.info(f"💸 ОТЛАДКА subtract_user_balance:")
|
||||||
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
|
logger.info(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
|
||||||
logger.error(f" 💰 Баланс до списания: {user.balance_kopeks} копеек")
|
logger.info(f" 💰 Баланс до списания: {user.balance_kopeks} копеек")
|
||||||
logger.error(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
|
logger.info(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
|
||||||
logger.error(f" 📝 Описание: {description}")
|
logger.info(f" 📝 Описание: {description}")
|
||||||
|
|
||||||
log_context: Optional[Dict[str, object]] = None
|
log_context: Optional[Dict[str, object]] = None
|
||||||
if consume_promo_offer:
|
if consume_promo_offer:
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ from typing import Optional, List
|
|||||||
from sqlalchemy import select, func, and_
|
from sqlalchemy import select, func, and_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database.models import UserMessage
|
from app.database.models import User, UserMessage
|
||||||
|
from app.utils.validators import sanitize_html, validate_html_tags
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -13,15 +14,25 @@ logger = logging.getLogger(__name__)
|
|||||||
async def create_user_message(
|
async def create_user_message(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
message_text: str,
|
message_text: str,
|
||||||
created_by: int,
|
created_by: Optional[int] = None,
|
||||||
is_active: bool = True,
|
is_active: bool = True,
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
) -> UserMessage:
|
) -> UserMessage:
|
||||||
|
is_valid, error_message = validate_html_tags(message_text)
|
||||||
|
if not is_valid:
|
||||||
|
raise ValueError(error_message)
|
||||||
|
|
||||||
|
resolved_creator = created_by
|
||||||
|
|
||||||
|
if created_by is not None:
|
||||||
|
result = await db.execute(select(User.id).where(User.id == created_by))
|
||||||
|
resolved_creator = result.scalar_one_or_none()
|
||||||
|
|
||||||
message = UserMessage(
|
message = UserMessage(
|
||||||
message_text=message_text,
|
message_text=message_text,
|
||||||
is_active=is_active,
|
is_active=is_active,
|
||||||
sort_order=sort_order,
|
sort_order=sort_order,
|
||||||
created_by=created_by
|
created_by=resolved_creator,
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(message)
|
db.add(message)
|
||||||
@@ -55,25 +66,33 @@ async def get_random_active_message(db: AsyncSession) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
random_message = random.choice(active_messages)
|
random_message = random.choice(active_messages)
|
||||||
return random_message.message_text
|
return sanitize_html(random_message.message_text)
|
||||||
|
|
||||||
|
|
||||||
async def get_all_user_messages(
|
async def get_all_user_messages(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
limit: int = 50
|
limit: int = 50,
|
||||||
|
include_inactive: bool = True,
|
||||||
) -> List[UserMessage]:
|
) -> List[UserMessage]:
|
||||||
|
query = select(UserMessage).order_by(UserMessage.created_at.desc())
|
||||||
|
if not include_inactive:
|
||||||
|
query = query.where(UserMessage.is_active == True)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(UserMessage)
|
query
|
||||||
.order_by(UserMessage.created_at.desc())
|
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
async def get_user_messages_count(db: AsyncSession) -> int:
|
async def get_user_messages_count(db: AsyncSession, include_inactive: bool = True) -> int:
|
||||||
result = await db.execute(select(func.count(UserMessage.id)))
|
query = select(func.count(UserMessage.id))
|
||||||
|
if not include_inactive:
|
||||||
|
query = query.where(UserMessage.is_active == True)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
return result.scalar()
|
return result.scalar()
|
||||||
|
|
||||||
|
|
||||||
@@ -88,8 +107,11 @@ async def update_user_message(
|
|||||||
|
|
||||||
if not message:
|
if not message:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if message_text is not None:
|
if message_text is not None:
|
||||||
|
is_valid, error_message = validate_html_tags(message_text)
|
||||||
|
if not is_valid:
|
||||||
|
raise ValueError(error_message)
|
||||||
message.message_text = message_text
|
message.message_text = message_text
|
||||||
|
|
||||||
if is_active is not None:
|
if is_active is not None:
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database.models import WelcomeText
|
from app.database.models import User, WelcomeText
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -45,6 +45,37 @@ async def get_current_welcome_text_settings(db: AsyncSession) -> dict:
|
|||||||
'id': None
|
'id': None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_welcome_text_by_id(db: AsyncSession, welcome_text_id: int) -> Optional[WelcomeText]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(WelcomeText).where(WelcomeText.id == welcome_text_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_welcome_texts(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
include_inactive: bool = True,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
):
|
||||||
|
query = select(WelcomeText).order_by(WelcomeText.updated_at.desc())
|
||||||
|
if not include_inactive:
|
||||||
|
query = query.where(WelcomeText.is_active == True)
|
||||||
|
|
||||||
|
result = await db.execute(query.limit(limit).offset(offset))
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def count_welcome_texts(db: AsyncSession, *, include_inactive: bool = True) -> int:
|
||||||
|
query = select(func.count(WelcomeText.id))
|
||||||
|
if not include_inactive:
|
||||||
|
query = query.where(WelcomeText.is_active == True)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
return result.scalar()
|
||||||
|
|
||||||
async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool:
|
async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool:
|
||||||
try:
|
try:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -113,6 +144,87 @@ async def set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) -
|
|||||||
await db.rollback()
|
await db.rollback()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def create_welcome_text(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
text_content: str,
|
||||||
|
created_by: Optional[int] = None,
|
||||||
|
is_enabled: bool = True,
|
||||||
|
is_active: bool = True,
|
||||||
|
) -> WelcomeText:
|
||||||
|
resolved_creator = created_by
|
||||||
|
|
||||||
|
if created_by is not None:
|
||||||
|
result = await db.execute(select(User.id).where(User.id == created_by))
|
||||||
|
resolved_creator = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if is_active:
|
||||||
|
await db.execute(update(WelcomeText).values(is_active=False))
|
||||||
|
|
||||||
|
welcome_text = WelcomeText(
|
||||||
|
text_content=text_content,
|
||||||
|
is_active=is_active,
|
||||||
|
is_enabled=is_enabled,
|
||||||
|
created_by=resolved_creator,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(welcome_text)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(welcome_text)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"✅ Создан приветственный текст ID %s (активный=%s, включен=%s)",
|
||||||
|
welcome_text.id,
|
||||||
|
welcome_text.is_active,
|
||||||
|
welcome_text.is_enabled,
|
||||||
|
)
|
||||||
|
return welcome_text
|
||||||
|
|
||||||
|
|
||||||
|
async def update_welcome_text(
|
||||||
|
db: AsyncSession,
|
||||||
|
welcome_text: WelcomeText,
|
||||||
|
*,
|
||||||
|
text_content: Optional[str] = None,
|
||||||
|
is_enabled: Optional[bool] = None,
|
||||||
|
is_active: Optional[bool] = None,
|
||||||
|
) -> WelcomeText:
|
||||||
|
if is_active:
|
||||||
|
await db.execute(
|
||||||
|
update(WelcomeText)
|
||||||
|
.where(WelcomeText.id != welcome_text.id)
|
||||||
|
.values(is_active=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
if text_content is not None:
|
||||||
|
welcome_text.text_content = text_content
|
||||||
|
|
||||||
|
if is_enabled is not None:
|
||||||
|
welcome_text.is_enabled = is_enabled
|
||||||
|
|
||||||
|
if is_active is not None:
|
||||||
|
welcome_text.is_active = is_active
|
||||||
|
|
||||||
|
welcome_text.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(welcome_text)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"📝 Обновлен приветственный текст ID %s (активный=%s, включен=%s)",
|
||||||
|
welcome_text.id,
|
||||||
|
welcome_text.is_active,
|
||||||
|
welcome_text.is_enabled,
|
||||||
|
)
|
||||||
|
return welcome_text
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_welcome_text(db: AsyncSession, welcome_text: WelcomeText) -> None:
|
||||||
|
await db.delete(welcome_text)
|
||||||
|
await db.commit()
|
||||||
|
logger.info("🗑️ Удален приветственный текст ID %s", welcome_text.id)
|
||||||
|
|
||||||
async def get_current_welcome_text_or_default() -> str:
|
async def get_current_welcome_text_or_default() -> str:
|
||||||
return (
|
return (
|
||||||
f"Привет, {{user_name}}! 🎁 3 дней VPN бесплатно! "
|
f"Привет, {{user_name}}! 🎁 3 дней VPN бесплатно! "
|
||||||
|
|||||||
+30
-1
@@ -541,7 +541,11 @@ class PromoGroup(Base):
|
|||||||
"traffic": self.traffic_discount_percent,
|
"traffic": self.traffic_discount_percent,
|
||||||
"devices": self.device_discount_percent,
|
"devices": self.device_discount_percent,
|
||||||
}
|
}
|
||||||
percent = mapping.get(category, 0)
|
percent = mapping.get(category) or 0
|
||||||
|
|
||||||
|
if percent == 0 and self.is_default:
|
||||||
|
base_period_discount = self._get_period_discount(period_days)
|
||||||
|
percent = max(percent, base_period_discount)
|
||||||
|
|
||||||
return max(0, min(100, percent))
|
return max(0, min(100, percent))
|
||||||
|
|
||||||
@@ -591,6 +595,7 @@ class User(Base):
|
|||||||
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
|
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
|
||||||
auto_promo_group_assigned = Column(Boolean, nullable=False, default=False)
|
auto_promo_group_assigned = Column(Boolean, nullable=False, default=False)
|
||||||
auto_promo_group_threshold_kopeks = Column(BigInteger, nullable=False, default=0)
|
auto_promo_group_threshold_kopeks = Column(BigInteger, nullable=False, default=0)
|
||||||
|
referral_commission_percent = Column(Integer, nullable=True)
|
||||||
promo_offer_discount_percent = Column(Integer, nullable=False, default=0)
|
promo_offer_discount_percent = Column(Integer, nullable=False, default=0)
|
||||||
promo_offer_discount_source = Column(String(100), nullable=True)
|
promo_offer_discount_source = Column(String(100), nullable=True)
|
||||||
promo_offer_discount_expires_at = Column(DateTime, nullable=True)
|
promo_offer_discount_expires_at = Column(DateTime, nullable=True)
|
||||||
@@ -1078,6 +1083,30 @@ class SentNotification(Base):
|
|||||||
subscription = relationship("Subscription", backref="sent_notifications")
|
subscription = relationship("Subscription", backref="sent_notifications")
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionEvent(Base):
|
||||||
|
__tablename__ = "subscription_events"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
event_type = Column(String(50), nullable=False)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
subscription_id = Column(
|
||||||
|
Integer, ForeignKey("subscriptions.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
transaction_id = Column(
|
||||||
|
Integer, ForeignKey("transactions.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
amount_kopeks = Column(Integer, nullable=True)
|
||||||
|
currency = Column(String(16), nullable=True)
|
||||||
|
message = Column(Text, nullable=True)
|
||||||
|
occurred_at = Column(DateTime, nullable=False, default=func.now())
|
||||||
|
extra = Column(JSON, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
user = relationship("User", backref="subscription_events")
|
||||||
|
subscription = relationship("Subscription", backref="subscription_events")
|
||||||
|
transaction = relationship("Transaction", backref="subscription_events")
|
||||||
|
|
||||||
|
|
||||||
class DiscountOffer(Base):
|
class DiscountOffer(Base):
|
||||||
__tablename__ = "discount_offers"
|
__tablename__ = "discount_offers"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
|||||||
@@ -2742,6 +2742,35 @@ async def fix_foreign_keys_for_user_deletion():
|
|||||||
logger.error(f"Ошибка обновления внешних ключей: {e}")
|
logger.error(f"Ошибка обновления внешних ключей: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def add_referral_commission_percent_column() -> bool:
|
||||||
|
column_exists = await check_column_exists('users', 'referral_commission_percent')
|
||||||
|
if column_exists:
|
||||||
|
logger.info("ℹ️ Колонка referral_commission_percent уже существует")
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
db_type = await get_database_type()
|
||||||
|
|
||||||
|
if db_type == 'sqlite':
|
||||||
|
alter_sql = "ALTER TABLE users ADD COLUMN referral_commission_percent INTEGER NULL"
|
||||||
|
elif db_type == 'postgresql':
|
||||||
|
alter_sql = "ALTER TABLE users ADD COLUMN referral_commission_percent INTEGER NULL"
|
||||||
|
elif db_type == 'mysql':
|
||||||
|
alter_sql = "ALTER TABLE users ADD COLUMN referral_commission_percent INT NULL"
|
||||||
|
else:
|
||||||
|
logger.error(f"Неподдерживаемый тип БД для добавления referral_commission_percent: {db_type}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
await conn.execute(text(alter_sql))
|
||||||
|
logger.info("✅ Добавлена колонка referral_commission_percent в таблицу users")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as error:
|
||||||
|
logger.error(f"Ошибка добавления referral_commission_percent: {error}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def add_referral_system_columns():
|
async def add_referral_system_columns():
|
||||||
logger.info("=== МИГРАЦИЯ РЕФЕРАЛЬНОЙ СИСТЕМЫ ===")
|
logger.info("=== МИГРАЦИЯ РЕФЕРАЛЬНОЙ СИСТЕМЫ ===")
|
||||||
|
|
||||||
@@ -2867,6 +2896,94 @@ async def create_subscription_conversions_table():
|
|||||||
logger.error(f"Ошибка создания таблицы subscription_conversions: {e}")
|
logger.error(f"Ошибка создания таблицы subscription_conversions: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def create_subscription_events_table():
|
||||||
|
table_exists = await check_table_exists("subscription_events")
|
||||||
|
if table_exists:
|
||||||
|
logger.info("Таблица subscription_events уже существует")
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
db_type = await get_database_type()
|
||||||
|
|
||||||
|
if db_type == "sqlite":
|
||||||
|
create_sql = """
|
||||||
|
CREATE TABLE subscription_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
event_type VARCHAR(50) NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
subscription_id INTEGER NULL,
|
||||||
|
transaction_id INTEGER NULL,
|
||||||
|
amount_kopeks INTEGER NULL,
|
||||||
|
currency VARCHAR(16) NULL,
|
||||||
|
message TEXT NULL,
|
||||||
|
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
extra JSON NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_subscription_events_event_type ON subscription_events(event_type);
|
||||||
|
CREATE INDEX ix_subscription_events_user_id ON subscription_events(user_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
elif db_type == "postgresql":
|
||||||
|
create_sql = """
|
||||||
|
CREATE TABLE subscription_events (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
event_type VARCHAR(50) NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
subscription_id INTEGER NULL REFERENCES subscriptions(id) ON DELETE SET NULL,
|
||||||
|
transaction_id INTEGER NULL REFERENCES transactions(id) ON DELETE SET NULL,
|
||||||
|
amount_kopeks INTEGER NULL,
|
||||||
|
currency VARCHAR(16) NULL,
|
||||||
|
message TEXT NULL,
|
||||||
|
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
extra JSON NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_subscription_events_event_type ON subscription_events(event_type);
|
||||||
|
CREATE INDEX ix_subscription_events_user_id ON subscription_events(user_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
elif db_type == "mysql":
|
||||||
|
create_sql = """
|
||||||
|
CREATE TABLE subscription_events (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
event_type VARCHAR(50) NOT NULL,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
subscription_id INT NULL,
|
||||||
|
transaction_id INT NULL,
|
||||||
|
amount_kopeks INT NULL,
|
||||||
|
currency VARCHAR(16) NULL,
|
||||||
|
message TEXT NULL,
|
||||||
|
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
extra JSON NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_subscription_events_event_type ON subscription_events(event_type);
|
||||||
|
CREATE INDEX ix_subscription_events_user_id ON subscription_events(user_id);
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
logger.error(f"Неподдерживаемый тип БД для создания таблицы subscription_events: {db_type}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
await conn.execute(text(create_sql))
|
||||||
|
logger.info("✅ Таблица subscription_events успешно создана")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка создания таблицы subscription_events: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
async def fix_subscription_duplicates_universal():
|
async def fix_subscription_duplicates_universal():
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
db_type = await get_database_type()
|
db_type = await get_database_type()
|
||||||
@@ -3721,6 +3838,12 @@ async def run_universal_migration():
|
|||||||
if not referral_migration_success:
|
if not referral_migration_success:
|
||||||
logger.warning("⚠️ Проблемы с миграцией реферальной системы")
|
logger.warning("⚠️ Проблемы с миграцией реферальной системы")
|
||||||
|
|
||||||
|
commission_column_ready = await add_referral_commission_percent_column()
|
||||||
|
if commission_column_ready:
|
||||||
|
logger.info("✅ Колонка referral_commission_percent готова")
|
||||||
|
else:
|
||||||
|
logger.warning("⚠️ Проблемы с колонкой referral_commission_percent")
|
||||||
|
|
||||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ SYSTEM_SETTINGS ===")
|
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ SYSTEM_SETTINGS ===")
|
||||||
system_settings_ready = await create_system_settings_table()
|
system_settings_ready = await create_system_settings_table()
|
||||||
if system_settings_ready:
|
if system_settings_ready:
|
||||||
@@ -4052,7 +4175,14 @@ async def run_universal_migration():
|
|||||||
logger.info("✅ Таблица subscription_conversions готова")
|
logger.info("✅ Таблица subscription_conversions готова")
|
||||||
else:
|
else:
|
||||||
logger.warning("⚠️ Проблемы с таблицей subscription_conversions")
|
logger.warning("⚠️ Проблемы с таблицей subscription_conversions")
|
||||||
|
|
||||||
|
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ SUBSCRIPTION_EVENTS ===")
|
||||||
|
events_created = await create_subscription_events_table()
|
||||||
|
if events_created:
|
||||||
|
logger.info("✅ Таблица subscription_events готова")
|
||||||
|
else:
|
||||||
|
logger.warning("⚠️ Проблемы с таблицей subscription_events")
|
||||||
|
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions"))
|
total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions"))
|
||||||
unique_users = await conn.execute(text("SELECT COUNT(DISTINCT user_id) FROM subscriptions"))
|
unique_users = await conn.execute(text("SELECT COUNT(DISTINCT user_id) FROM subscriptions"))
|
||||||
@@ -4089,6 +4219,7 @@ async def run_universal_migration():
|
|||||||
logger.info("✅ CryptoBot таблица готова")
|
logger.info("✅ CryptoBot таблица готова")
|
||||||
logger.info("✅ Heleket таблица готова")
|
logger.info("✅ Heleket таблица готова")
|
||||||
logger.info("✅ Таблица конверсий подписок создана")
|
logger.info("✅ Таблица конверсий подписок создана")
|
||||||
|
logger.info("✅ Таблица событий подписок создана")
|
||||||
logger.info("✅ Таблица welcome_texts с полем is_enabled готова")
|
logger.info("✅ Таблица welcome_texts с полем is_enabled готова")
|
||||||
logger.info("✅ Медиа поля в broadcast_history добавлены")
|
logger.info("✅ Медиа поля в broadcast_history добавлены")
|
||||||
logger.info("✅ Дубликаты подписок исправлены")
|
logger.info("✅ Дубликаты подписок исправлены")
|
||||||
@@ -4112,6 +4243,7 @@ async def check_migration_status():
|
|||||||
"broadcast_history_media_fields": False,
|
"broadcast_history_media_fields": False,
|
||||||
"subscription_duplicates": False,
|
"subscription_duplicates": False,
|
||||||
"subscription_conversions_table": False,
|
"subscription_conversions_table": False,
|
||||||
|
"subscription_events_table": False,
|
||||||
"promo_groups_table": False,
|
"promo_groups_table": False,
|
||||||
"server_promo_groups_table": False,
|
"server_promo_groups_table": False,
|
||||||
"server_squads_trial_column": False,
|
"server_squads_trial_column": False,
|
||||||
@@ -4126,6 +4258,7 @@ async def check_migration_status():
|
|||||||
"users_promo_offer_discount_percent_column": False,
|
"users_promo_offer_discount_percent_column": False,
|
||||||
"users_promo_offer_discount_source_column": False,
|
"users_promo_offer_discount_source_column": False,
|
||||||
"users_promo_offer_discount_expires_column": False,
|
"users_promo_offer_discount_expires_column": False,
|
||||||
|
"users_referral_commission_percent_column": False,
|
||||||
"subscription_crypto_link_column": False,
|
"subscription_crypto_link_column": False,
|
||||||
"discount_offers_table": False,
|
"discount_offers_table": False,
|
||||||
"discount_offers_effect_column": False,
|
"discount_offers_effect_column": False,
|
||||||
@@ -4145,6 +4278,7 @@ async def check_migration_status():
|
|||||||
status["privacy_policies_table"] = await check_table_exists('privacy_policies')
|
status["privacy_policies_table"] = await check_table_exists('privacy_policies')
|
||||||
status["public_offers_table"] = await check_table_exists('public_offers')
|
status["public_offers_table"] = await check_table_exists('public_offers')
|
||||||
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
|
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
|
||||||
|
status["subscription_events_table"] = await check_table_exists('subscription_events')
|
||||||
status["promo_groups_table"] = await check_table_exists('promo_groups')
|
status["promo_groups_table"] = await check_table_exists('promo_groups')
|
||||||
status["server_promo_groups_table"] = await check_table_exists('server_squad_promo_groups')
|
status["server_promo_groups_table"] = await check_table_exists('server_squad_promo_groups')
|
||||||
status["server_squads_trial_column"] = await check_column_exists('server_squads', 'is_trial_eligible')
|
status["server_squads_trial_column"] = await check_column_exists('server_squads', 'is_trial_eligible')
|
||||||
@@ -4167,6 +4301,7 @@ async def check_migration_status():
|
|||||||
status["users_promo_offer_discount_percent_column"] = await check_column_exists('users', 'promo_offer_discount_percent')
|
status["users_promo_offer_discount_percent_column"] = await check_column_exists('users', 'promo_offer_discount_percent')
|
||||||
status["users_promo_offer_discount_source_column"] = await check_column_exists('users', 'promo_offer_discount_source')
|
status["users_promo_offer_discount_source_column"] = await check_column_exists('users', 'promo_offer_discount_source')
|
||||||
status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at')
|
status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at')
|
||||||
|
status["users_referral_commission_percent_column"] = await check_column_exists('users', 'referral_commission_percent')
|
||||||
status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link')
|
status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link')
|
||||||
|
|
||||||
media_fields_exist = (
|
media_fields_exist = (
|
||||||
@@ -4200,6 +4335,7 @@ async def check_migration_status():
|
|||||||
"welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts",
|
"welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts",
|
||||||
"broadcast_history_media_fields": "Медиа поля в broadcast_history",
|
"broadcast_history_media_fields": "Медиа поля в broadcast_history",
|
||||||
"subscription_conversions_table": "Таблица конверсий подписок",
|
"subscription_conversions_table": "Таблица конверсий подписок",
|
||||||
|
"subscription_events_table": "Таблица событий подписок",
|
||||||
"subscription_duplicates": "Отсутствие дубликатов подписок",
|
"subscription_duplicates": "Отсутствие дубликатов подписок",
|
||||||
"promo_groups_table": "Таблица промо-групп",
|
"promo_groups_table": "Таблица промо-групп",
|
||||||
"server_promo_groups_table": "Связи серверов и промогрупп",
|
"server_promo_groups_table": "Связи серверов и промогрупп",
|
||||||
@@ -4213,6 +4349,7 @@ async def check_migration_status():
|
|||||||
"users_promo_offer_discount_percent_column": "Колонка процента промо-скидки у пользователей",
|
"users_promo_offer_discount_percent_column": "Колонка процента промо-скидки у пользователей",
|
||||||
"users_promo_offer_discount_source_column": "Колонка источника промо-скидки у пользователей",
|
"users_promo_offer_discount_source_column": "Колонка источника промо-скидки у пользователей",
|
||||||
"users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей",
|
"users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей",
|
||||||
|
"users_referral_commission_percent_column": "Колонка процента реферальной комиссии у пользователей",
|
||||||
"subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions",
|
"subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions",
|
||||||
"discount_offers_table": "Таблица discount_offers",
|
"discount_offers_table": "Таблица discount_offers",
|
||||||
"discount_offers_effect_column": "Колонка effect_type в discount_offers",
|
"discount_offers_effect_column": "Колонка effect_type в discount_offers",
|
||||||
|
|||||||
Vendored
+24
-6
@@ -28,11 +28,28 @@ class HeleketService:
|
|||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
return bool(self.merchant_id and self.api_key)
|
return bool(self.merchant_id and self.api_key)
|
||||||
|
|
||||||
def _prepare_body(self, payload: Dict[str, Any]) -> str:
|
def _prepare_body(
|
||||||
cleaned = {key: value for key, value in payload.items() if value is not None}
|
self,
|
||||||
serialized = json.dumps(cleaned, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
payload: Dict[str, Any],
|
||||||
|
*,
|
||||||
|
ignore_none: bool,
|
||||||
|
sort_keys: bool,
|
||||||
|
) -> str:
|
||||||
|
if ignore_none:
|
||||||
|
cleaned = {key: value for key, value in payload.items() if value is not None}
|
||||||
|
else:
|
||||||
|
cleaned = dict(payload)
|
||||||
|
|
||||||
|
serialized = json.dumps(
|
||||||
|
cleaned,
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
sort_keys=sort_keys,
|
||||||
|
)
|
||||||
|
|
||||||
if "/" in serialized:
|
if "/" in serialized:
|
||||||
serialized = serialized.replace("/", "\\/")
|
serialized = serialized.replace("/", "\\/")
|
||||||
|
|
||||||
return serialized
|
return serialized
|
||||||
|
|
||||||
def _generate_signature(self, body: str) -> str:
|
def _generate_signature(self, body: str) -> str:
|
||||||
@@ -52,7 +69,7 @@ class HeleketService:
|
|||||||
logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют")
|
logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
body = self._prepare_body(payload)
|
body = self._prepare_body(payload, ignore_none=True, sort_keys=True)
|
||||||
signature = self._generate_signature(body)
|
signature = self._generate_signature(body)
|
||||||
|
|
||||||
url = f"{self.base_url}/{endpoint.lstrip('/')}"
|
url = f"{self.base_url}/{endpoint.lstrip('/')}"
|
||||||
@@ -145,8 +162,9 @@ class HeleketService:
|
|||||||
logger.error("Heleket webhook без подписи")
|
logger.error("Heleket webhook без подписи")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
data = {key: value for key, value in payload.items() if key != "sign"}
|
data = dict(payload)
|
||||||
body = self._prepare_body(data)
|
data.pop("sign", None)
|
||||||
|
body = self._prepare_body(data, ignore_none=False, sort_keys=False)
|
||||||
expected = self._generate_signature(body)
|
expected = self._generate_signature(body)
|
||||||
|
|
||||||
is_valid = hmac.compare_digest(expected, str(signature))
|
is_valid = hmac.compare_digest(expected, str(signature))
|
||||||
|
|||||||
Vendored
+10
-8
@@ -254,17 +254,17 @@ class YooKassaWebhookHandler:
|
|||||||
logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}")
|
logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}")
|
||||||
logger.debug(f"🔍 Полные данные webhook: {webhook_data}")
|
logger.debug(f"🔍 Полные данные webhook: {webhook_data}")
|
||||||
|
|
||||||
# Извлекаем ID платежа из вебхука для предотвращения дублирования
|
|
||||||
yookassa_payment_id = webhook_data.get("object", {}).get("id")
|
|
||||||
if not yookassa_payment_id:
|
|
||||||
logger.warning("⚠️ Webhook YooKassa без ID платежа")
|
|
||||||
return web.Response(status=400, text="No payment ID")
|
|
||||||
|
|
||||||
event_type = webhook_data.get("event")
|
event_type = webhook_data.get("event")
|
||||||
if not event_type:
|
if not event_type:
|
||||||
logger.warning("⚠️ Webhook YooKassa без типа события")
|
logger.warning("⚠️ Webhook YooKassa без типа события")
|
||||||
return web.Response(status=400, text="No event type")
|
return web.Response(status=400, text="No event type")
|
||||||
|
|
||||||
|
# Извлекаем ID платежа из вебхука для предотвращения дублирования
|
||||||
|
yookassa_payment_id = webhook_data.get("object", {}).get("id")
|
||||||
|
if not yookassa_payment_id:
|
||||||
|
logger.warning("⚠️ Webhook YooKassa без ID платежа")
|
||||||
|
return web.Response(status=400, text="No payment id")
|
||||||
|
|
||||||
if event_type not in YOOKASSA_ALLOWED_EVENTS:
|
if event_type not in YOOKASSA_ALLOWED_EVENTS:
|
||||||
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
|
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
|
||||||
return web.Response(status=200, text="OK")
|
return web.Response(status=200, text="OK")
|
||||||
@@ -274,8 +274,10 @@ class YooKassaWebhookHandler:
|
|||||||
# Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования)
|
# Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования)
|
||||||
from app.database.models import PaymentMethod
|
from app.database.models import PaymentMethod
|
||||||
from app.database.crud.transaction import get_transaction_by_external_id
|
from app.database.crud.transaction import get_transaction_by_external_id
|
||||||
existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA)
|
existing_transaction = None
|
||||||
|
if yookassa_payment_id and hasattr(db, "execute"):
|
||||||
|
existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA)
|
||||||
|
|
||||||
if existing_transaction and event_type == "payment.succeeded":
|
if existing_transaction and event_type == "payment.succeeded":
|
||||||
logger.info(f"ℹ️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.")
|
logger.info(f"ℹ️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.")
|
||||||
return web.Response(status=200, text="OK")
|
return web.Response(status=200, text="OK")
|
||||||
|
|||||||
+140
-42
@@ -3,8 +3,10 @@ import asyncio
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from aiogram import Dispatcher, types, F
|
from aiogram import Dispatcher, types, F
|
||||||
|
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.exc import InterfaceError
|
||||||
from sqlalchemy import select, func, and_, or_
|
from sqlalchemy import select, func, and_, or_
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -16,6 +18,7 @@ from app.database.models import (
|
|||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
BroadcastHistory,
|
BroadcastHistory,
|
||||||
)
|
)
|
||||||
|
from app.database.database import AsyncSessionLocal
|
||||||
from app.keyboards.admin import (
|
from app.keyboards.admin import (
|
||||||
get_admin_messages_keyboard, get_broadcast_target_keyboard,
|
get_admin_messages_keyboard, get_broadcast_target_keyboard,
|
||||||
get_custom_criteria_keyboard, get_broadcast_history_keyboard,
|
get_custom_criteria_keyboard, get_broadcast_history_keyboard,
|
||||||
@@ -86,6 +89,56 @@ def create_broadcast_keyboard(selected_buttons: list, language: str = "ru") -> O
|
|||||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||||
|
|
||||||
|
|
||||||
|
async def _persist_broadcast_result(
|
||||||
|
db: AsyncSession,
|
||||||
|
broadcast_history: BroadcastHistory,
|
||||||
|
sent_count: int,
|
||||||
|
failed_count: int,
|
||||||
|
status: str,
|
||||||
|
) -> None:
|
||||||
|
"""Сохраняет результаты рассылки с повторной попыткой при обрыве соединения."""
|
||||||
|
|
||||||
|
broadcast_history.sent_count = sent_count
|
||||||
|
broadcast_history.failed_count = failed_count
|
||||||
|
broadcast_history.status = status
|
||||||
|
broadcast_history.completed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await db.commit()
|
||||||
|
return
|
||||||
|
except InterfaceError as error:
|
||||||
|
logger.warning(
|
||||||
|
"Соединение с БД потеряно при сохранении результатов рассылки, пробуем еще раз",
|
||||||
|
exc_info=error,
|
||||||
|
)
|
||||||
|
await db.rollback()
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as retry_session:
|
||||||
|
retry_history = await retry_session.get(BroadcastHistory, broadcast_history.id)
|
||||||
|
if not retry_history:
|
||||||
|
logger.critical(
|
||||||
|
"Не удалось найти запись BroadcastHistory #%s для повторной записи результатов",
|
||||||
|
broadcast_history.id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
retry_history.sent_count = sent_count
|
||||||
|
retry_history.failed_count = failed_count
|
||||||
|
retry_history.status = status
|
||||||
|
retry_history.completed_at = broadcast_history.completed_at
|
||||||
|
await retry_session.commit()
|
||||||
|
logger.info(
|
||||||
|
"Результаты рассылки успешно сохранены после повторного подключения к БД (id=%s)",
|
||||||
|
broadcast_history.id,
|
||||||
|
)
|
||||||
|
except Exception as retry_error:
|
||||||
|
logger.critical(
|
||||||
|
"Не удалось сохранить результаты рассылки после восстановления подключения",
|
||||||
|
exc_info=retry_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin_required
|
@admin_required
|
||||||
@error_handler
|
@error_handler
|
||||||
async def show_messages_menu(
|
async def show_messages_menu(
|
||||||
@@ -764,52 +817,77 @@ async def confirm_broadcast(
|
|||||||
|
|
||||||
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
|
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
|
||||||
|
|
||||||
# Ограничение на количество одновременных отправок
|
# Ограничение на количество одновременных отправок и базовая задержка между сообщениями,
|
||||||
semaphore = asyncio.Semaphore(20)
|
# чтобы избежать перегрузки бота и лимитов Telegram при больших рассылках
|
||||||
|
max_concurrent_sends = 5
|
||||||
|
per_message_delay = 0.05
|
||||||
|
semaphore = asyncio.Semaphore(max_concurrent_sends)
|
||||||
|
|
||||||
async def send_single_broadcast(user):
|
async def send_single_broadcast(user):
|
||||||
"""Отправляет одно сообщение рассылки с семафором ограничения"""
|
"""Отправляет одно сообщение рассылки с семафором ограничения"""
|
||||||
async with semaphore:
|
async with semaphore:
|
||||||
try:
|
for attempt in range(3):
|
||||||
if has_media and media_file_id:
|
try:
|
||||||
if media_type == "photo":
|
if has_media and media_file_id:
|
||||||
await callback.bot.send_photo(
|
if media_type == "photo":
|
||||||
|
await callback.bot.send_photo(
|
||||||
|
chat_id=user.telegram_id,
|
||||||
|
photo=media_file_id,
|
||||||
|
caption=message_text,
|
||||||
|
parse_mode="HTML",
|
||||||
|
reply_markup=broadcast_keyboard
|
||||||
|
)
|
||||||
|
elif media_type == "video":
|
||||||
|
await callback.bot.send_video(
|
||||||
|
chat_id=user.telegram_id,
|
||||||
|
video=media_file_id,
|
||||||
|
caption=message_text,
|
||||||
|
parse_mode="HTML",
|
||||||
|
reply_markup=broadcast_keyboard
|
||||||
|
)
|
||||||
|
elif media_type == "document":
|
||||||
|
await callback.bot.send_document(
|
||||||
|
chat_id=user.telegram_id,
|
||||||
|
document=media_file_id,
|
||||||
|
caption=message_text,
|
||||||
|
parse_mode="HTML",
|
||||||
|
reply_markup=broadcast_keyboard
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await callback.bot.send_message(
|
||||||
chat_id=user.telegram_id,
|
chat_id=user.telegram_id,
|
||||||
photo=media_file_id,
|
text=message_text,
|
||||||
caption=message_text,
|
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
reply_markup=broadcast_keyboard
|
reply_markup=broadcast_keyboard
|
||||||
)
|
)
|
||||||
elif media_type == "video":
|
|
||||||
await callback.bot.send_video(
|
await asyncio.sleep(per_message_delay)
|
||||||
chat_id=user.telegram_id,
|
return True, user.telegram_id
|
||||||
video=media_file_id,
|
except TelegramRetryAfter as e:
|
||||||
caption=message_text,
|
retry_delay = min(e.retry_after + 1, 30)
|
||||||
parse_mode="HTML",
|
logger.warning(
|
||||||
reply_markup=broadcast_keyboard
|
f"Превышен лимит Telegram для {user.telegram_id}, ожидание {retry_delay} сек."
|
||||||
)
|
|
||||||
elif media_type == "document":
|
|
||||||
await callback.bot.send_document(
|
|
||||||
chat_id=user.telegram_id,
|
|
||||||
document=media_file_id,
|
|
||||||
caption=message_text,
|
|
||||||
parse_mode="HTML",
|
|
||||||
reply_markup=broadcast_keyboard
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await callback.bot.send_message(
|
|
||||||
chat_id=user.telegram_id,
|
|
||||||
text=message_text,
|
|
||||||
parse_mode="HTML",
|
|
||||||
reply_markup=broadcast_keyboard
|
|
||||||
)
|
)
|
||||||
return True, user.telegram_id
|
await asyncio.sleep(retry_delay)
|
||||||
except Exception as e:
|
except TelegramForbiddenError:
|
||||||
logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}")
|
# Пользователь мог удалить бота или запретить сообщения
|
||||||
return False, user.telegram_id
|
logger.info(f"Рассылка недоступна для пользователя {user.telegram_id}: Forbidden")
|
||||||
|
return False, user.telegram_id
|
||||||
|
except TelegramBadRequest as e:
|
||||||
|
logger.error(
|
||||||
|
f"Некорректный запрос при рассылке пользователю {user.telegram_id}: {e}"
|
||||||
|
)
|
||||||
|
return False, user.telegram_id
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Ошибка отправки рассылки пользователю {user.telegram_id} (попытка {attempt + 1}/3): {e}"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0.5 * (attempt + 1))
|
||||||
|
|
||||||
|
return False, user.telegram_id
|
||||||
|
|
||||||
# Отправляем сообщения пакетами для эффективности
|
# Отправляем сообщения пакетами для эффективности
|
||||||
batch_size = 100
|
batch_size = 50
|
||||||
for i in range(0, len(users), batch_size):
|
for i in range(0, len(users), batch_size):
|
||||||
batch = users[i:i + batch_size]
|
batch = users[i:i + batch_size]
|
||||||
tasks = [send_single_broadcast(user) for user in batch]
|
tasks = [send_single_broadcast(user) for user in batch]
|
||||||
@@ -826,13 +904,16 @@ async def confirm_broadcast(
|
|||||||
failed_count += 1
|
failed_count += 1
|
||||||
|
|
||||||
# Небольшая задержка между пакетами для снижения нагрузки на API
|
# Небольшая задержка между пакетами для снижения нагрузки на API
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.25)
|
||||||
|
|
||||||
broadcast_history.sent_count = sent_count
|
status = "completed" if failed_count == 0 else "partial"
|
||||||
broadcast_history.failed_count = failed_count
|
await _persist_broadcast_result(
|
||||||
broadcast_history.status = "completed" if failed_count == 0 else "partial"
|
db=db,
|
||||||
broadcast_history.completed_at = datetime.utcnow()
|
broadcast_history=broadcast_history,
|
||||||
await db.commit()
|
sent_count=sent_count,
|
||||||
|
failed_count=failed_count,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
|
||||||
media_info = ""
|
media_info = ""
|
||||||
if has_media:
|
if has_media:
|
||||||
@@ -868,7 +949,24 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
async def get_target_users(db: AsyncSession, target: str) -> list:
|
async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
# Загружаем всех активных пользователей батчами, чтобы не ограничиваться 10к
|
||||||
|
users: list[User] = []
|
||||||
|
offset = 0
|
||||||
|
batch_size = 5000
|
||||||
|
|
||||||
|
while True:
|
||||||
|
batch = await get_users_list(
|
||||||
|
db,
|
||||||
|
offset=offset,
|
||||||
|
limit=batch_size,
|
||||||
|
status=UserStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
|
||||||
|
users.extend(batch)
|
||||||
|
offset += batch_size
|
||||||
|
|
||||||
if target == "all":
|
if target == "all":
|
||||||
return users
|
return users
|
||||||
|
|||||||
@@ -134,6 +134,24 @@ CORE_PRICING_ENTRIES: Tuple[SettingEntry, ...] = (
|
|||||||
label_en="💳 Base subscription price",
|
label_en="💳 Base subscription price",
|
||||||
action="price",
|
action="price",
|
||||||
),
|
),
|
||||||
|
SettingEntry(
|
||||||
|
key="BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED",
|
||||||
|
section="core",
|
||||||
|
label_ru="🎟️ Базовые скидки для групп",
|
||||||
|
label_en="🎟️ Base group discounts",
|
||||||
|
action="toggle",
|
||||||
|
description_ru="Включает применение базовых скидок для групповых промо-периодов.",
|
||||||
|
description_en="Enables base discounts for promo group periods.",
|
||||||
|
),
|
||||||
|
SettingEntry(
|
||||||
|
key="BASE_PROMO_GROUP_PERIOD_DISCOUNTS",
|
||||||
|
section="core",
|
||||||
|
label_ru="🔖 Скидки по периодам",
|
||||||
|
label_en="🔖 Period discounts",
|
||||||
|
action="input",
|
||||||
|
description_ru="Формат: список пар дней и скидки через запятую (например 30:10,60:20).",
|
||||||
|
description_en="Format: comma-separated day/discount pairs (e.g. 30:10,60:20).",
|
||||||
|
),
|
||||||
SettingEntry(
|
SettingEntry(
|
||||||
key="DEFAULT_DEVICE_LIMIT",
|
key="DEFAULT_DEVICE_LIMIT",
|
||||||
section="core",
|
section="core",
|
||||||
@@ -205,6 +223,26 @@ SETTING_ENTRY_BY_KEY: Dict[str, SettingEntry] = {
|
|||||||
entry.key: entry for entries in SETTING_ENTRIES_BY_SECTION.values() for entry in entries
|
entry.key: entry for entries in SETTING_ENTRIES_BY_SECTION.values() for entry in entries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SETTING_ENTRIES: Tuple[SettingEntry, ...] = tuple(
|
||||||
|
entry for entries in SETTING_ENTRIES_BY_SECTION.values() for entry in entries
|
||||||
|
)
|
||||||
|
|
||||||
|
SETTING_KEY_TO_TOKEN: Dict[str, str] = {
|
||||||
|
entry.key: f"s{index}" for index, entry in enumerate(SETTING_ENTRIES)
|
||||||
|
}
|
||||||
|
|
||||||
|
SETTING_TOKEN_TO_KEY: Dict[str, str] = {
|
||||||
|
token: key for key, token in SETTING_KEY_TO_TOKEN.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_setting_callback_key(key: str) -> str:
|
||||||
|
return SETTING_KEY_TO_TOKEN.get(key, key)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_setting_callback_key(raw: str) -> str:
|
||||||
|
return SETTING_TOKEN_TO_KEY.get(raw, raw)
|
||||||
|
|
||||||
|
|
||||||
def _traffic_package_sort_key(package: Dict[str, Any]) -> Tuple[int, int]:
|
def _traffic_package_sort_key(package: Dict[str, Any]) -> Tuple[int, int]:
|
||||||
order_index = TRAFFIC_PACKAGE_ORDER_INDEX.get(package["gb"])
|
order_index = TRAFFIC_PACKAGE_ORDER_INDEX.get(package["gb"])
|
||||||
@@ -447,7 +485,9 @@ def _build_settings_section(
|
|||||||
[
|
[
|
||||||
types.InlineKeyboardButton(
|
types.InlineKeyboardButton(
|
||||||
text=button_text,
|
text=button_text,
|
||||||
callback_data=f"admin_pricing_toggle:{section}:{entry.key}",
|
callback_data=(
|
||||||
|
f"admin_pricing_toggle:{section}:{_encode_setting_callback_key(entry.key)}"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -461,7 +501,7 @@ def _build_settings_section(
|
|||||||
types.InlineKeyboardButton(
|
types.InlineKeyboardButton(
|
||||||
text=f"{icon} {option.label(lang_code)}",
|
text=f"{icon} {option.label(lang_code)}",
|
||||||
callback_data=(
|
callback_data=(
|
||||||
f"admin_pricing_choice:{section}:{entry.key}:{option.value}"
|
f"admin_pricing_choice:{section}:{_encode_setting_callback_key(entry.key)}:{option.value}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -477,7 +517,9 @@ def _build_settings_section(
|
|||||||
[
|
[
|
||||||
types.InlineKeyboardButton(
|
types.InlineKeyboardButton(
|
||||||
text=button_text,
|
text=button_text,
|
||||||
callback_data=f"admin_pricing_setting:{section}:{entry.key}",
|
callback_data=(
|
||||||
|
f"admin_pricing_setting:{section}:{_encode_setting_callback_key(entry.key)}"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -939,11 +981,12 @@ async def start_setting_edit(
|
|||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
_, section, key = callback.data.split(":", 2)
|
_, section, raw_key = callback.data.split(":", 2)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
key = _decode_setting_callback_key(raw_key)
|
||||||
entry = SETTING_ENTRY_BY_KEY.get(key)
|
entry = SETTING_ENTRY_BY_KEY.get(key)
|
||||||
texts = get_texts(db_user.language)
|
texts = get_texts(db_user.language)
|
||||||
lang_code = _language_code(db_user.language)
|
lang_code = _language_code(db_user.language)
|
||||||
@@ -1140,11 +1183,12 @@ async def toggle_setting(
|
|||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
_, section, key = callback.data.split(":", 2)
|
_, section, raw_key = callback.data.split(":", 2)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
key = _decode_setting_callback_key(raw_key)
|
||||||
entry = SETTING_ENTRY_BY_KEY.get(key)
|
entry = SETTING_ENTRY_BY_KEY.get(key)
|
||||||
if not entry or entry.action != "toggle":
|
if not entry or entry.action != "toggle":
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
@@ -1171,11 +1215,12 @@ async def select_setting_choice(
|
|||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
_, section, key, value_raw = callback.data.split(":", 3)
|
_, section, raw_key, value_raw = callback.data.split(":", 3)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
key = _decode_setting_callback_key(raw_key)
|
||||||
entry = SETTING_ENTRY_BY_KEY.get(key)
|
entry = SETTING_ENTRY_BY_KEY.get(key)
|
||||||
if not entry or entry.action != "choice" or not entry.choices:
|
if not entry or entry.action != "choice" or not entry.choices:
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ MIGRATION_PAGE_SIZE = 8
|
|||||||
|
|
||||||
def _format_duration(seconds: float) -> str:
|
def _format_duration(seconds: float) -> str:
|
||||||
if seconds < 1:
|
if seconds < 1:
|
||||||
return "<1с"
|
return "менее 1с"
|
||||||
|
|
||||||
minutes, sec = divmod(int(seconds), 60)
|
minutes, sec = divmod(int(seconds), 60)
|
||||||
if minutes:
|
if minutes:
|
||||||
@@ -2285,6 +2285,9 @@ async def show_sync_options(
|
|||||||
"• При полной синхронизации подписки пользователей, отсутствующих в панели, будут деактивированы\n"
|
"• При полной синхронизации подписки пользователей, отсутствующих в панели, будут деактивированы\n"
|
||||||
"• Рекомендуется делать полную синхронизацию ежедневно\n"
|
"• Рекомендуется делать полную синхронизацию ежедневно\n"
|
||||||
"• Баланс пользователей НЕ удаляется\n\n"
|
"• Баланс пользователей НЕ удаляется\n\n"
|
||||||
|
"⬆️ <b>Обратная синхронизация:</b>\n"
|
||||||
|
"• Отправляет активных пользователей из бота в панель\n"
|
||||||
|
"• Используйте при сбоях панели или для восстановления данных\n\n"
|
||||||
+ "\n".join(status_lines)
|
+ "\n".join(status_lines)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2295,6 +2298,12 @@ async def show_sync_options(
|
|||||||
callback_data="sync_all_users",
|
callback_data="sync_all_users",
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
types.InlineKeyboardButton(
|
||||||
|
text="⬆️ Синхронизация в панель",
|
||||||
|
callback_data="sync_to_panel",
|
||||||
|
)
|
||||||
|
],
|
||||||
[
|
[
|
||||||
types.InlineKeyboardButton(
|
types.InlineKeyboardButton(
|
||||||
text="⚙️ Настройки автосинхронизации",
|
text="⚙️ Настройки автосинхронизации",
|
||||||
@@ -2654,6 +2663,50 @@ async def sync_all_users(
|
|||||||
)
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@admin_required
|
||||||
|
@error_handler
|
||||||
|
async def sync_users_to_panel(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
db_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
):
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"⬆️ Выполняется синхронизация данных бота в панель Remnawave...\n\n"
|
||||||
|
"Это может занять несколько минут.",
|
||||||
|
reply_markup=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
remnawave_service = RemnaWaveService()
|
||||||
|
stats = await remnawave_service.sync_users_to_panel(db)
|
||||||
|
|
||||||
|
if stats["errors"] == 0:
|
||||||
|
status_emoji = "✅"
|
||||||
|
status_text = "успешно завершена"
|
||||||
|
else:
|
||||||
|
status_emoji = "⚠️" if (stats["created"] + stats["updated"]) > 0 else "❌"
|
||||||
|
status_text = "завершена с предупреждениями" if status_emoji == "⚠️" else "завершена с ошибками"
|
||||||
|
|
||||||
|
text = (
|
||||||
|
f"{status_emoji} <b>Синхронизация в панель {status_text}</b>\n\n"
|
||||||
|
"📊 <b>Результаты:</b>\n"
|
||||||
|
f"• 🆕 Создано: {stats['created']}\n"
|
||||||
|
f"• 🔄 Обновлено: {stats['updated']}\n"
|
||||||
|
f"• ❌ Ошибок: {stats['errors']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard = [
|
||||||
|
[types.InlineKeyboardButton(text="🔄 Повторить", callback_data="sync_to_panel")],
|
||||||
|
[types.InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")],
|
||||||
|
[types.InlineKeyboardButton(text="⬅️ К синхронизации", callback_data="admin_rw_sync")],
|
||||||
|
]
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text,
|
||||||
|
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
@admin_required
|
@admin_required
|
||||||
@error_handler
|
@error_handler
|
||||||
async def show_sync_recommendations(
|
async def show_sync_recommendations(
|
||||||
@@ -3126,6 +3179,7 @@ def register_handlers(dp: Dispatcher):
|
|||||||
dp.callback_query.register(cancel_auto_sync_schedule, F.data == "remnawave_auto_sync_cancel")
|
dp.callback_query.register(cancel_auto_sync_schedule, F.data == "remnawave_auto_sync_cancel")
|
||||||
dp.callback_query.register(run_auto_sync_now, F.data == "remnawave_auto_sync_run")
|
dp.callback_query.register(run_auto_sync_now, F.data == "remnawave_auto_sync_run")
|
||||||
dp.callback_query.register(sync_all_users, F.data == "sync_all_users")
|
dp.callback_query.register(sync_all_users, F.data == "sync_all_users")
|
||||||
|
dp.callback_query.register(sync_users_to_panel, F.data == "sync_to_panel")
|
||||||
dp.callback_query.register(show_squad_migration_menu, F.data == "admin_rw_migration")
|
dp.callback_query.register(show_squad_migration_menu, F.data == "admin_rw_migration")
|
||||||
dp.callback_query.register(paginate_migration_source, F.data.startswith("admin_migration_source_page_"))
|
dp.callback_query.register(paginate_migration_source, F.data.startswith("admin_migration_source_page_"))
|
||||||
dp.callback_query.register(handle_migration_source_selection, F.data.startswith("admin_migration_source_"))
|
dp.callback_query.register(handle_migration_source_selection, F.data.startswith("admin_migration_source_"))
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from aiogram import Dispatcher, F, types
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database.crud.subscription import (
|
||||||
|
get_trial_statistics,
|
||||||
|
reset_trials_for_users_without_paid_subscription,
|
||||||
|
)
|
||||||
|
from app.database.models import User
|
||||||
|
from app.keyboards.admin import get_admin_trials_keyboard
|
||||||
|
from app.localization.texts import get_texts
|
||||||
|
from app.utils.decorators import admin_required, error_handler
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_required
|
||||||
|
@error_handler
|
||||||
|
async def show_trials_panel(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
db_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
):
|
||||||
|
texts = get_texts(db_user.language)
|
||||||
|
|
||||||
|
stats = await get_trial_statistics(db)
|
||||||
|
message = texts.t("ADMIN_TRIALS_TITLE", "🧪 Управление триалами") + "\n\n" + texts.t(
|
||||||
|
"ADMIN_TRIALS_STATS",
|
||||||
|
"• Использовано всего: {used}\n"
|
||||||
|
"• Активно сейчас: {active}\n"
|
||||||
|
"• Доступно к сбросу: {resettable}",
|
||||||
|
).format(
|
||||||
|
used=stats.get("used_trials", 0),
|
||||||
|
active=stats.get("active_trials", 0),
|
||||||
|
resettable=stats.get("resettable_trials", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
message,
|
||||||
|
reply_markup=get_admin_trials_keyboard(db_user.language),
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@admin_required
|
||||||
|
@error_handler
|
||||||
|
async def reset_trials(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
db_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
):
|
||||||
|
texts = get_texts(db_user.language)
|
||||||
|
|
||||||
|
reset_count = await reset_trials_for_users_without_paid_subscription(db)
|
||||||
|
stats = await get_trial_statistics(db)
|
||||||
|
|
||||||
|
message = texts.t(
|
||||||
|
"ADMIN_TRIALS_RESET_RESULT",
|
||||||
|
"♻️ Сбросили {reset_count} триалов.\n\n"
|
||||||
|
"• Использовано всего: {used}\n"
|
||||||
|
"• Активно сейчас: {active}\n"
|
||||||
|
"• Доступно к сбросу: {resettable}",
|
||||||
|
).format(
|
||||||
|
reset_count=reset_count,
|
||||||
|
used=stats.get("used_trials", 0),
|
||||||
|
active=stats.get("active_trials", 0),
|
||||||
|
resettable=stats.get("resettable_trials", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
message,
|
||||||
|
reply_markup=get_admin_trials_keyboard(db_user.language),
|
||||||
|
)
|
||||||
|
await callback.answer(texts.t("ADMIN_TRIALS_RESET_TOAST", "✅ Сброс завершен"))
|
||||||
|
|
||||||
|
|
||||||
|
def register_handlers(dp: Dispatcher) -> None:
|
||||||
|
dp.callback_query.register(
|
||||||
|
show_trials_panel,
|
||||||
|
F.data == "admin_trials",
|
||||||
|
)
|
||||||
|
dp.callback_query.register(
|
||||||
|
reset_trials,
|
||||||
|
F.data == "admin_trials_reset",
|
||||||
|
)
|
||||||
@@ -12,6 +12,11 @@ from app.database.crud.user_message import (
|
|||||||
)
|
)
|
||||||
from app.database.models import User
|
from app.database.models import User
|
||||||
from app.keyboards.admin import get_admin_main_keyboard
|
from app.keyboards.admin import get_admin_main_keyboard
|
||||||
|
from app.utils.validators import (
|
||||||
|
get_html_help_text,
|
||||||
|
sanitize_html,
|
||||||
|
validate_html_tags,
|
||||||
|
)
|
||||||
from app.utils.decorators import admin_required, error_handler
|
from app.utils.decorators import admin_required, error_handler
|
||||||
from app.localization.texts import get_texts
|
from app.localization.texts import get_texts
|
||||||
|
|
||||||
@@ -122,8 +127,6 @@ async def add_user_message_start(
|
|||||||
db_user: User,
|
db_user: User,
|
||||||
db: AsyncSession
|
db: AsyncSession
|
||||||
):
|
):
|
||||||
from app.utils.validators import get_html_help_text
|
|
||||||
|
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
f"📝 <b>Добавление нового сообщения</b>\n\n"
|
f"📝 <b>Добавление нового сообщения</b>\n\n"
|
||||||
f"Введите текст сообщения, которое будет показываться в главном меню.\n\n"
|
f"Введите текст сообщения, которое будет показываться в главном меню.\n\n"
|
||||||
@@ -161,8 +164,6 @@ async def process_new_message_text(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
from app.utils.validators import validate_html_tags, get_html_help_text
|
|
||||||
|
|
||||||
is_valid, error_msg = validate_html_tags(message_text)
|
is_valid, error_msg = validate_html_tags(message_text)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
await message.answer(
|
await message.answer(
|
||||||
@@ -312,20 +313,22 @@ async def view_user_message(
|
|||||||
return
|
return
|
||||||
|
|
||||||
message = await get_user_message_by_id(db, message_id)
|
message = await get_user_message_by_id(db, message_id)
|
||||||
|
|
||||||
if not message:
|
if not message:
|
||||||
await callback.answer("❌ Сообщение не найдено", show_alert=True)
|
await callback.answer("❌ Сообщение не найдено", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
safe_content = sanitize_html(message.message_text)
|
||||||
|
|
||||||
status_text = "🟢 Активно" if message.is_active else "🔴 Неактивно"
|
status_text = "🟢 Активно" if message.is_active else "🔴 Неактивно"
|
||||||
|
|
||||||
text = (
|
text = (
|
||||||
f"📋 <b>Сообщение ID {message.id}</b>\n\n"
|
f"📋 <b>Сообщение ID {message.id}</b>\n\n"
|
||||||
f"<b>Статус:</b> {status_text}\n"
|
f"<b>Статус:</b> {status_text}\n"
|
||||||
f"<b>Создано:</b> {message.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
f"<b>Создано:</b> {message.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||||
f"<b>Обновлено:</b> {message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
f"<b>Обновлено:</b> {message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||||
f"<b>Содержимое:</b>\n"
|
f"<b>Содержимое:</b>\n"
|
||||||
f"<blockquote>{message.message_text}</blockquote>"
|
f"<blockquote>{safe_content}</blockquote>"
|
||||||
)
|
)
|
||||||
|
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
@@ -455,7 +458,7 @@ async def edit_user_message_start(
|
|||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
f"✏️ <b>Редактирование сообщения ID {message.id}</b>\n\n"
|
f"✏️ <b>Редактирование сообщения ID {message.id}</b>\n\n"
|
||||||
f"<b>Текущий текст:</b>\n"
|
f"<b>Текущий текст:</b>\n"
|
||||||
f"<blockquote>{message.message_text}</blockquote>\n\n"
|
f"<blockquote>{sanitize_html(message.message_text)}</blockquote>\n\n"
|
||||||
f"Введите новый текст сообщения или отправьте /cancel для отмены:",
|
f"Введите новый текст сообщения или отправьте /cancel для отмены:",
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
@@ -489,14 +492,23 @@ async def process_edit_message_text(
|
|||||||
return
|
return
|
||||||
|
|
||||||
new_text = message.text.strip()
|
new_text = message.text.strip()
|
||||||
|
|
||||||
if len(new_text) > 4000:
|
if len(new_text) > 4000:
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"❌ Сообщение слишком длинное. Максимум 4000 символов.\n"
|
"❌ Сообщение слишком длинное. Максимум 4000 символов.\n"
|
||||||
"Попробуйте еще раз или отправьте /cancel для отмены."
|
"Попробуйте еще раз или отправьте /cancel для отмены."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
is_valid, error_msg = validate_html_tags(new_text)
|
||||||
|
if not is_valid:
|
||||||
|
await message.answer(
|
||||||
|
f"❌ Ошибка в HTML разметке: {error_msg}\n\n"
|
||||||
|
f"Исправьте ошибку и попробуйте еще раз, или отправьте /cancel для отмены.",
|
||||||
|
parse_mode=None
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
updated_message = await update_user_message(
|
updated_message = await update_user_message(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -511,7 +523,7 @@ async def process_edit_message_text(
|
|||||||
f"<b>ID:</b> {updated_message.id}\n"
|
f"<b>ID:</b> {updated_message.id}\n"
|
||||||
f"<b>Обновлено:</b> {updated_message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
f"<b>Обновлено:</b> {updated_message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||||
f"<b>Новый текст:</b>\n"
|
f"<b>Новый текст:</b>\n"
|
||||||
f"<blockquote>{new_text}</blockquote>",
|
f"<blockquote>{sanitize_html(new_text)}</blockquote>",
|
||||||
reply_markup=get_user_messages_keyboard(db_user.language),
|
reply_markup=get_user_messages_keyboard(db_user.language),
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|||||||
+301
-2
@@ -32,6 +32,7 @@ from app.services.admin_notification_service import AdminNotificationService
|
|||||||
from app.database.crud.promo_group import get_promo_groups_with_counts
|
from app.database.crud.promo_group import get_promo_groups_with_counts
|
||||||
from app.utils.decorators import admin_required, error_handler
|
from app.utils.decorators import admin_required, error_handler
|
||||||
from app.utils.formatters import format_datetime, format_time_ago
|
from app.utils.formatters import format_datetime, format_time_ago
|
||||||
|
from app.utils.user_utils import get_effective_referral_commission_percent
|
||||||
from app.services.remnawave_service import RemnaWaveService
|
from app.services.remnawave_service import RemnaWaveService
|
||||||
from app.external.remnawave_api import TrafficLimitStrategy
|
from app.external.remnawave_api import TrafficLimitStrategy
|
||||||
from app.database.crud.server_squad import (
|
from app.database.crud.server_squad import (
|
||||||
@@ -1536,6 +1537,9 @@ async def _build_user_referrals_view(
|
|||||||
|
|
||||||
referrals = await get_referrals(db, user_id)
|
referrals = await get_referrals(db, user_id)
|
||||||
|
|
||||||
|
effective_percent = get_effective_referral_commission_percent(user)
|
||||||
|
default_percent = settings.REFERRAL_COMMISSION_PERCENT
|
||||||
|
|
||||||
header = texts.t(
|
header = texts.t(
|
||||||
"ADMIN_USER_REFERRALS_TITLE",
|
"ADMIN_USER_REFERRALS_TITLE",
|
||||||
"🤝 <b>Рефералы пользователя</b>",
|
"🤝 <b>Рефералы пользователя</b>",
|
||||||
@@ -1551,6 +1555,24 @@ async def _build_user_referrals_view(
|
|||||||
|
|
||||||
lines: List[str] = [header, summary]
|
lines: List[str] = [header, summary]
|
||||||
|
|
||||||
|
if user.referral_commission_percent is None:
|
||||||
|
lines.append(
|
||||||
|
texts.t(
|
||||||
|
"ADMIN_USER_REFERRAL_COMMISSION_DEFAULT",
|
||||||
|
"• Процент комиссии: {percent}% (стандартное значение)",
|
||||||
|
).format(percent=effective_percent)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append(
|
||||||
|
texts.t(
|
||||||
|
"ADMIN_USER_REFERRAL_COMMISSION_CUSTOM",
|
||||||
|
"• Индивидуальный процент: {percent}% (стандарт: {default_percent}%)",
|
||||||
|
).format(
|
||||||
|
percent=user.referral_commission_percent,
|
||||||
|
default_percent=default_percent,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if referrals:
|
if referrals:
|
||||||
lines.append(
|
lines.append(
|
||||||
texts.t(
|
texts.t(
|
||||||
@@ -1604,6 +1626,15 @@ async def _build_user_referrals_view(
|
|||||||
|
|
||||||
keyboard = InlineKeyboardMarkup(
|
keyboard = InlineKeyboardMarkup(
|
||||||
inline_keyboard=[
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=texts.t(
|
||||||
|
"ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON",
|
||||||
|
"📈 Изменить процент",
|
||||||
|
),
|
||||||
|
callback_data=f"admin_user_referral_percent_{user_id}",
|
||||||
|
)
|
||||||
|
],
|
||||||
[
|
[
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=texts.t(
|
text=texts.t(
|
||||||
@@ -1636,12 +1667,12 @@ async def show_user_referrals(
|
|||||||
user_id = int(callback.data.split('_')[-1])
|
user_id = int(callback.data.split('_')[-1])
|
||||||
|
|
||||||
current_state = await state.get_state()
|
current_state = await state.get_state()
|
||||||
if current_state == AdminStates.editing_user_referrals:
|
if current_state in {AdminStates.editing_user_referrals, AdminStates.editing_user_referral_percent}:
|
||||||
data = await state.get_data()
|
data = await state.get_data()
|
||||||
preserved_data = {
|
preserved_data = {
|
||||||
key: value
|
key: value
|
||||||
for key, value in data.items()
|
for key, value in data.items()
|
||||||
if key not in {"editing_referrals_user_id", "referrals_message_id"}
|
if key not in {"editing_referrals_user_id", "referrals_message_id", "editing_referral_percent_user_id"}
|
||||||
}
|
}
|
||||||
await state.clear()
|
await state.clear()
|
||||||
if preserved_data:
|
if preserved_data:
|
||||||
@@ -1661,6 +1692,256 @@ async def show_user_referrals(
|
|||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@admin_required
|
||||||
|
@error_handler
|
||||||
|
async def start_edit_referral_percent(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
db_user: User,
|
||||||
|
state: FSMContext,
|
||||||
|
db: AsyncSession,
|
||||||
|
):
|
||||||
|
user_id = int(callback.data.split('_')[-1])
|
||||||
|
|
||||||
|
user = await get_user_by_id(db, user_id)
|
||||||
|
if not user:
|
||||||
|
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
texts = get_texts(db_user.language)
|
||||||
|
|
||||||
|
effective_percent = get_effective_referral_commission_percent(user)
|
||||||
|
default_percent = settings.REFERRAL_COMMISSION_PERCENT
|
||||||
|
|
||||||
|
prompt = texts.t(
|
||||||
|
"ADMIN_USER_REFERRAL_COMMISSION_PROMPT",
|
||||||
|
(
|
||||||
|
"📈 <b>Индивидуальный процент реферальной комиссии</b>\n\n"
|
||||||
|
"Текущее значение: {current}%\n"
|
||||||
|
"Стандартное значение: {default}%\n\n"
|
||||||
|
"Отправьте новое значение от 0 до 100 или слово 'стандарт' для сброса."
|
||||||
|
),
|
||||||
|
).format(current=effective_percent, default=default_percent)
|
||||||
|
|
||||||
|
keyboard = InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="5%",
|
||||||
|
callback_data=f"admin_user_referral_percent_set_{user_id}_5",
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="10%",
|
||||||
|
callback_data=f"admin_user_referral_percent_set_{user_id}_10",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="15%",
|
||||||
|
callback_data=f"admin_user_referral_percent_set_{user_id}_15",
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="20%",
|
||||||
|
callback_data=f"admin_user_referral_percent_set_{user_id}_20",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=texts.t(
|
||||||
|
"ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON",
|
||||||
|
"♻️ Сбросить на стандартный",
|
||||||
|
),
|
||||||
|
callback_data=f"admin_user_referral_percent_reset_{user_id}",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=texts.BACK,
|
||||||
|
callback_data=f"admin_user_referrals_{user_id}",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
await state.update_data(editing_referral_percent_user_id=user_id)
|
||||||
|
await state.set_state(AdminStates.editing_user_referral_percent)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
prompt,
|
||||||
|
reply_markup=keyboard,
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_referral_commission_percent(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
percent: Optional[int],
|
||||||
|
admin_id: int,
|
||||||
|
) -> Tuple[bool, Optional[int]]:
|
||||||
|
try:
|
||||||
|
user = await get_user_by_id(db, user_id)
|
||||||
|
if not user:
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
user.referral_commission_percent = percent
|
||||||
|
user.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
effective = get_effective_referral_commission_percent(user)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Админ %s обновил реферальный процент пользователя %s: %s",
|
||||||
|
admin_id,
|
||||||
|
user_id,
|
||||||
|
percent,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True, effective
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Ошибка обновления реферального процента пользователя %s: %s",
|
||||||
|
user_id,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await db.rollback()
|
||||||
|
except Exception as rollback_error:
|
||||||
|
logger.error("Ошибка отката транзакции: %s", rollback_error)
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
|
||||||
|
async def _render_referrals_after_update(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
db: AsyncSession,
|
||||||
|
db_user: User,
|
||||||
|
user_id: int,
|
||||||
|
success_message: str,
|
||||||
|
):
|
||||||
|
view = await _build_user_referrals_view(db, db_user.language, user_id)
|
||||||
|
if view:
|
||||||
|
text, keyboard = view
|
||||||
|
text = f"{success_message}\n\n" + text
|
||||||
|
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||||
|
else:
|
||||||
|
await callback.message.edit_text(success_message)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_required
|
||||||
|
@error_handler
|
||||||
|
async def set_referral_percent_button(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
db_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
parts = callback.data.split('_')
|
||||||
|
|
||||||
|
if "reset" in parts:
|
||||||
|
user_id = int(parts[-1])
|
||||||
|
percent_value: Optional[int] = None
|
||||||
|
else:
|
||||||
|
user_id = int(parts[-2])
|
||||||
|
percent_value = int(parts[-1])
|
||||||
|
|
||||||
|
texts = get_texts(db_user.language)
|
||||||
|
|
||||||
|
success, effective_percent = await _update_referral_commission_percent(
|
||||||
|
db,
|
||||||
|
user_id,
|
||||||
|
percent_value,
|
||||||
|
db_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
await callback.answer("❌ Не удалось обновить процент", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
success_message = texts.t(
|
||||||
|
"ADMIN_USER_REFERRAL_COMMISSION_UPDATED",
|
||||||
|
"✅ Процент обновлён: {percent}%",
|
||||||
|
).format(percent=effective_percent)
|
||||||
|
|
||||||
|
await _render_referrals_after_update(callback, db, db_user, user_id, success_message)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@admin_required
|
||||||
|
@error_handler
|
||||||
|
async def process_referral_percent_input(
|
||||||
|
message: types.Message,
|
||||||
|
db_user: User,
|
||||||
|
state: FSMContext,
|
||||||
|
db: AsyncSession,
|
||||||
|
):
|
||||||
|
data = await state.get_data()
|
||||||
|
user_id = data.get("editing_referral_percent_user_id")
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
await message.answer("❌ Не удалось определить пользователя")
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_text = message.text.strip()
|
||||||
|
normalized = raw_text.lower()
|
||||||
|
|
||||||
|
percent_value: Optional[int]
|
||||||
|
|
||||||
|
if normalized in {"стандарт", "standard", "default"}:
|
||||||
|
percent_value = None
|
||||||
|
else:
|
||||||
|
normalized_number = raw_text.replace(',', '.').strip()
|
||||||
|
try:
|
||||||
|
percent_float = float(normalized_number)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
await message.answer(
|
||||||
|
get_texts(db_user.language).t(
|
||||||
|
"ADMIN_USER_REFERRAL_COMMISSION_INVALID",
|
||||||
|
"❌ Введите число от 0 до 100 или слово 'стандарт'",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
percent_value = int(round(percent_float))
|
||||||
|
|
||||||
|
if percent_value < 0 or percent_value > 100:
|
||||||
|
await message.answer(
|
||||||
|
get_texts(db_user.language).t(
|
||||||
|
"ADMIN_USER_REFERRAL_COMMISSION_INVALID",
|
||||||
|
"❌ Введите число от 0 до 100 или слово 'стандарт'",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
texts = get_texts(db_user.language)
|
||||||
|
|
||||||
|
success, effective_percent = await _update_referral_commission_percent(
|
||||||
|
db,
|
||||||
|
int(user_id),
|
||||||
|
percent_value,
|
||||||
|
db_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
await message.answer("❌ Не удалось обновить процент")
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
success_message = texts.t(
|
||||||
|
"ADMIN_USER_REFERRAL_COMMISSION_UPDATED",
|
||||||
|
"✅ Процент обновлён: {percent}%",
|
||||||
|
).format(percent=effective_percent)
|
||||||
|
|
||||||
|
view = await _build_user_referrals_view(db, db_user.language, int(user_id))
|
||||||
|
if view:
|
||||||
|
text, keyboard = view
|
||||||
|
await message.answer(f"{success_message}\n\n{text}", reply_markup=keyboard)
|
||||||
|
else:
|
||||||
|
await message.answer(success_message)
|
||||||
|
|
||||||
|
|
||||||
@admin_required
|
@admin_required
|
||||||
@error_handler
|
@error_handler
|
||||||
async def start_edit_user_referrals(
|
async def start_edit_user_referrals(
|
||||||
@@ -4621,6 +4902,24 @@ def register_handlers(dp: Dispatcher):
|
|||||||
F.data.startswith("admin_user_referrals_") & ~F.data.contains("_edit")
|
F.data.startswith("admin_user_referrals_") & ~F.data.contains("_edit")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
dp.callback_query.register(
|
||||||
|
start_edit_referral_percent,
|
||||||
|
F.data.startswith("admin_user_referral_percent_")
|
||||||
|
& ~F.data.contains("_set_")
|
||||||
|
& ~F.data.contains("_reset")
|
||||||
|
)
|
||||||
|
|
||||||
|
dp.callback_query.register(
|
||||||
|
set_referral_percent_button,
|
||||||
|
F.data.startswith("admin_user_referral_percent_set_")
|
||||||
|
| F.data.startswith("admin_user_referral_percent_reset_")
|
||||||
|
)
|
||||||
|
|
||||||
|
dp.message.register(
|
||||||
|
process_referral_percent_input,
|
||||||
|
AdminStates.editing_user_referral_percent,
|
||||||
|
)
|
||||||
|
|
||||||
dp.callback_query.register(
|
dp.callback_query.register(
|
||||||
start_edit_user_referrals,
|
start_edit_user_referrals,
|
||||||
F.data.startswith("admin_user_referrals_edit_")
|
F.data.startswith("admin_user_referrals_edit_")
|
||||||
|
|||||||
@@ -79,7 +79,12 @@ async def start_cryptobot_payment(
|
|||||||
)
|
)
|
||||||
|
|
||||||
await state.set_state(BalanceStates.waiting_for_amount)
|
await state.set_state(BalanceStates.waiting_for_amount)
|
||||||
await state.update_data(payment_method="cryptobot", current_rate=current_rate)
|
await state.update_data(
|
||||||
|
payment_method="cryptobot",
|
||||||
|
current_rate=current_rate,
|
||||||
|
cryptobot_prompt_message_id=callback.message.message_id,
|
||||||
|
cryptobot_prompt_chat_id=callback.message.chat.id,
|
||||||
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@@ -158,8 +163,29 @@ async def process_cryptobot_payment_amount(
|
|||||||
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_cryptobot_{payment_result['local_payment_id']}")],
|
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_cryptobot_{payment_result['local_payment_id']}")],
|
||||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||||
])
|
])
|
||||||
|
|
||||||
await message.answer(
|
state_data = await state.get_data()
|
||||||
|
prompt_message_id = state_data.get("cryptobot_prompt_message_id")
|
||||||
|
prompt_chat_id = state_data.get("cryptobot_prompt_chat_id", message.chat.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception as delete_error: # pragma: no cover - depends on bot rights
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение с суммой CryptoBot: %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
if prompt_message_id:
|
||||||
|
try:
|
||||||
|
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - diagnostics
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение с запросом суммы CryptoBot: %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice_message = await message.answer(
|
||||||
f"🪙 <b>Оплата криптовалютой</b>\n\n"
|
f"🪙 <b>Оплата криптовалютой</b>\n\n"
|
||||||
f"💰 Сумма к зачислению: {amount_rubles:.0f} ₽\n"
|
f"💰 Сумма к зачислению: {amount_rubles:.0f} ₽\n"
|
||||||
f"💵 К оплате: {amount_usd:.2f} USD\n"
|
f"💵 К оплате: {amount_usd:.2f} USD\n"
|
||||||
@@ -177,7 +203,12 @@ async def process_cryptobot_payment_amount(
|
|||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
cryptobot_invoice_message_id=invoice_message.message_id,
|
||||||
|
cryptobot_invoice_chat_id=invoice_message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
logger.info(f"Создан CryptoBot платеж для пользователя {db_user.telegram_id}: "
|
logger.info(f"Создан CryptoBot платеж для пользователя {db_user.telegram_id}: "
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from aiogram import types
|
from aiogram import types
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from sqlalchemy import update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -66,7 +68,11 @@ async def start_heleket_payment(
|
|||||||
)
|
)
|
||||||
|
|
||||||
await state.set_state(BalanceStates.waiting_for_amount)
|
await state.set_state(BalanceStates.waiting_for_amount)
|
||||||
await state.update_data(payment_method="heleket")
|
await state.update_data(
|
||||||
|
payment_method="heleket",
|
||||||
|
heleket_prompt_message_id=callback.message.message_id,
|
||||||
|
heleket_prompt_chat_id=callback.message.chat.id,
|
||||||
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@@ -181,7 +187,52 @@ async def process_heleket_payment_amount(
|
|||||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||||
])
|
])
|
||||||
|
|
||||||
await message.answer("\n".join(details), parse_mode="HTML", reply_markup=keyboard)
|
state_data = await state.get_data()
|
||||||
|
prompt_message_id = state_data.get("heleket_prompt_message_id")
|
||||||
|
prompt_chat_id = state_data.get("heleket_prompt_chat_id", message.chat.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception as delete_error: # pragma: no cover - depends on bot rights
|
||||||
|
logger.warning("Не удалось удалить сообщение с суммой Heleket: %s", delete_error)
|
||||||
|
|
||||||
|
if prompt_message_id:
|
||||||
|
try:
|
||||||
|
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - diagnostic
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение с запросом суммы Heleket: %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice_message = await message.answer(
|
||||||
|
"\n".join(details), parse_mode="HTML", reply_markup=keyboard
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services import payment_service as payment_module
|
||||||
|
|
||||||
|
payment = await payment_module.get_heleket_payment_by_id(db, result["local_payment_id"])
|
||||||
|
if payment:
|
||||||
|
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||||
|
metadata["invoice_message"] = {
|
||||||
|
"chat_id": invoice_message.chat.id,
|
||||||
|
"message_id": invoice_message.message_id,
|
||||||
|
}
|
||||||
|
await db.execute(
|
||||||
|
update(payment.__class__)
|
||||||
|
.where(payment.__class__.id == payment.id)
|
||||||
|
.values(metadata_json=metadata, updated_at=datetime.utcnow())
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as error: # pragma: no cover - diagnostics
|
||||||
|
logger.warning("Не удалось сохранить сообщение Heleket: %s", error)
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
heleket_invoice_message_id=invoice_message.message_id,
|
||||||
|
heleket_invoice_chat_id=invoice_message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -421,7 +421,17 @@ async def request_support_topup(
|
|||||||
db_user: User
|
db_user: User
|
||||||
):
|
):
|
||||||
texts = get_texts(db_user.language)
|
texts = get_texts(db_user.language)
|
||||||
|
|
||||||
|
if not settings.is_support_topup_enabled():
|
||||||
|
await callback.answer(
|
||||||
|
texts.t(
|
||||||
|
"SUPPORT_TOPUP_DISABLED",
|
||||||
|
"Пополнение через поддержку отключено. Попробуйте другой способ оплаты.",
|
||||||
|
),
|
||||||
|
show_alert=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
support_text = f"""
|
support_text = f"""
|
||||||
🛠️ <b>Пополнение через поддержку</b>
|
🛠️ <b>Пополнение через поддержку</b>
|
||||||
|
|
||||||
@@ -748,11 +758,19 @@ async def handle_topup_amount_callback(
|
|||||||
)
|
)
|
||||||
elif method == "platega":
|
elif method == "platega":
|
||||||
from app.database.database import AsyncSessionLocal
|
from app.database.database import AsyncSessionLocal
|
||||||
from .platega import process_platega_payment_amount
|
from .platega import process_platega_payment_amount, start_platega_payment
|
||||||
async with AsyncSessionLocal() as db:
|
|
||||||
await process_platega_payment_amount(
|
data = await state.get_data()
|
||||||
callback.message, db_user, db, amount_kopeks, state
|
method_code = int(data.get("platega_method", 0)) if data else 0
|
||||||
)
|
|
||||||
|
if method_code > 0:
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
await process_platega_payment_amount(
|
||||||
|
callback.message, db_user, db, amount_kopeks, state
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await state.update_data(platega_pending_amount=amount_kopeks)
|
||||||
|
await start_platega_payment(callback, db_user, state)
|
||||||
elif method == "pal24":
|
elif method == "pal24":
|
||||||
from app.database.database import AsyncSessionLocal
|
from app.database.database import AsyncSessionLocal
|
||||||
from .pal24 import process_pal24_payment_amount
|
from .pal24 import process_pal24_payment_amount
|
||||||
|
|||||||
@@ -59,7 +59,11 @@ async def start_mulenpay_payment(
|
|||||||
)
|
)
|
||||||
|
|
||||||
await state.set_state(BalanceStates.waiting_for_amount)
|
await state.set_state(BalanceStates.waiting_for_amount)
|
||||||
await state.update_data(payment_method="mulenpay")
|
await state.update_data(
|
||||||
|
payment_method="mulenpay",
|
||||||
|
mulenpay_prompt_message_id=callback.message.message_id,
|
||||||
|
mulenpay_prompt_chat_id=callback.message.chat.id,
|
||||||
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@@ -93,6 +97,26 @@ async def process_mulenpay_payment_amount(
|
|||||||
|
|
||||||
amount_rubles = amount_kopeks / 100
|
amount_rubles = amount_kopeks / 100
|
||||||
|
|
||||||
|
state_data = await state.get_data()
|
||||||
|
prompt_message_id = state_data.get("mulenpay_prompt_message_id")
|
||||||
|
prompt_chat_id = state_data.get("mulenpay_prompt_chat_id", message.chat.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception as delete_error: # pragma: no cover - depends on bot permissions
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение с суммой MulenPay: %s", delete_error
|
||||||
|
)
|
||||||
|
|
||||||
|
if prompt_message_id:
|
||||||
|
try:
|
||||||
|
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - diagnostic
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение с запросом суммы MulenPay: %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payment_service = PaymentService(message.bot)
|
payment_service = PaymentService(message.bot)
|
||||||
payment_result = await payment_service.create_mulenpay_payment(
|
payment_result = await payment_service.create_mulenpay_payment(
|
||||||
@@ -163,12 +187,39 @@ async def process_mulenpay_payment_amount(
|
|||||||
mulenpay_name_html=mulenpay_name_html,
|
mulenpay_name_html=mulenpay_name_html,
|
||||||
)
|
)
|
||||||
|
|
||||||
await message.answer(
|
invoice_message = await message.answer(
|
||||||
message_text,
|
message_text,
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services import payment_service as payment_module
|
||||||
|
|
||||||
|
payment = await payment_module.get_mulenpay_payment_by_local_id(
|
||||||
|
db, local_payment_id
|
||||||
|
)
|
||||||
|
if payment:
|
||||||
|
payment_metadata = dict(
|
||||||
|
getattr(payment, "metadata_json", {}) or {}
|
||||||
|
)
|
||||||
|
payment_metadata["invoice_message"] = {
|
||||||
|
"chat_id": invoice_message.chat.id,
|
||||||
|
"message_id": invoice_message.message_id,
|
||||||
|
}
|
||||||
|
await payment_module.update_mulenpay_payment_metadata(
|
||||||
|
db,
|
||||||
|
payment=payment,
|
||||||
|
metadata=payment_metadata,
|
||||||
|
)
|
||||||
|
except Exception as error: # pragma: no cover - diagnostic logging only
|
||||||
|
logger.warning("Не удалось сохранить данные сообщения MulenPay: %s", error)
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
mulenpay_invoice_message_id=invoice_message.message_id,
|
||||||
|
mulenpay_invoice_chat_id=invoice_message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import html
|
import html
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from aiogram import types
|
from aiogram import types
|
||||||
from aiogram.exceptions import TelegramBadRequest
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from sqlalchemy import update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -204,12 +206,36 @@ async def _send_pal24_payment_message(
|
|||||||
support=settings.get_support_contact_display_html(),
|
support=settings.get_support_contact_display_html(),
|
||||||
)
|
)
|
||||||
|
|
||||||
await message.answer(
|
invoice_message = await message.answer(
|
||||||
message_text,
|
message_text,
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services import payment_service as payment_module
|
||||||
|
|
||||||
|
payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id)
|
||||||
|
if payment:
|
||||||
|
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||||
|
metadata["invoice_message"] = {
|
||||||
|
"chat_id": invoice_message.chat.id,
|
||||||
|
"message_id": invoice_message.message_id,
|
||||||
|
}
|
||||||
|
await db.execute(
|
||||||
|
update(payment.__class__)
|
||||||
|
.where(payment.__class__.id == payment.id)
|
||||||
|
.values(metadata_json=metadata, updated_at=datetime.utcnow())
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as error: # pragma: no cover - diagnostics
|
||||||
|
logger.warning("Не удалось сохранить сообщение PayPalych: %s", error)
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
pal24_invoice_message_id=invoice_message.message_id,
|
||||||
|
pal24_invoice_chat_id=invoice_message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -277,7 +303,11 @@ async def start_pal24_payment(
|
|||||||
)
|
)
|
||||||
|
|
||||||
await state.set_state(BalanceStates.waiting_for_amount)
|
await state.set_state(BalanceStates.waiting_for_amount)
|
||||||
await state.update_data(payment_method="pal24")
|
await state.update_data(
|
||||||
|
payment_method="pal24",
|
||||||
|
pal24_prompt_message_id=callback.message.message_id,
|
||||||
|
pal24_prompt_chat_id=callback.message.chat.id,
|
||||||
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@@ -309,6 +339,24 @@ async def process_pal24_payment_amount(
|
|||||||
|
|
||||||
available_methods = _get_available_pal24_methods()
|
available_methods = _get_available_pal24_methods()
|
||||||
|
|
||||||
|
state_data = await state.get_data()
|
||||||
|
prompt_message_id = state_data.get("pal24_prompt_message_id")
|
||||||
|
prompt_chat_id = state_data.get("pal24_prompt_chat_id", message.chat.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception as delete_error: # pragma: no cover - depends on bot rights
|
||||||
|
logger.warning("Не удалось удалить сообщение с суммой PayPalych: %s", delete_error)
|
||||||
|
|
||||||
|
if prompt_message_id:
|
||||||
|
try:
|
||||||
|
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - diagnostic
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение с запросом суммы PayPalych: %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
if len(available_methods) == 1:
|
if len(available_methods) == 1:
|
||||||
await _send_pal24_payment_message(
|
await _send_pal24_payment_message(
|
||||||
message,
|
message,
|
||||||
|
|||||||
@@ -32,6 +32,29 @@ async def _prompt_amount(
|
|||||||
texts = get_texts(db_user.language)
|
texts = get_texts(db_user.language)
|
||||||
method_name = settings.get_platega_method_display_title(method_code)
|
method_name = settings.get_platega_method_display_title(method_code)
|
||||||
|
|
||||||
|
# Всегда фиксируем выбранный метод для последующей обработки
|
||||||
|
await state.update_data(payment_method="platega", platega_method=method_code)
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
pending_amount = int(data.get("platega_pending_amount") or 0)
|
||||||
|
|
||||||
|
if pending_amount > 0:
|
||||||
|
# Если сумма уже известна (например, после быстрого выбора),
|
||||||
|
# сразу создаём платеж и сбрасываем временное значение.
|
||||||
|
await state.update_data(platega_pending_amount=None)
|
||||||
|
|
||||||
|
from app.database.database import AsyncSessionLocal
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
await process_platega_payment_amount(
|
||||||
|
message,
|
||||||
|
db_user,
|
||||||
|
db,
|
||||||
|
pending_amount,
|
||||||
|
state,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
min_amount_label = settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)
|
min_amount_label = settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)
|
||||||
max_amount_kopeks = settings.PLATEGA_MAX_AMOUNT_KOPEKS
|
max_amount_kopeks = settings.PLATEGA_MAX_AMOUNT_KOPEKS
|
||||||
max_amount_label = (
|
max_amount_label = (
|
||||||
@@ -75,7 +98,10 @@ async def _prompt_amount(
|
|||||||
)
|
)
|
||||||
|
|
||||||
await state.set_state(BalanceStates.waiting_for_amount)
|
await state.set_state(BalanceStates.waiting_for_amount)
|
||||||
await state.update_data(payment_method="platega", platega_method=method_code)
|
await state.update_data(
|
||||||
|
platega_prompt_message_id=message.message_id,
|
||||||
|
platega_prompt_chat_id=message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@error_handler
|
@error_handler
|
||||||
@@ -108,6 +134,8 @@ async def start_platega_payment(
|
|||||||
return
|
return
|
||||||
|
|
||||||
await state.update_data(payment_method="platega")
|
await state.update_data(payment_method="platega")
|
||||||
|
data = await state.get_data()
|
||||||
|
has_pending_amount = bool(int(data.get("platega_pending_amount") or 0))
|
||||||
|
|
||||||
if len(active_methods) == 1:
|
if len(active_methods) == 1:
|
||||||
await _prompt_amount(callback.message, db_user, state, active_methods[0])
|
await _prompt_amount(callback.message, db_user, state, active_methods[0])
|
||||||
@@ -137,7 +165,8 @@ async def start_platega_payment(
|
|||||||
),
|
),
|
||||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
|
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
|
||||||
)
|
)
|
||||||
await state.set_state(BalanceStates.waiting_for_platega_method)
|
if not has_pending_amount:
|
||||||
|
await state.set_state(BalanceStates.waiting_for_platega_method)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@@ -275,7 +304,25 @@ async def process_platega_payment_amount(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
await message.answer(
|
state_data = await state.get_data()
|
||||||
|
prompt_message_id = state_data.get("platega_prompt_message_id")
|
||||||
|
prompt_chat_id = state_data.get("platega_prompt_chat_id", message.chat.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception as delete_error: # pragma: no cover - зависит от прав бота
|
||||||
|
logger.warning("Не удалось удалить сообщение с суммой Platega: %s", delete_error)
|
||||||
|
|
||||||
|
if prompt_message_id:
|
||||||
|
try:
|
||||||
|
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - диагностический лог
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение с запросом суммы Platega: %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice_message = await message.answer(
|
||||||
instructions_template.format(
|
instructions_template.format(
|
||||||
method=method_title,
|
method=method_title,
|
||||||
amount=settings.format_price(amount_kopeks),
|
amount=settings.format_price(amount_kopeks),
|
||||||
@@ -286,6 +333,29 @@ async def process_platega_payment_amount(
|
|||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services import payment_service as payment_module
|
||||||
|
|
||||||
|
payment = await payment_module.get_platega_payment_by_id(db, local_payment_id)
|
||||||
|
if payment:
|
||||||
|
payment_metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||||
|
payment_metadata["invoice_message"] = {
|
||||||
|
"chat_id": invoice_message.chat.id,
|
||||||
|
"message_id": invoice_message.message_id,
|
||||||
|
}
|
||||||
|
await payment_module.update_platega_payment(
|
||||||
|
db,
|
||||||
|
payment=payment,
|
||||||
|
metadata=payment_metadata,
|
||||||
|
)
|
||||||
|
except Exception as error: # pragma: no cover - диагностический лог
|
||||||
|
logger.warning("Не удалось сохранить данные сообщения Platega: %s", error)
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
platega_invoice_message_id=invoice_message.message_id,
|
||||||
|
platega_invoice_chat_id=invoice_message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
from aiogram import types
|
from aiogram import types
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database.models import User
|
from app.database.models import User
|
||||||
from app.keyboards.inline import get_back_keyboard, get_payment_methods_keyboard
|
from app.keyboards.inline import get_back_keyboard
|
||||||
from app.localization.texts import get_texts
|
from app.localization.texts import get_texts
|
||||||
from app.services.payment_service import PaymentService
|
from app.services.payment_service import PaymentService
|
||||||
from app.states import BalanceStates
|
from app.states import BalanceStates
|
||||||
@@ -22,11 +21,11 @@ async def start_stars_payment(
|
|||||||
state: FSMContext
|
state: FSMContext
|
||||||
):
|
):
|
||||||
texts = get_texts(db_user.language)
|
texts = get_texts(db_user.language)
|
||||||
|
|
||||||
if not settings.TELEGRAM_STARS_ENABLED:
|
if not settings.TELEGRAM_STARS_ENABLED:
|
||||||
await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True)
|
await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Формируем текст сообщения в зависимости от настройки
|
# Формируем текст сообщения в зависимости от настройки
|
||||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||||
message_text = (
|
message_text = (
|
||||||
@@ -35,10 +34,10 @@ async def start_stars_payment(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
message_text = texts.TOP_UP_AMOUNT
|
message_text = texts.TOP_UP_AMOUNT
|
||||||
|
|
||||||
# Создаем клавиатуру
|
# Создаем клавиатуру
|
||||||
keyboard = get_back_keyboard(db_user.language)
|
keyboard = get_back_keyboard(db_user.language)
|
||||||
|
|
||||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||||
from .main import get_quick_amount_buttons
|
from .main import get_quick_amount_buttons
|
||||||
@@ -46,12 +45,17 @@ async def start_stars_payment(
|
|||||||
if quick_amount_buttons:
|
if quick_amount_buttons:
|
||||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||||
|
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
message_text,
|
message_text,
|
||||||
reply_markup=keyboard
|
reply_markup=keyboard
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
stars_prompt_message_id=callback.message.message_id,
|
||||||
|
stars_prompt_chat_id=callback.message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
await state.set_state(BalanceStates.waiting_for_amount)
|
await state.set_state(BalanceStates.waiting_for_amount)
|
||||||
await state.update_data(payment_method="stars")
|
await state.update_data(payment_method="stars")
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
@@ -65,29 +69,48 @@ async def process_stars_payment_amount(
|
|||||||
state: FSMContext
|
state: FSMContext
|
||||||
):
|
):
|
||||||
texts = get_texts(db_user.language)
|
texts = get_texts(db_user.language)
|
||||||
|
|
||||||
if not settings.TELEGRAM_STARS_ENABLED:
|
if not settings.TELEGRAM_STARS_ENABLED:
|
||||||
await message.answer("⚠️ Оплата Stars временно недоступна")
|
await message.answer("⚠️ Оплата Stars временно недоступна")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
amount_rubles = amount_kopeks / 100
|
amount_rubles = amount_kopeks / 100
|
||||||
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
|
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
|
||||||
stars_rate = settings.get_stars_rate()
|
stars_rate = settings.get_stars_rate()
|
||||||
|
|
||||||
payment_service = PaymentService(message.bot)
|
payment_service = PaymentService(message.bot)
|
||||||
invoice_link = await payment_service.create_stars_invoice(
|
invoice_link = await payment_service.create_stars_invoice(
|
||||||
amount_kopeks=amount_kopeks,
|
amount_kopeks=amount_kopeks,
|
||||||
description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}",
|
description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}",
|
||||||
payload=f"balance_{db_user.id}_{amount_kopeks}"
|
payload=f"balance_{db_user.id}_{amount_kopeks}"
|
||||||
)
|
)
|
||||||
|
|
||||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||||
[types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)],
|
[types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)],
|
||||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||||
])
|
])
|
||||||
|
|
||||||
await message.answer(
|
state_data = await state.get_data()
|
||||||
|
|
||||||
|
prompt_message_id = state_data.get("stars_prompt_message_id")
|
||||||
|
prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception as delete_error: # pragma: no cover - зависит от прав бота
|
||||||
|
logger.warning("Не удалось удалить сообщение с суммой Stars: %s", delete_error)
|
||||||
|
|
||||||
|
if prompt_message_id:
|
||||||
|
try:
|
||||||
|
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - диагностический лог
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение с запросом суммы Stars: %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice_message = await message.answer(
|
||||||
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
|
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
|
||||||
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
|
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
|
||||||
f"⭐ К оплате: {stars_amount} звезд\n"
|
f"⭐ К оплате: {stars_amount} звезд\n"
|
||||||
@@ -96,9 +119,14 @@ async def process_stars_payment_amount(
|
|||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
await state.clear()
|
await state.update_data(
|
||||||
|
stars_invoice_message_id=invoice_message.message_id,
|
||||||
|
stars_invoice_chat_id=invoice_message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
await state.set_state(None)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка создания Stars invoice: {e}")
|
logger.error(f"Ошибка создания Stars invoice: {e}")
|
||||||
await message.answer("⚠️ Ошибка создания платежа")
|
await message.answer("⚠️ Ошибка создания платежа")
|
||||||
|
|||||||
@@ -13,47 +13,59 @@ logger = logging.getLogger(__name__)
|
|||||||
@error_handler
|
@error_handler
|
||||||
async def start_tribute_payment(
|
async def start_tribute_payment(
|
||||||
callback: types.CallbackQuery,
|
callback: types.CallbackQuery,
|
||||||
db_user: User
|
db_user: User,
|
||||||
):
|
):
|
||||||
texts = get_texts(db_user.language)
|
texts = get_texts(db_user.language)
|
||||||
|
|
||||||
if not settings.TRIBUTE_ENABLED:
|
if not settings.TRIBUTE_ENABLED:
|
||||||
await callback.answer("❌ Оплата картой временно недоступна", show_alert=True)
|
await callback.answer("❌ Оплата картой временно недоступна", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app.services.tribute_service import TributeService
|
from app.services.tribute_service import TributeService
|
||||||
|
|
||||||
tribute_service = TributeService(callback.bot)
|
tribute_service = TributeService(callback.bot)
|
||||||
payment_url = await tribute_service.create_payment_link(
|
payment_url = await tribute_service.create_payment_link(
|
||||||
user_id=db_user.telegram_id,
|
user_id=db_user.telegram_id,
|
||||||
amount_kopeks=0,
|
amount_kopeks=0,
|
||||||
description="Пополнение баланса VPN"
|
description="Пополнение баланса VPN",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not payment_url:
|
if not payment_url:
|
||||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
keyboard = types.InlineKeyboardMarkup(
|
||||||
[types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)],
|
inline_keyboard=[
|
||||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
[types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)],
|
||||||
])
|
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||||
|
]
|
||||||
await callback.message.edit_text(
|
|
||||||
f"💳 <b>Пополнение банковской картой</b>\n\n"
|
|
||||||
f"• Введите любую сумму от 100₽\n"
|
|
||||||
f"• Безопасная оплата через Tribute\n"
|
|
||||||
f"• Мгновенное зачисление на баланс\n"
|
|
||||||
f"• Принимаем карты Visa, MasterCard, МИР\n\n"
|
|
||||||
f"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n"
|
|
||||||
f"Нажмите кнопку для перехода к оплате:",
|
|
||||||
reply_markup=keyboard,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
message_text = (
|
||||||
|
"💳 <b>Пополнение банковской картой</b>\n\n"
|
||||||
|
"• Введите любую сумму от 100₽\n"
|
||||||
|
"• Безопасная оплата через Tribute\n"
|
||||||
|
"• Мгновенное зачисление на баланс\n"
|
||||||
|
"• Принимаем карты Visa, MasterCard, МИР\n\n"
|
||||||
|
"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n"
|
||||||
|
"Нажмите кнопку для перехода к оплате:"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
message_text,
|
||||||
|
reply_markup=keyboard,
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
|
||||||
|
TributeService.remember_invoice_message(
|
||||||
|
db_user.telegram_id,
|
||||||
|
callback.message.chat.id,
|
||||||
|
callback.message.message_id,
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка создания Tribute платежа: {e}")
|
logger.error(f"Ошибка создания Tribute платежа: {e}")
|
||||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||||
|
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
from aiogram import types
|
from aiogram import types
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from sqlalchemy import update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -56,7 +58,11 @@ async def start_wata_payment(
|
|||||||
)
|
)
|
||||||
|
|
||||||
await state.set_state(BalanceStates.waiting_for_amount)
|
await state.set_state(BalanceStates.waiting_for_amount)
|
||||||
await state.update_data(payment_method="wata")
|
await state.update_data(
|
||||||
|
payment_method="wata",
|
||||||
|
wata_prompt_message_id=callback.message.message_id,
|
||||||
|
wata_prompt_chat_id=callback.message.chat.id,
|
||||||
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@@ -159,12 +165,54 @@ async def process_wata_payment_amount(
|
|||||||
support=settings.get_support_contact_display_html(),
|
support=settings.get_support_contact_display_html(),
|
||||||
)
|
)
|
||||||
|
|
||||||
await message.answer(
|
state_data = await state.get_data()
|
||||||
|
prompt_message_id = state_data.get("wata_prompt_message_id")
|
||||||
|
prompt_chat_id = state_data.get("wata_prompt_chat_id", message.chat.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception as delete_error: # pragma: no cover - depends on bot rights
|
||||||
|
logger.warning("Не удалось удалить сообщение с суммой WATA: %s", delete_error)
|
||||||
|
|
||||||
|
if prompt_message_id:
|
||||||
|
try:
|
||||||
|
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - diagnostic
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение с запросом суммы WATA: %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice_message = await message.answer(
|
||||||
message_text,
|
message_text,
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services import payment_service as payment_module
|
||||||
|
|
||||||
|
payment = await payment_module.get_wata_payment_by_local_id(db, local_payment_id)
|
||||||
|
if payment:
|
||||||
|
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||||
|
metadata["invoice_message"] = {
|
||||||
|
"chat_id": invoice_message.chat.id,
|
||||||
|
"message_id": invoice_message.message_id,
|
||||||
|
}
|
||||||
|
await db.execute(
|
||||||
|
update(payment.__class__)
|
||||||
|
.where(payment.__class__.id == payment.id)
|
||||||
|
.values(metadata_json=metadata, updated_at=datetime.utcnow())
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as error: # pragma: no cover - diagnostics
|
||||||
|
logger.warning("Не удалось сохранить сообщение WATA: %s", error)
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
wata_invoice_message_id=invoice_message.message_id,
|
||||||
|
wata_invoice_chat_id=invoice_message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from aiogram import types
|
from aiogram import types
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from sqlalchemy import update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -58,9 +61,13 @@ async def start_yookassa_payment(
|
|||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
await state.set_state(BalanceStates.waiting_for_amount)
|
await state.set_state(BalanceStates.waiting_for_amount)
|
||||||
await state.update_data(payment_method="yookassa")
|
await state.update_data(payment_method="yookassa")
|
||||||
|
await state.update_data(
|
||||||
|
yookassa_prompt_message_id=callback.message.message_id,
|
||||||
|
yookassa_prompt_chat_id=callback.message.chat.id,
|
||||||
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@@ -108,9 +115,13 @@ async def start_yookassa_sbp_payment(
|
|||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
await state.set_state(BalanceStates.waiting_for_amount)
|
await state.set_state(BalanceStates.waiting_for_amount)
|
||||||
await state.update_data(payment_method="yookassa_sbp")
|
await state.update_data(payment_method="yookassa_sbp")
|
||||||
|
await state.update_data(
|
||||||
|
yookassa_prompt_message_id=callback.message.message_id,
|
||||||
|
yookassa_prompt_chat_id=callback.message.chat.id,
|
||||||
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@@ -172,7 +183,25 @@ async def process_yookassa_payment_amount(
|
|||||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||||
])
|
])
|
||||||
|
|
||||||
await message.answer(
|
state_data = await state.get_data()
|
||||||
|
prompt_message_id = state_data.get("yookassa_prompt_message_id")
|
||||||
|
prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception as delete_error: # pragma: no cover - зависит от прав бота
|
||||||
|
logger.warning("Не удалось удалить сообщение с суммой YooKassa: %s", delete_error)
|
||||||
|
|
||||||
|
if prompt_message_id:
|
||||||
|
try:
|
||||||
|
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - диагностический лог
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение с запросом суммы YooKassa: %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice_message = await message.answer(
|
||||||
f"💳 <b>Оплата банковской картой</b>\n\n"
|
f"💳 <b>Оплата банковской картой</b>\n\n"
|
||||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||||
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
|
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
|
||||||
@@ -187,9 +216,34 @@ async def process_yookassa_payment_amount(
|
|||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services import payment_service as payment_module
|
||||||
|
|
||||||
|
payment = await payment_module.get_yookassa_payment_by_local_id(
|
||||||
|
db, payment_result["local_payment_id"]
|
||||||
|
)
|
||||||
|
if payment:
|
||||||
|
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||||
|
metadata["invoice_message"] = {
|
||||||
|
"chat_id": invoice_message.chat.id,
|
||||||
|
"message_id": invoice_message.message_id,
|
||||||
|
}
|
||||||
|
await db.execute(
|
||||||
|
update(payment.__class__)
|
||||||
|
.where(payment.__class__.id == payment.id)
|
||||||
|
.values(metadata_json=metadata, updated_at=datetime.utcnow())
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as error: # pragma: no cover - диагностический лог
|
||||||
|
logger.warning("Не удалось сохранить сообщение YooKassa: %s", error)
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
yookassa_invoice_message_id=invoice_message.message_id,
|
||||||
|
yookassa_invoice_chat_id=invoice_message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: "
|
logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: "
|
||||||
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
|
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
|
||||||
|
|
||||||
@@ -310,27 +364,45 @@ async def process_yookassa_sbp_payment_amount(
|
|||||||
|
|
||||||
# Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса
|
# Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса
|
||||||
keyboard_buttons = []
|
keyboard_buttons = []
|
||||||
|
|
||||||
# Добавляем кнопку оплаты, если доступна ссылка
|
# Добавляем кнопку оплаты, если доступна ссылка
|
||||||
if confirmation_url:
|
if confirmation_url:
|
||||||
keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)])
|
keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)])
|
||||||
else:
|
else:
|
||||||
# Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка
|
# Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка
|
||||||
keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")])
|
keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")])
|
||||||
|
|
||||||
# Добавляем общие кнопки
|
# Добавляем общие кнопки
|
||||||
keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")])
|
keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")])
|
||||||
keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")])
|
keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")])
|
||||||
|
|
||||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
|
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
|
||||||
|
|
||||||
|
state_data = await state.get_data()
|
||||||
|
prompt_message_id = state_data.get("yookassa_prompt_message_id")
|
||||||
|
prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception as delete_error: # pragma: no cover - зависит от прав бота
|
||||||
|
logger.warning("Не удалось удалить сообщение с суммой YooKassa (СБП): %s", delete_error)
|
||||||
|
|
||||||
|
if prompt_message_id:
|
||||||
|
try:
|
||||||
|
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - диагностический лог
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение с запросом суммы YooKassa (СБП): %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
# Подготавливаем текст сообщения
|
# Подготавливаем текст сообщения
|
||||||
message_text = (
|
message_text = (
|
||||||
f"🔗 <b>Оплата через СБП</b>\n\n"
|
f"🔗 <b>Оплата через СБП</b>\n\n"
|
||||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||||
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
|
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Добавляем инструкции в зависимости от доступных способов оплаты
|
# Добавляем инструкции в зависимости от доступных способов оплаты
|
||||||
if not confirmation_url:
|
if not confirmation_url:
|
||||||
message_text += (
|
message_text += (
|
||||||
@@ -341,18 +413,18 @@ async def process_yookassa_sbp_payment_amount(
|
|||||||
f"4. Подтвердите платеж в приложении банка\n"
|
f"4. Подтвердите платеж в приложении банка\n"
|
||||||
f"5. Деньги поступят на баланс автоматически\n\n"
|
f"5. Деньги поступят на баланс автоматически\n\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
message_text += (
|
message_text += (
|
||||||
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
|
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
|
||||||
f"✅ Принимаем СБП от всех банков-участников\n\n"
|
f"✅ Принимаем СБП от всех банков-участников\n\n"
|
||||||
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}"
|
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Отправляем сообщение с инструкциями и клавиатурой
|
# Отправляем сообщение с инструкциями и клавиатурой
|
||||||
# Если есть QR-код, отправляем его как медиа-сообщение
|
# Если есть QR-код, отправляем его как медиа-сообщение
|
||||||
if qr_photo:
|
if qr_photo:
|
||||||
# Используем метод отправки медиа-группы или фото с описанием
|
# Используем метод отправки медиа-группы или фото с описанием
|
||||||
await message.answer_photo(
|
invoice_message = await message.answer_photo(
|
||||||
photo=qr_photo,
|
photo=qr_photo,
|
||||||
caption=message_text,
|
caption=message_text,
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
@@ -360,12 +432,39 @@ async def process_yookassa_sbp_payment_amount(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Если QR-код недоступен, отправляем обычное текстовое сообщение
|
# Если QR-код недоступен, отправляем обычное текстовое сообщение
|
||||||
await message.answer(
|
invoice_message = await message.answer(
|
||||||
message_text,
|
message_text,
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services import payment_service as payment_module
|
||||||
|
|
||||||
|
payment = await payment_module.get_yookassa_payment_by_local_id(
|
||||||
|
db, payment_result["local_payment_id"]
|
||||||
|
)
|
||||||
|
if payment:
|
||||||
|
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||||
|
metadata["invoice_message"] = {
|
||||||
|
"chat_id": invoice_message.chat.id,
|
||||||
|
"message_id": invoice_message.message_id,
|
||||||
|
}
|
||||||
|
await db.execute(
|
||||||
|
update(payment.__class__)
|
||||||
|
.where(payment.__class__.id == payment.id)
|
||||||
|
.values(metadata_json=metadata, updated_at=datetime.utcnow())
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as error: # pragma: no cover - диагностический лог
|
||||||
|
logger.warning("Не удалось сохранить сообщение YooKassa (СБП): %s", error)
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
yookassa_invoice_message_id=invoice_message.message_id,
|
||||||
|
yookassa_invoice_chat_id=invoice_message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: "
|
logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: "
|
||||||
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
|
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
|
||||||
|
|
||||||
|
|||||||
@@ -972,7 +972,13 @@ def _get_subscription_status(user: User, texts) -> str:
|
|||||||
"🔴 Истекла\n📅 {end_date}",
|
"🔴 Истекла\n📅 {end_date}",
|
||||||
).format(end_date=end_date_text or "—")
|
).format(end_date=end_date_text or "—")
|
||||||
|
|
||||||
if actual_status == "trial":
|
is_trial_subscription = getattr(subscription, "is_trial", False)
|
||||||
|
|
||||||
|
is_trial_like_status = actual_status == "trial" or (
|
||||||
|
is_trial_subscription and actual_status in {"active", "trial"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_trial_like_status:
|
||||||
if days_left > 1 and end_date_text:
|
if days_left > 1 and end_date_text:
|
||||||
return texts.t(
|
return texts.t(
|
||||||
"SUB_STATUS_TRIAL_ACTIVE",
|
"SUB_STATUS_TRIAL_ACTIVE",
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ async def activate_promocode_for_registration(
|
|||||||
user,
|
user,
|
||||||
result.get("promocode", {"code": code}),
|
result.get("promocode", {"code": code}),
|
||||||
result["description"],
|
result["description"],
|
||||||
|
result.get("balance_before_kopeks"),
|
||||||
|
result.get("balance_after_kopeks"),
|
||||||
)
|
)
|
||||||
except Exception as notify_error:
|
except Exception as notify_error:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.localization.texts import get_texts
|
|||||||
from app.utils.photo_message import edit_or_answer_photo
|
from app.utils.photo_message import edit_or_answer_photo
|
||||||
from app.utils.user_utils import (
|
from app.utils.user_utils import (
|
||||||
get_detailed_referral_list,
|
get_detailed_referral_list,
|
||||||
|
get_effective_referral_commission_percent,
|
||||||
get_referral_analytics,
|
get_referral_analytics,
|
||||||
get_user_referral_summary,
|
get_user_referral_summary,
|
||||||
)
|
)
|
||||||
@@ -86,7 +87,7 @@ async def show_referral_info(
|
|||||||
+ texts.t(
|
+ texts.t(
|
||||||
"REFERRAL_REWARD_COMMISSION",
|
"REFERRAL_REWARD_COMMISSION",
|
||||||
"• Комиссия с каждого пополнения реферала: <b>{percent}%</b>",
|
"• Комиссия с каждого пополнения реферала: <b>{percent}%</b>",
|
||||||
).format(percent=settings.REFERRAL_COMMISSION_PERCENT)
|
).format(percent=get_effective_referral_commission_percent(db_user))
|
||||||
+ "\n\n"
|
+ "\n\n"
|
||||||
+ texts.t("REFERRAL_LINK_TITLE", "🔗 <b>Ваша реферальная ссылка:</b>")
|
+ texts.t("REFERRAL_LINK_TITLE", "🔗 <b>Ваша реферальная ссылка:</b>")
|
||||||
+ f"\n<code>{referral_link}</code>\n\n"
|
+ f"\n<code>{referral_link}</code>\n\n"
|
||||||
|
|||||||
@@ -134,8 +134,11 @@ async def start_simple_subscription_purchase(
|
|||||||
if show_devices:
|
if show_devices:
|
||||||
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
||||||
|
|
||||||
|
traffic_limit_gb = subscription_params["traffic_limit_gb"]
|
||||||
|
traffic_label = "Безлимит" if traffic_limit_gb == 0 else f"{traffic_limit_gb} ГБ"
|
||||||
|
|
||||||
message_lines.extend([
|
message_lines.extend([
|
||||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
|
f"📊 Трафик: {traffic_label}",
|
||||||
f"🌍 Сервер: {server_label}",
|
f"🌍 Сервер: {server_label}",
|
||||||
"",
|
"",
|
||||||
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
|
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
|
||||||
@@ -523,8 +526,11 @@ async def handle_simple_subscription_pay_with_balance(
|
|||||||
if show_devices:
|
if show_devices:
|
||||||
success_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
success_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
||||||
|
|
||||||
|
success_traffic_gb = subscription_params["traffic_limit_gb"]
|
||||||
|
success_traffic_label = "Безлимит" if success_traffic_gb == 0 else f"{success_traffic_gb} ГБ"
|
||||||
|
|
||||||
success_lines.extend([
|
success_lines.extend([
|
||||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
|
f"📊 Трафик: {success_traffic_label}",
|
||||||
f"🌍 Сервер: {server_label}",
|
f"🌍 Сервер: {server_label}",
|
||||||
"",
|
"",
|
||||||
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}",
|
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}",
|
||||||
@@ -721,8 +727,11 @@ async def handle_simple_subscription_other_payment_methods(
|
|||||||
if show_devices:
|
if show_devices:
|
||||||
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
||||||
|
|
||||||
|
payment_traffic_gb = subscription_params["traffic_limit_gb"]
|
||||||
|
payment_traffic_label = "Безлимит" if payment_traffic_gb == 0 else f"{payment_traffic_gb} ГБ"
|
||||||
|
|
||||||
message_lines.extend([
|
message_lines.extend([
|
||||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
|
f"📊 Трафик: {payment_traffic_label}",
|
||||||
f"🌍 Сервер: {server_label}",
|
f"🌍 Сервер: {server_label}",
|
||||||
"",
|
"",
|
||||||
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
|
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
|
||||||
@@ -826,6 +835,9 @@ async def handle_simple_subscription_payment_method(
|
|||||||
|
|
||||||
stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(price_kopeks))
|
stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(price_kopeks))
|
||||||
|
|
||||||
|
stars_traffic_gb = subscription_params["traffic_limit_gb"]
|
||||||
|
stars_traffic_label = "Безлимит" if stars_traffic_gb == 0 else f"{stars_traffic_gb} ГБ"
|
||||||
|
|
||||||
await callback.bot.send_invoice(
|
await callback.bot.send_invoice(
|
||||||
chat_id=callback.from_user.id,
|
chat_id=callback.from_user.id,
|
||||||
title=f"Подписка на {subscription_params['period_days']} дней",
|
title=f"Подписка на {subscription_params['period_days']} дней",
|
||||||
@@ -833,7 +845,7 @@ async def handle_simple_subscription_payment_method(
|
|||||||
f"Простая покупка подписки\n"
|
f"Простая покупка подписки\n"
|
||||||
f"Период: {subscription_params['period_days']} дней\n"
|
f"Период: {subscription_params['period_days']} дней\n"
|
||||||
f"Устройства: {subscription_params['device_limit']}\n"
|
f"Устройства: {subscription_params['device_limit']}\n"
|
||||||
f"Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}"
|
f"Трафик: {stars_traffic_label}"
|
||||||
),
|
),
|
||||||
payload=(
|
payload=(
|
||||||
f"simple_sub_{db_user.id}_{order.id}_{subscription_params['period_days']}"
|
f"simple_sub_{db_user.id}_{order.id}_{subscription_params['period_days']}"
|
||||||
@@ -977,8 +989,11 @@ async def handle_simple_subscription_payment_method(
|
|||||||
if show_devices:
|
if show_devices:
|
||||||
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
||||||
|
|
||||||
|
yookassa_traffic_gb = subscription_params["traffic_limit_gb"]
|
||||||
|
yookassa_traffic_label = "Безлимит" if yookassa_traffic_gb == 0 else f"{yookassa_traffic_gb} ГБ"
|
||||||
|
|
||||||
message_lines.extend([
|
message_lines.extend([
|
||||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
|
f"📊 Трафик: {yookassa_traffic_label}",
|
||||||
f"💰 Сумма: {settings.format_price(price_kopeks)}",
|
f"💰 Сумма: {settings.format_price(price_kopeks)}",
|
||||||
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...",
|
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...",
|
||||||
"",
|
"",
|
||||||
@@ -2220,8 +2235,11 @@ async def confirm_simple_subscription_purchase(
|
|||||||
if show_devices:
|
if show_devices:
|
||||||
success_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
success_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
|
||||||
|
|
||||||
|
success_traffic_gb = subscription_params["traffic_limit_gb"]
|
||||||
|
success_traffic_label = "Безлимит" if success_traffic_gb == 0 else f"{success_traffic_gb} ГБ"
|
||||||
|
|
||||||
success_lines.extend([
|
success_lines.extend([
|
||||||
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
|
f"📊 Трафик: {success_traffic_label}",
|
||||||
f"🌍 Сервер: {server_label}",
|
f"🌍 Сервер: {server_label}",
|
||||||
"",
|
"",
|
||||||
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}",
|
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}",
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
from decimal import Decimal, ROUND_HALF_UP
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
from aiogram import Dispatcher, types, F
|
from aiogram import Dispatcher, types, F
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database.models import User
|
|
||||||
from app.services.payment_service import PaymentService
|
from app.services.payment_service import PaymentService
|
||||||
from app.external.telegram_stars import TelegramStarsService
|
from app.external.telegram_stars import TelegramStarsService
|
||||||
from app.database.crud.user import get_user_by_telegram_id
|
from app.database.crud.user import get_user_by_telegram_id
|
||||||
@@ -18,7 +18,9 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
|||||||
texts = get_texts(DEFAULT_LANGUAGE)
|
texts = get_texts(DEFAULT_LANGUAGE)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
|
logger.info(
|
||||||
|
f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}"
|
||||||
|
)
|
||||||
|
|
||||||
allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_")
|
allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_")
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from app.database.database import get_db
|
from app.database.database import get_db
|
||||||
|
|
||||||
async for db in get_db():
|
async for db in get_db():
|
||||||
user = await get_user_by_telegram_id(db, query.from_user.id)
|
user = await get_user_by_telegram_id(db, query.from_user.id)
|
||||||
if not user:
|
if not user:
|
||||||
@@ -77,6 +80,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
|||||||
async def handle_successful_payment(
|
async def handle_successful_payment(
|
||||||
message: types.Message,
|
message: types.Message,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
|
state: FSMContext,
|
||||||
**kwargs
|
**kwargs
|
||||||
):
|
):
|
||||||
texts = get_texts(DEFAULT_LANGUAGE)
|
texts = get_texts(DEFAULT_LANGUAGE)
|
||||||
@@ -106,6 +110,27 @@ async def handle_successful_payment(
|
|||||||
return
|
return
|
||||||
|
|
||||||
payment_service = PaymentService(message.bot)
|
payment_service = PaymentService(message.bot)
|
||||||
|
|
||||||
|
state_data = await state.get_data()
|
||||||
|
prompt_message_id = state_data.get("stars_prompt_message_id")
|
||||||
|
prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id)
|
||||||
|
invoice_message_id = state_data.get("stars_invoice_message_id")
|
||||||
|
invoice_chat_id = state_data.get("stars_invoice_chat_id", message.chat.id)
|
||||||
|
|
||||||
|
for chat_id, message_id, label in [
|
||||||
|
(prompt_chat_id, prompt_message_id, "запрос суммы"),
|
||||||
|
(invoice_chat_id, invoice_message_id, "инвойс Stars"),
|
||||||
|
]:
|
||||||
|
if message_id:
|
||||||
|
try:
|
||||||
|
await message.bot.delete_message(chat_id, message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - зависит от прав бота
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение %s после оплаты Stars: %s",
|
||||||
|
label,
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
success = await payment_service.process_stars_payment(
|
success = await payment_service.process_stars_payment(
|
||||||
db=db,
|
db=db,
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
@@ -113,7 +138,14 @@ async def handle_successful_payment(
|
|||||||
payload=payment.invoice_payload,
|
payload=payment.invoice_payload,
|
||||||
telegram_payment_charge_id=payment.telegram_payment_charge_id
|
telegram_payment_charge_id=payment.telegram_payment_charge_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
stars_prompt_message_id=None,
|
||||||
|
stars_prompt_chat_id=None,
|
||||||
|
stars_invoice_message_id=None,
|
||||||
|
stars_invoice_chat_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
|
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
|
||||||
amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
|
amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
|
||||||
@@ -172,15 +204,15 @@ async def handle_successful_payment(
|
|||||||
|
|
||||||
|
|
||||||
def register_stars_handlers(dp: Dispatcher):
|
def register_stars_handlers(dp: Dispatcher):
|
||||||
|
|
||||||
dp.pre_checkout_query.register(
|
dp.pre_checkout_query.register(
|
||||||
handle_pre_checkout_query,
|
handle_pre_checkout_query,
|
||||||
F.currency == "XTR"
|
F.currency == "XTR"
|
||||||
)
|
)
|
||||||
|
|
||||||
dp.message.register(
|
dp.message.register(
|
||||||
handle_successful_payment,
|
handle_successful_payment,
|
||||||
F.successful_payment
|
F.successful_payment
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")
|
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")
|
||||||
|
|||||||
+244
-50
@@ -20,18 +20,20 @@ from app.database.crud.campaign import (
|
|||||||
from app.database.models import UserStatus, SubscriptionStatus
|
from app.database.models import UserStatus, SubscriptionStatus
|
||||||
from app.keyboards.inline import (
|
from app.keyboards.inline import (
|
||||||
get_rules_keyboard,
|
get_rules_keyboard,
|
||||||
|
get_privacy_policy_keyboard,
|
||||||
get_main_menu_keyboard,
|
get_main_menu_keyboard,
|
||||||
get_post_registration_keyboard,
|
get_post_registration_keyboard,
|
||||||
get_language_selection_keyboard,
|
get_language_selection_keyboard,
|
||||||
)
|
)
|
||||||
from app.localization.loader import DEFAULT_LANGUAGE
|
from app.localization.loader import DEFAULT_LANGUAGE
|
||||||
from app.localization.texts import get_texts, get_rules
|
from app.localization.texts import get_texts, get_rules, get_privacy_policy
|
||||||
from app.services.referral_service import process_referral_registration
|
from app.services.referral_service import process_referral_registration
|
||||||
from app.services.campaign_service import AdvertisingCampaignService
|
from app.services.campaign_service import AdvertisingCampaignService
|
||||||
from app.services.admin_notification_service import AdminNotificationService
|
from app.services.admin_notification_service import AdminNotificationService
|
||||||
from app.services.subscription_service import SubscriptionService
|
from app.services.subscription_service import SubscriptionService
|
||||||
from app.services.support_settings_service import SupportSettingsService
|
from app.services.support_settings_service import SupportSettingsService
|
||||||
from app.services.main_menu_button_service import MainMenuButtonService
|
from app.services.main_menu_button_service import MainMenuButtonService
|
||||||
|
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||||
from app.utils.user_utils import generate_unique_referral_code
|
from app.utils.user_utils import generate_unique_referral_code
|
||||||
from app.utils.promo_offer import (
|
from app.utils.promo_offer import (
|
||||||
build_promo_offer_hint,
|
build_promo_offer_hint,
|
||||||
@@ -104,6 +106,7 @@ async def handle_potential_referral_code(
|
|||||||
|
|
||||||
if current_state not in [
|
if current_state not in [
|
||||||
RegistrationStates.waiting_for_rules_accept.state,
|
RegistrationStates.waiting_for_rules_accept.state,
|
||||||
|
RegistrationStates.waiting_for_privacy_policy_accept.state,
|
||||||
RegistrationStates.waiting_for_referral_code.state,
|
RegistrationStates.waiting_for_referral_code.state,
|
||||||
None
|
None
|
||||||
]:
|
]:
|
||||||
@@ -611,12 +614,105 @@ async def process_language_selection(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _show_privacy_policy_after_rules(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
state: FSMContext,
|
||||||
|
db: AsyncSession,
|
||||||
|
language: str,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Показывает политику конфиденциальности после принятия правил.
|
||||||
|
Возвращает True, если политика была показана, False если её нет или произошла ошибка.
|
||||||
|
"""
|
||||||
|
policy = await PrivacyPolicyService.get_policy(db, language, fallback=True)
|
||||||
|
|
||||||
|
if not policy or not policy.is_enabled:
|
||||||
|
logger.info("⚠️ Политика конфиденциальности не включена, пропускаем её показ")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not policy.content or not policy.content.strip():
|
||||||
|
privacy_policy_text = get_privacy_policy(language)
|
||||||
|
if not privacy_policy_text or not privacy_policy_text.strip():
|
||||||
|
logger.info("⚠️ Политика конфиденциальности включена, но дефолтный текст пустой, пропускаем показ")
|
||||||
|
return False
|
||||||
|
logger.info(f"🔒 Используется дефолтный текст политики конфиденциальности из локализации для языка {language}")
|
||||||
|
else:
|
||||||
|
privacy_policy_text = policy.content
|
||||||
|
logger.info(f"🔒 Используется политика конфиденциальности из БД для языка {language}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
privacy_policy_text,
|
||||||
|
reply_markup=get_privacy_policy_keyboard(language)
|
||||||
|
)
|
||||||
|
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
|
||||||
|
logger.info(f"🔒 Политика конфиденциальности отправлена пользователю {callback.from_user.id}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при показе политики конфиденциальности: {e}", exc_info=True)
|
||||||
|
try:
|
||||||
|
await callback.message.answer(
|
||||||
|
privacy_policy_text,
|
||||||
|
reply_markup=get_privacy_policy_keyboard(language)
|
||||||
|
)
|
||||||
|
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
|
||||||
|
logger.info(f"🔒 Политика конфиденциальности отправлена новым сообщением пользователю {callback.from_user.id}")
|
||||||
|
return True
|
||||||
|
except Exception as e2:
|
||||||
|
logger.error(f"Критическая ошибка при отправке политики конфиденциальности: {e2}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _continue_registration_after_rules(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
state: FSMContext,
|
||||||
|
db: AsyncSession,
|
||||||
|
language: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Продолжает регистрацию после принятия правил (реферальный код или завершение).
|
||||||
|
"""
|
||||||
|
data = await state.get_data() or {}
|
||||||
|
texts = get_texts(language)
|
||||||
|
|
||||||
|
if data.get('referral_code'):
|
||||||
|
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
|
||||||
|
|
||||||
|
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||||
|
if referrer:
|
||||||
|
data['referrer_id'] = referrer.id
|
||||||
|
await state.set_data(data)
|
||||||
|
logger.info(f"✅ Реферер найден: {referrer.id}")
|
||||||
|
|
||||||
|
await complete_registration_from_callback(callback, state, db)
|
||||||
|
else:
|
||||||
|
if settings.SKIP_REFERRAL_CODE:
|
||||||
|
logger.info("⚙️ SKIP_REFERRAL_CODE включен - пропускаем запрос реферального кода")
|
||||||
|
await complete_registration_from_callback(callback, state, db)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
texts.t(
|
||||||
|
"REFERRAL_CODE_QUESTION",
|
||||||
|
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||||
|
),
|
||||||
|
reply_markup=get_referral_code_keyboard(language)
|
||||||
|
)
|
||||||
|
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||||
|
logger.info(f"🔍 Ожидание ввода реферального кода")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
|
||||||
|
await complete_registration_from_callback(callback, state, db)
|
||||||
|
|
||||||
|
|
||||||
async def process_rules_accept(
|
async def process_rules_accept(
|
||||||
callback: types.CallbackQuery,
|
callback: types.CallbackQuery,
|
||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
db: AsyncSession
|
db: AsyncSession
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Обрабатывает принятие или отклонение правил пользователем.
|
||||||
|
"""
|
||||||
logger.info(f"📋 RULES: Начало обработки правил")
|
logger.info(f"📋 RULES: Начало обработки правил")
|
||||||
logger.info(f"📊 Callback data: {callback.data}")
|
logger.info(f"📊 Callback data: {callback.data}")
|
||||||
logger.info(f"👤 User: {callback.from_user.id}")
|
logger.info(f"👤 User: {callback.from_user.id}")
|
||||||
@@ -637,50 +733,16 @@ async def process_rules_accept(
|
|||||||
if callback.data == 'rules_accept':
|
if callback.data == 'rules_accept':
|
||||||
logger.info(f"✅ Правила приняты пользователем {callback.from_user.id}")
|
logger.info(f"✅ Правила приняты пользователем {callback.from_user.id}")
|
||||||
|
|
||||||
try:
|
# Пытаемся показать политику конфиденциальности
|
||||||
await callback.message.delete()
|
policy_shown = await _show_privacy_policy_after_rules(
|
||||||
logger.info(f"🗑️ Сообщение с правилами удалено")
|
callback, state, db, language
|
||||||
except Exception as e:
|
)
|
||||||
logger.warning(f"⚠️ Не удалось удалить сообщение с правилами: {e}")
|
|
||||||
try:
|
|
||||||
await callback.message.edit_text(
|
|
||||||
texts.t(
|
|
||||||
"RULES_ACCEPTED_PROCESSING",
|
|
||||||
"✅ Правила приняты! Завершаем регистрацию...",
|
|
||||||
),
|
|
||||||
reply_markup=None
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if data.get('referral_code'):
|
# Если политика не была показана, продолжаем регистрацию
|
||||||
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
|
if not policy_shown:
|
||||||
|
await _continue_registration_after_rules(
|
||||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
callback, state, db, language
|
||||||
if referrer:
|
)
|
||||||
data['referrer_id'] = referrer.id
|
|
||||||
await state.set_data(data)
|
|
||||||
logger.info(f"✅ Реферер найден: {referrer.id}")
|
|
||||||
|
|
||||||
await complete_registration_from_callback(callback, state, db)
|
|
||||||
else:
|
|
||||||
if settings.SKIP_REFERRAL_CODE:
|
|
||||||
logger.info("⚙️ SKIP_REFERRAL_CODE включен - пропускаем запрос реферального кода")
|
|
||||||
await complete_registration_from_callback(callback, state, db)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
await callback.message.answer(
|
|
||||||
texts.t(
|
|
||||||
"REFERRAL_CODE_QUESTION",
|
|
||||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
|
||||||
),
|
|
||||||
reply_markup=get_referral_code_keyboard(language)
|
|
||||||
)
|
|
||||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
|
||||||
logger.info(f"🔍 Ожидание ввода реферального кода")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
|
|
||||||
await complete_registration_from_callback(callback, state, db)
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.info(f"❌ Правила отклонены пользователем {callback.from_user.id}")
|
logger.info(f"❌ Правила отклонены пользователем {callback.from_user.id}")
|
||||||
@@ -697,10 +759,13 @@ async def process_rules_accept(
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при показе сообщения об отклонении правил: {e}")
|
logger.error(f"Ошибка при показе сообщения об отклонении правил: {e}")
|
||||||
await callback.message.edit_text(
|
try:
|
||||||
rules_required_text,
|
await callback.message.edit_text(
|
||||||
reply_markup=get_rules_keyboard(language)
|
rules_required_text,
|
||||||
)
|
reply_markup=get_rules_keyboard(language)
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
logger.info(f"✅ Правила обработаны для пользователя {callback.from_user.id}")
|
logger.info(f"✅ Правила обработаны для пользователя {callback.from_user.id}")
|
||||||
|
|
||||||
@@ -727,6 +792,128 @@ async def process_rules_accept(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def process_privacy_policy_accept(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
state: FSMContext,
|
||||||
|
db: AsyncSession
|
||||||
|
):
|
||||||
|
|
||||||
|
logger.info(f"🔒 PRIVACY POLICY: Начало обработки политики конфиденциальности")
|
||||||
|
logger.info(f"📊 Callback data: {callback.data}")
|
||||||
|
logger.info(f"👤 User: {callback.from_user.id}")
|
||||||
|
|
||||||
|
current_state = await state.get_state()
|
||||||
|
logger.info(f"📊 Текущее состояние: {current_state}")
|
||||||
|
|
||||||
|
language = DEFAULT_LANGUAGE
|
||||||
|
texts = get_texts(language)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
data = await state.get_data() or {}
|
||||||
|
language = data.get('language', language)
|
||||||
|
texts = get_texts(language)
|
||||||
|
|
||||||
|
if callback.data == 'privacy_policy_accept':
|
||||||
|
logger.info(f"✅ Политика конфиденциальности принята пользователем {callback.from_user.id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.message.delete()
|
||||||
|
logger.info(f"🗑️ Сообщение с политикой конфиденциальности удалено")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠️ Не удалось удалить сообщение с политикой конфиденциальности: {e}")
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
texts.t(
|
||||||
|
"PRIVACY_POLICY_ACCEPTED_PROCESSING",
|
||||||
|
"✅ Политика конфиденциальности принята! Продолжаем регистрацию...",
|
||||||
|
),
|
||||||
|
reply_markup=None
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if data.get('referral_code'):
|
||||||
|
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
|
||||||
|
|
||||||
|
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||||
|
if referrer:
|
||||||
|
data['referrer_id'] = referrer.id
|
||||||
|
await state.set_data(data)
|
||||||
|
logger.info(f"✅ Реферер найден: {referrer.id}")
|
||||||
|
|
||||||
|
await complete_registration_from_callback(callback, state, db)
|
||||||
|
else:
|
||||||
|
if settings.SKIP_REFERRAL_CODE:
|
||||||
|
logger.info("⚙️ SKIP_REFERRAL_CODE включен - пропускаем запрос реферального кода")
|
||||||
|
await complete_registration_from_callback(callback, state, db)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await state.set_data(data)
|
||||||
|
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||||
|
|
||||||
|
await callback.bot.send_message(
|
||||||
|
chat_id=callback.from_user.id,
|
||||||
|
text=texts.t(
|
||||||
|
"REFERRAL_CODE_QUESTION",
|
||||||
|
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||||
|
),
|
||||||
|
reply_markup=get_referral_code_keyboard(language)
|
||||||
|
)
|
||||||
|
logger.info(f"🔍 Ожидание ввода реферального кода")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
|
||||||
|
await complete_registration_from_callback(callback, state, db)
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.info(f"❌ Политика конфиденциальности отклонена пользователем {callback.from_user.id}")
|
||||||
|
|
||||||
|
privacy_policy_required_text = texts.t(
|
||||||
|
"PRIVACY_POLICY_REQUIRED",
|
||||||
|
"Для использования бота необходимо принять политику конфиденциальности.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
privacy_policy_required_text,
|
||||||
|
reply_markup=get_privacy_policy_keyboard(language)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при показе сообщения об отклонении политики конфиденциальности: {e}")
|
||||||
|
try:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
privacy_policy_required_text,
|
||||||
|
reply_markup=get_privacy_policy_keyboard(language)
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info(f"✅ Политика конфиденциальности обработана для пользователя {callback.from_user.id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка обработки политики конфиденциальности: {e}", exc_info=True)
|
||||||
|
await callback.answer(
|
||||||
|
texts.t("ERROR_TRY_AGAIN", "❌ Произошла ошибка. Попробуйте еще раз."),
|
||||||
|
show_alert=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await state.get_data() or {}
|
||||||
|
language = data.get('language', language)
|
||||||
|
texts = get_texts(language)
|
||||||
|
await callback.message.answer(
|
||||||
|
texts.t(
|
||||||
|
"ERROR_PRIVACY_POLICY_RETRY",
|
||||||
|
"Произошла ошибка. Попробуйте принять политику конфиденциальности еще раз:",
|
||||||
|
),
|
||||||
|
reply_markup=get_privacy_policy_keyboard(language)
|
||||||
|
)
|
||||||
|
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def process_referral_code_input(
|
async def process_referral_code_input(
|
||||||
message: types.Message,
|
message: types.Message,
|
||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
@@ -1751,7 +1938,14 @@ def register_handlers(dp: Dispatcher):
|
|||||||
StateFilter(RegistrationStates.waiting_for_rules_accept)
|
StateFilter(RegistrationStates.waiting_for_rules_accept)
|
||||||
)
|
)
|
||||||
logger.info("✅ Зарегистрирован process_rules_accept")
|
logger.info("✅ Зарегистрирован process_rules_accept")
|
||||||
|
|
||||||
|
dp.callback_query.register(
|
||||||
|
process_privacy_policy_accept,
|
||||||
|
F.data.in_(["privacy_policy_accept", "privacy_policy_decline"]),
|
||||||
|
StateFilter(RegistrationStates.waiting_for_privacy_policy_accept)
|
||||||
|
)
|
||||||
|
logger.info("✅ Зарегистрирован process_privacy_policy_accept")
|
||||||
|
|
||||||
dp.callback_query.register(
|
dp.callback_query.register(
|
||||||
process_language_selection,
|
process_language_selection,
|
||||||
F.data.startswith("language_select:"),
|
F.data.startswith("language_select:"),
|
||||||
|
|||||||
@@ -1767,13 +1767,25 @@ async def select_devices(
|
|||||||
|
|
||||||
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||||
|
|
||||||
|
previous_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||||
|
|
||||||
data['devices'] = devices
|
data['devices'] = devices
|
||||||
data['total_price'] = base_price + countries_price + devices_price
|
data['total_price'] = base_price + countries_price + devices_price
|
||||||
await state.set_data(data)
|
await state.set_data(data)
|
||||||
|
|
||||||
await callback.message.edit_reply_markup(
|
if devices != previous_devices:
|
||||||
reply_markup=get_devices_keyboard(devices, db_user.language)
|
try:
|
||||||
)
|
await callback.message.edit_reply_markup(
|
||||||
|
reply_markup=get_devices_keyboard(devices, db_user.language)
|
||||||
|
)
|
||||||
|
except TelegramBadRequest as error:
|
||||||
|
if "message is not modified" in str(error).lower():
|
||||||
|
logger.debug(
|
||||||
|
"ℹ️ Пропускаем обновление клавиатуры устройств: содержимое не изменилось"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
async def devices_continue(
|
async def devices_continue(
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_t(texts, "ADMIN_MAIN_TRIALS", "🧪 Триалы"),
|
||||||
|
callback_data="admin_trials",
|
||||||
|
),
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=_t(texts, "ADMIN_MAIN_PAYMENTS", "💳 Пополнения"),
|
text=_t(texts, "ADMIN_MAIN_PAYMENTS", "💳 Пополнения"),
|
||||||
callback_data="admin_payments",
|
callback_data="admin_payments",
|
||||||
@@ -241,6 +245,20 @@ def get_admin_system_submenu_keyboard(language: str = "ru") -> InlineKeyboardMar
|
|||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_trials_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||||
|
texts = get_texts(language)
|
||||||
|
|
||||||
|
return InlineKeyboardMarkup(inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_t(texts, "ADMIN_TRIALS_RESET_BUTTON", "♻️ Сбросить все триалы"),
|
||||||
|
callback_data="admin_trials_reset",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")],
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
def get_admin_reports_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
def get_admin_reports_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||||
texts = get_texts(language)
|
texts = get_texts(language)
|
||||||
|
|
||||||
@@ -1100,6 +1118,12 @@ def get_sync_options_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
|||||||
callback_data="sync_all_users"
|
callback_data="sync_all_users"
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_t(texts, "ADMIN_SYNC_TO_PANEL", "⬆️ Синхронизация в панель"),
|
||||||
|
callback_data="sync_to_panel"
|
||||||
|
)
|
||||||
|
],
|
||||||
[
|
[
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=_t(texts, "ADMIN_SYNC_ONLY_NEW", "🆕 Только новые"),
|
text=_t(texts, "ADMIN_SYNC_ONLY_NEW", "🆕 Только новые"),
|
||||||
|
|||||||
+92
-17
@@ -82,7 +82,40 @@ def _build_additional_buttons(additional_section, language: str) -> List[InlineK
|
|||||||
|
|
||||||
_LANGUAGE_DISPLAY_NAMES = {
|
_LANGUAGE_DISPLAY_NAMES = {
|
||||||
"ru": "🇷🇺 Русский",
|
"ru": "🇷🇺 Русский",
|
||||||
|
"ru-ru": "🇷🇺 Русский",
|
||||||
"en": "🇬🇧 English",
|
"en": "🇬🇧 English",
|
||||||
|
"en-us": "🇺🇸 English",
|
||||||
|
"en-gb": "🇬🇧 English",
|
||||||
|
"ua": "🇺🇦 Українська",
|
||||||
|
"uk": "🇺🇦 Українська",
|
||||||
|
"uk-ua": "🇺🇦 Українська",
|
||||||
|
"kk": "🇰🇿 Қазақша",
|
||||||
|
"kk-kz": "🇰🇿 Қазақша",
|
||||||
|
"kz": "🇰🇿 Қазақша",
|
||||||
|
"uz": "🇺🇿 Oʻzbekcha",
|
||||||
|
"uz-uz": "🇺🇿 Oʻzbekcha",
|
||||||
|
"tr": "🇹🇷 Türkçe",
|
||||||
|
"tr-tr": "🇹🇷 Türkçe",
|
||||||
|
"pl": "🇵🇱 Polski",
|
||||||
|
"pl-pl": "🇵🇱 Polski",
|
||||||
|
"de": "🇩🇪 Deutsch",
|
||||||
|
"de-de": "🇩🇪 Deutsch",
|
||||||
|
"fr": "🇫🇷 Français",
|
||||||
|
"fr-fr": "🇫🇷 Français",
|
||||||
|
"es": "🇪🇸 Español",
|
||||||
|
"es-es": "🇪🇸 Español",
|
||||||
|
"it": "🇮🇹 Italiano",
|
||||||
|
"it-it": "🇮🇹 Italiano",
|
||||||
|
"pt": "🇵🇹 Português",
|
||||||
|
"pt-pt": "🇵🇹 Português",
|
||||||
|
"pt-br": "🇧🇷 Português",
|
||||||
|
"zh": "🇨🇳 中文",
|
||||||
|
"zh-cn": "🇨🇳 中文 (简体)",
|
||||||
|
"zh-hans": "🇨🇳 中文 (简体)",
|
||||||
|
"zh-tw": "🇹🇼 中文 (繁體)",
|
||||||
|
"zh-hant": "🇹🇼 中文 (繁體)",
|
||||||
|
"vi": "🇻🇳 Tiếng Việt",
|
||||||
|
"vi-vn": "🇻🇳 Tiếng Việt",
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||||
@@ -94,28 +127,50 @@ def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup
|
|||||||
]
|
]
|
||||||
])
|
])
|
||||||
|
|
||||||
|
def get_privacy_policy_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||||
|
texts = get_texts(language)
|
||||||
|
return InlineKeyboardMarkup(inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=texts.PRIVACY_POLICY_ACCEPT,
|
||||||
|
callback_data="privacy_policy_accept"
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=texts.PRIVACY_POLICY_DECLINE,
|
||||||
|
callback_data="privacy_policy_decline"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
])
|
||||||
|
|
||||||
def get_channel_sub_keyboard(
|
def get_channel_sub_keyboard(
|
||||||
channel_link: str,
|
channel_link: Optional[str],
|
||||||
language: str = DEFAULT_LANGUAGE,
|
language: str = DEFAULT_LANGUAGE,
|
||||||
) -> InlineKeyboardMarkup:
|
) -> InlineKeyboardMarkup:
|
||||||
texts = get_texts(language)
|
texts = get_texts(language)
|
||||||
return InlineKeyboardMarkup(
|
|
||||||
inline_keyboard=[
|
buttons: List[List[InlineKeyboardButton]] = []
|
||||||
|
|
||||||
|
if channel_link:
|
||||||
|
buttons.append(
|
||||||
[
|
[
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"),
|
text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"),
|
||||||
url=channel_link,
|
url=channel_link,
|
||||||
)
|
)
|
||||||
],
|
]
|
||||||
[
|
)
|
||||||
InlineKeyboardButton(
|
|
||||||
text=texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался"),
|
buttons.append(
|
||||||
callback_data="sub_channel_check",
|
[
|
||||||
)
|
InlineKeyboardButton(
|
||||||
],
|
text=texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался"),
|
||||||
|
callback_data="sub_channel_check",
|
||||||
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|
||||||
|
|
||||||
def get_post_registration_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
def get_post_registration_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||||
texts = get_texts(language)
|
texts = get_texts(language)
|
||||||
@@ -703,6 +758,7 @@ def get_subscription_keyboard(
|
|||||||
|
|
||||||
texts = get_texts(language)
|
texts = get_texts(language)
|
||||||
keyboard = []
|
keyboard = []
|
||||||
|
has_direct_payment_methods = False
|
||||||
|
|
||||||
if has_subscription:
|
if has_subscription:
|
||||||
subscription_link = get_display_subscription_link(subscription) if subscription else None
|
subscription_link = get_display_subscription_link(subscription) if subscription else None
|
||||||
@@ -1091,6 +1147,7 @@ def get_balance_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMark
|
|||||||
def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||||
texts = get_texts(language)
|
texts = get_texts(language)
|
||||||
keyboard = []
|
keyboard = []
|
||||||
|
has_direct_payment_methods = False
|
||||||
|
|
||||||
amount_kopeks = max(0, int(amount_kopeks or 0))
|
amount_kopeks = max(0, int(amount_kopeks or 0))
|
||||||
|
|
||||||
@@ -1106,6 +1163,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
|||||||
callback_data=_build_callback("stars")
|
callback_data=_build_callback("stars")
|
||||||
)
|
)
|
||||||
])
|
])
|
||||||
|
has_direct_payment_methods = True
|
||||||
|
|
||||||
if settings.is_yookassa_enabled():
|
if settings.is_yookassa_enabled():
|
||||||
if settings.YOOKASSA_SBP_ENABLED:
|
if settings.YOOKASSA_SBP_ENABLED:
|
||||||
@@ -1115,6 +1173,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
|||||||
callback_data=_build_callback("yookassa_sbp"),
|
callback_data=_build_callback("yookassa_sbp"),
|
||||||
)
|
)
|
||||||
])
|
])
|
||||||
|
has_direct_payment_methods = True
|
||||||
|
|
||||||
keyboard.append([
|
keyboard.append([
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
@@ -1122,6 +1181,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
|||||||
callback_data=_build_callback("yookassa"),
|
callback_data=_build_callback("yookassa"),
|
||||||
)
|
)
|
||||||
])
|
])
|
||||||
|
has_direct_payment_methods = True
|
||||||
|
|
||||||
if settings.TRIBUTE_ENABLED:
|
if settings.TRIBUTE_ENABLED:
|
||||||
keyboard.append([
|
keyboard.append([
|
||||||
@@ -1130,6 +1190,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
|||||||
callback_data=_build_callback("tribute")
|
callback_data=_build_callback("tribute")
|
||||||
)
|
)
|
||||||
])
|
])
|
||||||
|
has_direct_payment_methods = True
|
||||||
|
|
||||||
if settings.is_mulenpay_enabled():
|
if settings.is_mulenpay_enabled():
|
||||||
mulenpay_name = settings.get_mulenpay_display_name()
|
mulenpay_name = settings.get_mulenpay_display_name()
|
||||||
@@ -1142,6 +1203,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
|||||||
callback_data=_build_callback("mulenpay")
|
callback_data=_build_callback("mulenpay")
|
||||||
)
|
)
|
||||||
])
|
])
|
||||||
|
has_direct_payment_methods = True
|
||||||
|
|
||||||
if settings.is_wata_enabled():
|
if settings.is_wata_enabled():
|
||||||
keyboard.append([
|
keyboard.append([
|
||||||
@@ -1150,6 +1212,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
|||||||
callback_data=_build_callback("wata")
|
callback_data=_build_callback("wata")
|
||||||
)
|
)
|
||||||
])
|
])
|
||||||
|
has_direct_payment_methods = True
|
||||||
|
|
||||||
if settings.is_pal24_enabled():
|
if settings.is_pal24_enabled():
|
||||||
keyboard.append([
|
keyboard.append([
|
||||||
@@ -1158,6 +1221,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
|||||||
callback_data=_build_callback("pal24")
|
callback_data=_build_callback("pal24")
|
||||||
)
|
)
|
||||||
])
|
])
|
||||||
|
has_direct_payment_methods = True
|
||||||
|
|
||||||
if settings.is_platega_enabled() and settings.get_platega_active_methods():
|
if settings.is_platega_enabled() and settings.get_platega_active_methods():
|
||||||
keyboard.append([
|
keyboard.append([
|
||||||
@@ -1166,6 +1230,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
|||||||
callback_data=_build_callback("platega"),
|
callback_data=_build_callback("platega"),
|
||||||
)
|
)
|
||||||
])
|
])
|
||||||
|
has_direct_payment_methods = True
|
||||||
|
|
||||||
if settings.is_cryptobot_enabled():
|
if settings.is_cryptobot_enabled():
|
||||||
keyboard.append([
|
keyboard.append([
|
||||||
@@ -1174,6 +1239,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
|||||||
callback_data=_build_callback("cryptobot")
|
callback_data=_build_callback("cryptobot")
|
||||||
)
|
)
|
||||||
])
|
])
|
||||||
|
has_direct_payment_methods = True
|
||||||
|
|
||||||
if settings.is_heleket_enabled():
|
if settings.is_heleket_enabled():
|
||||||
keyboard.append([
|
keyboard.append([
|
||||||
@@ -1182,15 +1248,24 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
|||||||
callback_data=_build_callback("heleket")
|
callback_data=_build_callback("heleket")
|
||||||
)
|
)
|
||||||
])
|
])
|
||||||
|
has_direct_payment_methods = True
|
||||||
|
|
||||||
keyboard.append([
|
if settings.is_support_topup_enabled():
|
||||||
InlineKeyboardButton(
|
keyboard.append([
|
||||||
text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"),
|
InlineKeyboardButton(
|
||||||
callback_data="topup_support"
|
text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"),
|
||||||
)
|
callback_data="topup_support"
|
||||||
])
|
)
|
||||||
|
])
|
||||||
|
|
||||||
if len(keyboard) == 1:
|
if not keyboard:
|
||||||
|
keyboard.append([
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=texts.t("PAYMENTS_TEMPORARILY_UNAVAILABLE", "⚠️ Способы оплаты временно недоступны"),
|
||||||
|
callback_data="payment_methods_unavailable"
|
||||||
|
)
|
||||||
|
])
|
||||||
|
elif not has_direct_payment_methods and settings.is_support_topup_enabled():
|
||||||
keyboard.insert(0, [
|
keyboard.insert(0, [
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=texts.t("PAYMENTS_TEMPORARILY_UNAVAILABLE", "⚠️ Способы оплаты временно недоступны"),
|
text=texts.t("PAYMENTS_TEMPORARILY_UNAVAILABLE", "⚠️ Способы оплаты временно недоступны"),
|
||||||
|
|||||||
@@ -32,3 +32,12 @@ RULES_TEXT: |
|
|||||||
2. Do not distribute spam or malicious content.
|
2. Do not distribute spam or malicious content.
|
||||||
3. Respect other community members.
|
3. Respect other community members.
|
||||||
|
|
||||||
|
PAYMENT_METHODS_NONE_AVAILABLE: |
|
||||||
|
💳 <b>Balance top-up methods</b>
|
||||||
|
|
||||||
|
⚠️ Payment methods are temporarily unavailable.
|
||||||
|
Please try again later.
|
||||||
|
|
||||||
|
Choose a top-up option:
|
||||||
|
SUPPORT_TOPUP_DISABLED: "Top-ups via support are disabled. Please use another payment method."
|
||||||
|
|
||||||
|
|||||||
@@ -32,3 +32,12 @@ RULES_TEXT: |
|
|||||||
2. Не распространяйте спам и вредоносный контент.
|
2. Не распространяйте спам и вредоносный контент.
|
||||||
3. Уважайте других пользователей.
|
3. Уважайте других пользователей.
|
||||||
|
|
||||||
|
PAYMENT_METHODS_NONE_AVAILABLE: |
|
||||||
|
💳 <b>Способы пополнения баланса</b>
|
||||||
|
|
||||||
|
⚠️ В данный момент способы оплаты временно недоступны.
|
||||||
|
Попробуйте позже.
|
||||||
|
|
||||||
|
Выберите способ пополнения:
|
||||||
|
SUPPORT_TOPUP_DISABLED: "Пополнение через поддержку отключено. Попробуйте другой способ оплаты."
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,7 @@
|
|||||||
"ADMIN_MAIN_SETTINGS": "⚙️ Settings",
|
"ADMIN_MAIN_SETTINGS": "⚙️ Settings",
|
||||||
"ADMIN_MAIN_SUPPORT": "🛟 Support",
|
"ADMIN_MAIN_SUPPORT": "🛟 Support",
|
||||||
"ADMIN_MAIN_SYSTEM": "🛠️ System",
|
"ADMIN_MAIN_SYSTEM": "🛠️ System",
|
||||||
|
"ADMIN_MAIN_TRIALS": "🧪 Trials",
|
||||||
"ADMIN_MAIN_PAYMENTS": "💳 Top-ups",
|
"ADMIN_MAIN_PAYMENTS": "💳 Top-ups",
|
||||||
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions",
|
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions",
|
||||||
"ADMIN_MESSAGES": "📨 Broadcasts",
|
"ADMIN_MESSAGES": "📨 Broadcasts",
|
||||||
@@ -168,6 +169,11 @@
|
|||||||
"ADMIN_PAYMENTS_TITLE": "💳 <b>Top-up verification</b>",
|
"ADMIN_PAYMENTS_TITLE": "💳 <b>Top-up verification</b>",
|
||||||
"ADMIN_PAYMENTS_DESCRIPTION": "Pending top-up invoices created during the last 24 hours.",
|
"ADMIN_PAYMENTS_DESCRIPTION": "Pending top-up invoices created during the last 24 hours.",
|
||||||
"ADMIN_PAYMENTS_NOTICE": "Only invoices younger than 24 hours and waiting for payment can be checked.",
|
"ADMIN_PAYMENTS_NOTICE": "Only invoices younger than 24 hours and waiting for payment can be checked.",
|
||||||
|
"ADMIN_TRIALS_TITLE": "🧪 Trial management",
|
||||||
|
"ADMIN_TRIALS_STATS": "• Total trials used: {used}\n• Active now: {active}\n• Eligible for reset: {resettable}",
|
||||||
|
"ADMIN_TRIALS_RESET_BUTTON": "♻️ Reset all trials",
|
||||||
|
"ADMIN_TRIALS_RESET_RESULT": "♻️ Reset {reset_count} trials.\n\n• Total trials used: {used}\n• Active now: {active}\n• Eligible for reset: {resettable}",
|
||||||
|
"ADMIN_TRIALS_RESET_TOAST": "✅ Reset completed",
|
||||||
"ADMIN_PAYMENTS_EMPTY": "No pending top-up invoices found in the last 24 hours.",
|
"ADMIN_PAYMENTS_EMPTY": "No pending top-up invoices found in the last 24 hours.",
|
||||||
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 #{number}",
|
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 #{number}",
|
||||||
"ADMIN_PAYMENT_STATUS_PENDING": "Pending",
|
"ADMIN_PAYMENT_STATUS_PENDING": "Pending",
|
||||||
@@ -725,6 +731,22 @@
|
|||||||
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.",
|
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.",
|
||||||
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user",
|
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user",
|
||||||
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group",
|
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group",
|
||||||
|
"ADMIN_USER_REFERRALS_BUTTON": "🤝 Referrals",
|
||||||
|
"ADMIN_USER_REFERRALS_TITLE": "🤝 <b>User referrals</b>",
|
||||||
|
"ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Total referrals: {count}",
|
||||||
|
"ADMIN_USER_REFERRAL_COMMISSION_DEFAULT": "• Commission percent: {percent}% (default)",
|
||||||
|
"ADMIN_USER_REFERRAL_COMMISSION_CUSTOM": "• Custom percent: {percent}% (default: {default_percent}%)",
|
||||||
|
"ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON": "📈 Change percent",
|
||||||
|
"ADMIN_USER_REFERRAL_COMMISSION_PROMPT": "📈 <b>Custom referral commission</b>\n\nCurrent value: {current}%\nDefault value: {default}%\n\nSend a value from 0 to 100 or the word 'standard' to reset.",
|
||||||
|
"ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON": "♻️ Reset to default",
|
||||||
|
"ADMIN_USER_REFERRAL_COMMISSION_UPDATED": "✅ Percent updated: {percent}%",
|
||||||
|
"ADMIN_USER_REFERRAL_COMMISSION_INVALID": "❌ Enter a number from 0 to 100 or the word 'standard'",
|
||||||
|
"ADMIN_USER_REFERRALS_LIST_HEADER": "<b>List of referrals:</b>",
|
||||||
|
"ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: <code>{telegram_id}</code>{username_part})",
|
||||||
|
"ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … and {count} more referrals",
|
||||||
|
"ADMIN_USER_REFERRALS_EMPTY": "No referrals yet.",
|
||||||
|
"ADMIN_USER_REFERRALS_EDIT_HINT": "✏️ To change the list, tap “✏️ Edit” below.",
|
||||||
|
"ADMIN_USER_REFERRALS_EDIT_BUTTON": "✏️ Edit",
|
||||||
"ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}",
|
"ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}",
|
||||||
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned",
|
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned",
|
||||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
|
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
|
||||||
@@ -1017,7 +1039,6 @@
|
|||||||
"PAL24_INSTRUCTION_FOLLOW": "{step}. Follow the payment page instructions",
|
"PAL24_INSTRUCTION_FOLLOW": "{step}. Follow the payment page instructions",
|
||||||
"PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.",
|
"PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.",
|
||||||
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>PayPalych (SBP) payment</b>\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 <b>How to pay:</b>\n1. Press ‘Pay with PayPalych (SBP)’\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
|
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>PayPalych (SBP) payment</b>\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 <b>How to pay:</b>\n1. Press ‘Pay with PayPalych (SBP)’\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
|
||||||
"PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
|
|
||||||
"PAL24_SBP_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
|
"PAL24_SBP_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
|
||||||
"PAL24_SELECT_PAYMENT_METHOD": "Choose a PayPalych payment method:",
|
"PAL24_SELECT_PAYMENT_METHOD": "Choose a PayPalych payment method:",
|
||||||
"PAL24_TOPUP_PROMPT": "🏦 <b>PayPalych (SBP) payment</b>\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed via the PayPalych Faster Payments System.",
|
"PAL24_TOPUP_PROMPT": "🏦 <b>PayPalych (SBP) payment</b>\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed via the PayPalych Faster Payments System.",
|
||||||
@@ -1055,12 +1076,8 @@
|
|||||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.",
|
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.",
|
||||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot",
|
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot",
|
||||||
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</b>",
|
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</b>",
|
||||||
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "via Heleket",
|
|
||||||
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Cryptocurrency (Heleket)</b>",
|
|
||||||
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via {mulenpay_name}",
|
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via {mulenpay_name}",
|
||||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card ({mulenpay_name})</b>",
|
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card ({mulenpay_name})</b>",
|
||||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
|
|
||||||
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>SBP (PayPalych)</b>",
|
|
||||||
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "via Platega (cards + SBP)",
|
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "via Platega (cards + SBP)",
|
||||||
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Bank card (Platega)</b>",
|
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Bank card (Platega)</b>",
|
||||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
|
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
|
||||||
@@ -1069,8 +1086,6 @@
|
|||||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
|
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
|
||||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute",
|
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute",
|
||||||
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Bank card</b>",
|
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Bank card</b>",
|
||||||
"PAYMENT_METHOD_WATA_DESCRIPTION": "via WATA",
|
|
||||||
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Bank card (WATA)</b>",
|
|
||||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa",
|
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa",
|
||||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
|
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
|
||||||
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "via YooKassa Fast Payment System",
|
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "via YooKassa Fast Payment System",
|
||||||
@@ -1215,8 +1230,14 @@
|
|||||||
"RULES_ACCEPTED_PROCESSING": "✅ Rules accepted! Completing registration...",
|
"RULES_ACCEPTED_PROCESSING": "✅ Rules accepted! Completing registration...",
|
||||||
"RULES_DECLINE": "❌ I do not accept",
|
"RULES_DECLINE": "❌ I do not accept",
|
||||||
"RULES_HEADER": "📋 <b>Service Rules</b>",
|
"RULES_HEADER": "📋 <b>Service Rules</b>",
|
||||||
|
"PRIVACY_POLICY_ACCEPT": "✅ Accept",
|
||||||
|
"PRIVACY_POLICY_DECLINE": "❌ Decline",
|
||||||
|
"PRIVACY_POLICY_REQUIRED": "You must accept the privacy policy to use the bot.",
|
||||||
|
"PRIVACY_POLICY_ACCEPTED_PROCESSING": "✅ Privacy policy accepted! Continuing registration...",
|
||||||
|
"ERROR_PRIVACY_POLICY_RETRY": "An error occurred. Please try accepting the privacy policy again:",
|
||||||
"RULES_REQUIRED": "❗️ You must accept the rules to use the service!",
|
"RULES_REQUIRED": "❗️ You must accept the rules to use the service!",
|
||||||
"RULES_TEXT_DEFAULT": "📋 <b>Service Usage Rules</b>\n\n1. Do not use the service for illegal activity\n2. Avoid sharing pirated or malicious content\n3. Spam and phishing are prohibited\n4. Using the service for DDoS attacks is forbidden\n5. One account is intended for one person\n6. Refunds are provided only in exceptional cases\n7. The administration may block accounts that violate the rules\n\n<b>By using the service you agree to follow these rules.</b>",
|
"RULES_TEXT_DEFAULT": "📋 <b>Service Usage Rules</b>\n\n1. Do not use the service for illegal activity\n2. Avoid sharing pirated or malicious content\n3. Spam and phishing are prohibited\n4. Using the service for DDoS attacks is forbidden\n5. One account is intended for one person\n6. Refunds are provided only in exceptional cases\n7. The administration may block accounts that violate the rules\n\n<b>By using the service you agree to follow these rules.</b>",
|
||||||
|
"PRIVACY_POLICY_TEXT_DEFAULT": "🔒 <b>Privacy Policy</b>\n\nWe are committed to protecting your privacy and personal data.\n\n<b>Data Collection:</b>\n• We collect only necessary information to provide services\n• Data is used exclusively for service operation\n\n<b>Data Protection:</b>\n• Your data is protected by modern encryption methods\n• We do not share data with third parties without your consent\n\n<b>Your Rights:</b>\n• You can request information about stored data\n• You can request deletion of your data\n\n<b>By using the service, you agree to the privacy policy.</b>",
|
||||||
"SELECT_COUNTRIES": "Select countries:",
|
"SELECT_COUNTRIES": "Select countries:",
|
||||||
"SELECT_DEVICES": "Number of devices:",
|
"SELECT_DEVICES": "Number of devices:",
|
||||||
"SELECT_PERIOD": "Choose period:",
|
"SELECT_PERIOD": "Choose period:",
|
||||||
|
|||||||
@@ -130,6 +130,7 @@
|
|||||||
"ADMIN_MAIN_SETTINGS": "⚙️ Настройки",
|
"ADMIN_MAIN_SETTINGS": "⚙️ Настройки",
|
||||||
"ADMIN_MAIN_SUPPORT": "🛟 Поддержка",
|
"ADMIN_MAIN_SUPPORT": "🛟 Поддержка",
|
||||||
"ADMIN_MAIN_SYSTEM": "🛠️ Система",
|
"ADMIN_MAIN_SYSTEM": "🛠️ Система",
|
||||||
|
"ADMIN_MAIN_TRIALS": "🧪 Триалы",
|
||||||
"ADMIN_MAIN_PAYMENTS": "💳 Пополнения",
|
"ADMIN_MAIN_PAYMENTS": "💳 Пополнения",
|
||||||
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки",
|
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки",
|
||||||
"ADMIN_MESSAGES": "📨 Рассылки",
|
"ADMIN_MESSAGES": "📨 Рассылки",
|
||||||
@@ -168,6 +169,11 @@
|
|||||||
"ADMIN_PAYMENTS_TITLE": "💳 <b>Проверка пополнений</b>",
|
"ADMIN_PAYMENTS_TITLE": "💳 <b>Проверка пополнений</b>",
|
||||||
"ADMIN_PAYMENTS_DESCRIPTION": "Список счетов на пополнение, созданных за последние 24 часа и ожидающих оплаты.",
|
"ADMIN_PAYMENTS_DESCRIPTION": "Список счетов на пополнение, созданных за последние 24 часа и ожидающих оплаты.",
|
||||||
"ADMIN_PAYMENTS_NOTICE": "Проверять можно только счета моложе 24 часов и со статусом ожидания.",
|
"ADMIN_PAYMENTS_NOTICE": "Проверять можно только счета моложе 24 часов и со статусом ожидания.",
|
||||||
|
"ADMIN_TRIALS_TITLE": "🧪 Управление триалами",
|
||||||
|
"ADMIN_TRIALS_STATS": "• Использовано всего: {used}\n• Активно сейчас: {active}\n• Доступно к сбросу: {resettable}",
|
||||||
|
"ADMIN_TRIALS_RESET_BUTTON": "♻️ Сбросить все триалы",
|
||||||
|
"ADMIN_TRIALS_RESET_RESULT": "♻️ Сбросили {reset_count} триалов.\n\n• Использовано всего: {used}\n• Активно сейчас: {active}\n• Доступно к сбросу: {resettable}",
|
||||||
|
"ADMIN_TRIALS_RESET_TOAST": "✅ Сброс завершен",
|
||||||
"ADMIN_PAYMENTS_EMPTY": "За последние 24 часа не найдено счетов на пополнение в ожидании.",
|
"ADMIN_PAYMENTS_EMPTY": "За последние 24 часа не найдено счетов на пополнение в ожидании.",
|
||||||
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 №{number}",
|
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 №{number}",
|
||||||
"ADMIN_PAYMENT_STATUS_PENDING": "Ожидает оплаты",
|
"ADMIN_PAYMENT_STATUS_PENDING": "Ожидает оплаты",
|
||||||
@@ -682,6 +688,7 @@
|
|||||||
"ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Поддержка**\n\n",
|
"ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Поддержка**\n\n",
|
||||||
"ADMIN_SUPPORT_TICKETS": "🎫 Тикеты поддержки",
|
"ADMIN_SUPPORT_TICKETS": "🎫 Тикеты поддержки",
|
||||||
"ADMIN_SYNC_BACK": "⬅️ К синхронизации",
|
"ADMIN_SYNC_BACK": "⬅️ К синхронизации",
|
||||||
|
"ADMIN_SYNC_TO_PANEL": "⬆️ Синхронизация в панель",
|
||||||
"ADMIN_SYNC_CLEANUP": "🧹 Очистка",
|
"ADMIN_SYNC_CLEANUP": "🧹 Очистка",
|
||||||
"ADMIN_SYNC_CONFIRM": "✅ Подтвердить",
|
"ADMIN_SYNC_CONFIRM": "✅ Подтвердить",
|
||||||
"ADMIN_SYNC_FULL": "🔄 Полная синхронизация",
|
"ADMIN_SYNC_FULL": "🔄 Полная синхронизация",
|
||||||
@@ -728,6 +735,13 @@
|
|||||||
"ADMIN_USER_REFERRALS_BUTTON": "🤝 Рефералы",
|
"ADMIN_USER_REFERRALS_BUTTON": "🤝 Рефералы",
|
||||||
"ADMIN_USER_REFERRALS_TITLE": "🤝 <b>Рефералы пользователя</b>",
|
"ADMIN_USER_REFERRALS_TITLE": "🤝 <b>Рефералы пользователя</b>",
|
||||||
"ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Всего рефералов: {count}",
|
"ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Всего рефералов: {count}",
|
||||||
|
"ADMIN_USER_REFERRAL_COMMISSION_DEFAULT": "• Процент комиссии: {percent}% (стандартное значение)",
|
||||||
|
"ADMIN_USER_REFERRAL_COMMISSION_CUSTOM": "• Индивидуальный процент: {percent}% (стандарт: {default_percent}%)",
|
||||||
|
"ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON": "📈 Изменить процент",
|
||||||
|
"ADMIN_USER_REFERRAL_COMMISSION_PROMPT": "📈 <b>Индивидуальный процент реферальной комиссии</b>\n\nТекущее значение: {current}%\nСтандартное значение: {default}%\n\nОтправьте новое значение от 0 до 100 или слово 'стандарт' для сброса.",
|
||||||
|
"ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON": "♻️ Сбросить на стандартный",
|
||||||
|
"ADMIN_USER_REFERRAL_COMMISSION_UPDATED": "✅ Процент обновлён: {percent}%",
|
||||||
|
"ADMIN_USER_REFERRAL_COMMISSION_INVALID": "❌ Введите число от 0 до 100 или слово 'стандарт'",
|
||||||
"ADMIN_USER_REFERRALS_LIST_HEADER": "<b>Список рефералов:</b>",
|
"ADMIN_USER_REFERRALS_LIST_HEADER": "<b>Список рефералов:</b>",
|
||||||
"ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: <code>{telegram_id}</code>{username_part})",
|
"ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: <code>{telegram_id}</code>{username_part})",
|
||||||
"ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … и ещё {count} рефералов",
|
"ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … и ещё {count} рефералов",
|
||||||
@@ -1037,7 +1051,6 @@
|
|||||||
"PAL24_INSTRUCTION_FOLLOW": "{step}. Следуйте подсказкам платёжной системы",
|
"PAL24_INSTRUCTION_FOLLOW": "{step}. Следуйте подсказкам платёжной системы",
|
||||||
"PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
"PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||||
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
|
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
|
||||||
"PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
|
|
||||||
"PAL24_SBP_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
|
"PAL24_SBP_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
|
||||||
"PAL24_SELECT_PAYMENT_METHOD": "Выберите способ оплаты PayPalych:",
|
"PAL24_SELECT_PAYMENT_METHOD": "Выберите способ оплаты PayPalych:",
|
||||||
"PAL24_TOPUP_PROMPT": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.",
|
"PAL24_TOPUP_PROMPT": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.",
|
||||||
@@ -1075,12 +1088,8 @@
|
|||||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
|
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
|
||||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
|
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
|
||||||
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",
|
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",
|
||||||
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "через Heleket",
|
|
||||||
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Криптовалюта (Heleket)</b>",
|
|
||||||
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}",
|
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}",
|
||||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта ({mulenpay_name})</b>",
|
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта ({mulenpay_name})</b>",
|
||||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
|
|
||||||
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
|
|
||||||
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (карты + СБП)",
|
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (карты + СБП)",
|
||||||
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банковская карта (Platega)</b>",
|
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банковская карта (Platega)</b>",
|
||||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
|
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
|
||||||
@@ -1089,8 +1098,6 @@
|
|||||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
|
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
|
||||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
|
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
|
||||||
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банковская карта</b>",
|
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банковская карта</b>",
|
||||||
"PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA",
|
|
||||||
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Банковская карта (WATA)</b>",
|
|
||||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
|
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
|
||||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
|
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
|
||||||
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему быстрых платежей YooKassa",
|
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему быстрых платежей YooKassa",
|
||||||
@@ -1235,8 +1242,14 @@
|
|||||||
"RULES_ACCEPTED_PROCESSING": "✅ Правила приняты! Завершаем регистрацию...",
|
"RULES_ACCEPTED_PROCESSING": "✅ Правила приняты! Завершаем регистрацию...",
|
||||||
"RULES_DECLINE": "❌ Не принимаю",
|
"RULES_DECLINE": "❌ Не принимаю",
|
||||||
"RULES_HEADER": "📋 <b>Правила сервиса</b>",
|
"RULES_HEADER": "📋 <b>Правила сервиса</b>",
|
||||||
|
"PRIVACY_POLICY_ACCEPT": "✅ Принять",
|
||||||
|
"PRIVACY_POLICY_DECLINE": "❌ Отклонить",
|
||||||
|
"PRIVACY_POLICY_REQUIRED": "Для использования бота необходимо принять политику конфиденциальности.",
|
||||||
|
"PRIVACY_POLICY_ACCEPTED_PROCESSING": "✅ Политика конфиденциальности принята! Продолжаем регистрацию...",
|
||||||
|
"ERROR_PRIVACY_POLICY_RETRY": "Произошла ошибка. Попробуйте принять политику конфиденциальности еще раз:",
|
||||||
"RULES_REQUIRED": "❗️ Для использования сервиса необходимо принять правила!",
|
"RULES_REQUIRED": "❗️ Для использования сервиса необходимо принять правила!",
|
||||||
"RULES_TEXT_DEFAULT": "📋 <b>Правила использования сервиса</b>\n\n1. Запрещено использовать сервис для противоправной деятельности\n2. Не распространяйте пиратский или вредоносный контент\n3. Запрещены спам и фишинг\n4. Нельзя использовать сервис для DDoS-атак\n5. Один аккаунт предназначен для одного пользователя\n6. Возвраты возможны только в исключительных случаях\n7. Администрация может заблокировать аккаунт при нарушении правил\n\n<b>Используя сервис, вы подтверждаете согласие с этими правилами.</b>",
|
"RULES_TEXT_DEFAULT": "📋 <b>Правила использования сервиса</b>\n\n1. Запрещено использовать сервис для противоправной деятельности\n2. Не распространяйте пиратский или вредоносный контент\n3. Запрещены спам и фишинг\n4. Нельзя использовать сервис для DDoS-атак\n5. Один аккаунт предназначен для одного пользователя\n6. Возвраты возможны только в исключительных случаях\n7. Администрация может заблокировать аккаунт при нарушении правил\n\n<b>Используя сервис, вы подтверждаете согласие с этими правилами.</b>",
|
||||||
|
"PRIVACY_POLICY_TEXT_DEFAULT": "🔒 <b>Политика конфиденциальности</b>\n\nМы обязуемся защищать вашу конфиденциальность и личные данные.\n\n<b>Сбор данных:</b>\n• Мы собираем только необходимую информацию для предоставления услуг\n• Данные используются исключительно для работы сервиса\n\n<b>Защита данных:</b>\n• Ваши данные защищены современными методами шифрования\n• Мы не передаем данные третьим лицам без вашего согласия\n\n<b>Ваши права:</b>\n• Вы можете запросить информацию о хранимых данных\n• Вы можете запросить удаление ваших данных\n\n<b>Используя сервис, вы соглашаетесь с политикой конфиденциальности.</b>",
|
||||||
"SELECT_COUNTRIES": "Выберите страны:",
|
"SELECT_COUNTRIES": "Выберите страны:",
|
||||||
"SELECT_DEVICES": "Количество устройств:",
|
"SELECT_DEVICES": "Количество устройств:",
|
||||||
"SELECT_PERIOD": "Выберите период:",
|
"SELECT_PERIOD": "Выберите период:",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+110
-37
@@ -16,6 +16,79 @@ _logger = logging.getLogger(__name__)
|
|||||||
_cached_rules: Dict[str, str] = {}
|
_cached_rules: Dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
_LANGUAGE_ALIASES = {
|
||||||
|
"uk": "ua",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_DYNAMIC_LANGUAGE_CONFIGS = {
|
||||||
|
"ru": {
|
||||||
|
"traffic_pattern": "📊 {size} ГБ - {price}",
|
||||||
|
"unlimited_pattern": "📊 Безлимит - {price}",
|
||||||
|
"support_info": (
|
||||||
|
"\n🛟 <b>Поддержка</b>\n\n"
|
||||||
|
"Это центр тикетов: создавайте обращения, просматривайте ответы и историю.\n\n"
|
||||||
|
"• 🎫 Создать тикет — опишите проблему или вопрос\n"
|
||||||
|
"• 📋 Мои тикеты — статус и переписка\n"
|
||||||
|
"• 💬 Связаться — написать напрямую (если нужно)\n\n"
|
||||||
|
"Старайтесь использовать тикеты — так мы быстрее поможем и ничего не потеряется.\n"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"traffic_pattern": "📊 {size} GB - {price}",
|
||||||
|
"unlimited_pattern": "📊 Unlimited - {price}",
|
||||||
|
"support_info": (
|
||||||
|
"\n🛟 <b>RemnaWave Support</b>\n\n"
|
||||||
|
"This is the ticket center: create requests, view replies and history.\n\n"
|
||||||
|
"• 🎫 Create ticket — describe your issue or question\n"
|
||||||
|
"• 📋 My tickets — status and conversation\n"
|
||||||
|
"• 💬 Contact — message directly if needed\n\n"
|
||||||
|
"Prefer tickets — it helps us respond faster and keep context.\n"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"ua": {
|
||||||
|
"traffic_pattern": "📊 {size} ГБ - {price}",
|
||||||
|
"unlimited_pattern": "📊 Безліміт - {price}",
|
||||||
|
"support_info": (
|
||||||
|
"\n🛠️ <b>Технічна підтримка</b>\n\n"
|
||||||
|
"З усіх питань звертайтеся до нашої підтримки:\n\n"
|
||||||
|
"👤 {support_username}\n\n"
|
||||||
|
"Ми допоможемо з:\n"
|
||||||
|
"• Налаштуванням підключення\n"
|
||||||
|
"• Вирішенням технічних проблем\n"
|
||||||
|
"• Питаннями щодо оплати\n"
|
||||||
|
"• Іншими питаннями\n\n"
|
||||||
|
"⏰ Час відповіді: зазвичай протягом 1-2 годин\n"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"zh": {
|
||||||
|
"traffic_pattern": "📊{size}GB-{price}",
|
||||||
|
"unlimited_pattern": "📊无限-{price}",
|
||||||
|
"support_info": (
|
||||||
|
"\n🛠️ <b>技术支持</b>\n\n"
|
||||||
|
"如有任何问题,请联系我们的支持团队:\n\n"
|
||||||
|
"👤 {support_username}\n\n"
|
||||||
|
"我们将帮助您:\n"
|
||||||
|
"• 设置连接\n"
|
||||||
|
"• 解决技术问题\n"
|
||||||
|
"• 付款问题\n"
|
||||||
|
"• 其他问题\n\n"
|
||||||
|
"⏰ 响应时间:通常在 1-2 小时内\n"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_TRAFFIC_TIERS = (
|
||||||
|
("TRAFFIC_5GB", "5", "PRICE_TRAFFIC_5GB"),
|
||||||
|
("TRAFFIC_10GB", "10", "PRICE_TRAFFIC_10GB"),
|
||||||
|
("TRAFFIC_25GB", "25", "PRICE_TRAFFIC_25GB"),
|
||||||
|
("TRAFFIC_50GB", "50", "PRICE_TRAFFIC_50GB"),
|
||||||
|
("TRAFFIC_100GB", "100", "PRICE_TRAFFIC_100GB"),
|
||||||
|
("TRAFFIC_250GB", "250", "PRICE_TRAFFIC_250GB"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_cached_rules_value(language: str) -> str:
|
def _get_cached_rules_value(language: str) -> str:
|
||||||
if language in _cached_rules:
|
if language in _cached_rules:
|
||||||
return _cached_rules[language]
|
return _cached_rules[language]
|
||||||
@@ -28,45 +101,32 @@ def _get_cached_rules_value(language: str) -> str:
|
|||||||
def _build_dynamic_values(language: str) -> Dict[str, Any]:
|
def _build_dynamic_values(language: str) -> Dict[str, Any]:
|
||||||
language_code = (language or DEFAULT_LANGUAGE).split("-")[0].lower()
|
language_code = (language or DEFAULT_LANGUAGE).split("-")[0].lower()
|
||||||
|
|
||||||
if language_code == "ru":
|
language_code = _LANGUAGE_ALIASES.get(language_code, language_code)
|
||||||
return {
|
config = _DYNAMIC_LANGUAGE_CONFIGS.get(language_code)
|
||||||
"TRAFFIC_5GB": f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
|
||||||
"TRAFFIC_10GB": f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
|
|
||||||
"TRAFFIC_25GB": f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
|
|
||||||
"TRAFFIC_50GB": f"📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
|
|
||||||
"TRAFFIC_100GB": f"📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
|
|
||||||
"TRAFFIC_250GB": f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
|
|
||||||
"TRAFFIC_UNLIMITED": f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
|
||||||
"SUPPORT_INFO": (
|
|
||||||
"\n🛟 <b>Поддержка</b>\n\n"
|
|
||||||
"Это центр тикетов: создавайте обращения, просматривайте ответы и историю.\n\n"
|
|
||||||
"• 🎫 Создать тикет — опишите проблему или вопрос\n"
|
|
||||||
"• 📋 Мои тикеты — статус и переписка\n"
|
|
||||||
"• 💬 Связаться — написать напрямую (если нужно)\n\n"
|
|
||||||
"Старайтесь использовать тикеты — так мы быстрее поможем и ничего не потеряется.\n"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
if language_code == "en":
|
if not config:
|
||||||
return {
|
return {}
|
||||||
"TRAFFIC_5GB": f"📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
|
||||||
"TRAFFIC_10GB": f"📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
|
|
||||||
"TRAFFIC_25GB": f"📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
|
|
||||||
"TRAFFIC_50GB": f"📊 50 GB - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
|
|
||||||
"TRAFFIC_100GB": f"📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
|
|
||||||
"TRAFFIC_250GB": f"📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
|
|
||||||
"TRAFFIC_UNLIMITED": f"📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
|
||||||
"SUPPORT_INFO": (
|
|
||||||
"\n🛟 <b>RemnaWave Support</b>\n\n"
|
|
||||||
"This is the ticket center: create requests, view replies and history.\n\n"
|
|
||||||
"• 🎫 Create ticket — describe your issue or question\n"
|
|
||||||
"• 📋 My tickets — status and conversation\n"
|
|
||||||
"• 💬 Contact — message directly if needed\n\n"
|
|
||||||
"Prefer tickets — it helps us respond faster and keep context.\n"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {}
|
values: Dict[str, Any] = {}
|
||||||
|
traffic_pattern = config["traffic_pattern"]
|
||||||
|
for key, size, price_attr in _TRAFFIC_TIERS:
|
||||||
|
price_value = getattr(settings, price_attr)
|
||||||
|
values[key] = traffic_pattern.format(
|
||||||
|
size=size,
|
||||||
|
price=settings.format_price(price_value),
|
||||||
|
)
|
||||||
|
|
||||||
|
values["TRAFFIC_UNLIMITED"] = config["unlimited_pattern"].format(
|
||||||
|
price=settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)
|
||||||
|
)
|
||||||
|
|
||||||
|
support_template = config.get("support_info")
|
||||||
|
if support_template:
|
||||||
|
values["SUPPORT_INFO"] = support_template.format(
|
||||||
|
support_username=settings.SUPPORT_USERNAME
|
||||||
|
)
|
||||||
|
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
class Texts:
|
class Texts:
|
||||||
@@ -174,6 +234,19 @@ def _get_default_rules(language: str = DEFAULT_LANGUAGE) -> str:
|
|||||||
return fallback.get(default_key, "")
|
return fallback.get(default_key, "")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_default_privacy_policy(language: str = DEFAULT_LANGUAGE) -> str:
|
||||||
|
default_key = "PRIVACY_POLICY_TEXT_DEFAULT"
|
||||||
|
locale = load_locale(language)
|
||||||
|
if default_key in locale:
|
||||||
|
return locale[default_key]
|
||||||
|
fallback = load_locale(DEFAULT_LANGUAGE)
|
||||||
|
return fallback.get(default_key, "")
|
||||||
|
|
||||||
|
|
||||||
|
def get_privacy_policy(language: str = DEFAULT_LANGUAGE) -> str:
|
||||||
|
return _get_default_privacy_policy(language)
|
||||||
|
|
||||||
|
|
||||||
def get_rules_sync(language: str = DEFAULT_LANGUAGE) -> str:
|
def get_rules_sync(language: str = DEFAULT_LANGUAGE) -> str:
|
||||||
if language in _cached_rules:
|
if language in _cached_rules:
|
||||||
return _cached_rules[language]
|
return _cached_rules[language]
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ class AuthMiddleware(BaseMiddleware):
|
|||||||
registration_states = [
|
registration_states = [
|
||||||
RegistrationStates.waiting_for_language.state,
|
RegistrationStates.waiting_for_language.state,
|
||||||
RegistrationStates.waiting_for_rules_accept.state,
|
RegistrationStates.waiting_for_rules_accept.state,
|
||||||
|
RegistrationStates.waiting_for_privacy_policy_accept.state,
|
||||||
RegistrationStates.waiting_for_referral_code.state
|
RegistrationStates.waiting_for_referral_code.state
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -128,7 +129,7 @@ class AuthMiddleware(BaseMiddleware):
|
|||||||
isinstance(event, CallbackQuery)
|
isinstance(event, CallbackQuery)
|
||||||
and event.data
|
and event.data
|
||||||
and (
|
and (
|
||||||
event.data in ['rules_accept', 'rules_decline', 'referral_skip']
|
event.data in ['rules_accept', 'rules_decline', 'privacy_policy_accept', 'privacy_policy_decline', 'referral_skip']
|
||||||
or event.data.startswith('language_select:')
|
or event.data.startswith('language_select:')
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
|||||||
bot: Bot = data["bot"]
|
bot: Bot = data["bot"]
|
||||||
|
|
||||||
channel_id = settings.CHANNEL_SUB_ID
|
channel_id = settings.CHANNEL_SUB_ID
|
||||||
|
|
||||||
if not channel_id:
|
if not channel_id:
|
||||||
logger.warning("⚠️ CHANNEL_SUB_ID не установлен, пропускаем проверку")
|
logger.warning("⚠️ CHANNEL_SUB_ID не установлен, пропускаем проверку")
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
@@ -93,7 +93,12 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
|||||||
logger.debug("⚠️ Обязательная подписка отключена, пропускаем проверку")
|
logger.debug("⚠️ Обязательная подписка отключена, пропускаем проверку")
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
channel_link = settings.CHANNEL_LINK
|
channel_link = self._normalize_channel_link(settings.CHANNEL_LINK, channel_id)
|
||||||
|
|
||||||
|
if not channel_link:
|
||||||
|
logger.warning(
|
||||||
|
"⚠️ CHANNEL_LINK не задан или невалиден, кнопка подписки будет скрыта"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
|
member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
|
||||||
@@ -112,16 +117,16 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
|||||||
await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True)
|
await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
return await self._deny_message(event, bot, channel_link)
|
return await self._deny_message(event, bot, channel_link, channel_id)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}")
|
logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}")
|
||||||
await self._capture_start_payload(state, event, bot)
|
await self._capture_start_payload(state, event, bot)
|
||||||
return await self._deny_message(event, bot, channel_link)
|
return await self._deny_message(event, bot, channel_link, channel_id)
|
||||||
|
|
||||||
except TelegramForbiddenError as e:
|
except TelegramForbiddenError as e:
|
||||||
logger.error(f"❌ Бот заблокирован в канале {channel_id}: {e}")
|
logger.error(f"❌ Бот заблокирован в канале {channel_id}: {e}")
|
||||||
await self._capture_start_payload(state, event, bot)
|
await self._capture_start_payload(state, event, bot)
|
||||||
return await self._deny_message(event, bot, channel_link)
|
return await self._deny_message(event, bot, channel_link, channel_id)
|
||||||
except TelegramBadRequest as e:
|
except TelegramBadRequest as e:
|
||||||
if "chat not found" in str(e).lower():
|
if "chat not found" in str(e).lower():
|
||||||
logger.error(f"❌ Канал {channel_id} не найден: {e}")
|
logger.error(f"❌ Канал {channel_id} не найден: {e}")
|
||||||
@@ -130,11 +135,29 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
|||||||
else:
|
else:
|
||||||
logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}")
|
logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}")
|
||||||
await self._capture_start_payload(state, event, bot)
|
await self._capture_start_payload(state, event, bot)
|
||||||
return await self._deny_message(event, bot, channel_link)
|
return await self._deny_message(event, bot, channel_link, channel_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}")
|
logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}")
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_channel_link(channel_link: Optional[str], channel_id: Optional[str]) -> Optional[str]:
|
||||||
|
link = (channel_link or "").strip()
|
||||||
|
|
||||||
|
if link.startswith("@"): # raw username
|
||||||
|
return f"https://t.me/{link.lstrip('@')}"
|
||||||
|
|
||||||
|
if link and not link.lower().startswith(("http://", "https://", "tg://")):
|
||||||
|
return f"https://{link}"
|
||||||
|
|
||||||
|
if link:
|
||||||
|
return link
|
||||||
|
|
||||||
|
if channel_id and str(channel_id).startswith("@"):
|
||||||
|
return f"https://t.me/{str(channel_id).lstrip('@')}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
async def _capture_start_payload(
|
async def _capture_start_payload(
|
||||||
self,
|
self,
|
||||||
state: Optional[FSMContext],
|
state: Optional[FSMContext],
|
||||||
@@ -278,7 +301,12 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
|||||||
break
|
break
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _deny_message(event: TelegramObject, bot: Bot, channel_link: str):
|
async def _deny_message(
|
||||||
|
event: TelegramObject,
|
||||||
|
bot: Bot,
|
||||||
|
channel_link: Optional[str],
|
||||||
|
channel_id: Optional[str],
|
||||||
|
):
|
||||||
logger.debug("🚫 Отправляем сообщение о необходимости подписки")
|
logger.debug("🚫 Отправляем сообщение о необходимости подписки")
|
||||||
|
|
||||||
user = None
|
user = None
|
||||||
@@ -301,11 +329,26 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
|||||||
"🔒 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!",
|
"🔒 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not channel_link and channel_id:
|
||||||
|
channel_hint = None
|
||||||
|
|
||||||
|
if str(channel_id).startswith("@"): # username-based channel id
|
||||||
|
channel_hint = f"@{str(channel_id).lstrip('@')}"
|
||||||
|
|
||||||
|
if channel_hint:
|
||||||
|
text = f"{text}\n\n{channel_hint}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if isinstance(event, Message):
|
if isinstance(event, Message):
|
||||||
return await event.answer(text, reply_markup=channel_sub_kb)
|
return await event.answer(text, reply_markup=channel_sub_kb)
|
||||||
elif isinstance(event, CallbackQuery):
|
elif isinstance(event, CallbackQuery):
|
||||||
return await event.message.edit_text(text, reply_markup=channel_sub_kb)
|
try:
|
||||||
|
return await event.message.edit_text(text, reply_markup=channel_sub_kb)
|
||||||
|
except TelegramBadRequest as e:
|
||||||
|
if "message is not modified" in str(e).lower():
|
||||||
|
logger.debug("ℹ️ Сообщение уже содержит текст проверки подписки, пропускаем редактирование")
|
||||||
|
return await event.answer(text, show_alert=True)
|
||||||
|
raise
|
||||||
elif isinstance(event, Update) and event.message:
|
elif isinstance(event, Update) and event.message:
|
||||||
return await bot.send_message(event.message.chat.id, text, reply_markup=channel_sub_kb)
|
return await bot.send_message(event.message.chat.id, text, reply_markup=channel_sub_kb)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy.exc import MissingGreenlet
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database.crud.promo_group import get_promo_group_by_id
|
from app.database.crud.promo_group import get_promo_group_by_id
|
||||||
|
from app.database.crud.subscription_event import create_subscription_event
|
||||||
from app.database.crud.user import get_user_by_id
|
from app.database.crud.user import get_user_by_id
|
||||||
from app.database.crud.transaction import get_transaction_by_id
|
from app.database.crud.transaction import get_transaction_by_id
|
||||||
from app.database.models import (
|
from app.database.models import (
|
||||||
@@ -92,6 +93,51 @@ class AdminNotificationService:
|
|||||||
return "IDUnknown"
|
return "IDUnknown"
|
||||||
return f"ID{telegram_id}"
|
return f"ID{telegram_id}"
|
||||||
|
|
||||||
|
async def _record_subscription_event(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
event_type: str,
|
||||||
|
user: User,
|
||||||
|
subscription: Subscription | None,
|
||||||
|
transaction: Transaction | None = None,
|
||||||
|
amount_kopeks: int | None = None,
|
||||||
|
message: str | None = None,
|
||||||
|
extra: Dict[str, Any] | None = None,
|
||||||
|
occurred_at: datetime | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Persist subscription-related event for external dashboards."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
await create_subscription_event(
|
||||||
|
db,
|
||||||
|
user_id=user.id,
|
||||||
|
event_type=event_type,
|
||||||
|
subscription_id=subscription.id if subscription else None,
|
||||||
|
transaction_id=transaction.id if transaction else None,
|
||||||
|
amount_kopeks=amount_kopeks,
|
||||||
|
currency=None,
|
||||||
|
message=message,
|
||||||
|
occurred_at=occurred_at,
|
||||||
|
extra=extra or None,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.error(
|
||||||
|
"Не удалось сохранить событие подписки (%s) для пользователя %s",
|
||||||
|
event_type,
|
||||||
|
getattr(user, "id", "unknown"),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await db.rollback()
|
||||||
|
except Exception:
|
||||||
|
logger.error(
|
||||||
|
"Не удалось выполнить rollback после ошибки события подписки пользователя %s",
|
||||||
|
getattr(user, "id", "unknown"),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
def _format_promo_group_discounts(self, promo_group: PromoGroup) -> List[str]:
|
def _format_promo_group_discounts(self, promo_group: PromoGroup) -> List[str]:
|
||||||
discount_lines: List[str] = []
|
discount_lines: List[str] = []
|
||||||
|
|
||||||
@@ -194,10 +240,27 @@ class AdminNotificationService:
|
|||||||
*,
|
*,
|
||||||
charged_amount_kopeks: Optional[int] = None,
|
charged_amount_kopeks: Optional[int] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if not self._is_enabled():
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
await self._record_subscription_event(
|
||||||
|
db,
|
||||||
|
event_type="activation",
|
||||||
|
user=user,
|
||||||
|
subscription=subscription,
|
||||||
|
transaction=None,
|
||||||
|
amount_kopeks=charged_amount_kopeks,
|
||||||
|
message="Trial activation",
|
||||||
|
occurred_at=datetime.utcnow(),
|
||||||
|
extra={
|
||||||
|
"charged_amount_kopeks": charged_amount_kopeks,
|
||||||
|
"trial_duration_days": settings.TRIAL_DURATION_DAYS,
|
||||||
|
"traffic_limit_gb": settings.TRIAL_TRAFFIC_LIMIT_GB,
|
||||||
|
"device_limit": subscription.device_limit,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._is_enabled():
|
||||||
|
return False
|
||||||
|
|
||||||
user_status = "🆕 Новый" if not user.has_had_paid_subscription else "🔄 Существующий"
|
user_status = "🆕 Новый" if not user.has_had_paid_subscription else "🔄 Существующий"
|
||||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||||
promo_group = await self._get_user_promo_group(db, user)
|
promo_group = await self._get_user_promo_group(db, user)
|
||||||
@@ -255,10 +318,28 @@ class AdminNotificationService:
|
|||||||
was_trial_conversion: bool = False,
|
was_trial_conversion: bool = False,
|
||||||
amount_kopeks: Optional[int] = None,
|
amount_kopeks: Optional[int] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if not self._is_enabled():
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
total_amount = amount_kopeks if amount_kopeks is not None else (transaction.amount_kopeks if transaction else 0)
|
||||||
|
|
||||||
|
await self._record_subscription_event(
|
||||||
|
db,
|
||||||
|
event_type="purchase",
|
||||||
|
user=user,
|
||||||
|
subscription=subscription,
|
||||||
|
transaction=transaction,
|
||||||
|
amount_kopeks=total_amount,
|
||||||
|
message="Subscription purchase",
|
||||||
|
occurred_at=(transaction.completed_at or transaction.created_at) if transaction else datetime.utcnow(),
|
||||||
|
extra={
|
||||||
|
"period_days": period_days,
|
||||||
|
"was_trial_conversion": was_trial_conversion,
|
||||||
|
"payment_method": self._get_payment_method_display(transaction.payment_method) if transaction else "Баланс",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._is_enabled():
|
||||||
|
return False
|
||||||
|
|
||||||
event_type = "🔄 КОНВЕРСИЯ ИЗ ТРИАЛА" if was_trial_conversion else "💎 ПОКУПКА ПОДПИСКИ"
|
event_type = "🔄 КОНВЕРСИЯ ИЗ ТРИАЛА" if was_trial_conversion else "💎 ПОКУПКА ПОДПИСКИ"
|
||||||
|
|
||||||
if was_trial_conversion:
|
if was_trial_conversion:
|
||||||
@@ -275,7 +356,6 @@ class AdminNotificationService:
|
|||||||
promo_block = self._format_promo_group_block(promo_group)
|
promo_block = self._format_promo_group_block(promo_group)
|
||||||
user_display = self._get_user_display(user)
|
user_display = self._get_user_display(user)
|
||||||
|
|
||||||
total_amount = amount_kopeks if amount_kopeks is not None else (transaction.amount_kopeks if transaction else 0)
|
|
||||||
transaction_id = transaction.id if transaction else "—"
|
transaction_id = transaction.id if transaction else "—"
|
||||||
|
|
||||||
message = f"""💎 <b>{event_type}</b>
|
message = f"""💎 <b>{event_type}</b>
|
||||||
@@ -469,11 +549,38 @@ class AdminNotificationService:
|
|||||||
promo_group: PromoGroup | None,
|
promo_group: PromoGroup | None,
|
||||||
db: AsyncSession | None = None,
|
db: AsyncSession | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
logger.info("Начинаем отправку уведомления о пополнении баланса")
|
||||||
|
|
||||||
|
if db:
|
||||||
|
try:
|
||||||
|
await self._record_subscription_event(
|
||||||
|
db,
|
||||||
|
event_type="balance_topup",
|
||||||
|
user=user,
|
||||||
|
subscription=subscription,
|
||||||
|
transaction=transaction,
|
||||||
|
amount_kopeks=transaction.amount_kopeks,
|
||||||
|
message="Balance top-up",
|
||||||
|
occurred_at=transaction.completed_at or transaction.created_at,
|
||||||
|
extra={
|
||||||
|
"status": topup_status,
|
||||||
|
"balance_before": old_balance,
|
||||||
|
"balance_after": user.balance_kopeks,
|
||||||
|
"referrer_info": referrer_info,
|
||||||
|
"promo_group_id": getattr(promo_group, "id", None),
|
||||||
|
"promo_group_name": getattr(promo_group, "name", None),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.error(
|
||||||
|
"Не удалось сохранить событие пополнения баланса пользователя %s",
|
||||||
|
getattr(user, "id", "unknown"),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
if not self._is_enabled():
|
if not self._is_enabled():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
logger.info("Начинаем отправку уведомления о пополнении баланса")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("Пытаемся создать сообщение уведомления")
|
logger.info("Пытаемся создать сообщение уведомления")
|
||||||
message = self._build_balance_topup_message(
|
message = self._build_balance_topup_message(
|
||||||
@@ -567,19 +674,37 @@ class AdminNotificationService:
|
|||||||
new_end_date: datetime | None = None,
|
new_end_date: datetime | None = None,
|
||||||
balance_after: int | None = None,
|
balance_after: int | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if not self._is_enabled():
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
current_end_date = new_end_date or subscription.end_date
|
||||||
|
current_balance = balance_after if balance_after is not None else user.balance_kopeks
|
||||||
|
|
||||||
|
await self._record_subscription_event(
|
||||||
|
db,
|
||||||
|
event_type="renewal",
|
||||||
|
user=user,
|
||||||
|
subscription=subscription,
|
||||||
|
transaction=transaction,
|
||||||
|
amount_kopeks=transaction.amount_kopeks,
|
||||||
|
message="Subscription renewed",
|
||||||
|
occurred_at=transaction.completed_at or transaction.created_at,
|
||||||
|
extra={
|
||||||
|
"extended_days": extended_days,
|
||||||
|
"previous_end_date": old_end_date.isoformat(),
|
||||||
|
"new_end_date": current_end_date.isoformat(),
|
||||||
|
"payment_method": transaction.payment_method,
|
||||||
|
"balance_after": current_balance,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._is_enabled():
|
||||||
|
return False
|
||||||
|
|
||||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||||
promo_group = await self._get_user_promo_group(db, user)
|
promo_group = await self._get_user_promo_group(db, user)
|
||||||
promo_block = self._format_promo_group_block(promo_group)
|
promo_block = self._format_promo_group_block(promo_group)
|
||||||
user_display = self._get_user_display(user)
|
user_display = self._get_user_display(user)
|
||||||
|
|
||||||
current_end_date = new_end_date or subscription.end_date
|
|
||||||
current_balance = balance_after if balance_after is not None else user.balance_kopeks
|
|
||||||
|
|
||||||
message = f"""⏰ <b>ПРОДЛЕНИЕ ПОДПИСКИ</b>
|
message = f"""⏰ <b>ПРОДЛЕНИЕ ПОДПИСКИ</b>
|
||||||
|
|
||||||
👤 <b>Пользователь:</b> {user_display}
|
👤 <b>Пользователь:</b> {user_display}
|
||||||
@@ -619,7 +744,41 @@ class AdminNotificationService:
|
|||||||
user: User,
|
user: User,
|
||||||
promocode_data: Dict[str, Any],
|
promocode_data: Dict[str, Any],
|
||||||
effect_description: str,
|
effect_description: str,
|
||||||
|
balance_before_kopeks: int | None = None,
|
||||||
|
balance_after_kopeks: int | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
try:
|
||||||
|
await self._record_subscription_event(
|
||||||
|
db,
|
||||||
|
event_type="promocode_activation",
|
||||||
|
user=user,
|
||||||
|
subscription=None,
|
||||||
|
transaction=None,
|
||||||
|
amount_kopeks=promocode_data.get("balance_bonus_kopeks"),
|
||||||
|
message="Promocode activation",
|
||||||
|
occurred_at=datetime.utcnow(),
|
||||||
|
extra={
|
||||||
|
"code": promocode_data.get("code"),
|
||||||
|
"type": promocode_data.get("type"),
|
||||||
|
"subscription_days": promocode_data.get("subscription_days"),
|
||||||
|
"balance_bonus_kopeks": promocode_data.get("balance_bonus_kopeks"),
|
||||||
|
"description": effect_description,
|
||||||
|
"valid_until": (
|
||||||
|
promocode_data.get("valid_until").isoformat()
|
||||||
|
if isinstance(promocode_data.get("valid_until"), datetime)
|
||||||
|
else promocode_data.get("valid_until")
|
||||||
|
),
|
||||||
|
"balance_before_kopeks": balance_before_kopeks,
|
||||||
|
"balance_after_kopeks": balance_after_kopeks,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.error(
|
||||||
|
"Не удалось сохранить событие активации промокода пользователя %s",
|
||||||
|
getattr(user, "id", "unknown"),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
if not self._is_enabled():
|
if not self._is_enabled():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -665,6 +824,13 @@ class AdminNotificationService:
|
|||||||
|
|
||||||
message_lines.extend(
|
message_lines.extend(
|
||||||
[
|
[
|
||||||
|
"",
|
||||||
|
"💼 <b>Баланс:</b>",
|
||||||
|
(
|
||||||
|
f"{settings.format_price(balance_before_kopeks)} → {settings.format_price(balance_after_kopeks)}"
|
||||||
|
if balance_before_kopeks is not None and balance_after_kopeks is not None
|
||||||
|
else "ℹ️ Баланс не изменился"
|
||||||
|
),
|
||||||
"",
|
"",
|
||||||
"📝 <b>Эффект:</b>",
|
"📝 <b>Эффект:</b>",
|
||||||
effect_description.strip() or "✅ Промокод активирован",
|
effect_description.strip() or "✅ Промокод активирован",
|
||||||
@@ -686,6 +852,31 @@ class AdminNotificationService:
|
|||||||
campaign: AdvertisingCampaign,
|
campaign: AdvertisingCampaign,
|
||||||
user: Optional[User] = None,
|
user: Optional[User] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
if user:
|
||||||
|
try:
|
||||||
|
await self._record_subscription_event(
|
||||||
|
db,
|
||||||
|
event_type="referral_link_visit",
|
||||||
|
user=user,
|
||||||
|
subscription=None,
|
||||||
|
transaction=None,
|
||||||
|
amount_kopeks=None,
|
||||||
|
message="Referral link visit",
|
||||||
|
occurred_at=datetime.utcnow(),
|
||||||
|
extra={
|
||||||
|
"campaign_id": campaign.id,
|
||||||
|
"campaign_name": campaign.name,
|
||||||
|
"start_parameter": campaign.start_parameter,
|
||||||
|
"was_registered": bool(user),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.error(
|
||||||
|
"Не удалось сохранить событие перехода по кампании для пользователя %s",
|
||||||
|
getattr(user, "id", "unknown"),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
if not self._is_enabled():
|
if not self._is_enabled():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -744,6 +935,33 @@ class AdminNotificationService:
|
|||||||
initiator: Optional[User] = None,
|
initiator: Optional[User] = None,
|
||||||
automatic: bool = False,
|
automatic: bool = False,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
try:
|
||||||
|
await self._record_subscription_event(
|
||||||
|
db,
|
||||||
|
event_type="promo_group_change",
|
||||||
|
user=user,
|
||||||
|
subscription=None,
|
||||||
|
transaction=None,
|
||||||
|
message="Promo group change",
|
||||||
|
occurred_at=datetime.utcnow(),
|
||||||
|
extra={
|
||||||
|
"old_group_id": getattr(old_group, "id", None),
|
||||||
|
"old_group_name": getattr(old_group, "name", None),
|
||||||
|
"new_group_id": new_group.id,
|
||||||
|
"new_group_name": new_group.name,
|
||||||
|
"reason": reason,
|
||||||
|
"initiator_id": getattr(initiator, "id", None),
|
||||||
|
"initiator_telegram_id": getattr(initiator, "telegram_id", None),
|
||||||
|
"automatic": automatic,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.error(
|
||||||
|
"Не удалось сохранить событие смены промогруппы пользователя %s",
|
||||||
|
getattr(user, "id", "unknown"),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
if not self._is_enabled():
|
if not self._is_enabled():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from aiogram.types import InlineKeyboardMarkup
|
from aiogram.types import InlineKeyboardMarkup
|
||||||
|
from sqlalchemy.exc import InterfaceError, SQLAlchemyError
|
||||||
|
|
||||||
from app.database.database import AsyncSessionLocal
|
from app.database.database import AsyncSessionLocal
|
||||||
from app.database.models import BroadcastHistory
|
from app.database.models import BroadcastHistory
|
||||||
@@ -22,6 +23,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
VALID_MEDIA_TYPES = {"photo", "video", "document"}
|
VALID_MEDIA_TYPES = {"photo", "video", "document"}
|
||||||
|
LARGE_BROADCAST_THRESHOLD = 20_000
|
||||||
|
PROGRESS_UPDATE_STEP = 5_000
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -137,52 +140,53 @@ class BroadcastService:
|
|||||||
|
|
||||||
keyboard = self._build_keyboard(config.selected_buttons)
|
keyboard = self._build_keyboard(config.selected_buttons)
|
||||||
|
|
||||||
# Ограничение на количество одновременных отправок
|
if len(recipients) > LARGE_BROADCAST_THRESHOLD:
|
||||||
semaphore = asyncio.Semaphore(20)
|
logger.info(
|
||||||
|
"Запускаем стабильный режим рассылки для %s получателей", len(recipients)
|
||||||
|
)
|
||||||
|
(
|
||||||
|
sent_count,
|
||||||
|
failed_count,
|
||||||
|
cancelled_during_run,
|
||||||
|
) = await self._run_resilient_broadcast(
|
||||||
|
broadcast_id,
|
||||||
|
recipients,
|
||||||
|
config,
|
||||||
|
keyboard,
|
||||||
|
cancel_event,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
(
|
||||||
|
sent_count,
|
||||||
|
failed_count,
|
||||||
|
cancelled_during_run,
|
||||||
|
) = await self._run_standard_broadcast(
|
||||||
|
broadcast_id,
|
||||||
|
recipients,
|
||||||
|
config,
|
||||||
|
keyboard,
|
||||||
|
cancel_event,
|
||||||
|
)
|
||||||
|
|
||||||
async def send_single_message(user):
|
if cancelled_during_run:
|
||||||
"""Отправляет одно сообщение с семафором ограничения"""
|
logger.info(
|
||||||
async with semaphore:
|
"Рассылка %s была отменена во время выполнения, финальный статус уже установлен",
|
||||||
if cancel_event.is_set():
|
broadcast_id,
|
||||||
return False
|
)
|
||||||
|
return
|
||||||
|
|
||||||
telegram_id = getattr(user, "telegram_id", None)
|
if cancel_event.is_set():
|
||||||
if telegram_id is None:
|
logger.info(
|
||||||
return False
|
"Запрос на отмену рассылки %s пришел после завершения отправки, фиксируем итоговый статус",
|
||||||
|
broadcast_id,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
await self._mark_finished(
|
||||||
await self._deliver_message(telegram_id, config, keyboard)
|
broadcast_id,
|
||||||
return True
|
sent_count,
|
||||||
except Exception as exc: # noqa: BLE001
|
failed_count,
|
||||||
logger.error(
|
cancelled=False,
|
||||||
"Ошибка отправки рассылки %s пользователю %s: %s",
|
)
|
||||||
broadcast_id,
|
|
||||||
telegram_id,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Отправляем сообщения пакетами для эффективности
|
|
||||||
batch_size = 100
|
|
||||||
for i in range(0, len(recipients), batch_size):
|
|
||||||
if cancel_event.is_set():
|
|
||||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
|
||||||
return
|
|
||||||
|
|
||||||
batch = recipients[i:i + batch_size]
|
|
||||||
tasks = [send_single_message(user) for user in batch]
|
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
||||||
|
|
||||||
for result in results:
|
|
||||||
if result is True:
|
|
||||||
sent_count += 1
|
|
||||||
else:
|
|
||||||
failed_count += 1
|
|
||||||
|
|
||||||
# Небольшая задержка между пакетами для снижения нагрузки на API
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
|
|
||||||
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||||
@@ -198,6 +202,127 @@ class BroadcastService:
|
|||||||
return await get_custom_users(session, criteria)
|
return await get_custom_users(session, criteria)
|
||||||
return await get_target_users(session, target)
|
return await get_target_users(session, target)
|
||||||
|
|
||||||
|
async def _run_standard_broadcast(
|
||||||
|
self,
|
||||||
|
broadcast_id: int,
|
||||||
|
recipients: list,
|
||||||
|
config: BroadcastConfig,
|
||||||
|
keyboard: Optional[InlineKeyboardMarkup],
|
||||||
|
cancel_event: asyncio.Event,
|
||||||
|
) -> tuple[int, int, bool]:
|
||||||
|
"""Базовый режим рассылки для небольших списков."""
|
||||||
|
|
||||||
|
sent_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
|
||||||
|
# Ограничение на количество одновременных отправок
|
||||||
|
semaphore = asyncio.Semaphore(20)
|
||||||
|
|
||||||
|
async def send_single_message(user):
|
||||||
|
"""Отправляет одно сообщение с семафором ограничения"""
|
||||||
|
async with semaphore:
|
||||||
|
if cancel_event.is_set():
|
||||||
|
return False
|
||||||
|
|
||||||
|
telegram_id = getattr(user, "telegram_id", None)
|
||||||
|
if telegram_id is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._deliver_message(telegram_id, config, keyboard)
|
||||||
|
return True
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.error(
|
||||||
|
"Ошибка отправки рассылки %s пользователю %s: %s",
|
||||||
|
broadcast_id,
|
||||||
|
telegram_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Отправляем сообщения пакетами для эффективности
|
||||||
|
batch_size = 100
|
||||||
|
for i in range(0, len(recipients), batch_size):
|
||||||
|
if cancel_event.is_set():
|
||||||
|
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||||
|
return sent_count, failed_count, True
|
||||||
|
|
||||||
|
batch = recipients[i:i + batch_size]
|
||||||
|
tasks = [send_single_message(user) for user in batch]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
if result is True:
|
||||||
|
sent_count += 1
|
||||||
|
else:
|
||||||
|
failed_count += 1
|
||||||
|
|
||||||
|
# Небольшая задержка между пакетами для снижения нагрузки на API
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
return sent_count, failed_count, False
|
||||||
|
|
||||||
|
async def _run_resilient_broadcast(
|
||||||
|
self,
|
||||||
|
broadcast_id: int,
|
||||||
|
recipients: list,
|
||||||
|
config: BroadcastConfig,
|
||||||
|
keyboard: Optional[InlineKeyboardMarkup],
|
||||||
|
cancel_event: asyncio.Event,
|
||||||
|
) -> tuple[int, int, bool]:
|
||||||
|
"""Режим рассылки с периодическим обновлением статуса для больших списков."""
|
||||||
|
|
||||||
|
sent_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
|
||||||
|
# Ограничение на количество одновременных отправок
|
||||||
|
semaphore = asyncio.Semaphore(15)
|
||||||
|
|
||||||
|
async def send_single_message(user):
|
||||||
|
async with semaphore:
|
||||||
|
if cancel_event.is_set():
|
||||||
|
return False
|
||||||
|
|
||||||
|
telegram_id = getattr(user, "telegram_id", None)
|
||||||
|
if telegram_id is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._deliver_message(telegram_id, config, keyboard)
|
||||||
|
return True
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.error(
|
||||||
|
"Ошибка отправки рассылки %s пользователю %s: %s",
|
||||||
|
broadcast_id,
|
||||||
|
telegram_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
batch_size = 100
|
||||||
|
for i in range(0, len(recipients), batch_size):
|
||||||
|
if cancel_event.is_set():
|
||||||
|
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||||
|
return sent_count, failed_count, True
|
||||||
|
|
||||||
|
batch = recipients[i:i + batch_size]
|
||||||
|
tasks = [send_single_message(user) for user in batch]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
if result is True:
|
||||||
|
sent_count += 1
|
||||||
|
else:
|
||||||
|
failed_count += 1
|
||||||
|
|
||||||
|
processed = sent_count + failed_count
|
||||||
|
if processed % PROGRESS_UPDATE_STEP == 0:
|
||||||
|
await self._update_progress(broadcast_id, sent_count, failed_count)
|
||||||
|
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
return sent_count, failed_count, False
|
||||||
|
|
||||||
def _build_keyboard(self, selected_buttons: Optional[list[str]]) -> Optional[InlineKeyboardMarkup]:
|
def _build_keyboard(self, selected_buttons: Optional[list[str]]) -> Optional[InlineKeyboardMarkup]:
|
||||||
if selected_buttons is None:
|
if selected_buttons is None:
|
||||||
selected_buttons = []
|
selected_buttons = []
|
||||||
@@ -251,18 +376,14 @@ class BroadcastService:
|
|||||||
*,
|
*,
|
||||||
cancelled: bool,
|
cancelled: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
async with AsyncSessionLocal() as session:
|
await self._safe_status_update(
|
||||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
broadcast_id,
|
||||||
if not broadcast:
|
sent_count,
|
||||||
return
|
failed_count,
|
||||||
|
status="cancelled" if cancelled else (
|
||||||
broadcast.sent_count = sent_count
|
|
||||||
broadcast.failed_count = failed_count
|
|
||||||
broadcast.status = "cancelled" if cancelled else (
|
|
||||||
"completed" if failed_count == 0 else "partial"
|
"completed" if failed_count == 0 else "partial"
|
||||||
)
|
),
|
||||||
broadcast.completed_at = datetime.utcnow()
|
)
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
async def _mark_cancelled(
|
async def _mark_cancelled(
|
||||||
self,
|
self,
|
||||||
@@ -283,16 +404,70 @@ class BroadcastService:
|
|||||||
sent_count: int = 0,
|
sent_count: int = 0,
|
||||||
failed_count: int = 0,
|
failed_count: int = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
async with AsyncSessionLocal() as session:
|
await self._safe_status_update(
|
||||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
broadcast_id,
|
||||||
if not broadcast:
|
sent_count,
|
||||||
return
|
failed_count,
|
||||||
|
status="failed",
|
||||||
|
)
|
||||||
|
|
||||||
broadcast.sent_count = sent_count
|
async def _update_progress(
|
||||||
broadcast.failed_count = failed_count or broadcast.failed_count
|
self,
|
||||||
broadcast.status = "failed"
|
broadcast_id: int,
|
||||||
broadcast.completed_at = datetime.utcnow()
|
sent_count: int,
|
||||||
await session.commit()
|
failed_count: int,
|
||||||
|
) -> None:
|
||||||
|
"""Периодически обновляет прогресс рассылки, чтобы держать соединение активным."""
|
||||||
|
|
||||||
|
await self._safe_status_update(
|
||||||
|
broadcast_id,
|
||||||
|
sent_count,
|
||||||
|
failed_count,
|
||||||
|
status="in_progress",
|
||||||
|
update_completed_at=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _safe_status_update(
|
||||||
|
self,
|
||||||
|
broadcast_id: int,
|
||||||
|
sent_count: int,
|
||||||
|
failed_count: int,
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
update_completed_at: bool = True,
|
||||||
|
) -> None:
|
||||||
|
attempts = 0
|
||||||
|
|
||||||
|
while attempts < 2:
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||||
|
if not broadcast:
|
||||||
|
return
|
||||||
|
|
||||||
|
broadcast.sent_count = sent_count
|
||||||
|
broadcast.failed_count = failed_count
|
||||||
|
broadcast.status = status
|
||||||
|
|
||||||
|
if update_completed_at:
|
||||||
|
broadcast.completed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return
|
||||||
|
except InterfaceError as exc:
|
||||||
|
attempts += 1
|
||||||
|
logger.warning(
|
||||||
|
"Проблемы с соединением при обновлении статуса рассылки %s: %s. Повтор %s/2",
|
||||||
|
broadcast_id,
|
||||||
|
exc,
|
||||||
|
attempts,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
except SQLAlchemyError:
|
||||||
|
logger.exception(
|
||||||
|
"Не удалось обновить статус рассылки %s", broadcast_id
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
broadcast_service = BroadcastService()
|
broadcast_service = BroadcastService()
|
||||||
|
|||||||
@@ -912,12 +912,17 @@ class MonitoringService:
|
|||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Subscription)
|
select(Subscription)
|
||||||
.options(selectinload(Subscription.user))
|
.options(
|
||||||
|
selectinload(Subscription.user).options(
|
||||||
|
selectinload(User.promo_group),
|
||||||
|
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||||
|
)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and_(
|
and_(
|
||||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||||
Subscription.autopay_enabled == True,
|
Subscription.autopay_enabled == True,
|
||||||
Subscription.is_trial == False
|
Subscription.is_trial == False
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,9 +38,28 @@ class PaymentCommonMixin:
|
|||||||
texts = get_texts(user.language if user else "ru")
|
texts = get_texts(user.language if user else "ru")
|
||||||
|
|
||||||
# Определяем статус подписки, чтобы показать подходящую кнопку.
|
# Определяем статус подписки, чтобы показать подходящую кнопку.
|
||||||
has_active_subscription = bool(
|
has_active_subscription = False
|
||||||
user and user.subscription and not user.subscription.is_trial and user.subscription.is_active
|
subscription = None
|
||||||
)
|
if user:
|
||||||
|
try:
|
||||||
|
subscription = user.subscription
|
||||||
|
has_active_subscription = bool(
|
||||||
|
subscription
|
||||||
|
and not getattr(subscription, "is_trial", False)
|
||||||
|
and getattr(subscription, "is_active", False)
|
||||||
|
)
|
||||||
|
except MissingGreenlet as error:
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось лениво загрузить подписку пользователя %s при построении клавиатуры после пополнения: %s",
|
||||||
|
getattr(user, "id", None),
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
except Exception as error: # pragma: no cover - защитный код
|
||||||
|
logger.error(
|
||||||
|
"Ошибка загрузки подписки пользователя %s при построении клавиатуры после пополнения: %s",
|
||||||
|
getattr(user, "id", None),
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
# Создаем основную кнопку: если есть активная подписка - продлить, иначе купить
|
# Создаем основную кнопку: если есть активная подписка - продлить, иначе купить
|
||||||
first_button = build_miniapp_or_callback_button(
|
first_button = build_miniapp_or_callback_button(
|
||||||
@@ -86,7 +105,7 @@ class PaymentCommonMixin:
|
|||||||
])
|
])
|
||||||
else:
|
else:
|
||||||
draft_exists = await has_subscription_checkout_draft(user.id)
|
draft_exists = await has_subscription_checkout_draft(user.id)
|
||||||
if should_offer_checkout_resume(user, draft_exists):
|
if should_offer_checkout_resume(user, draft_exists, subscription=subscription):
|
||||||
keyboard_rows.append([
|
keyboard_rows.append([
|
||||||
build_miniapp_or_callback_button(
|
build_miniapp_or_callback_button(
|
||||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||||
|
|||||||
@@ -16,12 +16,24 @@ from app.database.models import PaymentMethod, TransactionType
|
|||||||
from app.services.subscription_auto_purchase_service import (
|
from app.services.subscription_auto_purchase_service import (
|
||||||
auto_purchase_saved_cart_after_topup,
|
auto_purchase_saved_cart_after_topup,
|
||||||
)
|
)
|
||||||
|
from app.services.subscription_renewal_service import (
|
||||||
|
SubscriptionRenewalChargeError,
|
||||||
|
SubscriptionRenewalPricing,
|
||||||
|
SubscriptionRenewalService,
|
||||||
|
RenewalPaymentDescriptor,
|
||||||
|
build_renewal_period_id,
|
||||||
|
decode_payment_payload,
|
||||||
|
parse_payment_metadata,
|
||||||
|
)
|
||||||
from app.utils.currency_converter import currency_converter
|
from app.utils.currency_converter import currency_converter
|
||||||
from app.utils.user_utils import format_referrer_info
|
from app.utils.user_utils import format_referrer_info
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
renewal_service = SubscriptionRenewalService()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class _AdminNotificationContext:
|
class _AdminNotificationContext:
|
||||||
user_id: int
|
user_id: int
|
||||||
@@ -173,6 +185,36 @@ class CryptoBotPaymentMixin:
|
|||||||
db, invoice_id, status, paid_at
|
db, invoice_id, status, paid_at
|
||||||
)
|
)
|
||||||
|
|
||||||
|
descriptor = decode_payment_payload(
|
||||||
|
getattr(updated_payment, "payload", "") or "",
|
||||||
|
expected_user_id=updated_payment.user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if descriptor is None:
|
||||||
|
inline_payload = payload.get("payload")
|
||||||
|
if isinstance(inline_payload, str) and inline_payload:
|
||||||
|
descriptor = decode_payment_payload(
|
||||||
|
inline_payload,
|
||||||
|
expected_user_id=updated_payment.user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if descriptor is None:
|
||||||
|
metadata = payload.get("metadata")
|
||||||
|
if isinstance(metadata, dict) and metadata:
|
||||||
|
descriptor = parse_payment_metadata(
|
||||||
|
metadata,
|
||||||
|
expected_user_id=updated_payment.user_id,
|
||||||
|
)
|
||||||
|
if descriptor:
|
||||||
|
renewal_handled = await self._process_subscription_renewal_payment(
|
||||||
|
db,
|
||||||
|
updated_payment,
|
||||||
|
descriptor,
|
||||||
|
cryptobot_crud,
|
||||||
|
)
|
||||||
|
if renewal_handled:
|
||||||
|
return True
|
||||||
|
|
||||||
if not updated_payment.transaction_id:
|
if not updated_payment.transaction_id:
|
||||||
amount_usd = updated_payment.amount_float
|
amount_usd = updated_payment.amount_float
|
||||||
|
|
||||||
@@ -339,7 +381,7 @@ class CryptoBotPaymentMixin:
|
|||||||
|
|
||||||
texts = get_texts(user.language)
|
texts = get_texts(user.language)
|
||||||
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
|
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
|
||||||
total_amount=settings.format_price(payment.amount_kopeks)
|
total_amount=settings.format_price(amount_kopeks)
|
||||||
)
|
)
|
||||||
|
|
||||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||||
@@ -360,7 +402,7 @@ class CryptoBotPaymentMixin:
|
|||||||
saved_cart_notification = _SavedCartNotificationPayload(
|
saved_cart_notification = _SavedCartNotificationPayload(
|
||||||
telegram_id=user.telegram_id,
|
telegram_id=user.telegram_id,
|
||||||
text=(
|
text=(
|
||||||
f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
|
f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n"
|
||||||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||||
f"Обязательно активируйте подписку отдельно!\n\n"
|
f"Обязательно активируйте подписку отдельно!\n\n"
|
||||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||||
@@ -394,6 +436,161 @@ class CryptoBotPaymentMixin:
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def _process_subscription_renewal_payment(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
payment: Any,
|
||||||
|
descriptor: RenewalPaymentDescriptor,
|
||||||
|
cryptobot_crud: Any,
|
||||||
|
) -> bool:
|
||||||
|
try:
|
||||||
|
payment_service_module = import_module("app.services.payment_service")
|
||||||
|
user = await payment_service_module.get_user_by_id(db, payment.user_id)
|
||||||
|
except Exception as error:
|
||||||
|
logger.error(
|
||||||
|
"Не удалось загрузить пользователя %s для продления через CryptoBot: %s",
|
||||||
|
getattr(payment, "user_id", None),
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
logger.error(
|
||||||
|
"Пользователь %s не найден при обработке продления через CryptoBot",
|
||||||
|
getattr(payment, "user_id", None),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
subscription = getattr(user, "subscription", None)
|
||||||
|
if not subscription or subscription.id != descriptor.subscription_id:
|
||||||
|
logger.warning(
|
||||||
|
"Продление через CryptoBot отклонено: подписка %s не совпадает с ожидаемой %s",
|
||||||
|
getattr(subscription, "id", None),
|
||||||
|
descriptor.subscription_id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
pricing_model: Optional[SubscriptionRenewalPricing] = None
|
||||||
|
if descriptor.pricing_snapshot:
|
||||||
|
try:
|
||||||
|
pricing_model = SubscriptionRenewalPricing.from_payload(
|
||||||
|
descriptor.pricing_snapshot
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось восстановить сохраненную стоимость продления из payload %s: %s",
|
||||||
|
payment.invoice_id,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
|
if pricing_model is None:
|
||||||
|
try:
|
||||||
|
pricing_model = await renewal_service.calculate_pricing(
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
subscription,
|
||||||
|
descriptor.period_days,
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
logger.error(
|
||||||
|
"Не удалось пересчитать стоимость продления для CryptoBot %s: %s",
|
||||||
|
payment.invoice_id,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if pricing_model.final_total != descriptor.total_amount_kopeks:
|
||||||
|
logger.warning(
|
||||||
|
"Сумма продления через CryptoBot %s изменилась (ожидалось %s, получено %s)",
|
||||||
|
payment.invoice_id,
|
||||||
|
descriptor.total_amount_kopeks,
|
||||||
|
pricing_model.final_total,
|
||||||
|
)
|
||||||
|
pricing_model.final_total = descriptor.total_amount_kopeks
|
||||||
|
pricing_model.per_month = (
|
||||||
|
descriptor.total_amount_kopeks // pricing_model.months
|
||||||
|
if pricing_model.months
|
||||||
|
else descriptor.total_amount_kopeks
|
||||||
|
)
|
||||||
|
|
||||||
|
pricing_model.period_days = descriptor.period_days
|
||||||
|
pricing_model.period_id = build_renewal_period_id(descriptor.period_days)
|
||||||
|
|
||||||
|
required_balance = max(
|
||||||
|
0,
|
||||||
|
min(
|
||||||
|
pricing_model.final_total,
|
||||||
|
descriptor.balance_component_kopeks,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
current_balance = getattr(user, "balance_kopeks", 0)
|
||||||
|
if current_balance < required_balance:
|
||||||
|
logger.warning(
|
||||||
|
"Недостаточно средств на балансе пользователя %s для завершения продления: нужно %s, доступно %s",
|
||||||
|
user.id,
|
||||||
|
required_balance,
|
||||||
|
current_balance,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
description = f"Продление подписки на {descriptor.period_days} дней"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await renewal_service.finalize(
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
subscription,
|
||||||
|
pricing_model,
|
||||||
|
charge_balance_amount=required_balance,
|
||||||
|
description=description,
|
||||||
|
payment_method=PaymentMethod.CRYPTOBOT,
|
||||||
|
)
|
||||||
|
except SubscriptionRenewalChargeError as error:
|
||||||
|
logger.error(
|
||||||
|
"Списание баланса не выполнено при продлении через CryptoBot %s: %s",
|
||||||
|
payment.invoice_id,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except Exception as error:
|
||||||
|
logger.error(
|
||||||
|
"Ошибка завершения продления через CryptoBot %s: %s",
|
||||||
|
payment.invoice_id,
|
||||||
|
error,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
transaction = result.transaction
|
||||||
|
if transaction:
|
||||||
|
try:
|
||||||
|
await cryptobot_crud.link_cryptobot_payment_to_transaction(
|
||||||
|
db,
|
||||||
|
payment.invoice_id,
|
||||||
|
transaction.id,
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось связать платеж CryptoBot %s с транзакцией %s: %s",
|
||||||
|
payment.invoice_id,
|
||||||
|
transaction.id,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
|
external_amount_label = settings.format_price(descriptor.missing_amount_kopeks)
|
||||||
|
balance_amount_label = settings.format_price(required_balance)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Подписка %s продлена через CryptoBot invoice %s (внешний платеж %s, списано с баланса %s)",
|
||||||
|
subscription.id,
|
||||||
|
payment.invoice_id,
|
||||||
|
external_amount_label,
|
||||||
|
balance_amount_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
async def _deliver_admin_topup_notification(
|
async def _deliver_admin_topup_notification(
|
||||||
self, context: _AdminNotificationContext
|
self, context: _AdminNotificationContext
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -255,6 +255,42 @@ class HeleketPaymentMixin:
|
|||||||
if updated_payment is None:
|
if updated_payment is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
metadata = dict(getattr(updated_payment, "metadata_json", {}) or {})
|
||||||
|
invoice_message = metadata.get("invoice_message") or {}
|
||||||
|
invoice_message_removed = False
|
||||||
|
|
||||||
|
if getattr(self, "bot", None) and invoice_message:
|
||||||
|
chat_id = invoice_message.get("chat_id")
|
||||||
|
message_id = invoice_message.get("message_id")
|
||||||
|
if chat_id and message_id:
|
||||||
|
try:
|
||||||
|
await self.bot.delete_message(chat_id, message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - depends on rights
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить счёт Heleket %s: %s",
|
||||||
|
message_id,
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
metadata.pop("invoice_message", None)
|
||||||
|
invoice_message_removed = True
|
||||||
|
|
||||||
|
if invoice_message_removed:
|
||||||
|
try:
|
||||||
|
from app.database.crud import heleket as heleket_crud
|
||||||
|
|
||||||
|
await heleket_crud.update_heleket_payment(
|
||||||
|
db,
|
||||||
|
updated_payment.uuid,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
updated_payment.metadata_json = metadata
|
||||||
|
except Exception as error: # pragma: no cover - diagnostics
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось обновить метаданные Heleket после удаления счёта: %s",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
if updated_payment.transaction_id:
|
if updated_payment.transaction_id:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Heleket платеж %s уже связан с транзакцией %s",
|
"Heleket платеж %s уже связан с транзакцией %s",
|
||||||
|
|||||||
@@ -182,7 +182,43 @@ class MulenPayPaymentMixin:
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||||
|
invoice_message = metadata.get("invoice_message") or {}
|
||||||
|
|
||||||
|
invoice_message_removed = False
|
||||||
|
|
||||||
|
if getattr(self, "bot", None):
|
||||||
|
chat_id = invoice_message.get("chat_id")
|
||||||
|
message_id = invoice_message.get("message_id")
|
||||||
|
if chat_id and message_id:
|
||||||
|
try:
|
||||||
|
await self.bot.delete_message(chat_id, message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - depends on bot rights
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить %s счёт %s: %s",
|
||||||
|
display_name,
|
||||||
|
message_id,
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
metadata.pop("invoice_message", None)
|
||||||
|
invoice_message_removed = True
|
||||||
|
|
||||||
if payment.is_paid:
|
if payment.is_paid:
|
||||||
|
if invoice_message_removed:
|
||||||
|
try:
|
||||||
|
await payment_module.update_mulenpay_payment_metadata(
|
||||||
|
db,
|
||||||
|
payment=payment,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
except Exception as error: # pragma: no cover - diagnostics
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось обновить метаданные %s после удаления счёта: %s",
|
||||||
|
display_name,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"%s платеж %s уже обработан, игнорируем повторный callback",
|
"%s платеж %s уже обработан, игнорируем повторный callback",
|
||||||
display_name,
|
display_name,
|
||||||
@@ -197,6 +233,7 @@ class MulenPayPaymentMixin:
|
|||||||
status="success",
|
status="success",
|
||||||
callback_payload=callback_data,
|
callback_payload=callback_data,
|
||||||
mulen_payment_id=mulen_payment_id_int,
|
mulen_payment_id=mulen_payment_id_int,
|
||||||
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
if payment.transaction_id:
|
if payment.transaction_id:
|
||||||
|
|||||||
@@ -333,6 +333,41 @@ class Pal24PaymentMixin:
|
|||||||
|
|
||||||
payment_module = import_module("app.services.payment_service")
|
payment_module = import_module("app.services.payment_service")
|
||||||
|
|
||||||
|
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||||
|
invoice_message = metadata.get("invoice_message") or {}
|
||||||
|
invoice_message_removed = False
|
||||||
|
|
||||||
|
if getattr(self, "bot", None) and invoice_message:
|
||||||
|
chat_id = invoice_message.get("chat_id")
|
||||||
|
message_id = invoice_message.get("message_id")
|
||||||
|
if chat_id and message_id:
|
||||||
|
try:
|
||||||
|
await self.bot.delete_message(chat_id, message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - depends on rights
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить счёт PayPalych %s: %s",
|
||||||
|
message_id,
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
metadata.pop("invoice_message", None)
|
||||||
|
invoice_message_removed = True
|
||||||
|
|
||||||
|
if invoice_message_removed:
|
||||||
|
try:
|
||||||
|
await payment_module.update_pal24_payment_status(
|
||||||
|
db,
|
||||||
|
payment,
|
||||||
|
status=payment.status,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
payment.metadata_json = metadata
|
||||||
|
except Exception as error: # pragma: no cover - diagnostics
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось обновить метаданные PayPalych после удаления счёта: %s",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
if payment.transaction_id:
|
if payment.transaction_id:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Pal24 платеж %s уже привязан к транзакции (trigger=%s)",
|
"Pal24 платеж %s уже привязан к транзакции (trigger=%s)",
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ class PlategaPaymentMixin:
|
|||||||
"status": status,
|
"status": status,
|
||||||
"expires_at": expires_at,
|
"expires_at": expires_at,
|
||||||
"correlation_id": correlation_id,
|
"correlation_id": correlation_id,
|
||||||
|
"payload": payload_token,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def process_platega_webhook(
|
async def process_platega_webhook(
|
||||||
@@ -306,6 +307,22 @@ class PlategaPaymentMixin:
|
|||||||
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||||
balance_already_credited = bool(metadata.get("balance_credited"))
|
balance_already_credited = bool(metadata.get("balance_credited"))
|
||||||
|
|
||||||
|
invoice_message = metadata.get("invoice_message") or {}
|
||||||
|
if getattr(self, "bot", None):
|
||||||
|
chat_id = invoice_message.get("chat_id")
|
||||||
|
message_id = invoice_message.get("message_id")
|
||||||
|
if chat_id and message_id:
|
||||||
|
try:
|
||||||
|
await self.bot.delete_message(chat_id, message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - depends on bot rights
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить Platega счёт %s: %s",
|
||||||
|
message_id,
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
metadata.pop("invoice_message", None)
|
||||||
|
|
||||||
if payment.transaction_id:
|
if payment.transaction_id:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Platega платеж %s уже связан с транзакцией %s",
|
"Platega платеж %s уже связан с транзакцией %s",
|
||||||
@@ -319,6 +336,16 @@ class PlategaPaymentMixin:
|
|||||||
logger.error("Пользователь %s не найден для Platega", payment.user_id)
|
logger.error("Пользователь %s не найден для Platega", payment.user_id)
|
||||||
return payment
|
return payment
|
||||||
|
|
||||||
|
# Убеждаемся, что промогруппы загружены в асинхронном контексте,
|
||||||
|
# чтобы избежать попыток ленивой загрузки без greenlet
|
||||||
|
await db.refresh(user, attribute_names=["promo_group", "user_promo_groups"])
|
||||||
|
for user_promo_group in getattr(user, "user_promo_groups", []):
|
||||||
|
await db.refresh(user_promo_group, attribute_names=["promo_group"])
|
||||||
|
|
||||||
|
promo_group = user.get_primary_promo_group()
|
||||||
|
subscription = getattr(user, "subscription", None)
|
||||||
|
referrer_info = format_referrer_info(user)
|
||||||
|
|
||||||
transaction_external_id = (
|
transaction_external_id = (
|
||||||
str(payload.get("id"))
|
str(payload.get("id"))
|
||||||
if isinstance(payload, dict) and payload.get("id")
|
if isinstance(payload, dict) and payload.get("id")
|
||||||
@@ -376,10 +403,6 @@ class PlategaPaymentMixin:
|
|||||||
user.updated_at = datetime.utcnow()
|
user.updated_at = datetime.utcnow()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
|
|
||||||
promo_group = user.get_primary_promo_group()
|
|
||||||
subscription = getattr(user, "subscription", None)
|
|
||||||
referrer_info = format_referrer_info(user)
|
|
||||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -515,36 +515,6 @@ class TelegramStarsMixin:
|
|||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if getattr(self, "bot", None):
|
|
||||||
try:
|
|
||||||
keyboard = await self.build_topup_success_keyboard(user)
|
|
||||||
|
|
||||||
charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8]
|
|
||||||
|
|
||||||
await self.bot.send_message(
|
|
||||||
user.telegram_id,
|
|
||||||
(
|
|
||||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
|
||||||
f"⭐ Звезд: {stars_amount}\n"
|
|
||||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
|
||||||
"🦊 Способ: Telegram Stars\n"
|
|
||||||
f"🆔 Транзакция: {charge_id_short}...\n\n"
|
|
||||||
"Баланс пополнен автоматически!"
|
|
||||||
),
|
|
||||||
parse_mode="HTML",
|
|
||||||
reply_markup=keyboard,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"✅ Отправлено уведомление пользователю %s о пополнении на %s",
|
|
||||||
user.telegram_id,
|
|
||||||
settings.format_price(amount_kopeks),
|
|
||||||
)
|
|
||||||
except Exception as error: # pragma: no cover - диагностический лог
|
|
||||||
logger.error(
|
|
||||||
"Ошибка отправки уведомления о пополнении Stars: %s",
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||||
try:
|
try:
|
||||||
from aiogram import types
|
from aiogram import types
|
||||||
|
|||||||
@@ -415,6 +415,25 @@ class WataPaymentMixin:
|
|||||||
if not paid_at and getattr(payment, "paid_at", None):
|
if not paid_at and getattr(payment, "paid_at", None):
|
||||||
paid_at = payment.paid_at
|
paid_at = payment.paid_at
|
||||||
existing_metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
existing_metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||||
|
|
||||||
|
invoice_message = existing_metadata.get("invoice_message") or {}
|
||||||
|
invoice_message_removed = False
|
||||||
|
if getattr(self, "bot", None) and invoice_message:
|
||||||
|
chat_id = invoice_message.get("chat_id")
|
||||||
|
message_id = invoice_message.get("message_id")
|
||||||
|
if chat_id and message_id:
|
||||||
|
try:
|
||||||
|
await self.bot.delete_message(chat_id, message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - depends on rights
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить счёт WATA %s: %s",
|
||||||
|
message_id,
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
invoice_message_removed = True
|
||||||
|
existing_metadata.pop("invoice_message", None)
|
||||||
|
|
||||||
existing_metadata["transaction"] = transaction_payload
|
existing_metadata["transaction"] = transaction_payload
|
||||||
|
|
||||||
await payment_module.update_wata_payment_status(
|
await payment_module.update_wata_payment_status(
|
||||||
|
|||||||
@@ -383,11 +383,19 @@ class YooKassaPaymentMixin:
|
|||||||
payment_module = import_module("app.services.payment_service")
|
payment_module = import_module("app.services.payment_service")
|
||||||
|
|
||||||
# Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования)
|
# Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования)
|
||||||
existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined]
|
get_transaction_by_external_id = getattr(
|
||||||
db,
|
payment_module, "get_transaction_by_external_id", None
|
||||||
payment.yookassa_payment_id,
|
|
||||||
PaymentMethod.YOOKASSA,
|
|
||||||
)
|
)
|
||||||
|
existing_transaction = None
|
||||||
|
if get_transaction_by_external_id:
|
||||||
|
try:
|
||||||
|
existing_transaction = await get_transaction_by_external_id( # type: ignore[attr-defined]
|
||||||
|
db,
|
||||||
|
payment.yookassa_payment_id,
|
||||||
|
PaymentMethod.YOOKASSA,
|
||||||
|
)
|
||||||
|
except AttributeError:
|
||||||
|
logger.debug("get_transaction_by_external_id недоступен, пропускаем проверку дубликатов")
|
||||||
|
|
||||||
if existing_transaction:
|
if existing_transaction:
|
||||||
# Если транзакция уже существует, просто завершаем обработку
|
# Если транзакция уже существует, просто завершаем обработку
|
||||||
@@ -437,6 +445,22 @@ class YooKassaPaymentMixin:
|
|||||||
except Exception as parse_error:
|
except Exception as parse_error:
|
||||||
logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}")
|
logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}")
|
||||||
|
|
||||||
|
invoice_message = payment_metadata.get("invoice_message") or {}
|
||||||
|
if getattr(self, "bot", None):
|
||||||
|
chat_id = invoice_message.get("chat_id")
|
||||||
|
message_id = invoice_message.get("message_id")
|
||||||
|
if chat_id and message_id:
|
||||||
|
try:
|
||||||
|
await self.bot.delete_message(chat_id, message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - depends on bot rights
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение YooKassa %s: %s",
|
||||||
|
message_id,
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
payment_metadata.pop("invoice_message", None)
|
||||||
|
|
||||||
processing_completed = bool(payment_metadata.get("processing_completed"))
|
processing_completed = bool(payment_metadata.get("processing_completed"))
|
||||||
|
|
||||||
transaction = None
|
transaction = None
|
||||||
@@ -472,11 +496,20 @@ class YooKassaPaymentMixin:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if transaction is None:
|
if transaction is None:
|
||||||
existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined]
|
get_transaction_by_external_id = getattr(
|
||||||
db,
|
payment_module, "get_transaction_by_external_id", None
|
||||||
payment.yookassa_payment_id,
|
|
||||||
PaymentMethod.YOOKASSA,
|
|
||||||
)
|
)
|
||||||
|
existing_transaction = None
|
||||||
|
|
||||||
|
if get_transaction_by_external_id:
|
||||||
|
try:
|
||||||
|
existing_transaction = await get_transaction_by_external_id( # type: ignore[attr-defined]
|
||||||
|
db,
|
||||||
|
payment.yookassa_payment_id,
|
||||||
|
PaymentMethod.YOOKASSA,
|
||||||
|
)
|
||||||
|
except AttributeError:
|
||||||
|
logger.debug("get_transaction_by_external_id недоступен, пропускаем проверку дубликатов")
|
||||||
|
|
||||||
if existing_transaction:
|
if existing_transaction:
|
||||||
# Если транзакция уже существует, пропускаем обработку
|
# Если транзакция уже существует, пропускаем обработку
|
||||||
|
|||||||
@@ -112,6 +112,11 @@ async def update_mulenpay_payment_status(*args, **kwargs):
|
|||||||
return await mulenpay_crud.update_mulenpay_payment_status(*args, **kwargs)
|
return await mulenpay_crud.update_mulenpay_payment_status(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_mulenpay_payment_metadata(*args, **kwargs):
|
||||||
|
mulenpay_crud = import_module("app.database.crud.mulenpay")
|
||||||
|
return await mulenpay_crud.update_mulenpay_payment_metadata(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
async def link_mulenpay_payment_to_transaction(*args, **kwargs):
|
async def link_mulenpay_payment_to_transaction(*args, **kwargs):
|
||||||
mulenpay_crud = import_module("app.database.crud.mulenpay")
|
mulenpay_crud = import_module("app.database.crud.mulenpay")
|
||||||
return await mulenpay_crud.link_mulenpay_payment_to_transaction(*args, **kwargs)
|
return await mulenpay_crud.link_mulenpay_payment_to_transaction(*args, **kwargs)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class PlategaService:
|
|||||||
self._max_retries = 3
|
self._max_retries = 3
|
||||||
self._retry_delay = 0.5
|
self._retry_delay = 0.5
|
||||||
self._retryable_statuses = {500, 502, 503, 504}
|
self._retryable_statuses = {500, 502, 503, 504}
|
||||||
|
self._description_max_length = 64
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
@@ -51,7 +52,10 @@ class PlategaService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if description:
|
if description:
|
||||||
body["description"] = description
|
sanitized_description = self._sanitize_description(
|
||||||
|
description, self._description_max_length
|
||||||
|
)
|
||||||
|
body["description"] = sanitized_description
|
||||||
if return_url:
|
if return_url:
|
||||||
body["return"] = return_url
|
body["return"] = return_url
|
||||||
if failed_url:
|
if failed_url:
|
||||||
@@ -176,6 +180,32 @@ class PlategaService:
|
|||||||
|
|
||||||
return None, raw_text
|
return None, raw_text
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sanitize_description(description: str, max_bytes: int) -> str:
|
||||||
|
"""Обрезает описание с учётом байтового лимита Platega."""
|
||||||
|
|
||||||
|
cleaned = (description or "").strip()
|
||||||
|
if not max_bytes:
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
encoded = cleaned.encode("utf-8")
|
||||||
|
if len(encoded) <= max_bytes:
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Platega description trimmed from %s to %s bytes",
|
||||||
|
len(encoded),
|
||||||
|
max_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
trimmed_bytes = encoded[:max_bytes]
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
return trimmed_bytes.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
trimmed_bytes = trimmed_bytes[:-1]
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_expires_at(expires_in: Optional[str]) -> Optional[datetime]:
|
def parse_expires_at(expires_in: Optional[str]) -> Optional[datetime]:
|
||||||
if not expires_in:
|
if not expires_in:
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ class PromoCodeService:
|
|||||||
if existing_use:
|
if existing_use:
|
||||||
return {"success": False, "error": "already_used_by_user"}
|
return {"success": False, "error": "already_used_by_user"}
|
||||||
|
|
||||||
|
balance_before_kopeks = user.balance_kopeks
|
||||||
|
|
||||||
result_description = await self._apply_promocode_effects(db, user, promocode)
|
result_description = await self._apply_promocode_effects(db, user, promocode)
|
||||||
|
balance_after_kopeks = user.balance_kopeks
|
||||||
|
|
||||||
if promocode.type == PromoCodeType.SUBSCRIPTION_DAYS.value and promocode.subscription_days > 0:
|
if promocode.type == PromoCodeType.SUBSCRIPTION_DAYS.value and promocode.subscription_days > 0:
|
||||||
from app.utils.user_utils import mark_user_as_had_paid_subscription
|
from app.utils.user_utils import mark_user_as_had_paid_subscription
|
||||||
@@ -123,6 +126,8 @@ class PromoCodeService:
|
|||||||
"success": True,
|
"success": True,
|
||||||
"description": result_description,
|
"description": result_description,
|
||||||
"promocode": promocode_data,
|
"promocode": promocode_data,
|
||||||
|
"balance_before_kopeks": balance_before_kopeks,
|
||||||
|
"balance_after_kopeks": balance_after_kopeks,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from app.config import settings
|
|||||||
from app.database.crud.user import add_user_balance, get_user_by_id
|
from app.database.crud.user import add_user_balance, get_user_by_id
|
||||||
from app.database.crud.referral import create_referral_earning
|
from app.database.crud.referral import create_referral_earning
|
||||||
from app.database.models import TransactionType, ReferralEarning
|
from app.database.models import TransactionType, ReferralEarning
|
||||||
|
from app.utils.user_utils import get_effective_referral_commission_percent
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -48,8 +49,9 @@ async def process_referral_registration(
|
|||||||
amount_kopeks=0,
|
amount_kopeks=0,
|
||||||
reason="referral_registration_pending"
|
reason="referral_registration_pending"
|
||||||
)
|
)
|
||||||
|
|
||||||
if bot:
|
if bot:
|
||||||
|
commission_percent = get_effective_referral_commission_percent(referrer)
|
||||||
referral_notification = (
|
referral_notification = (
|
||||||
f"🎉 <b>Добро пожаловать!</b>\n\n"
|
f"🎉 <b>Добро пожаловать!</b>\n\n"
|
||||||
f"Вы перешли по реферальной ссылке пользователя <b>{referrer.full_name}</b>!\n\n"
|
f"Вы перешли по реферальной ссылке пользователя <b>{referrer.full_name}</b>!\n\n"
|
||||||
@@ -64,8 +66,8 @@ async def process_referral_registration(
|
|||||||
f"По вашей ссылке зарегистрировался пользователь <b>{new_user.full_name}</b>!\n\n"
|
f"По вашей ссылке зарегистрировался пользователь <b>{new_user.full_name}</b>!\n\n"
|
||||||
f"💰 Когда он пополнит баланс от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}, "
|
f"💰 Когда он пополнит баланс от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}, "
|
||||||
f"вы получите минимум {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)} или "
|
f"вы получите минимум {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)} или "
|
||||||
f"{settings.REFERRAL_COMMISSION_PERCENT}% от суммы (что больше).\n\n"
|
f"{commission_percent}% от суммы (что больше).\n\n"
|
||||||
f"📈 С каждого последующего пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии."
|
f"📈 С каждого последующего пополнения вы будете получать {commission_percent}% комиссии."
|
||||||
)
|
)
|
||||||
await send_referral_notification(bot, referrer.telegram_id, inviter_notification)
|
await send_referral_notification(bot, referrer.telegram_id, inviter_notification)
|
||||||
|
|
||||||
@@ -94,13 +96,14 @@ async def process_referral_topup(
|
|||||||
logger.error(f"Реферер {user.referred_by_id} не найден")
|
logger.error(f"Реферер {user.referred_by_id} не найден")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
commission_percent = get_effective_referral_commission_percent(referrer)
|
||||||
qualifies_for_first_bonus = (
|
qualifies_for_first_bonus = (
|
||||||
topup_amount_kopeks >= settings.REFERRAL_MINIMUM_TOPUP_KOPEKS
|
topup_amount_kopeks >= settings.REFERRAL_MINIMUM_TOPUP_KOPEKS
|
||||||
)
|
)
|
||||||
commission_amount = 0
|
commission_amount = 0
|
||||||
if settings.REFERRAL_COMMISSION_PERCENT > 0:
|
if commission_percent > 0:
|
||||||
commission_amount = int(
|
commission_amount = int(
|
||||||
topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100
|
topup_amount_kopeks * commission_percent / 100
|
||||||
)
|
)
|
||||||
|
|
||||||
if not user.has_made_first_topup:
|
if not user.has_made_first_topup:
|
||||||
@@ -116,7 +119,7 @@ async def process_referral_topup(
|
|||||||
db,
|
db,
|
||||||
referrer,
|
referrer,
|
||||||
commission_amount,
|
commission_amount,
|
||||||
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
|
f"Комиссия {commission_percent}% с пополнения {user.full_name}",
|
||||||
bot=bot,
|
bot=bot,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -139,7 +142,7 @@ async def process_referral_topup(
|
|||||||
f"💰 <b>Реферальная комиссия!</b>\n\n"
|
f"💰 <b>Реферальная комиссия!</b>\n\n"
|
||||||
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
|
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
|
||||||
f"{settings.format_price(topup_amount_kopeks)}\n\n"
|
f"{settings.format_price(topup_amount_kopeks)}\n\n"
|
||||||
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
|
f"🎁 Ваша комиссия ({commission_percent}%): "
|
||||||
f"{settings.format_price(commission_amount)}\n\n"
|
f"{settings.format_price(commission_amount)}\n\n"
|
||||||
f"💎 Средства зачислены на ваш баланс."
|
f"💎 Средства зачислены на ваш баланс."
|
||||||
)
|
)
|
||||||
@@ -180,7 +183,7 @@ async def process_referral_topup(
|
|||||||
)
|
)
|
||||||
await send_referral_notification(bot, user.telegram_id, bonus_notification)
|
await send_referral_notification(bot, user.telegram_id, bonus_notification)
|
||||||
|
|
||||||
commission_amount = int(topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100)
|
commission_amount = int(topup_amount_kopeks * commission_percent / 100)
|
||||||
inviter_bonus = max(settings.REFERRAL_INVITER_BONUS_KOPEKS, commission_amount)
|
inviter_bonus = max(settings.REFERRAL_INVITER_BONUS_KOPEKS, commission_amount)
|
||||||
|
|
||||||
if inviter_bonus > 0:
|
if inviter_bonus > 0:
|
||||||
@@ -204,7 +207,7 @@ async def process_referral_topup(
|
|||||||
f"💰 <b>Реферальная награда!</b>\n\n"
|
f"💰 <b>Реферальная награда!</b>\n\n"
|
||||||
f"Ваш реферал <b>{user.full_name}</b> сделал первое пополнение!\n\n"
|
f"Ваш реферал <b>{user.full_name}</b> сделал первое пополнение!\n\n"
|
||||||
f"🎁 Вы получили награду: {settings.format_price(inviter_bonus)}\n\n"
|
f"🎁 Вы получили награду: {settings.format_price(inviter_bonus)}\n\n"
|
||||||
f"📈 Теперь с каждого его пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии."
|
f"📈 Теперь с каждого его пополнения вы будете получать {commission_percent}% комиссии."
|
||||||
)
|
)
|
||||||
await send_referral_notification(bot, referrer.telegram_id, inviter_bonus_notification)
|
await send_referral_notification(bot, referrer.telegram_id, inviter_bonus_notification)
|
||||||
|
|
||||||
@@ -212,7 +215,7 @@ async def process_referral_topup(
|
|||||||
if commission_amount > 0:
|
if commission_amount > 0:
|
||||||
await add_user_balance(
|
await add_user_balance(
|
||||||
db, referrer, commission_amount,
|
db, referrer, commission_amount,
|
||||||
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
|
f"Комиссия {commission_percent}% с пополнения {user.full_name}",
|
||||||
bot=bot
|
bot=bot
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -231,7 +234,7 @@ async def process_referral_topup(
|
|||||||
f"💰 <b>Реферальная комиссия!</b>\n\n"
|
f"💰 <b>Реферальная комиссия!</b>\n\n"
|
||||||
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
|
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
|
||||||
f"{settings.format_price(topup_amount_kopeks)}\n\n"
|
f"{settings.format_price(topup_amount_kopeks)}\n\n"
|
||||||
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
|
f"🎁 Ваша комиссия ({commission_percent}%): "
|
||||||
f"{settings.format_price(commission_amount)}\n\n"
|
f"{settings.format_price(commission_amount)}\n\n"
|
||||||
f"💎 Средства зачислены на ваш баланс."
|
f"💎 Средства зачислены на ваш баланс."
|
||||||
)
|
)
|
||||||
@@ -261,11 +264,7 @@ async def process_referral_purchase(
|
|||||||
logger.error(f"Реферер {user.referred_by_id} не найден")
|
logger.error(f"Реферер {user.referred_by_id} не найден")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not (0 <= settings.REFERRAL_COMMISSION_PERCENT <= 100):
|
commission_percent = get_effective_referral_commission_percent(referrer)
|
||||||
logger.error(f"❌ КРИТИЧЕСКАЯ ОШИБКА: REFERRAL_COMMISSION_PERCENT = {settings.REFERRAL_COMMISSION_PERCENT} некорректный!")
|
|
||||||
commission_percent = 10
|
|
||||||
else:
|
|
||||||
commission_percent = settings.REFERRAL_COMMISSION_PERCENT
|
|
||||||
|
|
||||||
commission_amount = int(purchase_amount_kopeks * commission_percent / 100)
|
commission_amount = int(purchase_amount_kopeks * commission_percent / 100)
|
||||||
|
|
||||||
|
|||||||
@@ -256,6 +256,29 @@ class RemnaWaveService:
|
|||||||
)
|
)
|
||||||
return self._now_utc() + timedelta(days=30)
|
return self._now_utc() + timedelta(days=30)
|
||||||
|
|
||||||
|
def _safe_expire_at_for_panel(self, expire_at: Optional[datetime]) -> datetime:
|
||||||
|
"""Гарантирует, что дата окончания не в прошлом для панели."""
|
||||||
|
|
||||||
|
now = self._now_utc()
|
||||||
|
minimum_expire = now + timedelta(minutes=1)
|
||||||
|
|
||||||
|
if not expire_at:
|
||||||
|
return minimum_expire
|
||||||
|
|
||||||
|
normalized_expire = expire_at
|
||||||
|
if normalized_expire.tzinfo is not None:
|
||||||
|
normalized_expire = normalized_expire.replace(tzinfo=None)
|
||||||
|
|
||||||
|
if normalized_expire < minimum_expire:
|
||||||
|
logger.debug(
|
||||||
|
"⚙️ Коррекция даты истечения (%s) до минимально допустимой (%s) для панели",
|
||||||
|
normalized_expire,
|
||||||
|
minimum_expire,
|
||||||
|
)
|
||||||
|
return minimum_expire
|
||||||
|
|
||||||
|
return normalized_expire
|
||||||
|
|
||||||
def _safe_panel_expire_date(self, panel_user: Dict[str, Any]) -> datetime:
|
def _safe_panel_expire_date(self, panel_user: Dict[str, Any]) -> datetime:
|
||||||
"""Парсит дату окончания подписки пользователя панели для сравнения."""
|
"""Парсит дату окончания подписки пользователя панели для сравнения."""
|
||||||
|
|
||||||
@@ -539,6 +562,13 @@ class RemnaWaveService:
|
|||||||
nodes_weekly_data = list(nodes_by_name.values())
|
nodes_weekly_data = list(nodes_by_name.values())
|
||||||
nodes_weekly_data.sort(key=lambda x: x['total_bytes'], reverse=True)
|
nodes_weekly_data.sort(key=lambda x: x['total_bytes'], reverse=True)
|
||||||
|
|
||||||
|
uptime_seconds = 0
|
||||||
|
uptime_value = system_stats.get('uptime')
|
||||||
|
try:
|
||||||
|
uptime_seconds = int(float(uptime_value)) if uptime_value is not None else 0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.warning(f"Не удалось преобразовать uptime '{uptime_value}' в число, используем 0")
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"system": {
|
"system": {
|
||||||
"users_online": system_stats.get('onlineStats', {}).get('onlineNow', 0),
|
"users_online": system_stats.get('onlineStats', {}).get('onlineNow', 0),
|
||||||
@@ -558,7 +588,7 @@ class RemnaWaveService:
|
|||||||
"memory_used": system_stats.get('memory', {}).get('used', 0),
|
"memory_used": system_stats.get('memory', {}).get('used', 0),
|
||||||
"memory_free": system_stats.get('memory', {}).get('free', 0),
|
"memory_free": system_stats.get('memory', {}).get('free', 0),
|
||||||
"memory_available": system_stats.get('memory', {}).get('available', 0),
|
"memory_available": system_stats.get('memory', {}).get('available', 0),
|
||||||
"uptime_seconds": system_stats.get('uptime', 0)
|
"uptime_seconds": uptime_seconds
|
||||||
},
|
},
|
||||||
"bandwidth": {
|
"bandwidth": {
|
||||||
"realtime_download": total_download,
|
"realtime_download": total_download,
|
||||||
@@ -1608,39 +1638,28 @@ class RemnaWaveService:
|
|||||||
async def sync_users_to_panel(self, db: AsyncSession) -> Dict[str, int]:
|
async def sync_users_to_panel(self, db: AsyncSession) -> Dict[str, int]:
|
||||||
try:
|
try:
|
||||||
stats = {"created": 0, "updated": 0, "errors": 0}
|
stats = {"created": 0, "updated": 0, "errors": 0}
|
||||||
|
|
||||||
users = await get_users_list(db, offset=0, limit=10000)
|
batch_size = 100
|
||||||
|
offset = 0
|
||||||
|
|
||||||
async with self.get_api_client() as api:
|
async with self.get_api_client() as api:
|
||||||
for user in users:
|
while True:
|
||||||
if not user.subscription:
|
users = await get_users_list(db, offset=offset, limit=batch_size)
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
if not users:
|
||||||
subscription = user.subscription
|
break
|
||||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
|
||||||
|
|
||||||
if user.remnawave_uuid:
|
for user in users:
|
||||||
update_kwargs = dict(
|
if not user.subscription:
|
||||||
uuid=user.remnawave_uuid,
|
continue
|
||||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
|
||||||
expire_at=subscription.end_date,
|
|
||||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
|
|
||||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
|
||||||
description=settings.format_remnawave_user_description(
|
|
||||||
full_name=user.full_name,
|
|
||||||
username=user.username,
|
|
||||||
telegram_id=user.telegram_id
|
|
||||||
),
|
|
||||||
active_internal_squads=subscription.connected_squads,
|
|
||||||
)
|
|
||||||
|
|
||||||
if hwid_limit is not None:
|
try:
|
||||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
subscription = user.subscription
|
||||||
|
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||||
|
|
||||||
|
expire_at = self._safe_expire_at_for_panel(subscription.end_date)
|
||||||
|
status = UserStatus.ACTIVE if subscription.is_active else UserStatus.DISABLED
|
||||||
|
|
||||||
await api.update_user(**update_kwargs)
|
|
||||||
stats["updated"] += 1
|
|
||||||
else:
|
|
||||||
username = settings.format_remnawave_username(
|
username = settings.format_remnawave_username(
|
||||||
full_name=user.full_name,
|
full_name=user.full_name,
|
||||||
username=user.username,
|
username=user.username,
|
||||||
@@ -1649,8 +1668,8 @@ class RemnaWaveService:
|
|||||||
|
|
||||||
create_kwargs = dict(
|
create_kwargs = dict(
|
||||||
username=username,
|
username=username,
|
||||||
expire_at=subscription.end_date,
|
expire_at=expire_at,
|
||||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
status=status,
|
||||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
|
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
|
||||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||||
telegram_id=user.telegram_id,
|
telegram_id=user.telegram_id,
|
||||||
@@ -1665,20 +1684,66 @@ class RemnaWaveService:
|
|||||||
if hwid_limit is not None:
|
if hwid_limit is not None:
|
||||||
create_kwargs['hwid_device_limit'] = hwid_limit
|
create_kwargs['hwid_device_limit'] = hwid_limit
|
||||||
|
|
||||||
new_user = await api.create_user(**create_kwargs)
|
if user.remnawave_uuid:
|
||||||
|
update_kwargs = dict(
|
||||||
await update_user(db, user, remnawave_uuid=new_user.uuid)
|
uuid=user.remnawave_uuid,
|
||||||
subscription.remnawave_short_uuid = new_user.short_uuid
|
status=status,
|
||||||
# Убираем немедленный коммит для пакетной обработки
|
expire_at=expire_at,
|
||||||
# await db.commit()
|
traffic_limit_bytes=create_kwargs['traffic_limit_bytes'],
|
||||||
|
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||||
stats["created"] += 1
|
description=create_kwargs['description'],
|
||||||
|
active_internal_squads=subscription.connected_squads,
|
||||||
except Exception as e:
|
)
|
||||||
logger.error(f"Ошибка синхронизации пользователя {user.telegram_id} в панель: {e}")
|
|
||||||
stats["errors"] += 1
|
if hwid_limit is not None:
|
||||||
|
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||||
logger.info(f"✅ Синхронизация в панель завершена: создано {stats['created']}, обновлено {stats['updated']}, ошибок {stats['errors']}")
|
|
||||||
|
try:
|
||||||
|
await api.update_user(**update_kwargs)
|
||||||
|
stats["updated"] += 1
|
||||||
|
except RemnaWaveAPIError as api_error:
|
||||||
|
if api_error.status_code == 404:
|
||||||
|
logger.warning(
|
||||||
|
"⚠️ Не найден пользователь %s в панели, создаем заново",
|
||||||
|
user.remnawave_uuid,
|
||||||
|
)
|
||||||
|
|
||||||
|
new_user = await api.create_user(**create_kwargs)
|
||||||
|
user.remnawave_uuid = new_user.uuid
|
||||||
|
subscription.remnawave_short_uuid = new_user.short_uuid
|
||||||
|
stats["created"] += 1
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
new_user = await api.create_user(**create_kwargs)
|
||||||
|
|
||||||
|
user.remnawave_uuid = new_user.uuid
|
||||||
|
subscription.remnawave_short_uuid = new_user.short_uuid
|
||||||
|
|
||||||
|
stats["created"] += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка синхронизации пользователя {user.telegram_id} в панель: {e}")
|
||||||
|
stats["errors"] += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
await db.commit()
|
||||||
|
except Exception as commit_error:
|
||||||
|
logger.error(
|
||||||
|
"Ошибка фиксации транзакции при синхронизации в панель: %s",
|
||||||
|
commit_error,
|
||||||
|
)
|
||||||
|
await db.rollback()
|
||||||
|
stats["errors"] += len(users)
|
||||||
|
|
||||||
|
if len(users) < batch_size:
|
||||||
|
break
|
||||||
|
|
||||||
|
offset += batch_size
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"✅ Синхронизация в панель завершена: создано {stats['created']}, обновлено {stats['updated']}, ошибок {stats['errors']}"
|
||||||
|
)
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from app.database.models import User
|
from sqlalchemy.exc import MissingGreenlet
|
||||||
|
|
||||||
|
from app.database.models import Subscription, User
|
||||||
from app.utils.cache import UserCache
|
from app.utils.cache import UserCache
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
_CHECKOUT_SESSION_KEY = "subscription_checkout"
|
_CHECKOUT_SESSION_KEY = "subscription_checkout"
|
||||||
_CHECKOUT_TTL_SECONDS = 3600
|
_CHECKOUT_TTL_SECONDS = 3600
|
||||||
|
|
||||||
@@ -33,7 +39,12 @@ async def has_subscription_checkout_draft(user_id: int) -> bool:
|
|||||||
return draft is not None
|
return draft is not None
|
||||||
|
|
||||||
|
|
||||||
def should_offer_checkout_resume(user: User, has_draft: bool) -> bool:
|
def should_offer_checkout_resume(
|
||||||
|
user: User,
|
||||||
|
has_draft: bool,
|
||||||
|
*,
|
||||||
|
subscription: Subscription | None = None,
|
||||||
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Determine whether checkout resume button should be available for the user.
|
Determine whether checkout resume button should be available for the user.
|
||||||
|
|
||||||
@@ -44,7 +55,16 @@ def should_offer_checkout_resume(user: User, has_draft: bool) -> bool:
|
|||||||
if not has_draft:
|
if not has_draft:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
subscription = getattr(user, "subscription", None)
|
if subscription is None:
|
||||||
|
try:
|
||||||
|
subscription = getattr(user, "subscription", None)
|
||||||
|
except MissingGreenlet as error:
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось лениво загрузить подписку пользователя %s при проверке возврата к checkout: %s",
|
||||||
|
getattr(user, "id", None),
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
subscription = None
|
||||||
|
|
||||||
if subscription is None:
|
if subscription is None:
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -0,0 +1,564 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from aiogram import Bot
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database.crud.server_squad import get_server_ids_by_uuids
|
||||||
|
from app.database.crud.subscription import (
|
||||||
|
add_subscription_servers,
|
||||||
|
calculate_subscription_total_cost,
|
||||||
|
extend_subscription,
|
||||||
|
)
|
||||||
|
from app.database.crud.transaction import create_transaction
|
||||||
|
from app.database.crud.user import subtract_user_balance
|
||||||
|
from app.database.models import PaymentMethod, Subscription, Transaction, TransactionType, User
|
||||||
|
from app.services.admin_notification_service import AdminNotificationService
|
||||||
|
from app.services.remnawave_service import RemnaWaveConfigurationError
|
||||||
|
from app.services.subscription_service import SubscriptionService
|
||||||
|
from app.utils.pricing_utils import (
|
||||||
|
apply_percentage_discount,
|
||||||
|
calculate_months_from_days,
|
||||||
|
format_period_description,
|
||||||
|
validate_pricing_calculation,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionRenewalError(Exception):
|
||||||
|
"""Base class for subscription renewal related errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionRenewalChargeError(SubscriptionRenewalError):
|
||||||
|
"""Raised when the balance charge step fails."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class SubscriptionRenewalPricing:
|
||||||
|
period_days: int
|
||||||
|
period_id: str
|
||||||
|
months: int
|
||||||
|
base_original_total: int
|
||||||
|
discounted_total: int
|
||||||
|
final_total: int
|
||||||
|
promo_discount_value: int
|
||||||
|
promo_discount_percent: int
|
||||||
|
overall_discount_percent: int
|
||||||
|
per_month: int
|
||||||
|
server_ids: List[int]
|
||||||
|
details: Dict[str, Any]
|
||||||
|
|
||||||
|
def to_payload(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"period_id": self.period_id,
|
||||||
|
"period_days": self.period_days,
|
||||||
|
"months": self.months,
|
||||||
|
"base_original_total": self.base_original_total,
|
||||||
|
"discounted_total": self.discounted_total,
|
||||||
|
"final_total": self.final_total,
|
||||||
|
"promo_discount_value": self.promo_discount_value,
|
||||||
|
"promo_discount_percent": self.promo_discount_percent,
|
||||||
|
"overall_discount_percent": self.overall_discount_percent,
|
||||||
|
"per_month": self.per_month,
|
||||||
|
"server_ids": list(self.server_ids),
|
||||||
|
"details": dict(self.details),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_payload(cls, payload: Dict[str, Any]) -> "SubscriptionRenewalPricing":
|
||||||
|
return cls(
|
||||||
|
period_days=int(payload.get("period_days", 0) or 0),
|
||||||
|
period_id=str(payload.get("period_id") or build_renewal_period_id(int(payload.get("period_days", 0) or 0))),
|
||||||
|
months=int(payload.get("months", 0) or 0),
|
||||||
|
base_original_total=int(payload.get("base_original_total", 0) or 0),
|
||||||
|
discounted_total=int(payload.get("discounted_total", 0) or 0),
|
||||||
|
final_total=int(payload.get("final_total", 0) or 0),
|
||||||
|
promo_discount_value=int(payload.get("promo_discount_value", 0) or 0),
|
||||||
|
promo_discount_percent=int(payload.get("promo_discount_percent", 0) or 0),
|
||||||
|
overall_discount_percent=int(payload.get("overall_discount_percent", 0) or 0),
|
||||||
|
per_month=int(payload.get("per_month", 0) or 0),
|
||||||
|
server_ids=list(payload.get("server_ids", []) or []),
|
||||||
|
details=dict(payload.get("details", {}) or {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class SubscriptionRenewalResult:
|
||||||
|
subscription: Subscription
|
||||||
|
transaction: Optional[Transaction]
|
||||||
|
total_amount_kopeks: int
|
||||||
|
charged_from_balance_kopeks: int
|
||||||
|
old_end_date: Optional[datetime]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RenewalPaymentDescriptor:
|
||||||
|
user_id: int
|
||||||
|
subscription_id: int
|
||||||
|
period_days: int
|
||||||
|
total_amount_kopeks: int
|
||||||
|
missing_amount_kopeks: int
|
||||||
|
payload_id: str
|
||||||
|
pricing_snapshot: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def balance_component_kopeks(self) -> int:
|
||||||
|
remaining = self.total_amount_kopeks - self.missing_amount_kopeks
|
||||||
|
return max(0, remaining)
|
||||||
|
|
||||||
|
|
||||||
|
_PAYLOAD_PREFIX = "subscription_renewal"
|
||||||
|
|
||||||
|
|
||||||
|
def build_renewal_period_id(period_days: int) -> str:
|
||||||
|
return f"days:{period_days}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_payment_descriptor(
|
||||||
|
user_id: int,
|
||||||
|
subscription_id: int,
|
||||||
|
period_days: int,
|
||||||
|
total_amount_kopeks: int,
|
||||||
|
missing_amount_kopeks: int,
|
||||||
|
*,
|
||||||
|
pricing_snapshot: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> RenewalPaymentDescriptor:
|
||||||
|
return RenewalPaymentDescriptor(
|
||||||
|
user_id=user_id,
|
||||||
|
subscription_id=subscription_id,
|
||||||
|
period_days=period_days,
|
||||||
|
total_amount_kopeks=max(0, int(total_amount_kopeks)),
|
||||||
|
missing_amount_kopeks=max(0, int(missing_amount_kopeks)),
|
||||||
|
payload_id=uuid4().hex[:8],
|
||||||
|
pricing_snapshot=pricing_snapshot or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_payment_payload(descriptor: RenewalPaymentDescriptor) -> str:
|
||||||
|
snapshot_segment = ""
|
||||||
|
if descriptor.pricing_snapshot:
|
||||||
|
try:
|
||||||
|
raw_snapshot = json.dumps(
|
||||||
|
descriptor.pricing_snapshot,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
snapshot_segment = base64.urlsafe_b64encode(raw_snapshot).decode("ascii").rstrip("=")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
snapshot_segment = ""
|
||||||
|
|
||||||
|
payload = (
|
||||||
|
f"{_PAYLOAD_PREFIX}|{descriptor.user_id}|{descriptor.subscription_id}|"
|
||||||
|
f"{descriptor.period_days}|{descriptor.total_amount_kopeks}|"
|
||||||
|
f"{descriptor.missing_amount_kopeks}|{descriptor.payload_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if snapshot_segment:
|
||||||
|
payload = f"{payload}|{snapshot_segment}"
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def decode_payment_payload(payload: str, expected_user_id: Optional[int] = None) -> Optional[RenewalPaymentDescriptor]:
|
||||||
|
if not payload or not payload.startswith(f"{_PAYLOAD_PREFIX}|"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
parts = payload.split("|")
|
||||||
|
if len(parts) < 7:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
(
|
||||||
|
_,
|
||||||
|
user_id_raw,
|
||||||
|
subscription_raw,
|
||||||
|
period_raw,
|
||||||
|
total_raw,
|
||||||
|
missing_raw,
|
||||||
|
payload_id,
|
||||||
|
*snapshot_parts,
|
||||||
|
) = parts
|
||||||
|
user_id = int(user_id_raw)
|
||||||
|
subscription_id = int(subscription_raw)
|
||||||
|
period_days = int(period_raw)
|
||||||
|
total_amount = int(total_raw)
|
||||||
|
missing_amount = int(missing_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
pricing_snapshot: Optional[Dict[str, Any]] = None
|
||||||
|
if snapshot_parts:
|
||||||
|
encoded_snapshot = snapshot_parts[0]
|
||||||
|
if encoded_snapshot:
|
||||||
|
padding = "=" * (-len(encoded_snapshot) % 4)
|
||||||
|
try:
|
||||||
|
decoded = base64.urlsafe_b64decode((encoded_snapshot + padding).encode("ascii"))
|
||||||
|
snapshot_data = json.loads(decoded.decode("utf-8"))
|
||||||
|
if isinstance(snapshot_data, dict):
|
||||||
|
pricing_snapshot = snapshot_data
|
||||||
|
except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
logger.warning("Failed to decode renewal pricing snapshot from payload")
|
||||||
|
|
||||||
|
if expected_user_id is not None and user_id != expected_user_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return RenewalPaymentDescriptor(
|
||||||
|
user_id=user_id,
|
||||||
|
subscription_id=subscription_id,
|
||||||
|
period_days=period_days,
|
||||||
|
total_amount_kopeks=max(0, total_amount),
|
||||||
|
missing_amount_kopeks=max(0, missing_amount),
|
||||||
|
payload_id=payload_id,
|
||||||
|
pricing_snapshot=pricing_snapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_payment_metadata(descriptor: RenewalPaymentDescriptor) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"payment_purpose": _PAYLOAD_PREFIX,
|
||||||
|
"subscription_id": str(descriptor.subscription_id),
|
||||||
|
"period_days": str(descriptor.period_days),
|
||||||
|
"total_amount_kopeks": str(descriptor.total_amount_kopeks),
|
||||||
|
"missing_amount_kopeks": str(descriptor.missing_amount_kopeks),
|
||||||
|
"payload_id": descriptor.payload_id,
|
||||||
|
"pricing_snapshot": descriptor.pricing_snapshot or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_payment_metadata(
|
||||||
|
metadata: Optional[Dict[str, Any]],
|
||||||
|
*,
|
||||||
|
expected_user_id: Optional[int] = None,
|
||||||
|
) -> Optional[RenewalPaymentDescriptor]:
|
||||||
|
if not metadata:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if metadata.get("payment_purpose") != _PAYLOAD_PREFIX:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
subscription_id = int(metadata.get("subscription_id"))
|
||||||
|
period_days = int(metadata.get("period_days"))
|
||||||
|
total_amount = int(metadata.get("total_amount_kopeks"))
|
||||||
|
missing_amount = int(metadata.get("missing_amount_kopeks"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload_id = str(metadata.get("payload_id") or "")
|
||||||
|
user_id = metadata.get("user_id")
|
||||||
|
if user_id is not None:
|
||||||
|
try:
|
||||||
|
user_id_int = int(user_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
user_id_int = None
|
||||||
|
else:
|
||||||
|
user_id_int = None
|
||||||
|
|
||||||
|
if expected_user_id is not None and user_id_int is not None and user_id_int != expected_user_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
pricing_snapshot = metadata.get("pricing_snapshot")
|
||||||
|
if isinstance(pricing_snapshot, dict):
|
||||||
|
snapshot_dict = pricing_snapshot
|
||||||
|
else:
|
||||||
|
snapshot_dict = None
|
||||||
|
|
||||||
|
return RenewalPaymentDescriptor(
|
||||||
|
user_id=user_id_int or expected_user_id or 0,
|
||||||
|
subscription_id=subscription_id,
|
||||||
|
period_days=period_days,
|
||||||
|
total_amount_kopeks=max(0, total_amount),
|
||||||
|
missing_amount_kopeks=max(0, missing_amount),
|
||||||
|
payload_id=payload_id,
|
||||||
|
pricing_snapshot=snapshot_dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def with_admin_notification_service(
|
||||||
|
handler: Callable[[AdminNotificationService], Awaitable[Any]],
|
||||||
|
) -> None:
|
||||||
|
if not getattr(settings, "ADMIN_NOTIFICATIONS_ENABLED", False):
|
||||||
|
return
|
||||||
|
if not settings.BOT_TOKEN:
|
||||||
|
logger.debug("Skipping admin notification: bot token is not configured")
|
||||||
|
return
|
||||||
|
|
||||||
|
bot: Bot | None = None
|
||||||
|
try:
|
||||||
|
bot = Bot(token=settings.BOT_TOKEN)
|
||||||
|
service = AdminNotificationService(bot)
|
||||||
|
await handler(service)
|
||||||
|
except Exception as error: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Failed to send admin notification from renewal service: %s", error)
|
||||||
|
finally:
|
||||||
|
if bot is not None:
|
||||||
|
await bot.session.close()
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionRenewalService:
|
||||||
|
"""Shared helpers for subscription renewal pricing and processing."""
|
||||||
|
|
||||||
|
async def calculate_pricing(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
subscription: Subscription,
|
||||||
|
period_days: int,
|
||||||
|
) -> SubscriptionRenewalPricing:
|
||||||
|
connected_uuids = [str(uuid) for uuid in list(subscription.connected_squads or [])]
|
||||||
|
server_ids: List[int] = []
|
||||||
|
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
|
||||||
|
|
||||||
|
devices_limit = subscription.device_limit
|
||||||
|
if devices_limit is None:
|
||||||
|
devices_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||||
|
|
||||||
|
total_cost, details = await calculate_subscription_total_cost(
|
||||||
|
db,
|
||||||
|
period_days,
|
||||||
|
int(traffic_limit or 0),
|
||||||
|
server_ids,
|
||||||
|
int(devices_limit or 0),
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
months = details.get("months_in_period") or calculate_months_from_days(period_days)
|
||||||
|
|
||||||
|
base_original_total = (
|
||||||
|
details.get("base_price_original", 0)
|
||||||
|
+ details.get("traffic_price_per_month", 0) * months
|
||||||
|
+ details.get("servers_price_per_month", 0) * months
|
||||||
|
+ details.get("devices_price_per_month", 0) * months
|
||||||
|
)
|
||||||
|
|
||||||
|
discounted_total = total_cost
|
||||||
|
|
||||||
|
monthly_additions = 0
|
||||||
|
if months > 0:
|
||||||
|
monthly_additions = (
|
||||||
|
details.get("total_servers_price", 0) // months
|
||||||
|
+ details.get("total_devices_price", 0) // months
|
||||||
|
+ details.get("total_traffic_price", 0) // months
|
||||||
|
)
|
||||||
|
|
||||||
|
if not validate_pricing_calculation(
|
||||||
|
details.get("base_price", 0),
|
||||||
|
monthly_additions,
|
||||||
|
months,
|
||||||
|
discounted_total,
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"Renewal pricing validation failed for subscription %s (period %s)",
|
||||||
|
subscription.id,
|
||||||
|
period_days,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||||
|
|
||||||
|
promo_percent = get_user_active_promo_discount_percent(user)
|
||||||
|
|
||||||
|
final_total = discounted_total
|
||||||
|
promo_discount_value = 0
|
||||||
|
if promo_percent > 0 and discounted_total > 0:
|
||||||
|
final_total, promo_discount_value = apply_percentage_discount(
|
||||||
|
discounted_total,
|
||||||
|
promo_percent,
|
||||||
|
)
|
||||||
|
|
||||||
|
overall_discount_value = max(0, base_original_total - final_total)
|
||||||
|
overall_discount_percent = 0
|
||||||
|
if base_original_total > 0 and overall_discount_value > 0:
|
||||||
|
overall_discount_percent = int(
|
||||||
|
round(overall_discount_value * 100 / base_original_total)
|
||||||
|
)
|
||||||
|
|
||||||
|
per_month = final_total // months if months else final_total
|
||||||
|
|
||||||
|
return SubscriptionRenewalPricing(
|
||||||
|
period_days=period_days,
|
||||||
|
period_id=build_renewal_period_id(period_days),
|
||||||
|
months=months,
|
||||||
|
base_original_total=base_original_total,
|
||||||
|
discounted_total=discounted_total,
|
||||||
|
final_total=final_total,
|
||||||
|
promo_discount_value=promo_discount_value,
|
||||||
|
promo_discount_percent=promo_percent if promo_discount_value else 0,
|
||||||
|
overall_discount_percent=overall_discount_percent,
|
||||||
|
per_month=per_month,
|
||||||
|
server_ids=list(server_ids),
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def finalize(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
subscription: Subscription,
|
||||||
|
pricing: SubscriptionRenewalPricing,
|
||||||
|
*,
|
||||||
|
charge_balance_amount: Optional[int] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
payment_method: Optional[PaymentMethod] = None,
|
||||||
|
) -> SubscriptionRenewalResult:
|
||||||
|
final_total = int(pricing.final_total)
|
||||||
|
if final_total < 0:
|
||||||
|
final_total = 0
|
||||||
|
|
||||||
|
period_days = int(pricing.period_days)
|
||||||
|
charge_from_balance = charge_balance_amount
|
||||||
|
if charge_from_balance is None:
|
||||||
|
charge_from_balance = final_total
|
||||||
|
charge_from_balance = max(0, min(charge_from_balance, final_total))
|
||||||
|
|
||||||
|
consume_promo_offer = bool(pricing.promo_discount_value)
|
||||||
|
|
||||||
|
description_text = description or f"Продление подписки на {period_days} дней"
|
||||||
|
|
||||||
|
if charge_from_balance > 0 or consume_promo_offer:
|
||||||
|
success = await subtract_user_balance(
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
charge_from_balance,
|
||||||
|
description_text,
|
||||||
|
consume_promo_offer=consume_promo_offer,
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
raise SubscriptionRenewalChargeError("Failed to charge balance")
|
||||||
|
await db.refresh(user)
|
||||||
|
|
||||||
|
subscription_before = subscription
|
||||||
|
old_end_date = subscription_before.end_date
|
||||||
|
|
||||||
|
subscription_after = await extend_subscription(db, subscription_before, period_days)
|
||||||
|
|
||||||
|
server_ids = pricing.server_ids or []
|
||||||
|
server_prices_for_period = pricing.details.get("servers_individual_prices", [])
|
||||||
|
if server_ids:
|
||||||
|
try:
|
||||||
|
await add_subscription_servers(
|
||||||
|
db,
|
||||||
|
subscription_after,
|
||||||
|
server_ids,
|
||||||
|
server_prices_for_period,
|
||||||
|
)
|
||||||
|
except Exception as error: # pragma: no cover - defensive logging
|
||||||
|
logger.warning(
|
||||||
|
"Failed to record renewal server prices for subscription %s: %s",
|
||||||
|
subscription_after.id,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
|
subscription_service = SubscriptionService()
|
||||||
|
try:
|
||||||
|
await subscription_service.update_remnawave_user(
|
||||||
|
db,
|
||||||
|
subscription_after,
|
||||||
|
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||||
|
reset_reason="subscription renewal",
|
||||||
|
)
|
||||||
|
except RemnaWaveConfigurationError as error: # pragma: no cover - configuration issues
|
||||||
|
logger.warning("RemnaWave update skipped: %s", error)
|
||||||
|
except Exception as error: # pragma: no cover - defensive logging
|
||||||
|
logger.error(
|
||||||
|
"Failed to update RemnaWave user for subscription %s: %s",
|
||||||
|
subscription_after.id,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
|
transaction: Optional[Transaction] = None
|
||||||
|
try:
|
||||||
|
transaction = await create_transaction(
|
||||||
|
db=db,
|
||||||
|
user_id=user.id,
|
||||||
|
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||||
|
amount_kopeks=final_total,
|
||||||
|
description=description_text,
|
||||||
|
payment_method=payment_method,
|
||||||
|
)
|
||||||
|
except Exception as error: # pragma: no cover - defensive logging
|
||||||
|
logger.warning(
|
||||||
|
"Failed to create renewal transaction for subscription %s: %s",
|
||||||
|
subscription_after.id,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.refresh(user)
|
||||||
|
await db.refresh(subscription_after)
|
||||||
|
|
||||||
|
if transaction and old_end_date and subscription_after.end_date:
|
||||||
|
await with_admin_notification_service(
|
||||||
|
lambda service: service.send_subscription_extension_notification(
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
subscription_after,
|
||||||
|
transaction,
|
||||||
|
period_days,
|
||||||
|
old_end_date,
|
||||||
|
new_end_date=subscription_after.end_date,
|
||||||
|
balance_after=user.balance_kopeks,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return SubscriptionRenewalResult(
|
||||||
|
subscription=subscription_after,
|
||||||
|
transaction=transaction,
|
||||||
|
total_amount_kopeks=final_total,
|
||||||
|
charged_from_balance_kopeks=charge_from_balance,
|
||||||
|
old_end_date=old_end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_option_payload(
|
||||||
|
self,
|
||||||
|
pricing: SubscriptionRenewalPricing,
|
||||||
|
*,
|
||||||
|
language: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
label = format_period_description(pricing.period_days, language)
|
||||||
|
price_label = settings.format_price(pricing.final_total)
|
||||||
|
original_label = None
|
||||||
|
if (
|
||||||
|
pricing.base_original_total
|
||||||
|
and pricing.base_original_total != pricing.final_total
|
||||||
|
):
|
||||||
|
original_label = settings.format_price(pricing.base_original_total)
|
||||||
|
|
||||||
|
per_month_label = settings.format_price(pricing.per_month)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"id": pricing.period_id,
|
||||||
|
"days": pricing.period_days,
|
||||||
|
"months": pricing.months,
|
||||||
|
"price_kopeks": pricing.final_total,
|
||||||
|
"price_label": price_label,
|
||||||
|
"original_price_kopeks": pricing.base_original_total,
|
||||||
|
"original_price_label": original_label,
|
||||||
|
"discount_percent": pricing.overall_discount_percent,
|
||||||
|
"price_per_month_kopeks": pricing.per_month,
|
||||||
|
"price_per_month_label": per_month_label,
|
||||||
|
"title": label,
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_missing_amount(balance_kopeks: int, total_kopeks: int) -> int:
|
||||||
|
if total_kopeks <= 0:
|
||||||
|
return 0
|
||||||
|
if balance_kopeks <= 0:
|
||||||
|
return total_kopeks
|
||||||
|
return max(0, total_kopeks - min(balance_kopeks, total_kopeks))
|
||||||
|
|
||||||
@@ -521,36 +521,36 @@ class SubscriptionService:
|
|||||||
|
|
||||||
total_price = base_price + discounted_traffic_price + total_servers_price + discounted_devices_price
|
total_price = base_price + discounted_traffic_price + total_servers_price + discounted_devices_price
|
||||||
|
|
||||||
logger.info(f"Расчет стоимости новой подписки:")
|
logger.debug("Расчет стоимости новой подписки:")
|
||||||
base_log = f" Период {period_days} дней: {base_price_original/100}₽"
|
base_log = f" Период {period_days} дней: {base_price_original/100}₽"
|
||||||
if base_discount_total > 0:
|
if base_discount_total > 0:
|
||||||
base_log += (
|
base_log += (
|
||||||
f" → {base_price/100}₽"
|
f" → {base_price/100}₽"
|
||||||
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
||||||
)
|
)
|
||||||
logger.info(base_log)
|
logger.debug(base_log)
|
||||||
if discounted_traffic_price > 0:
|
if discounted_traffic_price > 0:
|
||||||
message = f" Трафик {traffic_gb} ГБ: {traffic_price/100}₽"
|
message = f" Трафик {traffic_gb} ГБ: {traffic_price/100}₽"
|
||||||
if traffic_discount > 0:
|
if traffic_discount > 0:
|
||||||
message += (
|
message += (
|
||||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ → {discounted_traffic_price/100}₽)"
|
f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ → {discounted_traffic_price/100}₽)"
|
||||||
)
|
)
|
||||||
logger.info(message)
|
logger.debug(message)
|
||||||
if total_servers_price > 0:
|
if total_servers_price > 0:
|
||||||
message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽"
|
message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽"
|
||||||
if servers_discount_percent > 0:
|
if servers_discount_percent > 0:
|
||||||
message += (
|
message += (
|
||||||
f" (скидка {servers_discount_percent}% применяется ко всем серверам)"
|
f" (скидка {servers_discount_percent}% применяется ко всем серверам)"
|
||||||
)
|
)
|
||||||
logger.info(message)
|
logger.debug(message)
|
||||||
if discounted_devices_price > 0:
|
if discounted_devices_price > 0:
|
||||||
message = f" Устройства ({devices}): {devices_price/100}₽"
|
message = f" Устройства ({devices}): {devices_price/100}₽"
|
||||||
if devices_discount > 0:
|
if devices_discount > 0:
|
||||||
message += (
|
message += (
|
||||||
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ → {discounted_devices_price/100}₽)"
|
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ → {discounted_devices_price/100}₽)"
|
||||||
)
|
)
|
||||||
logger.info(message)
|
logger.debug(message)
|
||||||
logger.info(f" ИТОГО: {total_price/100}₽")
|
logger.debug(f" ИТОГО: {total_price/100}₽")
|
||||||
|
|
||||||
return total_price, server_prices
|
return total_price, server_prices
|
||||||
|
|
||||||
@@ -634,36 +634,36 @@ class SubscriptionService:
|
|||||||
+ discounted_traffic_price
|
+ discounted_traffic_price
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
|
logger.debug(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
|
||||||
base_log = f" 📅 Период {period_days} дней: {base_price_original/100}₽"
|
base_log = f" 📅 Период {period_days} дней: {base_price_original/100}₽"
|
||||||
if base_discount_total > 0:
|
if base_discount_total > 0:
|
||||||
base_log += (
|
base_log += (
|
||||||
f" → {base_price/100}₽"
|
f" → {base_price/100}₽"
|
||||||
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
||||||
)
|
)
|
||||||
logger.info(base_log)
|
logger.debug(base_log)
|
||||||
if servers_price > 0:
|
if servers_price > 0:
|
||||||
message = f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {discounted_servers_price/100}₽"
|
message = f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {discounted_servers_price/100}₽"
|
||||||
if servers_discount > 0:
|
if servers_discount > 0:
|
||||||
message += (
|
message += (
|
||||||
f" (скидка {servers_discount_percent}%: -{servers_discount/100}₽ от {servers_price/100}₽)"
|
f" (скидка {servers_discount_percent}%: -{servers_discount/100}₽ от {servers_price/100}₽)"
|
||||||
)
|
)
|
||||||
logger.info(message)
|
logger.debug(message)
|
||||||
if devices_price > 0:
|
if devices_price > 0:
|
||||||
message = f" 📱 Устройства ({device_limit}): {discounted_devices_price/100}₽"
|
message = f" 📱 Устройства ({device_limit}): {discounted_devices_price/100}₽"
|
||||||
if devices_discount > 0:
|
if devices_discount > 0:
|
||||||
message += (
|
message += (
|
||||||
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ от {devices_price/100}₽)"
|
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ от {devices_price/100}₽)"
|
||||||
)
|
)
|
||||||
logger.info(message)
|
logger.debug(message)
|
||||||
if traffic_price > 0:
|
if traffic_price > 0:
|
||||||
message = f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {discounted_traffic_price/100}₽"
|
message = f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {discounted_traffic_price/100}₽"
|
||||||
if traffic_discount > 0:
|
if traffic_discount > 0:
|
||||||
message += (
|
message += (
|
||||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ от {traffic_price/100}₽)"
|
f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ от {traffic_price/100}₽)"
|
||||||
)
|
)
|
||||||
logger.info(message)
|
logger.debug(message)
|
||||||
logger.info(f" 💎 ИТОГО: {total_price/100}₽")
|
logger.debug(f" 💎 ИТОГО: {total_price/100}₽")
|
||||||
|
|
||||||
return total_price
|
return total_price
|
||||||
|
|
||||||
@@ -855,14 +855,14 @@ class SubscriptionService:
|
|||||||
|
|
||||||
total_price = base_price + total_traffic_price + total_servers_price + total_devices_price
|
total_price = base_price + total_traffic_price + total_servers_price + total_devices_price
|
||||||
|
|
||||||
logger.info(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
|
logger.debug(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
|
||||||
base_log = f" Период {period_days} дней: {base_price_original/100}₽"
|
base_log = f" Период {period_days} дней: {base_price_original/100}₽"
|
||||||
if base_discount_total > 0:
|
if base_discount_total > 0:
|
||||||
base_log += (
|
base_log += (
|
||||||
f" → {base_price/100}₽"
|
f" → {base_price/100}₽"
|
||||||
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
||||||
)
|
)
|
||||||
logger.info(base_log)
|
logger.debug(base_log)
|
||||||
if total_traffic_price > 0:
|
if total_traffic_price > 0:
|
||||||
message = (
|
message = (
|
||||||
f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽"
|
f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽"
|
||||||
@@ -871,14 +871,14 @@ class SubscriptionService:
|
|||||||
message += (
|
message += (
|
||||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)"
|
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)"
|
||||||
)
|
)
|
||||||
logger.info(message)
|
logger.debug(message)
|
||||||
if total_servers_price > 0:
|
if total_servers_price > 0:
|
||||||
message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽"
|
message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽"
|
||||||
if servers_discount_percent > 0:
|
if servers_discount_percent > 0:
|
||||||
message += (
|
message += (
|
||||||
f" (скидка {servers_discount_percent}% применяется ко всем серверам)"
|
f" (скидка {servers_discount_percent}% применяется ко всем серверам)"
|
||||||
)
|
)
|
||||||
logger.info(message)
|
logger.debug(message)
|
||||||
if total_devices_price > 0:
|
if total_devices_price > 0:
|
||||||
message = (
|
message = (
|
||||||
f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽"
|
f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽"
|
||||||
@@ -887,8 +887,8 @@ class SubscriptionService:
|
|||||||
message += (
|
message += (
|
||||||
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)"
|
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)"
|
||||||
)
|
)
|
||||||
logger.info(message)
|
logger.debug(message)
|
||||||
logger.info(f" ИТОГО: {total_price/100}₽")
|
logger.debug(f" ИТОГО: {total_price/100}₽")
|
||||||
|
|
||||||
return total_price, server_prices
|
return total_price, server_prices
|
||||||
|
|
||||||
@@ -972,14 +972,14 @@ class SubscriptionService:
|
|||||||
|
|
||||||
total_price = base_price + total_servers_price + total_devices_price + total_traffic_price
|
total_price = base_price + total_servers_price + total_devices_price + total_traffic_price
|
||||||
|
|
||||||
logger.info(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
|
logger.debug(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
|
||||||
base_log = f" 📅 Период {period_days} дней: {base_price_original/100}₽"
|
base_log = f" 📅 Период {period_days} дней: {base_price_original/100}₽"
|
||||||
if base_discount_total > 0:
|
if base_discount_total > 0:
|
||||||
base_log += (
|
base_log += (
|
||||||
f" → {base_price/100}₽"
|
f" → {base_price/100}₽"
|
||||||
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
|
||||||
)
|
)
|
||||||
logger.info(base_log)
|
logger.debug(base_log)
|
||||||
if total_servers_price > 0:
|
if total_servers_price > 0:
|
||||||
message = (
|
message = (
|
||||||
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}₽"
|
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}₽"
|
||||||
@@ -988,7 +988,7 @@ class SubscriptionService:
|
|||||||
message += (
|
message += (
|
||||||
f" (скидка {servers_discount_percent}%: -{servers_discount_per_month * months_in_period/100}₽)"
|
f" (скидка {servers_discount_percent}%: -{servers_discount_per_month * months_in_period/100}₽)"
|
||||||
)
|
)
|
||||||
logger.info(message)
|
logger.debug(message)
|
||||||
if total_devices_price > 0:
|
if total_devices_price > 0:
|
||||||
message = (
|
message = (
|
||||||
f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽"
|
f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽"
|
||||||
@@ -997,7 +997,7 @@ class SubscriptionService:
|
|||||||
message += (
|
message += (
|
||||||
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)"
|
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)"
|
||||||
)
|
)
|
||||||
logger.info(message)
|
logger.debug(message)
|
||||||
if total_traffic_price > 0:
|
if total_traffic_price > 0:
|
||||||
message = (
|
message = (
|
||||||
f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽"
|
f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽"
|
||||||
@@ -1006,8 +1006,8 @@ class SubscriptionService:
|
|||||||
message += (
|
message += (
|
||||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)"
|
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)"
|
||||||
)
|
)
|
||||||
logger.info(message)
|
logger.debug(message)
|
||||||
logger.info(f" 💎 ИТОГО: {total_price/100}₽")
|
logger.debug(f" 💎 ИТОГО: {total_price/100}₽")
|
||||||
|
|
||||||
return total_price
|
return total_price
|
||||||
|
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ class BotConfigurationService:
|
|||||||
"SIMPLE_SUBSCRIPTION_TRAFFIC_GB": "SIMPLE_SUBSCRIPTION",
|
"SIMPLE_SUBSCRIPTION_TRAFFIC_GB": "SIMPLE_SUBSCRIPTION",
|
||||||
"SIMPLE_SUBSCRIPTION_SQUAD_UUID": "SIMPLE_SUBSCRIPTION",
|
"SIMPLE_SUBSCRIPTION_SQUAD_UUID": "SIMPLE_SUBSCRIPTION",
|
||||||
"DISABLE_TOPUP_BUTTONS": "PAYMENT",
|
"DISABLE_TOPUP_BUTTONS": "PAYMENT",
|
||||||
|
"SUPPORT_TOPUP_ENABLED": "PAYMENT",
|
||||||
"ENABLE_NOTIFICATIONS": "NOTIFICATIONS",
|
"ENABLE_NOTIFICATIONS": "NOTIFICATIONS",
|
||||||
"NOTIFICATION_RETRY_ATTEMPTS": "NOTIFICATIONS",
|
"NOTIFICATION_RETRY_ATTEMPTS": "NOTIFICATIONS",
|
||||||
"NOTIFICATION_CACHE_HOURS": "NOTIFICATIONS",
|
"NOTIFICATION_CACHE_HOURS": "NOTIFICATIONS",
|
||||||
@@ -504,6 +505,22 @@ class BotConfigurationService:
|
|||||||
"warning": "Слишком малый интервал может привести к частым обращениям к платёжным API.",
|
"warning": "Слишком малый интервал может привести к частым обращениям к платёжным API.",
|
||||||
"dependencies": "PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED",
|
"dependencies": "PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED",
|
||||||
},
|
},
|
||||||
|
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": {
|
||||||
|
"description": (
|
||||||
|
"Включает применение базовых скидок на периоды подписок в групповых промо."
|
||||||
|
),
|
||||||
|
"format": "Булево значение.",
|
||||||
|
"example": "true",
|
||||||
|
"warning": "Скидки применяются только если указаны корректные пары периодов и процентов.",
|
||||||
|
},
|
||||||
|
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": {
|
||||||
|
"description": (
|
||||||
|
"Список скидок для групп: каждая пара задаёт дни периода и процент скидки."
|
||||||
|
),
|
||||||
|
"format": "Через запятую пары вида <дней>:<скидка>.",
|
||||||
|
"example": "30:10,60:20,90:30,180:50,360:65",
|
||||||
|
"warning": "Некорректные записи будут проигнорированы. Процент ограничен 0-100.",
|
||||||
|
},
|
||||||
"AUTO_PURCHASE_AFTER_TOPUP_ENABLED": {
|
"AUTO_PURCHASE_AFTER_TOPUP_ENABLED": {
|
||||||
"description": (
|
"description": (
|
||||||
"При достаточном балансе автоматически оформляет сохранённую подписку сразу после пополнения."
|
"При достаточном балансе автоматически оформляет сохранённую подписку сразу после пополнения."
|
||||||
|
|||||||
@@ -23,10 +23,30 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class TributeService:
|
class TributeService:
|
||||||
|
_invoice_messages: Dict[int, Dict[str, int]] = {}
|
||||||
|
|
||||||
def __init__(self, bot: Bot):
|
def __init__(self, bot: Bot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.tribute_api = TributeAPI()
|
self.tribute_api = TributeAPI()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def remember_invoice_message(cls, user_id: int, chat_id: int, message_id: int) -> None:
|
||||||
|
cls._invoice_messages[user_id] = {"chat_id": chat_id, "message_id": message_id}
|
||||||
|
|
||||||
|
async def _cleanup_invoice_message(self, user_id: int) -> None:
|
||||||
|
invoice_message = self._invoice_messages.pop(user_id, None)
|
||||||
|
if not invoice_message or not getattr(self, "bot", None):
|
||||||
|
return
|
||||||
|
|
||||||
|
chat_id = invoice_message.get("chat_id")
|
||||||
|
message_id = invoice_message.get("message_id")
|
||||||
|
if not chat_id or not message_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.bot.delete_message(chat_id, message_id)
|
||||||
|
except Exception as error: # pragma: no cover - depends on bot rights
|
||||||
|
logger.warning("Не удалось удалить Tribute счёт %s: %s", message_id, error)
|
||||||
|
|
||||||
async def create_payment_link(
|
async def create_payment_link(
|
||||||
self,
|
self,
|
||||||
@@ -174,7 +194,8 @@ class TributeService:
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка отправки уведомления о Tribute пополнении: {e}")
|
logger.error(f"Ошибка отправки уведомления о Tribute пополнении: {e}")
|
||||||
|
|
||||||
|
await self._cleanup_invoice_message(user_telegram_id)
|
||||||
await self._send_success_notification(user_telegram_id, amount_kopeks)
|
await self._send_success_notification(user_telegram_id, amount_kopeks)
|
||||||
|
|
||||||
logger.info(f"🎉 Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_telegram_id}")
|
logger.info(f"🎉 Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_telegram_id}")
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from app.database.crud.subscription import (
|
|||||||
from app.database.models import (
|
from app.database.models import (
|
||||||
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
|
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
|
||||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
|
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
|
||||||
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
|
CryptoBotPayment, PlategaPayment, SubscriptionConversion, UserMessage, WelcomeText,
|
||||||
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, HeleketPayment,
|
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, HeleketPayment,
|
||||||
AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod,
|
AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod,
|
||||||
TransactionType
|
TransactionType
|
||||||
@@ -732,6 +732,27 @@ class UserService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Ошибка удаления CryptoBot платежей: {e}")
|
logger.error(f"❌ Ошибка удаления CryptoBot платежей: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
platega_result = await db.execute(
|
||||||
|
select(PlategaPayment).where(PlategaPayment.user_id == user_id)
|
||||||
|
)
|
||||||
|
platega_payments = platega_result.scalars().all()
|
||||||
|
|
||||||
|
if platega_payments:
|
||||||
|
logger.info(f"🔄 Удаляем {len(platega_payments)} Platega платежей")
|
||||||
|
await db.execute(
|
||||||
|
update(PlategaPayment)
|
||||||
|
.where(PlategaPayment.user_id == user_id)
|
||||||
|
.values(transaction_id=None)
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
await db.execute(
|
||||||
|
delete(PlategaPayment).where(PlategaPayment.user_id == user_id)
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка удаления Platega платежей: {e}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
mulenpay_result = await db.execute(
|
mulenpay_result = await db.execute(
|
||||||
select(MulenPayPayment).where(MulenPayPayment.user_id == user_id)
|
select(MulenPayPayment).where(MulenPayPayment.user_id == user_id)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from aiogram.fsm.state import State, StatesGroup
|
|||||||
class RegistrationStates(StatesGroup):
|
class RegistrationStates(StatesGroup):
|
||||||
waiting_for_language = State()
|
waiting_for_language = State()
|
||||||
waiting_for_rules_accept = State()
|
waiting_for_rules_accept = State()
|
||||||
|
waiting_for_privacy_policy_accept = State()
|
||||||
waiting_for_referral_code = State()
|
waiting_for_referral_code = State()
|
||||||
|
|
||||||
class SubscriptionStates(StatesGroup):
|
class SubscriptionStates(StatesGroup):
|
||||||
@@ -95,6 +96,7 @@ class AdminStates(StatesGroup):
|
|||||||
editing_user_devices = State()
|
editing_user_devices = State()
|
||||||
editing_user_traffic = State()
|
editing_user_traffic = State()
|
||||||
editing_user_referrals = State()
|
editing_user_referrals = State()
|
||||||
|
editing_user_referral_percent = State()
|
||||||
|
|
||||||
editing_rules_page = State()
|
editing_rules_page = State()
|
||||||
editing_privacy_policy = State()
|
editing_privacy_policy = State()
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ def is_registration_process(event: TelegramObject, current_state: Optional[str])
|
|||||||
registration_states = [
|
registration_states = [
|
||||||
RegistrationStates.waiting_for_language.state,
|
RegistrationStates.waiting_for_language.state,
|
||||||
RegistrationStates.waiting_for_rules_accept.state,
|
RegistrationStates.waiting_for_rules_accept.state,
|
||||||
|
RegistrationStates.waiting_for_privacy_policy_accept.state,
|
||||||
RegistrationStates.waiting_for_referral_code.state
|
RegistrationStates.waiting_for_referral_code.state
|
||||||
]
|
]
|
||||||
|
|
||||||
registration_callbacks = [
|
registration_callbacks = [
|
||||||
"rules_accept",
|
"rules_accept",
|
||||||
"rules_decline",
|
"rules_decline",
|
||||||
|
"privacy_policy_accept",
|
||||||
|
"privacy_policy_decline",
|
||||||
"referral_skip"
|
"referral_skip"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+22
-11
@@ -100,14 +100,14 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
|
|||||||
"callback": "topup_platega",
|
"callback": "topup_platega",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Поддержка всегда доступна
|
if settings.is_support_topup_enabled():
|
||||||
methods.append({
|
methods.append({
|
||||||
"id": "support",
|
"id": "support",
|
||||||
"name": "Через поддержку",
|
"name": "Через поддержку",
|
||||||
"icon": "🛠️",
|
"icon": "🛠️",
|
||||||
"description": "другие способы",
|
"description": "другие способы",
|
||||||
"callback": "topup_support"
|
"callback": "topup_support"
|
||||||
})
|
})
|
||||||
|
|
||||||
return methods
|
return methods
|
||||||
|
|
||||||
@@ -118,7 +118,18 @@ def get_payment_methods_text(language: str) -> str:
|
|||||||
texts = get_texts(language)
|
texts = get_texts(language)
|
||||||
methods = get_available_payment_methods()
|
methods = get_available_payment_methods()
|
||||||
|
|
||||||
if len(methods) <= 1: # Только поддержка
|
if not methods:
|
||||||
|
return texts.t(
|
||||||
|
"PAYMENT_METHODS_NONE_AVAILABLE",
|
||||||
|
"""💳 <b>Способы пополнения баланса</b>
|
||||||
|
|
||||||
|
⚠️ В данный момент способы оплаты временно недоступны.
|
||||||
|
Попробуйте позже.
|
||||||
|
|
||||||
|
Выберите способ пополнения:""",
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(methods) == 1 and methods[0]["id"] == "support":
|
||||||
return texts.t(
|
return texts.t(
|
||||||
"PAYMENT_METHODS_ONLY_SUPPORT",
|
"PAYMENT_METHODS_ONLY_SUPPORT",
|
||||||
"""💳 <b>Способы пополнения баланса</b>
|
"""💳 <b>Способы пополнения баланса</b>
|
||||||
@@ -186,7 +197,7 @@ def is_payment_method_available(method_id: str) -> bool:
|
|||||||
elif method_id == "platega":
|
elif method_id == "platega":
|
||||||
return settings.is_platega_enabled() and bool(settings.get_platega_active_methods())
|
return settings.is_platega_enabled() and bool(settings.get_platega_active_methods())
|
||||||
elif method_id == "support":
|
elif method_id == "support":
|
||||||
return True # Поддержка всегда доступна
|
return settings.is_support_topup_enabled()
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -204,7 +215,7 @@ def get_payment_method_status() -> Dict[str, bool]:
|
|||||||
"cryptobot": settings.is_cryptobot_enabled(),
|
"cryptobot": settings.is_cryptobot_enabled(),
|
||||||
"heleket": settings.is_heleket_enabled(),
|
"heleket": settings.is_heleket_enabled(),
|
||||||
"platega": settings.is_platega_enabled() and bool(settings.get_platega_active_methods()),
|
"platega": settings.is_platega_enabled() and bool(settings.get_platega_active_methods()),
|
||||||
"support": True
|
"support": settings.is_support_topup_enabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_enabled_payment_methods_count() -> int:
|
def get_enabled_payment_methods_count() -> int:
|
||||||
|
|||||||
@@ -72,11 +72,8 @@ def calculate_user_price(
|
|||||||
# Get user's promo group discount for this category
|
# Get user's promo group discount for this category
|
||||||
discount_percent = user.get_promo_discount(category, period_days)
|
discount_percent = user.get_promo_discount(category, period_days)
|
||||||
else:
|
else:
|
||||||
# For None user, use base settings discount (only for period category)
|
# For None user, use base settings discount
|
||||||
if category == "period":
|
discount_percent = settings.get_base_promo_group_period_discount(period_days)
|
||||||
discount_percent = settings.get_base_promo_group_period_discount(period_days)
|
|
||||||
else:
|
|
||||||
discount_percent = 0
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"calculate_user_price: user={user.telegram_id if user else 'None'}, "
|
f"calculate_user_price: user={user.telegram_id if user else 'None'}, "
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional, Dict, List
|
from typing import Optional, Dict, List
|
||||||
from sqlalchemy import select, func, and_, update
|
from sqlalchemy import select, func, and_, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
from app.database.models import User, ReferralEarning, Transaction, TransactionType
|
from app.database.models import User, ReferralEarning, Transaction, TransactionType
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -58,6 +60,25 @@ async def generate_unique_referral_code(db: AsyncSession, telegram_id: int) -> s
|
|||||||
return f"ref{timestamp}"
|
return f"ref{timestamp}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_effective_referral_commission_percent(user: User) -> int:
|
||||||
|
"""Возвращает индивидуальный процент комиссии пользователя или дефолтное значение."""
|
||||||
|
|
||||||
|
percent = getattr(user, "referral_commission_percent", None)
|
||||||
|
|
||||||
|
if percent is None:
|
||||||
|
percent = settings.REFERRAL_COMMISSION_PERCENT
|
||||||
|
|
||||||
|
if percent < 0 or percent > 100:
|
||||||
|
logger.error(
|
||||||
|
"❌ Некорректный процент комиссии для пользователя %s: %s",
|
||||||
|
getattr(user, "telegram_id", None),
|
||||||
|
percent,
|
||||||
|
)
|
||||||
|
return max(0, min(100, settings.REFERRAL_COMMISSION_PERCENT))
|
||||||
|
|
||||||
|
return percent
|
||||||
|
|
||||||
|
|
||||||
async def mark_user_as_had_paid_subscription(db: AsyncSession, user: User) -> bool:
|
async def mark_user_as_had_paid_subscription(db: AsyncSession, user: User) -> bool:
|
||||||
try:
|
try:
|
||||||
if user.has_had_paid_subscription:
|
if user.has_had_paid_subscription:
|
||||||
|
|||||||
+16
-13
@@ -123,23 +123,26 @@ def validate_subscription_period(days: Union[str, int]) -> Optional[int]:
|
|||||||
def sanitize_html(text: str) -> str:
|
def sanitize_html(text: str) -> str:
|
||||||
if not text:
|
if not text:
|
||||||
return text
|
return text
|
||||||
|
|
||||||
text = html.escape(text)
|
text = html.escape(text)
|
||||||
|
|
||||||
for tag in ALLOWED_HTML_TAGS:
|
allowed_tags = ALLOWED_HTML_TAGS.union(SELF_CLOSING_TAGS)
|
||||||
|
|
||||||
|
for tag in allowed_tags:
|
||||||
text = re.sub(
|
text = re.sub(
|
||||||
f'<{tag}(>|\\s[^&]*>)',
|
f'<(/?{tag}\\b[^>]*)>',
|
||||||
lambda m: m.group(0).replace('<', '<').replace('>', '>'),
|
lambda m: "<"
|
||||||
text,
|
+ (
|
||||||
|
m.group(1)
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("&", "&")
|
||||||
|
)
|
||||||
|
+ ">",
|
||||||
|
text,
|
||||||
flags=re.IGNORECASE
|
flags=re.IGNORECASE
|
||||||
)
|
)
|
||||||
text = re.sub(
|
|
||||||
f'</{tag}>',
|
|
||||||
f'</{tag}>',
|
|
||||||
text,
|
|
||||||
flags=re.IGNORECASE
|
|
||||||
)
|
|
||||||
|
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+43
-1
@@ -13,14 +13,19 @@ from .routes import (
|
|||||||
config,
|
config,
|
||||||
health,
|
health,
|
||||||
main_menu_buttons,
|
main_menu_buttons,
|
||||||
|
media,
|
||||||
miniapp,
|
miniapp,
|
||||||
|
partners,
|
||||||
polls,
|
polls,
|
||||||
promocodes,
|
promocodes,
|
||||||
promo_groups,
|
promo_groups,
|
||||||
promo_offers,
|
promo_offers,
|
||||||
|
user_messages,
|
||||||
|
welcome_texts,
|
||||||
pages,
|
pages,
|
||||||
remnawave,
|
remnawave,
|
||||||
servers,
|
servers,
|
||||||
|
subscription_events,
|
||||||
stats,
|
stats,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
tickets,
|
tickets,
|
||||||
@@ -46,7 +51,11 @@ OPENAPI_TAGS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "main-menu",
|
"name": "main-menu",
|
||||||
"description": "Управление кнопками главного меню Telegram-бота.",
|
"description": "Управление кнопками и сообщениями главного меню Telegram-бота.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "welcome-texts",
|
||||||
|
"description": "Создание, редактирование и управление приветственными текстами.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "users",
|
"name": "users",
|
||||||
@@ -96,10 +105,18 @@ OPENAPI_TAGS = [
|
|||||||
"данных между ботом и панелью."
|
"данных между ботом и панелью."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "media",
|
||||||
|
"description": "Загрузка файлов в Telegram и получение ссылок на медиа.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "miniapp",
|
"name": "miniapp",
|
||||||
"description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.",
|
"description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "partners",
|
||||||
|
"description": "Просмотр участников реферальной программы, их доходов и рефералов.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "polls",
|
"name": "polls",
|
||||||
"description": "Создание опросов, удаление, статистика и ответы пользователей.",
|
"description": "Создание опросов, удаление, статистика и ответы пользователей.",
|
||||||
@@ -108,6 +125,14 @@ OPENAPI_TAGS = [
|
|||||||
"name": "pages",
|
"name": "pages",
|
||||||
"description": "Управление контентом публичных страниц: оферта, политика, FAQ и правила.",
|
"description": "Управление контентом публичных страниц: оферта, политика, FAQ и правила.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "notifications",
|
||||||
|
"description": (
|
||||||
|
"Получение и просмотр уведомлений о покупках, активациях и продлениях подписок, "
|
||||||
|
"пополнениях баланса, активациях промокодов, переходах по реферальным ссылкам и "
|
||||||
|
"сменах промогрупп пользователей для административной панели."
|
||||||
|
),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -151,6 +176,16 @@ def create_web_api_app() -> FastAPI:
|
|||||||
prefix="/main-menu/buttons",
|
prefix="/main-menu/buttons",
|
||||||
tags=["main-menu"],
|
tags=["main-menu"],
|
||||||
)
|
)
|
||||||
|
app.include_router(
|
||||||
|
user_messages.router,
|
||||||
|
prefix="/main-menu/messages",
|
||||||
|
tags=["main-menu"],
|
||||||
|
)
|
||||||
|
app.include_router(
|
||||||
|
welcome_texts.router,
|
||||||
|
prefix="/welcome-texts",
|
||||||
|
tags=["welcome-texts"],
|
||||||
|
)
|
||||||
app.include_router(pages.router, prefix="/pages", tags=["pages"])
|
app.include_router(pages.router, prefix="/pages", tags=["pages"])
|
||||||
app.include_router(promocodes.router, prefix="/promo-codes", tags=["promo-codes"])
|
app.include_router(promocodes.router, prefix="/promo-codes", tags=["promo-codes"])
|
||||||
app.include_router(broadcasts.router, prefix="/broadcasts", tags=["broadcasts"])
|
app.include_router(broadcasts.router, prefix="/broadcasts", tags=["broadcasts"])
|
||||||
@@ -158,8 +193,15 @@ def create_web_api_app() -> FastAPI:
|
|||||||
app.include_router(campaigns.router, prefix="/campaigns", tags=["campaigns"])
|
app.include_router(campaigns.router, prefix="/campaigns", tags=["campaigns"])
|
||||||
app.include_router(tokens.router, prefix="/tokens", tags=["auth"])
|
app.include_router(tokens.router, prefix="/tokens", tags=["auth"])
|
||||||
app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"])
|
app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"])
|
||||||
|
app.include_router(media.router, tags=["media"])
|
||||||
app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"])
|
app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"])
|
||||||
|
app.include_router(partners.router, prefix="/partners", tags=["partners"])
|
||||||
app.include_router(polls.router, prefix="/polls", tags=["polls"])
|
app.include_router(polls.router, prefix="/polls", tags=["polls"])
|
||||||
app.include_router(logs.router, prefix="/logs", tags=["logs"])
|
app.include_router(logs.router, prefix="/logs", tags=["logs"])
|
||||||
|
app.include_router(
|
||||||
|
subscription_events.router,
|
||||||
|
prefix="/notifications/subscriptions",
|
||||||
|
tags=["notifications"],
|
||||||
|
)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -2,13 +2,18 @@ from . import (
|
|||||||
config,
|
config,
|
||||||
health,
|
health,
|
||||||
main_menu_buttons,
|
main_menu_buttons,
|
||||||
|
media,
|
||||||
miniapp,
|
miniapp,
|
||||||
|
partners,
|
||||||
polls,
|
polls,
|
||||||
promo_offers,
|
promo_offers,
|
||||||
|
user_messages,
|
||||||
|
welcome_texts,
|
||||||
pages,
|
pages,
|
||||||
promo_groups,
|
promo_groups,
|
||||||
servers,
|
servers,
|
||||||
remnawave,
|
remnawave,
|
||||||
|
subscription_events,
|
||||||
stats,
|
stats,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
tickets,
|
tickets,
|
||||||
@@ -22,13 +27,18 @@ __all__ = [
|
|||||||
"config",
|
"config",
|
||||||
"health",
|
"health",
|
||||||
"main_menu_buttons",
|
"main_menu_buttons",
|
||||||
|
"media",
|
||||||
"miniapp",
|
"miniapp",
|
||||||
|
"partners",
|
||||||
"polls",
|
"polls",
|
||||||
"promo_offers",
|
"promo_offers",
|
||||||
|
"user_messages",
|
||||||
|
"welcome_texts",
|
||||||
"pages",
|
"pages",
|
||||||
"promo_groups",
|
"promo_groups",
|
||||||
"servers",
|
"servers",
|
||||||
"remnawave",
|
"remnawave",
|
||||||
|
"subscription_events",
|
||||||
"stats",
|
"stats",
|
||||||
"subscriptions",
|
"subscriptions",
|
||||||
"tickets",
|
"tickets",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from ..schemas.logs import (
|
|||||||
SupportAuditLogEntry,
|
SupportAuditLogEntry,
|
||||||
SupportAuditLogListResponse,
|
SupportAuditLogListResponse,
|
||||||
SystemLogPreviewResponse,
|
SystemLogPreviewResponse,
|
||||||
|
SystemLogFullResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -141,6 +142,33 @@ async def download_system_log(
|
|||||||
raise HTTPException(status_code=500, detail="Не удалось отправить лог-файл") from error
|
raise HTTPException(status_code=500, detail="Не удалось отправить лог-файл") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/system/full", response_model=SystemLogFullResponse)
|
||||||
|
async def get_system_log_full(
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
) -> SystemLogFullResponse:
|
||||||
|
"""Получить полный системный лог-файл бота."""
|
||||||
|
|
||||||
|
log_path = _resolve_system_log_path()
|
||||||
|
|
||||||
|
if not log_path.exists() or not log_path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Лог-файл не найден")
|
||||||
|
|
||||||
|
try:
|
||||||
|
content, size_bytes, mtime = await _read_system_log(log_path)
|
||||||
|
except Exception as error: # pragma: no cover - защита от неожиданных ошибок чтения
|
||||||
|
logger.error("Ошибка чтения лог-файла %s: %s", log_path, error)
|
||||||
|
raise HTTPException(status_code=500, detail="Не удалось прочитать лог-файл") from error
|
||||||
|
|
||||||
|
return SystemLogFullResponse(
|
||||||
|
path=str(log_path),
|
||||||
|
exists=True,
|
||||||
|
updated_at=_format_timestamp(mtime),
|
||||||
|
size_bytes=size_bytes,
|
||||||
|
size_chars=len(content),
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/monitoring", response_model=MonitoringLogListResponse)
|
@router.get("/monitoring", response_model=MonitoringLogListResponse)
|
||||||
async def list_monitoring_logs(
|
async def list_monitoring_logs(
|
||||||
_: Any = Security(require_api_token),
|
_: Any = Security(require_api_token),
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiogram import Bot
|
||||||
|
from aiogram.client.default import DefaultBotProperties
|
||||||
|
from aiogram.enums import ParseMode
|
||||||
|
from aiogram.types import BufferedInputFile
|
||||||
|
from fastapi import (
|
||||||
|
APIRouter,
|
||||||
|
File,
|
||||||
|
Form,
|
||||||
|
HTTPException,
|
||||||
|
Request,
|
||||||
|
Response,
|
||||||
|
Security,
|
||||||
|
UploadFile,
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
from ..dependencies import require_api_token
|
||||||
|
from ..schemas.media import MediaUploadResponse
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ALLOWED_MEDIA_TYPES = {"photo", "video", "document"}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_target_chat_id() -> int:
|
||||||
|
"""Выбирает чат для загрузки файлов (канал уведомлений или первый админ)."""
|
||||||
|
|
||||||
|
chat_id = settings.get_admin_notifications_chat_id()
|
||||||
|
if chat_id is not None:
|
||||||
|
return chat_id
|
||||||
|
|
||||||
|
admin_ids = settings.get_admin_ids()
|
||||||
|
if admin_ids:
|
||||||
|
return admin_ids[0]
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
"Не настроен чат для загрузки файлов (ADMIN_NOTIFICATIONS_CHAT_ID или ADMIN_IDS)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_media_url(request: Request, file_id: str) -> str:
|
||||||
|
return str(request.url_for("download_media", file_id=file_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload", response_model=MediaUploadResponse, tags=["media"], status_code=status.HTTP_201_CREATED)
|
||||||
|
async def upload_media(
|
||||||
|
request: Request,
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
media_type: str = Form("document", description="Тип файла: photo, video или document"),
|
||||||
|
caption: str | None = Form(None, description="Необязательная подпись к файлу"),
|
||||||
|
) -> MediaUploadResponse:
|
||||||
|
media_type_normalized = (media_type or "").strip().lower()
|
||||||
|
if media_type_normalized not in ALLOWED_MEDIA_TYPES:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unsupported media type")
|
||||||
|
|
||||||
|
file_bytes = await file.read()
|
||||||
|
if not file_bytes:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "File is empty")
|
||||||
|
|
||||||
|
target_chat_id = _resolve_target_chat_id()
|
||||||
|
upload = BufferedInputFile(file_bytes, filename=file.filename or "upload")
|
||||||
|
|
||||||
|
bot = Bot(
|
||||||
|
token=settings.BOT_TOKEN,
|
||||||
|
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if media_type_normalized == "photo":
|
||||||
|
message = await bot.send_photo(
|
||||||
|
chat_id=target_chat_id,
|
||||||
|
photo=upload,
|
||||||
|
caption=caption,
|
||||||
|
)
|
||||||
|
media = message.photo[-1]
|
||||||
|
elif media_type_normalized == "video":
|
||||||
|
message = await bot.send_video(
|
||||||
|
chat_id=target_chat_id,
|
||||||
|
video=upload,
|
||||||
|
caption=caption,
|
||||||
|
)
|
||||||
|
media = message.video
|
||||||
|
else:
|
||||||
|
message = await bot.send_document(
|
||||||
|
chat_id=target_chat_id,
|
||||||
|
document=upload,
|
||||||
|
caption=caption,
|
||||||
|
)
|
||||||
|
media = message.document
|
||||||
|
|
||||||
|
media_url = _build_media_url(request, media.file_id)
|
||||||
|
return MediaUploadResponse(
|
||||||
|
media_type=media_type_normalized,
|
||||||
|
file_id=media.file_id,
|
||||||
|
file_unique_id=getattr(media, "file_unique_id", None),
|
||||||
|
media_url=media_url,
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as error:
|
||||||
|
logger.error("Failed to upload media: %s", error)
|
||||||
|
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to upload media") from error
|
||||||
|
finally:
|
||||||
|
await bot.session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/media/{file_id}", name="download_media", tags=["media"])
|
||||||
|
async def download_media(
|
||||||
|
file_id: str,
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
) -> Response:
|
||||||
|
bot = Bot(
|
||||||
|
token=settings.BOT_TOKEN,
|
||||||
|
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
file = await bot.get_file(file_id)
|
||||||
|
if not file.file_path:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Media file not found")
|
||||||
|
|
||||||
|
buffer = await bot.download_file(file.file_path)
|
||||||
|
|
||||||
|
if hasattr(buffer, "seek"):
|
||||||
|
buffer.seek(0)
|
||||||
|
|
||||||
|
content = buffer.read() if hasattr(buffer, "read") else bytes(buffer)
|
||||||
|
filename = file.file_path.split("/")[-1]
|
||||||
|
|
||||||
|
media_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type=media_type,
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"inline; filename={filename}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as error: # pragma: no cover - неожиданные ошибки загрузки файла
|
||||||
|
logger.error("Failed to download media %s: %s", file_id, error)
|
||||||
|
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to download media") from error
|
||||||
|
finally:
|
||||||
|
await bot.session.close()
|
||||||
|
|
||||||
+623
-272
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,199 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||||
|
from sqlalchemy import func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import aliased, selectinload
|
||||||
|
|
||||||
|
from app.database.crud.referral import get_user_referral_stats
|
||||||
|
from app.database.crud.user import (
|
||||||
|
get_user_by_id,
|
||||||
|
get_user_by_telegram_id,
|
||||||
|
update_user,
|
||||||
|
)
|
||||||
|
from app.database.models import User
|
||||||
|
from app.utils.user_utils import (
|
||||||
|
get_detailed_referral_list,
|
||||||
|
get_effective_referral_commission_percent,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..dependencies import get_db_session, require_api_token
|
||||||
|
from ..schemas.partners import (
|
||||||
|
PartnerReferralItem,
|
||||||
|
PartnerReferralList,
|
||||||
|
PartnerReferralCommissionUpdate,
|
||||||
|
PartnerReferrerDetail,
|
||||||
|
PartnerReferrerItem,
|
||||||
|
PartnerReferrerListResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_search_filter(query, search: str):
|
||||||
|
search_lower = f"%{search.lower()}%"
|
||||||
|
conditions = [
|
||||||
|
func.lower(User.username).like(search_lower),
|
||||||
|
func.lower(User.first_name).like(search_lower),
|
||||||
|
func.lower(User.last_name).like(search_lower),
|
||||||
|
func.lower(User.referral_code).like(search_lower),
|
||||||
|
]
|
||||||
|
|
||||||
|
if search.isdigit():
|
||||||
|
conditions.append(User.telegram_id == int(search))
|
||||||
|
conditions.append(User.id == int(search))
|
||||||
|
|
||||||
|
return query.where(or_(*conditions))
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_referrer(user: User, stats: dict) -> PartnerReferrerItem:
|
||||||
|
total_earned_kopeks = int(stats.get("total_earned_kopeks") or 0)
|
||||||
|
month_earned_kopeks = int(stats.get("month_earned_kopeks") or 0)
|
||||||
|
|
||||||
|
return PartnerReferrerItem(
|
||||||
|
id=user.id,
|
||||||
|
telegram_id=user.telegram_id,
|
||||||
|
username=user.username,
|
||||||
|
first_name=user.first_name,
|
||||||
|
last_name=user.last_name,
|
||||||
|
referral_code=user.referral_code,
|
||||||
|
referral_commission_percent=getattr(user, "referral_commission_percent", None),
|
||||||
|
effective_referral_commission_percent=get_effective_referral_commission_percent(user),
|
||||||
|
invited_count=int(stats.get("invited_count") or 0),
|
||||||
|
active_referrals=int(stats.get("active_referrals") or 0),
|
||||||
|
total_earned_kopeks=total_earned_kopeks,
|
||||||
|
total_earned_rubles=round(total_earned_kopeks / 100, 2),
|
||||||
|
month_earned_kopeks=month_earned_kopeks,
|
||||||
|
month_earned_rubles=round(month_earned_kopeks / 100, 2),
|
||||||
|
created_at=user.created_at,
|
||||||
|
last_activity=user.last_activity,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_referral_item(referral: dict) -> PartnerReferralItem:
|
||||||
|
balance_kopeks = int(referral.get("balance_kopeks") or 0)
|
||||||
|
total_earned_kopeks = int(referral.get("total_earned_kopeks") or 0)
|
||||||
|
|
||||||
|
return PartnerReferralItem(
|
||||||
|
id=int(referral.get("id")),
|
||||||
|
telegram_id=int(referral.get("telegram_id")),
|
||||||
|
full_name=str(referral.get("full_name")),
|
||||||
|
username=referral.get("username"),
|
||||||
|
created_at=referral.get("created_at"),
|
||||||
|
last_activity=referral.get("last_activity"),
|
||||||
|
has_made_first_topup=bool(referral.get("has_made_first_topup", False)),
|
||||||
|
balance_kopeks=balance_kopeks,
|
||||||
|
balance_rubles=round(balance_kopeks / 100, 2),
|
||||||
|
total_earned_kopeks=total_earned_kopeks,
|
||||||
|
total_earned_rubles=round(total_earned_kopeks / 100, 2),
|
||||||
|
topups_count=int(referral.get("topups_count") or 0),
|
||||||
|
days_since_registration=int(referral.get("days_since_registration") or 0),
|
||||||
|
days_since_activity=referral.get("days_since_activity"),
|
||||||
|
status=str(referral.get("status") or "inactive"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/referrers", response_model=PartnerReferrerListResponse)
|
||||||
|
async def list_referrers(
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
search: Optional[str] = Query(default=None),
|
||||||
|
) -> PartnerReferrerListResponse:
|
||||||
|
referral_alias = aliased(User)
|
||||||
|
has_referrals = (
|
||||||
|
select(referral_alias.id)
|
||||||
|
.where(referral_alias.referred_by_id == User.id)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
base_query = select(User).options(selectinload(User.referrer)).where(
|
||||||
|
or_(User.referral_code.isnot(None), has_referrals)
|
||||||
|
)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
base_query = _apply_search_filter(base_query, search)
|
||||||
|
|
||||||
|
total_query = base_query.with_only_columns(func.count()).order_by(None)
|
||||||
|
total = await db.scalar(total_query) or 0
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
base_query.order_by(User.created_at.desc()).offset(offset).limit(limit)
|
||||||
|
)
|
||||||
|
referrers = result.scalars().unique().all()
|
||||||
|
|
||||||
|
items: list[PartnerReferrerItem] = []
|
||||||
|
for referrer in referrers:
|
||||||
|
stats = await get_user_referral_stats(db, referrer.id)
|
||||||
|
items.append(_serialize_referrer(referrer, stats))
|
||||||
|
|
||||||
|
return PartnerReferrerListResponse(
|
||||||
|
items=items,
|
||||||
|
total=int(total),
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/referrers/{user_id}", response_model=PartnerReferrerDetail)
|
||||||
|
async def get_referrer_detail(
|
||||||
|
user_id: int,
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
) -> PartnerReferrerDetail:
|
||||||
|
user = await get_user_by_telegram_id(db, user_id)
|
||||||
|
if not user:
|
||||||
|
user = await get_user_by_id(db, user_id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||||
|
|
||||||
|
stats = await get_user_referral_stats(db, user.id)
|
||||||
|
referrer_item = _serialize_referrer(user, stats)
|
||||||
|
|
||||||
|
referrals_data = await get_detailed_referral_list(db, user.id, limit=limit, offset=offset)
|
||||||
|
referral_items = [
|
||||||
|
_serialize_referral_item(referral) for referral in referrals_data.get("referrals", [])
|
||||||
|
]
|
||||||
|
|
||||||
|
referrals_list = PartnerReferralList(
|
||||||
|
items=referral_items,
|
||||||
|
total=int(referrals_data.get("total_count") or 0),
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
has_next=bool(referrals_data.get("has_next")),
|
||||||
|
has_prev=bool(referrals_data.get("has_prev")),
|
||||||
|
current_page=int(referrals_data.get("current_page") or 1),
|
||||||
|
total_pages=int(referrals_data.get("total_pages") or 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
return PartnerReferrerDetail(referrer=referrer_item, referrals=referrals_list)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/referrers/{user_id}/commission", response_model=PartnerReferrerItem)
|
||||||
|
async def update_referrer_commission(
|
||||||
|
user_id: int,
|
||||||
|
payload: PartnerReferralCommissionUpdate,
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> PartnerReferrerItem:
|
||||||
|
user = await get_user_by_telegram_id(db, user_id)
|
||||||
|
if not user:
|
||||||
|
user = await get_user_by_id(db, user_id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||||
|
|
||||||
|
await update_user(
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
referral_commission_percent=payload.referral_commission_percent,
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = await get_user_referral_stats(db, user.id)
|
||||||
|
return _serialize_referrer(user, stats)
|
||||||
@@ -2,6 +2,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from aiogram import Bot
|
||||||
|
from aiogram.client.default import DefaultBotProperties
|
||||||
|
from aiogram.enums import ParseMode
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
APIRouter,
|
APIRouter,
|
||||||
Depends,
|
Depends,
|
||||||
@@ -23,6 +26,8 @@ from app.database.crud.poll import (
|
|||||||
get_poll_statistics,
|
get_poll_statistics,
|
||||||
)
|
)
|
||||||
from app.database.models import Poll, PollAnswer, PollOption, PollQuestion, PollResponse
|
from app.database.models import Poll, PollAnswer, PollOption, PollQuestion, PollResponse
|
||||||
|
from app.handlers.admin.messages import get_custom_users, get_target_users
|
||||||
|
from app.services.poll_service import send_poll_to_users
|
||||||
|
|
||||||
from ..dependencies import get_db_session, require_api_token
|
from ..dependencies import get_db_session, require_api_token
|
||||||
from ..schemas.polls import (
|
from ..schemas.polls import (
|
||||||
@@ -38,6 +43,8 @@ from ..schemas.polls import (
|
|||||||
PollStatisticsResponse,
|
PollStatisticsResponse,
|
||||||
PollSummaryResponse,
|
PollSummaryResponse,
|
||||||
PollUserResponse,
|
PollUserResponse,
|
||||||
|
PollSendRequest,
|
||||||
|
PollSendResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -306,3 +313,55 @@ async def get_poll_responses(
|
|||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{poll_id}/send", response_model=PollSendResponse)
|
||||||
|
async def send_poll(
|
||||||
|
poll_id: int,
|
||||||
|
payload: PollSendRequest,
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> PollSendResponse:
|
||||||
|
poll = await get_poll_by_id(db, poll_id)
|
||||||
|
if not poll:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found")
|
||||||
|
|
||||||
|
target = payload.target.strip()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Target must not be empty")
|
||||||
|
|
||||||
|
if target.startswith("custom_"):
|
||||||
|
users = await get_custom_users(db, target.replace("custom_", ""))
|
||||||
|
else:
|
||||||
|
users = await get_target_users(db, target)
|
||||||
|
|
||||||
|
if not users:
|
||||||
|
return PollSendResponse(
|
||||||
|
poll_id=poll_id,
|
||||||
|
target=target,
|
||||||
|
sent=0,
|
||||||
|
failed=0,
|
||||||
|
skipped=0,
|
||||||
|
total=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
bot = Bot(
|
||||||
|
token=settings.BOT_TOKEN,
|
||||||
|
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await send_poll_to_users(bot, db, poll, users)
|
||||||
|
finally:
|
||||||
|
await bot.session.close()
|
||||||
|
|
||||||
|
return PollSendResponse(
|
||||||
|
poll_id=poll_id,
|
||||||
|
target=target,
|
||||||
|
sent=result.get("sent", 0),
|
||||||
|
failed=result.get("failed", 0),
|
||||||
|
skipped=result.get("skipped", 0),
|
||||||
|
total=result.get("total", 0),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Iterable, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database.models import Subscription, SubscriptionEvent, Transaction, User
|
||||||
|
from app.database.crud.subscription_event import (
|
||||||
|
create_subscription_event,
|
||||||
|
list_subscription_events,
|
||||||
|
)
|
||||||
|
from ..dependencies import get_db_session, require_api_token
|
||||||
|
from ..schemas.subscription_events import (
|
||||||
|
SubscriptionEventCreate,
|
||||||
|
SubscriptionEventListResponse,
|
||||||
|
SubscriptionEventResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_user_or_error(db: AsyncSession, user_id: int) -> User:
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_subscription_exists(
|
||||||
|
db: AsyncSession, subscription_id: Optional[int]
|
||||||
|
) -> None:
|
||||||
|
if not subscription_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
subscription = await db.get(Subscription, subscription_id)
|
||||||
|
if not subscription:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Subscription not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_transaction_exists(db: AsyncSession, transaction_id: Optional[int]) -> None:
|
||||||
|
if not transaction_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
transaction_exists = await db.scalar(
|
||||||
|
select(Transaction.id).where(Transaction.id == transaction_id)
|
||||||
|
)
|
||||||
|
if not transaction_exists:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Transaction not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_event(event: SubscriptionEvent) -> SubscriptionEventResponse:
|
||||||
|
user = event.user
|
||||||
|
|
||||||
|
extra = event.extra or {}
|
||||||
|
|
||||||
|
if event.event_type == "promocode_activation":
|
||||||
|
extra = {**extra}
|
||||||
|
extra.setdefault("balance_before_kopeks", None)
|
||||||
|
extra.setdefault("balance_after_kopeks", None)
|
||||||
|
|
||||||
|
return SubscriptionEventResponse(
|
||||||
|
id=event.id,
|
||||||
|
event_type=event.event_type,
|
||||||
|
user_id=event.user_id,
|
||||||
|
user_full_name=user.full_name if user else "",
|
||||||
|
user_username=user.username if user else None,
|
||||||
|
user_telegram_id=user.telegram_id if user else 0,
|
||||||
|
subscription_id=event.subscription_id,
|
||||||
|
transaction_id=event.transaction_id,
|
||||||
|
amount_kopeks=event.amount_kopeks,
|
||||||
|
currency=event.currency,
|
||||||
|
message=event.message,
|
||||||
|
occurred_at=event.occurred_at,
|
||||||
|
created_at=event.created_at,
|
||||||
|
extra=extra,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=SubscriptionEventResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def receive_subscription_event(
|
||||||
|
payload: SubscriptionEventCreate,
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> SubscriptionEventResponse:
|
||||||
|
user = await _get_user_or_error(db, payload.user_id)
|
||||||
|
await _ensure_subscription_exists(db, payload.subscription_id)
|
||||||
|
await _ensure_transaction_exists(db, payload.transaction_id)
|
||||||
|
|
||||||
|
event = await create_subscription_event(
|
||||||
|
db,
|
||||||
|
user_id=payload.user_id,
|
||||||
|
event_type=payload.event_type,
|
||||||
|
subscription_id=payload.subscription_id,
|
||||||
|
transaction_id=payload.transaction_id,
|
||||||
|
amount_kopeks=payload.amount_kopeks,
|
||||||
|
currency=payload.currency,
|
||||||
|
message=payload.message,
|
||||||
|
occurred_at=payload.occurred_at,
|
||||||
|
extra=payload.extra or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.refresh(event, attribute_names=["user"])
|
||||||
|
|
||||||
|
event.user = user
|
||||||
|
|
||||||
|
return _serialize_event(event)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=SubscriptionEventListResponse)
|
||||||
|
async def list_subscription_event_logs(
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
event_types: Optional[Iterable[str]] = Query(default=None, alias="event_type"),
|
||||||
|
user_id: Optional[int] = Query(default=None),
|
||||||
|
) -> SubscriptionEventListResponse:
|
||||||
|
events, total = await list_subscription_events(
|
||||||
|
db,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
event_types=event_types,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return SubscriptionEventListResponse(
|
||||||
|
items=[_serialize_event(event) for event in events],
|
||||||
|
total=total,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||||
@@ -8,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||||
from app.database.crud.subscription import (
|
from app.database.crud.subscription import (
|
||||||
add_subscription_devices,
|
add_subscription_devices,
|
||||||
add_subscription_squad,
|
add_subscription_squad,
|
||||||
@@ -16,6 +18,7 @@ from app.database.crud.subscription import (
|
|||||||
create_trial_subscription,
|
create_trial_subscription,
|
||||||
extend_subscription,
|
extend_subscription,
|
||||||
get_subscription_by_user_id,
|
get_subscription_by_user_id,
|
||||||
|
replace_subscription,
|
||||||
remove_subscription_squad,
|
remove_subscription_squad,
|
||||||
)
|
)
|
||||||
from app.database.models import Subscription, SubscriptionStatus
|
from app.database.models import Subscription, SubscriptionStatus
|
||||||
@@ -30,6 +33,8 @@ from ..schemas.subscriptions import (
|
|||||||
SubscriptionTrafficRequest,
|
SubscriptionTrafficRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -55,6 +60,28 @@ def _serialize_subscription(subscription: Subscription) -> SubscriptionResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _choose_trial_squads(
|
||||||
|
db: AsyncSession, requested_squad_uuid: Optional[str], fallback_squads: list[str]
|
||||||
|
) -> list[str]:
|
||||||
|
if requested_squad_uuid:
|
||||||
|
return [requested_squad_uuid]
|
||||||
|
|
||||||
|
if fallback_squads:
|
||||||
|
return fallback_squads
|
||||||
|
|
||||||
|
try:
|
||||||
|
squad_uuid = await get_random_trial_squad_uuid(db)
|
||||||
|
except Exception as error:
|
||||||
|
logger.error("Failed to select trial squad: %s", error)
|
||||||
|
squad_uuid = None
|
||||||
|
|
||||||
|
if not squad_uuid:
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.debug("Selected trial squad %s for subscription replacement", squad_uuid)
|
||||||
|
return [squad_uuid]
|
||||||
|
|
||||||
|
|
||||||
async def _get_subscription(db: AsyncSession, subscription_id: int) -> Subscription:
|
async def _get_subscription(db: AsyncSession, subscription_id: int) -> Subscription:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Subscription)
|
select(Subscription)
|
||||||
@@ -109,7 +136,7 @@ async def create_subscription(
|
|||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
) -> SubscriptionResponse:
|
) -> SubscriptionResponse:
|
||||||
existing = await get_subscription_by_user_id(db, payload.user_id)
|
existing = await get_subscription_by_user_id(db, payload.user_id)
|
||||||
if existing:
|
if existing and not payload.replace_existing:
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "User already has a subscription")
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "User already has a subscription")
|
||||||
|
|
||||||
forced_devices = None
|
forced_devices = None
|
||||||
@@ -120,15 +147,36 @@ async def create_subscription(
|
|||||||
trial_device_limit = payload.device_limit
|
trial_device_limit = payload.device_limit
|
||||||
if trial_device_limit is None:
|
if trial_device_limit is None:
|
||||||
trial_device_limit = forced_devices
|
trial_device_limit = forced_devices
|
||||||
|
duration_days = payload.duration_days or settings.TRIAL_DURATION_DAYS
|
||||||
|
traffic_limit_gb = payload.traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||||
|
|
||||||
subscription = await create_trial_subscription(
|
if existing:
|
||||||
db,
|
connected_squads = await _choose_trial_squads(
|
||||||
user_id=payload.user_id,
|
db, payload.squad_uuid, list(existing.connected_squads or [])
|
||||||
duration_days=payload.duration_days,
|
)
|
||||||
traffic_limit_gb=payload.traffic_limit_gb,
|
subscription = await replace_subscription(
|
||||||
device_limit=trial_device_limit,
|
db,
|
||||||
squad_uuid=payload.squad_uuid,
|
existing,
|
||||||
)
|
duration_days=duration_days,
|
||||||
|
traffic_limit_gb=traffic_limit_gb,
|
||||||
|
device_limit=(
|
||||||
|
trial_device_limit
|
||||||
|
if trial_device_limit is not None
|
||||||
|
else settings.TRIAL_DEVICE_LIMIT
|
||||||
|
),
|
||||||
|
connected_squads=connected_squads,
|
||||||
|
is_trial=True,
|
||||||
|
update_server_counters=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
subscription = await create_trial_subscription(
|
||||||
|
db,
|
||||||
|
user_id=payload.user_id,
|
||||||
|
duration_days=duration_days,
|
||||||
|
traffic_limit_gb=traffic_limit_gb,
|
||||||
|
device_limit=trial_device_limit,
|
||||||
|
squad_uuid=payload.squad_uuid,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
if payload.duration_days is None:
|
if payload.duration_days is None:
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "duration_days is required for paid subscriptions")
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "duration_days is required for paid subscriptions")
|
||||||
@@ -138,15 +186,27 @@ async def create_subscription(
|
|||||||
device_limit = forced_devices
|
device_limit = forced_devices
|
||||||
else:
|
else:
|
||||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||||
subscription = await create_paid_subscription(
|
if existing:
|
||||||
db,
|
subscription = await replace_subscription(
|
||||||
user_id=payload.user_id,
|
db,
|
||||||
duration_days=payload.duration_days,
|
existing,
|
||||||
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
duration_days=payload.duration_days,
|
||||||
device_limit=device_limit,
|
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||||||
connected_squads=payload.connected_squads or [],
|
device_limit=device_limit,
|
||||||
update_server_counters=True,
|
connected_squads=payload.connected_squads or [],
|
||||||
)
|
is_trial=False,
|
||||||
|
update_server_counters=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
subscription = await create_paid_subscription(
|
||||||
|
db,
|
||||||
|
user_id=payload.user_id,
|
||||||
|
duration_days=payload.duration_days,
|
||||||
|
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||||||
|
device_limit=device_limit,
|
||||||
|
connected_squads=payload.connected_squads or [],
|
||||||
|
update_server_counters=True,
|
||||||
|
)
|
||||||
|
|
||||||
subscription = await _get_subscription(db, subscription.id)
|
subscription = await _get_subscription(db, subscription.id)
|
||||||
return _serialize_subscription(subscription)
|
return _serialize_subscription(subscription)
|
||||||
|
|||||||
@@ -3,10 +3,17 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Security, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database.crud.ticket import TicketCRUD
|
from aiogram import Bot
|
||||||
|
from aiogram.client.default import DefaultBotProperties
|
||||||
|
from aiogram.enums import ParseMode
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
|
||||||
from app.database.models import Ticket, TicketMessage, TicketStatus
|
from app.database.models import Ticket, TicketMessage, TicketStatus
|
||||||
|
|
||||||
from ..dependencies import get_db_session, require_api_token
|
from ..dependencies import get_db_session, require_api_token
|
||||||
@@ -14,11 +21,15 @@ from ..schemas.tickets import (
|
|||||||
TicketMessageResponse,
|
TicketMessageResponse,
|
||||||
TicketPriorityUpdateRequest,
|
TicketPriorityUpdateRequest,
|
||||||
TicketReplyBlockRequest,
|
TicketReplyBlockRequest,
|
||||||
|
TicketReplyRequest,
|
||||||
|
TicketReplyResponse,
|
||||||
TicketResponse,
|
TicketResponse,
|
||||||
|
TicketMediaResponse,
|
||||||
TicketStatusUpdateRequest,
|
TicketStatusUpdateRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _serialize_message(message: TicketMessage) -> TicketMessageResponse:
|
def _serialize_message(message: TicketMessage) -> TicketMessageResponse:
|
||||||
@@ -29,6 +40,7 @@ def _serialize_message(message: TicketMessage) -> TicketMessageResponse:
|
|||||||
is_from_admin=message.is_from_admin,
|
is_from_admin=message.is_from_admin,
|
||||||
has_media=message.has_media,
|
has_media=message.has_media,
|
||||||
media_type=message.media_type,
|
media_type=message.media_type,
|
||||||
|
media_file_id=message.media_file_id,
|
||||||
media_caption=message.media_caption,
|
media_caption=message.media_caption,
|
||||||
created_at=message.created_at,
|
created_at=message.created_at,
|
||||||
)
|
)
|
||||||
@@ -183,3 +195,96 @@ async def clear_reply_block(
|
|||||||
|
|
||||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||||
return _serialize_ticket(ticket, include_messages=True)
|
return _serialize_ticket(ticket, include_messages=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{ticket_id}/reply", response_model=TicketReplyResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def reply_to_ticket(
|
||||||
|
ticket_id: int,
|
||||||
|
payload: TicketReplyRequest,
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> TicketReplyResponse:
|
||||||
|
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False, load_user=True)
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
|
||||||
|
|
||||||
|
message_text = (payload.message_text or "").strip()
|
||||||
|
if not message_text and not payload.media_file_id:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Message text or media is required")
|
||||||
|
|
||||||
|
final_message_text = message_text or (payload.media_caption or "").strip() or "[media]"
|
||||||
|
|
||||||
|
message = await TicketMessageCRUD.add_message(
|
||||||
|
db,
|
||||||
|
ticket_id=ticket_id,
|
||||||
|
user_id=ticket.user_id,
|
||||||
|
message_text=final_message_text,
|
||||||
|
is_from_admin=True,
|
||||||
|
media_type=payload.media_type,
|
||||||
|
media_file_id=payload.media_file_id,
|
||||||
|
media_caption=payload.media_caption,
|
||||||
|
)
|
||||||
|
|
||||||
|
bot = Bot(
|
||||||
|
token=settings.BOT_TOKEN,
|
||||||
|
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from app.handlers.admin.tickets import notify_user_about_ticket_reply
|
||||||
|
|
||||||
|
await notify_user_about_ticket_reply(bot, ticket, final_message_text, db)
|
||||||
|
finally:
|
||||||
|
await bot.session.close()
|
||||||
|
|
||||||
|
ticket_with_messages = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||||
|
|
||||||
|
return TicketReplyResponse(
|
||||||
|
ticket=_serialize_ticket(ticket_with_messages, include_messages=True),
|
||||||
|
message=_serialize_message(message),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{ticket_id}/messages/{message_id}/media",
|
||||||
|
response_model=TicketMediaResponse,
|
||||||
|
)
|
||||||
|
async def get_ticket_message_media(
|
||||||
|
ticket_id: int,
|
||||||
|
message_id: int,
|
||||||
|
request: Request,
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> TicketMediaResponse:
|
||||||
|
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
|
||||||
|
|
||||||
|
message = next((m for m in ticket.messages if m.id == message_id), None)
|
||||||
|
if not message:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Message not found")
|
||||||
|
|
||||||
|
if not message.has_media or not message.media_file_id or not message.media_type:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Media not found for this message")
|
||||||
|
|
||||||
|
media_url: Optional[str] = None
|
||||||
|
bot = Bot(
|
||||||
|
token=settings.BOT_TOKEN,
|
||||||
|
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
file = await bot.get_file(message.media_file_id)
|
||||||
|
if file.file_path:
|
||||||
|
media_url = str(request.url_for("download_media", file_id=message.media_file_id))
|
||||||
|
except Exception as error:
|
||||||
|
logger.warning("Failed to resolve media URL for ticket %s message %s: %s", ticket_id, message_id, error)
|
||||||
|
finally:
|
||||||
|
await bot.session.close()
|
||||||
|
|
||||||
|
return TicketMediaResponse(
|
||||||
|
id=message.id,
|
||||||
|
ticket_id=ticket.id,
|
||||||
|
media_type=message.media_type,
|
||||||
|
media_file_id=message.media_file_id,
|
||||||
|
media_caption=message.media_caption,
|
||||||
|
media_url=media_url,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database.crud.user_message import (
|
||||||
|
create_user_message,
|
||||||
|
delete_user_message,
|
||||||
|
get_all_user_messages,
|
||||||
|
get_user_message_by_id,
|
||||||
|
get_user_messages_count,
|
||||||
|
toggle_user_message_status,
|
||||||
|
update_user_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..dependencies import get_db_session, require_api_token
|
||||||
|
from ..schemas.user_messages import (
|
||||||
|
UserMessageCreateRequest,
|
||||||
|
UserMessageListResponse,
|
||||||
|
UserMessageResponse,
|
||||||
|
UserMessageUpdateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(message) -> UserMessageResponse:
|
||||||
|
return UserMessageResponse(
|
||||||
|
id=message.id,
|
||||||
|
message_text=message.message_text,
|
||||||
|
is_active=message.is_active,
|
||||||
|
sort_order=message.sort_order,
|
||||||
|
created_by=message.created_by,
|
||||||
|
created_at=message.created_at,
|
||||||
|
updated_at=message.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=UserMessageListResponse)
|
||||||
|
async def list_user_messages(
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
include_inactive: bool = Query(True, description="Включать неактивные сообщения"),
|
||||||
|
) -> UserMessageListResponse:
|
||||||
|
total = await get_user_messages_count(db, include_inactive=include_inactive)
|
||||||
|
messages = await get_all_user_messages(
|
||||||
|
db,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
include_inactive=include_inactive,
|
||||||
|
)
|
||||||
|
|
||||||
|
return UserMessageListResponse(
|
||||||
|
items=[_serialize(message) for message in messages],
|
||||||
|
total=total,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=UserMessageResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_user_message_endpoint(
|
||||||
|
payload: UserMessageCreateRequest,
|
||||||
|
token: Any = Security(require_api_token),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> UserMessageResponse:
|
||||||
|
created_by = getattr(token, "id", None)
|
||||||
|
try:
|
||||||
|
message = await create_user_message(
|
||||||
|
db,
|
||||||
|
message_text=payload.message_text,
|
||||||
|
created_by=created_by,
|
||||||
|
is_active=payload.is_active,
|
||||||
|
sort_order=payload.sort_order,
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(error)) from error
|
||||||
|
|
||||||
|
return _serialize(message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{message_id}", response_model=UserMessageResponse)
|
||||||
|
async def update_user_message_endpoint(
|
||||||
|
message_id: int,
|
||||||
|
payload: UserMessageUpdateRequest,
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> UserMessageResponse:
|
||||||
|
update_payload = payload.dict(exclude_unset=True)
|
||||||
|
try:
|
||||||
|
message = await update_user_message(db, message_id, **update_payload)
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(error)) from error
|
||||||
|
|
||||||
|
if not message:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found")
|
||||||
|
|
||||||
|
return _serialize(message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{message_id}/toggle", response_model=UserMessageResponse)
|
||||||
|
async def toggle_user_message_endpoint(
|
||||||
|
message_id: int,
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> UserMessageResponse:
|
||||||
|
message = await toggle_user_message_status(db, message_id)
|
||||||
|
|
||||||
|
if not message:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found")
|
||||||
|
|
||||||
|
return _serialize(message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{message_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_user_message_endpoint(
|
||||||
|
message_id: int,
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> Response:
|
||||||
|
message = await get_user_message_by_id(db, message_id)
|
||||||
|
if not message:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found")
|
||||||
|
|
||||||
|
await delete_user_message(db, message_id)
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -171,6 +171,22 @@ async def get_user(
|
|||||||
return _serialize_user(user)
|
return _serialize_user(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/by-telegram-id/{telegram_id}", response_model=UserResponse)
|
||||||
|
async def get_user_by_telegram_id_endpoint(
|
||||||
|
telegram_id: int,
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> UserResponse:
|
||||||
|
"""
|
||||||
|
Get user by Telegram ID
|
||||||
|
"""
|
||||||
|
user = await get_user_by_telegram_id(db, telegram_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||||
|
|
||||||
|
return _serialize_user(user)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_user_endpoint(
|
async def create_user_endpoint(
|
||||||
payload: UserCreateRequest,
|
payload: UserCreateRequest,
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database.crud.welcome_text import (
|
||||||
|
count_welcome_texts,
|
||||||
|
create_welcome_text,
|
||||||
|
delete_welcome_text,
|
||||||
|
get_welcome_text_by_id,
|
||||||
|
list_welcome_texts,
|
||||||
|
update_welcome_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..dependencies import get_db_session, require_api_token
|
||||||
|
from ..schemas.welcome_texts import (
|
||||||
|
WelcomeTextCreateRequest,
|
||||||
|
WelcomeTextListResponse,
|
||||||
|
WelcomeTextResponse,
|
||||||
|
WelcomeTextUpdateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(text) -> WelcomeTextResponse:
|
||||||
|
return WelcomeTextResponse(
|
||||||
|
id=text.id,
|
||||||
|
text=text.text_content,
|
||||||
|
is_active=text.is_active,
|
||||||
|
is_enabled=text.is_enabled,
|
||||||
|
created_by=text.created_by,
|
||||||
|
created_at=text.created_at,
|
||||||
|
updated_at=text.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=WelcomeTextListResponse)
|
||||||
|
async def list_welcome_texts_endpoint(
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
include_inactive: bool = Query(True, description="Включать неактивные тексты"),
|
||||||
|
) -> WelcomeTextListResponse:
|
||||||
|
total = await count_welcome_texts(db, include_inactive=include_inactive)
|
||||||
|
records = await list_welcome_texts(
|
||||||
|
db,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
include_inactive=include_inactive,
|
||||||
|
)
|
||||||
|
|
||||||
|
return WelcomeTextListResponse(
|
||||||
|
items=[_serialize(item) for item in records],
|
||||||
|
total=total,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=WelcomeTextResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_welcome_text_endpoint(
|
||||||
|
payload: WelcomeTextCreateRequest,
|
||||||
|
token: Any = Security(require_api_token),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> WelcomeTextResponse:
|
||||||
|
created_by = getattr(token, "id", None)
|
||||||
|
record = await create_welcome_text(
|
||||||
|
db,
|
||||||
|
text_content=payload.text,
|
||||||
|
created_by=created_by,
|
||||||
|
is_enabled=payload.is_enabled,
|
||||||
|
is_active=payload.is_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _serialize(record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{welcome_text_id}", response_model=WelcomeTextResponse)
|
||||||
|
async def get_welcome_text_endpoint(
|
||||||
|
welcome_text_id: int,
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> WelcomeTextResponse:
|
||||||
|
record = await get_welcome_text_by_id(db, welcome_text_id)
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found")
|
||||||
|
|
||||||
|
return _serialize(record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{welcome_text_id}", response_model=WelcomeTextResponse)
|
||||||
|
async def update_welcome_text_endpoint(
|
||||||
|
welcome_text_id: int,
|
||||||
|
payload: WelcomeTextUpdateRequest,
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> WelcomeTextResponse:
|
||||||
|
record = await get_welcome_text_by_id(db, welcome_text_id)
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found")
|
||||||
|
|
||||||
|
update_payload = payload.dict(exclude_unset=True)
|
||||||
|
if "text" in update_payload:
|
||||||
|
update_payload["text_content"] = update_payload.pop("text")
|
||||||
|
updated = await update_welcome_text(db, record, **update_payload)
|
||||||
|
return _serialize(updated)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{welcome_text_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_welcome_text_endpoint(
|
||||||
|
welcome_text_id: int,
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> Response:
|
||||||
|
record = await get_welcome_text_by_id(db, welcome_text_id)
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found")
|
||||||
|
|
||||||
|
await delete_welcome_text(db, record)
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -88,3 +88,14 @@ class SystemLogPreviewResponse(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Относительный путь до endpoint для скачивания лог-файла",
|
description="Относительный путь до endpoint для скачивания лог-файла",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SystemLogFullResponse(BaseModel):
|
||||||
|
"""Полное содержимое системного лог-файла."""
|
||||||
|
|
||||||
|
path: str
|
||||||
|
exists: bool
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
size_bytes: int
|
||||||
|
size_chars: int
|
||||||
|
content: str
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class MediaUploadResponse(BaseModel):
|
||||||
|
media_type: str = Field(description="Тип загруженного файла (photo, video, document)")
|
||||||
|
file_id: str = Field(description="Telegram file_id загруженного файла")
|
||||||
|
file_unique_id: Optional[str] = Field(
|
||||||
|
default=None, description="Уникальный идентификатор файла"
|
||||||
|
)
|
||||||
|
media_url: Optional[str] = Field(
|
||||||
|
default=None, description="Прямая ссылка на файл для предпросмотра"
|
||||||
|
)
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, ConfigDict, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
class MiniAppBranding(BaseModel):
|
class MiniAppBranding(BaseModel):
|
||||||
@@ -15,6 +17,14 @@ class MiniAppSubscriptionRequest(BaseModel):
|
|||||||
init_data: str = Field(..., alias="initData")
|
init_data: str = Field(..., alias="initData")
|
||||||
|
|
||||||
|
|
||||||
|
class MiniAppMaintenanceStatusResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
is_active: bool = Field(..., alias="isActive")
|
||||||
|
message: Optional[str] = None
|
||||||
|
reason: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class MiniAppSubscriptionUser(BaseModel):
|
class MiniAppSubscriptionUser(BaseModel):
|
||||||
telegram_id: int
|
telegram_id: int
|
||||||
username: Optional[str] = None
|
username: Optional[str] = None
|
||||||
@@ -200,6 +210,7 @@ class MiniAppSubscriptionRenewalRequest(BaseModel):
|
|||||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||||
period_id: Optional[str] = Field(default=None, alias="periodId")
|
period_id: Optional[str] = Field(default=None, alias="periodId")
|
||||||
period_days: Optional[int] = Field(default=None, alias="periodDays")
|
period_days: Optional[int] = Field(default=None, alias="periodDays")
|
||||||
|
method: Optional[str] = None
|
||||||
|
|
||||||
model_config = ConfigDict(populate_by_name=True)
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
@@ -211,6 +222,14 @@ class MiniAppSubscriptionRenewalResponse(BaseModel):
|
|||||||
balance_label: Optional[str] = Field(default=None, alias="balanceLabel")
|
balance_label: Optional[str] = Field(default=None, alias="balanceLabel")
|
||||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||||
renewed_until: Optional[datetime] = Field(default=None, alias="renewedUntil")
|
renewed_until: Optional[datetime] = Field(default=None, alias="renewedUntil")
|
||||||
|
requires_payment: bool = Field(default=False, alias="requiresPayment")
|
||||||
|
payment_method: Optional[str] = Field(default=None, alias="paymentMethod")
|
||||||
|
payment_url: Optional[str] = Field(default=None, alias="paymentUrl")
|
||||||
|
payment_amount_kopeks: Optional[int] = Field(default=None, alias="paymentAmountKopeks")
|
||||||
|
payment_id: Optional[int] = Field(default=None, alias="paymentId")
|
||||||
|
invoice_id: Optional[str] = Field(default=None, alias="invoiceId")
|
||||||
|
payment_payload: Optional[str] = Field(default=None, alias="paymentPayload")
|
||||||
|
payment_extra: Optional[Dict[str, Any]] = Field(default=None, alias="paymentExtra")
|
||||||
|
|
||||||
model_config = ConfigDict(populate_by_name=True)
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
@@ -357,6 +376,41 @@ class MiniAppPaymentMethodsRequest(BaseModel):
|
|||||||
init_data: str = Field(..., alias="initData")
|
init_data: str = Field(..., alias="initData")
|
||||||
|
|
||||||
|
|
||||||
|
class MiniAppPaymentIntegrationType(str, Enum):
|
||||||
|
IFRAME = "iframe"
|
||||||
|
REDIRECT = "redirect"
|
||||||
|
|
||||||
|
|
||||||
|
class MiniAppPaymentOption(BaseModel):
|
||||||
|
id: str
|
||||||
|
icon: Optional[str] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
title_key: Optional[str] = Field(default=None, alias="titleKey")
|
||||||
|
description_key: Optional[str] = Field(default=None, alias="descriptionKey")
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
|
||||||
|
class MiniAppPaymentIframeConfig(BaseModel):
|
||||||
|
expected_origin: str
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _normalize_expected_origin(
|
||||||
|
cls, values: "MiniAppPaymentIframeConfig"
|
||||||
|
) -> "MiniAppPaymentIframeConfig":
|
||||||
|
origin = (values.expected_origin or "").strip()
|
||||||
|
if not origin:
|
||||||
|
raise ValueError("expected_origin must not be empty")
|
||||||
|
|
||||||
|
parsed = urlparse(origin)
|
||||||
|
if not parsed.scheme or not parsed.netloc:
|
||||||
|
raise ValueError("expected_origin must include scheme and host")
|
||||||
|
|
||||||
|
values.expected_origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
class MiniAppPaymentMethod(BaseModel):
|
class MiniAppPaymentMethod(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
@@ -366,6 +420,18 @@ class MiniAppPaymentMethod(BaseModel):
|
|||||||
min_amount_kopeks: Optional[int] = None
|
min_amount_kopeks: Optional[int] = None
|
||||||
max_amount_kopeks: Optional[int] = None
|
max_amount_kopeks: Optional[int] = None
|
||||||
amount_step_kopeks: Optional[int] = None
|
amount_step_kopeks: Optional[int] = None
|
||||||
|
integration_type: MiniAppPaymentIntegrationType
|
||||||
|
options: List[MiniAppPaymentOption] = Field(default_factory=list)
|
||||||
|
iframe_config: Optional[MiniAppPaymentIframeConfig] = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _ensure_iframe_config(cls, values: "MiniAppPaymentMethod") -> "MiniAppPaymentMethod":
|
||||||
|
if (
|
||||||
|
values.integration_type == MiniAppPaymentIntegrationType.IFRAME
|
||||||
|
and values.iframe_config is None
|
||||||
|
):
|
||||||
|
raise ValueError("iframe_config is required when integration_type is 'iframe'")
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
class MiniAppPaymentMethodsResponse(BaseModel):
|
class MiniAppPaymentMethodsResponse(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class PartnerReferrerItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
telegram_id: int
|
||||||
|
username: Optional[str] = None
|
||||||
|
first_name: Optional[str] = None
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
referral_code: Optional[str] = None
|
||||||
|
referral_commission_percent: Optional[int] = None
|
||||||
|
effective_referral_commission_percent: int
|
||||||
|
invited_count: int
|
||||||
|
active_referrals: int
|
||||||
|
total_earned_kopeks: int
|
||||||
|
total_earned_rubles: float
|
||||||
|
month_earned_kopeks: int
|
||||||
|
month_earned_rubles: float
|
||||||
|
created_at: datetime
|
||||||
|
last_activity: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PartnerReferrerListResponse(BaseModel):
|
||||||
|
items: List[PartnerReferrerItem] = Field(default_factory=list)
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class PartnerReferralItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
telegram_id: int
|
||||||
|
full_name: str
|
||||||
|
username: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
last_activity: Optional[datetime] = None
|
||||||
|
has_made_first_topup: bool
|
||||||
|
balance_kopeks: int
|
||||||
|
balance_rubles: float
|
||||||
|
total_earned_kopeks: int
|
||||||
|
total_earned_rubles: float
|
||||||
|
topups_count: int
|
||||||
|
days_since_registration: int
|
||||||
|
days_since_activity: Optional[int] = None
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class PartnerReferralList(BaseModel):
|
||||||
|
items: List[PartnerReferralItem] = Field(default_factory=list)
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
has_next: bool
|
||||||
|
has_prev: bool
|
||||||
|
current_page: int
|
||||||
|
total_pages: int
|
||||||
|
|
||||||
|
|
||||||
|
class PartnerReferrerDetail(BaseModel):
|
||||||
|
referrer: PartnerReferrerItem
|
||||||
|
referrals: PartnerReferralList
|
||||||
|
|
||||||
|
|
||||||
|
class PartnerReferralCommissionUpdate(BaseModel):
|
||||||
|
referral_commission_percent: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
ge=0,
|
||||||
|
le=100,
|
||||||
|
description="Индивидуальный процент реферальной комиссии для пользователя",
|
||||||
|
)
|
||||||
@@ -169,3 +169,23 @@ class PollResponsesListResponse(BaseModel):
|
|||||||
total: int
|
total: int
|
||||||
limit: int
|
limit: int
|
||||||
offset: int
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class PollSendRequest(BaseModel):
|
||||||
|
target: str = Field(
|
||||||
|
...,
|
||||||
|
description=(
|
||||||
|
"Аудитория для отправки опроса (например: all, active, trial, "
|
||||||
|
"custom_today и т.д.)"
|
||||||
|
),
|
||||||
|
max_length=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PollSendResponse(BaseModel):
|
||||||
|
poll_id: int
|
||||||
|
target: str
|
||||||
|
sent: int
|
||||||
|
failed: int
|
||||||
|
skipped: int
|
||||||
|
total: int
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user