Compare commits
162 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7da2a64f8 | |||
| 813193102c | |||
| 42112d6c8a | |||
| dfe5e36fe0 | |||
| b5d4a54e97 | |||
| 1eaf7204ce | |||
| 8cc37dc439 | |||
| ccafb18122 | |||
| 654b132ae6 | |||
| 815fa0560c | |||
| c4d19810a4 | |||
| 9fbb32eb5b | |||
| 3ce3cde859 | |||
| 4e71dcd2b1 | |||
| acf21fa6d2 | |||
| f2b724a78d | |||
| 03473360c1 | |||
| 27dc68be41 | |||
| b15d78f8af | |||
| d232d14937 | |||
| 927d469db2 | |||
| e20e9e7cec | |||
| 0f097c3108 | |||
| eaa3c80d59 | |||
| 0c49eddfde | |||
| 33882f1e93 | |||
| f94666b68d | |||
| d32a49f801 | |||
| 9596f82ea6 | |||
| 0e3fdf7d61 | |||
| 54cfec6029 | |||
| 9e88eae416 | |||
| 8d32efac73 | |||
| 9f07b6007e | |||
| 2fb3b6c5dc | |||
| 0637576425 | |||
| ee7a445610 | |||
| ba4c7ee7ac | |||
| 4da169eb0f | |||
| 28937abab4 | |||
| b1dcf2779e | |||
| 3219062ce2 | |||
| 2ac72951f8 | |||
| cefb8cba78 | |||
| e869655028 | |||
| ae03ede209 | |||
| a72bb25ea2 | |||
| 0b22bc7056 | |||
| cde84ff20b | |||
| f76114d7dc | |||
| 9708a56fd7 | |||
| a360337660 | |||
| 9f78335651 | |||
| ef8142a99b | |||
| 06d51f9fcf | |||
| 4d94347179 | |||
| 2f6aa0b1e6 | |||
| 536a58d4d3 | |||
| 62f7c07059 | |||
| 0648edb3c4 | |||
| 9b6c235424 | |||
| 2fc35950cf | |||
| d2a5d74239 | |||
| 6b04c6748e | |||
| 10b44cbdb7 | |||
| dde04c716c | |||
| 819f19a7ea | |||
| e7201597ab | |||
| ea61aa7053 | |||
| 14546a8485 | |||
| f55455761f | |||
| b1a7767827 | |||
| 43c04b7e64 | |||
| add679cf6f | |||
| ee173190a0 | |||
| a6e21b47f6 | |||
| cd627e5840 | |||
| fc096e6867 | |||
| b591844c4e | |||
| 9557acb546 | |||
| 1fa6e75bd3 | |||
| 8965e43a55 | |||
| 8e7a6beece | |||
| e9f4fd8007 | |||
| a991c59d58 | |||
| dd0624adf8 | |||
| ab51cbe96a | |||
| 2f7a08deb0 | |||
| a0fa7f986b | |||
| b2d3eebe39 | |||
| a043fc0e46 | |||
| d6eec8787e | |||
| 95eee42047 | |||
| c673d6db4f | |||
| bb32a63d48 | |||
| af5acb272c | |||
| 5cd5ac8710 | |||
| 8bb58b44b3 | |||
| 511ecd506f | |||
| 62fc014bc3 | |||
| 961f564274 | |||
| 984870c78c | |||
| 6196c135d2 | |||
| bf1b8315a8 | |||
| b7eabcf7e7 | |||
| e83ebc4f02 | |||
| 1a4758f4cc | |||
| b80e8b3804 | |||
| e036173670 | |||
| bd728c6466 | |||
| e3aec310a3 | |||
| 1c59ce601e | |||
| 0563f7524f | |||
| cedbe07759 | |||
| 0746b2a5a7 | |||
| e8a72860dc | |||
| abbfe4a7d3 | |||
| 1ad5d9123b | |||
| 57031e4afe | |||
| b54d2434c0 | |||
| 7c2d7b6b4f | |||
| 9e731a5f18 | |||
| cd3832fef0 | |||
| cb6d51ed3d | |||
| 8b75768296 | |||
| 3f0a26b1c7 | |||
| de4f608d09 | |||
| 618327b9f1 | |||
| 54fdad8f0c | |||
| e9bbd68aa1 | |||
| 5df4837087 | |||
| 025e74c462 | |||
| 3208303c23 | |||
| 4cf0c5a971 | |||
| 7519352e79 | |||
| e7f94f9661 | |||
| cb68a612e9 | |||
| 44d4deb5a6 | |||
| 23c73d1f21 | |||
| 607beca837 | |||
| 31a5f8766f | |||
| cfdc829f48 | |||
| c44cefb983 | |||
| 79c2307c92 | |||
| 25f126a5cb | |||
| 45bd54e7b0 | |||
| 4ce43f80b8 | |||
| f7a4cedbbf | |||
| 6f83122e42 | |||
| dbcf3541a8 | |||
| b7adf3e971 | |||
| 037b1f876b | |||
| 4495658fad | |||
| 017b758e6c | |||
| edff6d5102 | |||
| 979fbe305a | |||
| d1edb8f892 | |||
| 0eeda6b9ac | |||
| 7c31c3616e | |||
| fba80b1a0d | |||
| 86dbb317f3 | |||
| 7432d43b55 |
+18
-2
@@ -85,7 +85,8 @@ REMNAWAVE_USER_DELETE_MODE=delete
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_LIMIT_GB=10
|
||||
TRIAL_DEVICE_LIMIT=1
|
||||
TRIAL_SQUAD_UUID=
|
||||
TRIAL_PAYMENT_ENABLED=false
|
||||
TRIAL_ACTIVATION_PRICE=0
|
||||
|
||||
# ===== ПЛАТНАЯ ПОДПИСКА =====
|
||||
# Сколько устройств доступно по дефолту при покупке платной подписки
|
||||
@@ -242,7 +243,6 @@ YOOKASSA_PAYMENT_SUBJECT=service
|
||||
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
|
||||
YOOKASSA_WEBHOOK_HOST=0.0.0.0
|
||||
YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
|
||||
|
||||
# Лимиты сумм пополнения через YooKassa (в копейках)
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
|
||||
@@ -331,6 +331,22 @@ PAL24_SBP_BUTTON_VISIBLE=true
|
||||
# Отображать кнопку оплаты картой в PayPalych (true - отображать, false - скрывать)
|
||||
PAL24_CARD_BUTTON_VISIBLE=true
|
||||
|
||||
# PLATEGA
|
||||
PLATEGA_ENABLED=false
|
||||
PLATEGA_MERCHANT_ID=
|
||||
PLATEGA_SECRET=
|
||||
PLATEGA_BASE_URL=https://app.platega.io
|
||||
PLATEGA_RETURN_URL=
|
||||
PLATEGA_FAILED_URL=
|
||||
PLATEGA_CURRENCY=RUB
|
||||
# Список ID активных методов из кабинета Platega (через запятую)
|
||||
PLATEGA_ACTIVE_METHODS=2,10,11,12,13
|
||||
PLATEGA_MIN_AMOUNT_KOPEKS=10000
|
||||
PLATEGA_MAX_AMOUNT_KOPEKS=100000000
|
||||
PLATEGA_WEBHOOK_PATH=/platega-webhook
|
||||
PLATEGA_WEBHOOK_HOST=0.0.0.0
|
||||
PLATEGA_WEBHOOK_PORT=8086
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
|
||||
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
|
||||
|
||||
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.6.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.6.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.2-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.6.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.6.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.6.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.2-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.6.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
FROM python:3.13-slim AS builder
|
||||
FROM python:3.14-slim AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
@@ -12,9 +12,9 @@ COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM python:3.13-slim
|
||||
FROM python:3.14-slim
|
||||
|
||||
ARG VERSION="v2.6.0"
|
||||
ARG VERSION="v2.6.2"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
### ⚡ **Полная автоматизация VPN бизнеса**
|
||||
- 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня
|
||||
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + Heleket + YooKassa (СБП + карты) + MulenPay + PayPalych (СБП + карты) + WATA
|
||||
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + Heleket + YooKassa (СБП + карты) + MulenPay + PayPalych (СБП + карты) + Platega (карты + СБП) + WATA
|
||||
- 🔄 **Автоматизация 99%** - от регистрации до продления подписок
|
||||
- - 📱 **MiniApp лк** - личный кабинет с возможностью покупки/продления подписки
|
||||
- 📊 **Детальная аналитика** - полная картина вашего бизнеса
|
||||
@@ -150,8 +150,8 @@ docker compose logs
|
||||
|
||||
```env
|
||||
BOT_RUN_MODE=webhook
|
||||
WEBHOOK_URL=https://bot.example.com
|
||||
WEBHOOK_PATH=/telegram/webhook
|
||||
WEBHOOK_URL=https://api.domain.com
|
||||
WEBHOOK_PATH=/webhook
|
||||
WEBHOOK_SECRET_TOKEN=super-secret-token
|
||||
WEBHOOK_DROP_PENDING_UPDATES=true
|
||||
WEBHOOK_MAX_QUEUE_SIZE=1024
|
||||
@@ -437,7 +437,7 @@ http {
|
||||
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
|
||||
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима.
|
||||
|
||||
[Полный список доступных параметров:](remnawave-bedolaga-telegram-bot/blob/main/.env.example)
|
||||
[Полный список доступных параметров:](.env.example)
|
||||
|
||||
### 🌐 Интеграция веб-админки
|
||||
|
||||
@@ -479,6 +479,32 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=secret_key_name
|
||||
```
|
||||
|
||||
### 💳 Platega.io
|
||||
|
||||
Платёжный провайдер [Platega.io](https://platega.io) добавляет ещё один способ приёма оплат картой и по СБП. Включите его, если у вас есть кабинет мерчанта и доступ к API.
|
||||
|
||||
1. В кабинете Platega получите `Merchant ID` и `Secret` (раздел **Интеграция → API**).
|
||||
2. В настройках провайдера укажите URL возврата и ошибки. Их можно задать в `.env` (`PLATEGA_RETURN_URL`, `PLATEGA_FAILED_URL`).
|
||||
3. Активируйте только нужные платёжные методы и пропишите их ID через запятую в `PLATEGA_ACTIVE_METHODS`.
|
||||
4. Добавьте вебхук `https://your-domain.com/platega-webhook` в личном кабинете Platega.
|
||||
|
||||
Пример набора переменных окружения:
|
||||
|
||||
```env
|
||||
PLATEGA_ENABLED=true
|
||||
PLATEGA_MERCHANT_ID=your_merchant_id
|
||||
PLATEGA_SECRET=your_secret_key
|
||||
PLATEGA_RETURN_URL=https://your-domain.com/payments/success
|
||||
PLATEGA_FAILED_URL=https://your-domain.com/payments/failed
|
||||
PLATEGA_ACTIVE_METHODS=2,10,11
|
||||
PLATEGA_MIN_AMOUNT_KOPEKS=10000
|
||||
PLATEGA_MAX_AMOUNT_KOPEKS=5000000
|
||||
PLATEGA_CURRENCY=RUB
|
||||
PLATEGA_WEBHOOK_PATH=/platega-webhook
|
||||
```
|
||||
|
||||
Остальные параметры (`PLATEGA_BASE_URL`, `PLATEGA_WEBHOOK_HOST`, `PLATEGA_WEBHOOK_PORT`) оставьте по умолчанию, если работаете через встроенный FastAPI сервер.
|
||||
|
||||
### 📊 Режимы продажи трафика
|
||||
|
||||
#### **Выбираемые пакеты** (по умолчанию)
|
||||
@@ -512,6 +538,12 @@ TRAFFIC_PACKAGES_CONFIG="100:15000:true"
|
||||
# Бесплатные устройства в триал подписке
|
||||
TRIAL_DEVICE_LIMIT=1
|
||||
|
||||
# Требовать оплату за активацию триала
|
||||
TRIAL_PAYMENT_ENABLED=false
|
||||
|
||||
# Стоимость активации триала (в копейках)
|
||||
TRIAL_ACTIVATION_PRICE=0
|
||||
|
||||
# Бесплатные устройства в платной подписке
|
||||
DEFAULT_DEVICE_LIMIT=3
|
||||
|
||||
@@ -609,6 +641,7 @@ REDIS_URL=redis://redis:6379/0
|
||||
- 🪙 Heleket (криптовалюта с наценкой)
|
||||
- 💳 MulenPay (СБП)
|
||||
- 🏦 PayPalych/Pal24 (СБП + карты)
|
||||
- 💳 Platega (СБП + банковские карты)
|
||||
- 💳 **WATA**
|
||||
- 📥 Автогенерация счетов и webhook-уведомления
|
||||
- 💼 История операций
|
||||
@@ -807,6 +840,7 @@ REDIS_URL=redis://redis:6379/0
|
||||
- **Heleket**: Настрой webhook на `https://your-domain.com/heleket-webhook`
|
||||
- **MulenPay**: Настрой webhook на `https://your-domain.com/mulenpay-webhook`
|
||||
- **PayPalych**: Укажи Result URL `https://your-domain.com/pal24-webhook` в кабинете Pal24
|
||||
- **Platega**: Настрой webhook на `https://your-domain.com/platega-webhook`
|
||||
- **WATA**: Настрой webhook на `https://your-domain.com/wata-webhook`
|
||||
|
||||
4. **🔄 Настройка автосинхронизации** (опционально)
|
||||
@@ -1131,7 +1165,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
|
||||
| Метрика | Значение |
|
||||
|---------|----------|
|
||||
| 💳 **Платёжных систем** | 8 (Stars, YooKassa, Tribute, CryptoBot, Heleket, MulenPay, Pal24, WATA) |
|
||||
| 💳 **Платёжных систем** | 9 (Stars, YooKassa, Tribute, CryptoBot, Heleket, MulenPay, Pal24, Platega, WATA) |
|
||||
| 🌍 **Языков интерфейса** | 2 (RU, EN) с возможностью расширения |
|
||||
| 📊 **Периодов подписки** | 6 (от 14 дней до года) |
|
||||
| 🎁 **Типов промо-акций** | 5 (коды, группы, предложения, скидки, кампании) |
|
||||
|
||||
@@ -238,7 +238,6 @@ REMNAWAVE_API_KEY=your_api_key
|
||||
ADMIN_IDS=123456789,987654321
|
||||
|
||||
# Webhook секреты - генерируйте случайные значения
|
||||
YOOKASSA_WEBHOOK_SECRET=random_secret_here
|
||||
TRIBUTE_WEBHOOK_SECRET=another_random_secret
|
||||
CRYPTOBOT_WEBHOOK_SECRET=yet_another_secret
|
||||
```
|
||||
|
||||
+103
-2
@@ -85,9 +85,10 @@ class Settings(BaseSettings):
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
TRIAL_DEVICE_LIMIT: int = 2
|
||||
TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False
|
||||
TRIAL_PAYMENT_ENABLED: bool = False
|
||||
TRIAL_ACTIVATION_PRICE: int = 0
|
||||
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
|
||||
DEFAULT_DEVICE_LIMIT: int = 1
|
||||
TRIAL_SQUAD_UUID: Optional[str] = None
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
|
||||
RESET_TRAFFIC_ON_PAYMENT: bool = False
|
||||
MAX_DEVICES_LIMIT: int = 20
|
||||
@@ -187,7 +188,7 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
|
||||
YOOKASSA_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
YOOKASSA_WEBHOOK_PORT: int = 8082
|
||||
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
|
||||
YOOKASSA_TRUSTED_PROXY_NETWORKS: str = ""
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
|
||||
@@ -270,6 +271,20 @@ class Settings(BaseSettings):
|
||||
PAL24_SBP_BUTTON_VISIBLE: bool = True
|
||||
PAL24_CARD_BUTTON_VISIBLE: bool = True
|
||||
|
||||
PLATEGA_ENABLED: bool = False
|
||||
PLATEGA_MERCHANT_ID: Optional[str] = None
|
||||
PLATEGA_SECRET: Optional[str] = None
|
||||
PLATEGA_BASE_URL: str = "https://app.platega.io"
|
||||
PLATEGA_RETURN_URL: Optional[str] = None
|
||||
PLATEGA_FAILED_URL: Optional[str] = None
|
||||
PLATEGA_CURRENCY: str = "RUB"
|
||||
PLATEGA_ACTIVE_METHODS: str = "2,10,11,12,13"
|
||||
PLATEGA_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
PLATEGA_MAX_AMOUNT_KOPEKS: int = 100000000
|
||||
PLATEGA_WEBHOOK_PATH: str = "/platega-webhook"
|
||||
PLATEGA_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
PLATEGA_WEBHOOK_PORT: int = 8086
|
||||
|
||||
WATA_ENABLED: bool = False
|
||||
WATA_BASE_URL: str = "https://api.wata.pro/api/h2h"
|
||||
WATA_ACCESS_TOKEN: Optional[str] = None
|
||||
@@ -869,6 +884,24 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_disabled_mode_device_limit(self) -> Optional[int]:
|
||||
return self.get_devices_selection_disabled_amount()
|
||||
|
||||
def is_trial_paid_activation_enabled(self) -> bool:
|
||||
return bool(self.TRIAL_PAYMENT_ENABLED)
|
||||
|
||||
def get_trial_activation_price(self) -> int:
|
||||
try:
|
||||
value = int(self.TRIAL_ACTIVATION_PRICE)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Некорректное значение TRIAL_ACTIVATION_PRICE: %s",
|
||||
self.TRIAL_ACTIVATION_PRICE,
|
||||
)
|
||||
return 0
|
||||
|
||||
if value < 0:
|
||||
return 0
|
||||
|
||||
return value
|
||||
|
||||
def is_yookassa_enabled(self) -> bool:
|
||||
return (self.YOOKASSA_ENABLED and
|
||||
@@ -917,6 +950,74 @@ class Settings(BaseSettings):
|
||||
and self.PAL24_SHOP_ID is not None
|
||||
)
|
||||
|
||||
def is_platega_enabled(self) -> bool:
|
||||
return (
|
||||
self.PLATEGA_ENABLED
|
||||
and self.PLATEGA_MERCHANT_ID is not None
|
||||
and self.PLATEGA_SECRET is not None
|
||||
)
|
||||
|
||||
def get_platega_return_url(self) -> Optional[str]:
|
||||
if self.PLATEGA_RETURN_URL:
|
||||
return self.PLATEGA_RETURN_URL
|
||||
if self.WEBHOOK_URL:
|
||||
return f"{self.WEBHOOK_URL}/payment-success"
|
||||
return None
|
||||
|
||||
def get_platega_failed_url(self) -> Optional[str]:
|
||||
if self.PLATEGA_FAILED_URL:
|
||||
return self.PLATEGA_FAILED_URL
|
||||
if self.WEBHOOK_URL:
|
||||
return f"{self.WEBHOOK_URL}/payment-failed"
|
||||
return None
|
||||
|
||||
def get_platega_active_methods(self) -> List[int]:
|
||||
raw_value = str(self.PLATEGA_ACTIVE_METHODS or "")
|
||||
normalized = raw_value.replace(";", ",")
|
||||
methods: list[int] = []
|
||||
seen: set[int] = set()
|
||||
for part in normalized.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
method_code = int(part)
|
||||
except ValueError:
|
||||
logger.warning("Некорректный код метода Platega: %s", part)
|
||||
continue
|
||||
if method_code in {2, 10, 11, 12, 13} and method_code not in seen:
|
||||
methods.append(method_code)
|
||||
seen.add(method_code)
|
||||
|
||||
if not methods:
|
||||
return [2]
|
||||
|
||||
return methods
|
||||
|
||||
@staticmethod
|
||||
def get_platega_method_definitions() -> Dict[int, Dict[str, str]]:
|
||||
return {
|
||||
2: {"name": "СБП (QR)", "title": "🏦 СБП (QR)"},
|
||||
10: {"name": "Банковские карты (RUB)", "title": "💳 Карты (RUB)"},
|
||||
11: {"name": "Банковские карты", "title": "💳 Банковские карты"},
|
||||
12: {"name": "Международные карты", "title": "🌍 Международные карты"},
|
||||
13: {"name": "Криптовалюта", "title": "🪙 Криптовалюта"},
|
||||
}
|
||||
|
||||
def get_platega_method_display_name(self, method_code: int) -> str:
|
||||
definitions = self.get_platega_method_definitions()
|
||||
info = definitions.get(method_code)
|
||||
if info and info.get("name"):
|
||||
return info["name"]
|
||||
return f"Метод {method_code}"
|
||||
|
||||
def get_platega_method_display_title(self, method_code: int) -> str:
|
||||
definitions = self.get_platega_method_definitions()
|
||||
info = definitions.get(method_code)
|
||||
if not info:
|
||||
return f"Platega {method_code}"
|
||||
return info.get("title") or info.get("name") or f"Platega {method_code}"
|
||||
|
||||
def is_wata_enabled(self) -> bool:
|
||||
return (
|
||||
self.WATA_ENABLED
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Database package exports."""
|
||||
|
||||
from .database import (
|
||||
DatabaseManager,
|
||||
batch_ops,
|
||||
close_db,
|
||||
db_manager,
|
||||
get_db,
|
||||
get_db_read_only,
|
||||
get_pool_metrics,
|
||||
init_db,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DatabaseManager",
|
||||
"batch_ops",
|
||||
"close_db",
|
||||
"db_manager",
|
||||
"get_db",
|
||||
"get_db_read_only",
|
||||
"get_pool_metrics",
|
||||
"init_db",
|
||||
]
|
||||
@@ -0,0 +1,156 @@
|
||||
"""CRUD-операции для платежей Platega."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import PlategaPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_platega_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
currency: str,
|
||||
description: Optional[str],
|
||||
status: str,
|
||||
payment_method_code: int,
|
||||
correlation_id: str,
|
||||
platega_transaction_id: Optional[str],
|
||||
redirect_url: Optional[str],
|
||||
return_url: Optional[str],
|
||||
failed_url: Optional[str],
|
||||
payload: Optional[str],
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
) -> PlategaPayment:
|
||||
payment = PlategaPayment(
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
status=status,
|
||||
payment_method_code=payment_method_code,
|
||||
correlation_id=correlation_id,
|
||||
platega_transaction_id=platega_transaction_id,
|
||||
redirect_url=redirect_url,
|
||||
return_url=return_url,
|
||||
failed_url=failed_url,
|
||||
payload=payload,
|
||||
metadata_json=metadata or {},
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан Platega платеж #%s (tx=%s) на сумму %s копеек для пользователя %s",
|
||||
payment.id,
|
||||
platega_transaction_id,
|
||||
amount_kopeks,
|
||||
user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_platega_payment_by_id(
|
||||
db: AsyncSession, payment_id: int
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment).where(PlategaPayment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_platega_payment_by_id_for_update(
|
||||
db: AsyncSession, payment_id: int
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment)
|
||||
.where(PlategaPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_platega_payment_by_transaction_id(
|
||||
db: AsyncSession, transaction_id: str
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment).where(
|
||||
PlategaPayment.platega_transaction_id == transaction_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_platega_payment_by_correlation_id(
|
||||
db: AsyncSession, correlation_id: str
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment).where(
|
||||
PlategaPayment.correlation_id == correlation_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_platega_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: PlategaPayment,
|
||||
status: Optional[str] = None,
|
||||
is_paid: Optional[bool] = None,
|
||||
paid_at: Optional[datetime] = None,
|
||||
platega_transaction_id: Optional[str] = None,
|
||||
redirect_url: Optional[str] = None,
|
||||
callback_payload: Optional[dict[str, Any]] = None,
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
) -> PlategaPayment:
|
||||
if status is not None:
|
||||
payment.status = status
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if paid_at is not None:
|
||||
payment.paid_at = paid_at
|
||||
if platega_transaction_id and not payment.platega_transaction_id:
|
||||
payment.platega_transaction_id = platega_transaction_id
|
||||
if redirect_url is not None:
|
||||
payment.redirect_url = redirect_url
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if metadata is not None:
|
||||
payment.metadata_json = metadata
|
||||
if expires_at is not None:
|
||||
payment.expires_at = expires_at
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
|
||||
|
||||
async def link_platega_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: PlategaPayment,
|
||||
transaction_id: int,
|
||||
) -> PlategaPayment:
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -515,7 +515,6 @@ async def choose_random_trial_server_squad(
|
||||
|
||||
async def get_random_trial_squad_uuid(
|
||||
db: AsyncSession,
|
||||
fallback_uuid: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
|
||||
squad = await choose_random_trial_server_squad(db)
|
||||
@@ -523,7 +522,7 @@ async def get_random_trial_squad_uuid(
|
||||
if squad:
|
||||
return squad.squad_uuid
|
||||
|
||||
return fallback_uuid
|
||||
return None
|
||||
|
||||
|
||||
def _generate_display_name(original_name: str) -> str:
|
||||
|
||||
@@ -55,10 +55,7 @@ async def create_trial_subscription(
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
squad_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
settings.TRIAL_SQUAD_UUID,
|
||||
)
|
||||
squad_uuid = await get_random_trial_squad_uuid(db)
|
||||
|
||||
if squad_uuid:
|
||||
logger.debug(
|
||||
@@ -72,7 +69,6 @@ async def create_trial_subscription(
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
squad_uuid = settings.TRIAL_SQUAD_UUID
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
|
||||
+360
-14
@@ -1,31 +1,167 @@
|
||||
import logging
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from typing import AsyncGenerator, Optional
|
||||
from contextlib import asynccontextmanager
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
create_async_engine,
|
||||
async_sessionmaker,
|
||||
AsyncEngine
|
||||
)
|
||||
from sqlalchemy.pool import NullPool, AsyncAdaptedQueuePool
|
||||
from sqlalchemy import event, text, bindparam, inspect
|
||||
from sqlalchemy.engine import Engine
|
||||
import time
|
||||
from app.config import settings
|
||||
from app.database.models import Base
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# PRODUCTION-GRADE CONNECTION POOLING
|
||||
# ============================================================================
|
||||
|
||||
if settings.get_database_url().startswith("sqlite"):
|
||||
poolclass = NullPool
|
||||
pool_kwargs = {}
|
||||
else:
|
||||
poolclass = AsyncAdaptedQueuePool
|
||||
pool_kwargs = {
|
||||
"pool_size": 20,
|
||||
"max_overflow": 30,
|
||||
"pool_timeout": 30,
|
||||
"pool_recycle": 3600,
|
||||
"pool_pre_ping": True,
|
||||
# 🔥 Агрессивная очистка мертвых соединений
|
||||
"pool_reset_on_return": "rollback",
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# ENGINE WITH ADVANCED OPTIMIZATIONS
|
||||
# ============================================================================
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.get_database_url(),
|
||||
poolclass=NullPool,
|
||||
poolclass=poolclass,
|
||||
echo=settings.DEBUG,
|
||||
future=True
|
||||
future=True,
|
||||
**pool_kwargs,
|
||||
|
||||
connect_args={
|
||||
"server_settings": {
|
||||
"application_name": "remnawave_bot",
|
||||
"jit": "on",
|
||||
"statement_timeout": "60000", # 60 секунд
|
||||
"idle_in_transaction_session_timeout": "300000", # 5 минут
|
||||
},
|
||||
"command_timeout": 60,
|
||||
"timeout": 10,
|
||||
} if not settings.get_database_url().startswith("sqlite") else {},
|
||||
|
||||
execution_options={
|
||||
"isolation_level": "READ COMMITTED", # Оптимальный для большинства случаев
|
||||
"compiled_cache_size": 500, # Кеш скомпилированных запросов
|
||||
}
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# SESSION FACTORY WITH OPTIMIZATIONS
|
||||
# ============================================================================
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=True,
|
||||
autocommit=False
|
||||
autoflush=False, # 🔥 Критично для производительности
|
||||
autocommit=False,
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# QUERY PERFORMANCE MONITORING
|
||||
# ============================================================================
|
||||
|
||||
if settings.DEBUG:
|
||||
@event.listens_for(Engine, "before_cursor_execute")
|
||||
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||
conn.info.setdefault("query_start_time", []).append(time.time())
|
||||
logger.debug(f"🔍 Executing query: {statement[:100]}...")
|
||||
|
||||
@event.listens_for(Engine, "after_cursor_execute")
|
||||
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||
total = time.time() - conn.info["query_start_time"].pop(-1)
|
||||
if total > 0.1: # Логируем медленные запросы > 100ms
|
||||
logger.warning(f"🐌 Slow query ({total:.3f}s): {statement[:100]}...")
|
||||
else:
|
||||
logger.debug(f"⚡ Query executed in {total:.3f}s")
|
||||
|
||||
# ============================================================================
|
||||
# ADVANCED SESSION MANAGER WITH READ REPLICAS
|
||||
# ============================================================================
|
||||
|
||||
class DatabaseManager:
|
||||
"""Продвинутый менеджер БД с поддержкой реплик и кеширования"""
|
||||
|
||||
def __init__(self):
|
||||
self.engine = engine
|
||||
self.read_replica_engine: Optional[AsyncEngine] = None
|
||||
|
||||
if hasattr(settings, 'DATABASE_READ_REPLICA_URL') and settings.DATABASE_READ_REPLICA_URL:
|
||||
self.read_replica_engine = create_async_engine(
|
||||
settings.DATABASE_READ_REPLICA_URL,
|
||||
poolclass=poolclass,
|
||||
pool_size=30, # Больше для read операций
|
||||
max_overflow=50,
|
||||
pool_pre_ping=True,
|
||||
echo=False,
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def session(self, read_only: bool = False):
|
||||
target_engine = self.read_replica_engine if (read_only and self.read_replica_engine) else self.engine
|
||||
|
||||
async_session = async_sessionmaker(
|
||||
bind=target_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
if not read_only:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def health_check(self) -> dict:
|
||||
pool = self.engine.pool
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
start = time.time()
|
||||
await session.execute(text("SELECT 1"))
|
||||
latency = (time.time() - start) * 1000
|
||||
status = "healthy"
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Database health check failed: {e}")
|
||||
status = "unhealthy"
|
||||
latency = None
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"latency_ms": round(latency, 2) if latency else None,
|
||||
"pool": _collect_health_pool_metrics(pool),
|
||||
}
|
||||
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
# ============================================================================
|
||||
# SESSION DEPENDENCY FOR FASTAPI/AIOGRAM
|
||||
# ============================================================================
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Стандартная dependency для FastAPI"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
@@ -33,19 +169,229 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
async def get_db_read_only() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Read-only dependency для тяжелых SELECT запросов"""
|
||||
async with db_manager.session(read_only=True) as session:
|
||||
yield session
|
||||
|
||||
# ============================================================================
|
||||
# BATCH OPERATIONS FOR PERFORMANCE
|
||||
# ============================================================================
|
||||
|
||||
class BatchOperations:
|
||||
"""Утилиты для массовых операций"""
|
||||
|
||||
@staticmethod
|
||||
async def bulk_insert(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
|
||||
"""Массовая вставка с чанками"""
|
||||
for i in range(0, len(data), chunk_size):
|
||||
chunk = data[i:i + chunk_size]
|
||||
session.add_all([model(**item) for item in chunk])
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
|
||||
@staticmethod
|
||||
async def bulk_update(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
|
||||
"""Массовое обновление с чанками"""
|
||||
if not data:
|
||||
return
|
||||
|
||||
primary_keys = [column.name for column in model.__table__.primary_key.columns]
|
||||
if not primary_keys:
|
||||
raise ValueError("Model must have a primary key for bulk_update")
|
||||
|
||||
updatable_columns = [
|
||||
column.name
|
||||
for column in model.__table__.columns
|
||||
if column.name not in primary_keys
|
||||
]
|
||||
|
||||
if not updatable_columns:
|
||||
raise ValueError("No columns available for update in bulk_update")
|
||||
|
||||
stmt = (
|
||||
model.__table__.update()
|
||||
.where(
|
||||
*[
|
||||
getattr(model.__table__.c, pk) == bindparam(pk)
|
||||
for pk in primary_keys
|
||||
]
|
||||
)
|
||||
.values(
|
||||
**{
|
||||
column: bindparam(column, required=False)
|
||||
for column in updatable_columns
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
for i in range(0, len(data), chunk_size):
|
||||
chunk = data[i:i + chunk_size]
|
||||
filtered_chunk = []
|
||||
for item in chunk:
|
||||
missing_keys = [pk for pk in primary_keys if pk not in item]
|
||||
if missing_keys:
|
||||
raise ValueError(
|
||||
f"Missing primary key values {missing_keys} for bulk_update"
|
||||
)
|
||||
|
||||
filtered_item = {
|
||||
key: value
|
||||
for key, value in item.items()
|
||||
if key in primary_keys or key in updatable_columns
|
||||
}
|
||||
filtered_chunk.append(filtered_item)
|
||||
|
||||
await session.execute(stmt, filtered_chunk)
|
||||
await session.commit()
|
||||
|
||||
batch_ops = BatchOperations()
|
||||
|
||||
# ============================================================================
|
||||
# INITIALIZATION AND CLEANUP
|
||||
# ============================================================================
|
||||
|
||||
async def init_db():
|
||||
logger.info("Создание таблиц базы данных...")
|
||||
"""Инициализация БД с оптимизациями"""
|
||||
logger.info("🚀 Создание таблиц базы данных...")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
if not settings.get_database_url().startswith("sqlite"):
|
||||
logger.info("📊 Создание индексов для оптимизации...")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
indexes = [
|
||||
("users", "CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id)"),
|
||||
(
|
||||
"subscriptions",
|
||||
"CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id)",
|
||||
),
|
||||
(
|
||||
"subscriptions",
|
||||
"CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status) WHERE status = 'active'",
|
||||
),
|
||||
(
|
||||
"payments",
|
||||
"CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at DESC)",
|
||||
),
|
||||
]
|
||||
|
||||
for table_name, index_sql in indexes:
|
||||
table_exists = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table(table_name))
|
||||
|
||||
if not table_exists:
|
||||
logger.debug(
|
||||
"Пропускаем создание индекса %s: таблица %s отсутствует",
|
||||
index_sql,
|
||||
table_name,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
await conn.execute(text(index_sql))
|
||||
except Exception as e:
|
||||
logger.debug("Index creation skipped for %s: %s", table_name, e)
|
||||
|
||||
logger.info("✅ База данных успешно инициализирована")
|
||||
|
||||
|
||||
health = await db_manager.health_check()
|
||||
logger.info(f"📊 Database health: {health}")
|
||||
|
||||
async def close_db():
|
||||
"""Корректное закрытие всех соединений"""
|
||||
logger.info("🔄 Закрытие соединений с БД...")
|
||||
|
||||
await engine.dispose()
|
||||
logger.info("✅ Подключение к базе данных закрыто")
|
||||
|
||||
if db_manager.read_replica_engine:
|
||||
await db_manager.read_replica_engine.dispose()
|
||||
|
||||
logger.info("✅ Все подключения к базе данных закрыты")
|
||||
|
||||
# ============================================================================
|
||||
# CONNECTION POOL METRICS (для мониторинга)
|
||||
# ============================================================================
|
||||
|
||||
def _pool_counters(pool):
|
||||
"""Return basic pool counters or ``None`` when unsupported."""
|
||||
|
||||
required_methods = ("size", "checkedin", "checkedout", "overflow")
|
||||
|
||||
for method_name in required_methods:
|
||||
method = getattr(pool, method_name, None)
|
||||
if method is None or not callable(method):
|
||||
return None
|
||||
|
||||
size = pool.size()
|
||||
checked_in = pool.checkedin()
|
||||
checked_out = pool.checkedout()
|
||||
overflow = pool.overflow()
|
||||
|
||||
total_connections = size + overflow
|
||||
|
||||
return {
|
||||
"size": size,
|
||||
"checked_in": checked_in,
|
||||
"checked_out": checked_out,
|
||||
"overflow": overflow,
|
||||
"total_connections": total_connections,
|
||||
"utilization_percent": (checked_out / total_connections * 100) if total_connections else 0.0,
|
||||
}
|
||||
|
||||
|
||||
def _collect_health_pool_metrics(pool) -> dict:
|
||||
counters = _pool_counters(pool)
|
||||
|
||||
if counters is None:
|
||||
return {
|
||||
"metrics_available": False,
|
||||
"size": 0,
|
||||
"checked_in": 0,
|
||||
"checked_out": 0,
|
||||
"overflow": 0,
|
||||
"total_connections": 0,
|
||||
"utilization": "0.0%",
|
||||
}
|
||||
|
||||
return {
|
||||
"metrics_available": True,
|
||||
"size": counters["size"],
|
||||
"checked_in": counters["checked_in"],
|
||||
"checked_out": counters["checked_out"],
|
||||
"overflow": counters["overflow"],
|
||||
"total_connections": counters["total_connections"],
|
||||
"utilization": f"{counters['utilization_percent']:.1f}%",
|
||||
}
|
||||
|
||||
|
||||
async def get_pool_metrics() -> dict:
|
||||
"""Детальные метрики пула для Prometheus/Grafana"""
|
||||
pool = engine.pool
|
||||
|
||||
counters = _pool_counters(pool)
|
||||
|
||||
if counters is None:
|
||||
return {
|
||||
"metrics_available": False,
|
||||
"pool_size": 0,
|
||||
"checked_in_connections": 0,
|
||||
"checked_out_connections": 0,
|
||||
"overflow_connections": 0,
|
||||
"total_connections": 0,
|
||||
"max_possible_connections": 0,
|
||||
"pool_utilization_percent": 0.0,
|
||||
}
|
||||
|
||||
return {
|
||||
"metrics_available": True,
|
||||
"pool_size": counters["size"],
|
||||
"checked_in_connections": counters["checked_in"],
|
||||
"checked_out_connections": counters["checked_out"],
|
||||
"overflow_connections": counters["overflow"],
|
||||
"total_connections": counters["total_connections"],
|
||||
"max_possible_connections": counters["total_connections"] + (getattr(pool, "_max_overflow", 0) or 0),
|
||||
"pool_utilization_percent": round(counters["utilization_percent"], 2),
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ class PaymentMethod(Enum):
|
||||
MULENPAY = "mulenpay"
|
||||
PAL24 = "pal24"
|
||||
WATA = "wata"
|
||||
PLATEGA = "platega"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
@@ -414,6 +415,56 @@ class WataPayment(Base):
|
||||
)
|
||||
|
||||
|
||||
class PlategaPayment(Base):
|
||||
__tablename__ = "platega_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
platega_transaction_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
correlation_id = Column(String(64), unique=True, nullable=False, index=True)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default="RUB")
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
payment_method_code = Column(Integer, nullable=False)
|
||||
status = Column(String(50), nullable=False, default="PENDING")
|
||||
is_paid = Column(Boolean, default=False)
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
|
||||
redirect_url = Column(Text, nullable=True)
|
||||
return_url = Column(Text, nullable=True)
|
||||
failed_url = Column(Text, nullable=True)
|
||||
payload = Column(String(255), nullable=True)
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
|
||||
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", backref="platega_payments")
|
||||
transaction = relationship("Transaction", backref="platega_payment")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return (
|
||||
"<PlategaPayment(id={0}, transaction_id={1}, amount={2}₽, status={3}, method={4})>".format(
|
||||
self.id,
|
||||
self.platega_transaction_id,
|
||||
self.amount_rubles,
|
||||
self.status,
|
||||
self.payment_method_code,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = "promo_groups"
|
||||
|
||||
|
||||
@@ -2107,6 +2107,9 @@ async def ensure_promo_groups_setup():
|
||||
addon_discount_column_exists = await check_column_exists(
|
||||
"promo_groups", "apply_discounts_to_addons"
|
||||
)
|
||||
priority_column_exists = await check_column_exists(
|
||||
"promo_groups", "priority"
|
||||
)
|
||||
|
||||
if not addon_discount_column_exists:
|
||||
if db_type == "sqlite":
|
||||
@@ -2289,7 +2292,23 @@ async def ensure_promo_groups_setup():
|
||||
"is_default": True,
|
||||
}
|
||||
|
||||
if addon_discount_column_exists:
|
||||
if priority_column_exists:
|
||||
insert_params["priority"] = 0
|
||||
|
||||
if addon_discount_column_exists and priority_column_exists:
|
||||
insert_sql = """
|
||||
INSERT INTO promo_groups (
|
||||
name,
|
||||
priority,
|
||||
server_discount_percent,
|
||||
traffic_discount_percent,
|
||||
device_discount_percent,
|
||||
apply_discounts_to_addons,
|
||||
is_default
|
||||
) VALUES (:name, :priority, 0, 0, 0, :apply_discounts_to_addons, :is_default)
|
||||
"""
|
||||
insert_params["apply_discounts_to_addons"] = True
|
||||
elif addon_discount_column_exists:
|
||||
insert_sql = """
|
||||
INSERT INTO promo_groups (
|
||||
name,
|
||||
@@ -2301,6 +2320,17 @@ async def ensure_promo_groups_setup():
|
||||
) VALUES (:name, 0, 0, 0, :apply_discounts_to_addons, :is_default)
|
||||
"""
|
||||
insert_params["apply_discounts_to_addons"] = True
|
||||
elif priority_column_exists:
|
||||
insert_sql = """
|
||||
INSERT INTO promo_groups (
|
||||
name,
|
||||
priority,
|
||||
server_discount_percent,
|
||||
traffic_discount_percent,
|
||||
device_discount_percent,
|
||||
is_default
|
||||
) VALUES (:name, :priority, 0, 0, 0, :is_default)
|
||||
"""
|
||||
else:
|
||||
insert_sql = """
|
||||
INSERT INTO promo_groups (
|
||||
|
||||
Vendored
+4
-4
@@ -1,4 +1,4 @@
|
||||
"""Flask webhook server for PayPalych postbacks."""
|
||||
"""Flask webhook server for PayPalych callbacks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -65,7 +65,7 @@ def create_pal24_flask_app(
|
||||
return jsonify({"status": "error", "reason": "empty_payload"}), 400
|
||||
|
||||
try:
|
||||
parsed_payload = pal24_service.parse_postback(payload)
|
||||
parsed_payload = pal24_service.parse_callback(payload)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка валидации Pal24 webhook: %s", error)
|
||||
return jsonify({"status": "error", "reason": str(error)}), 400
|
||||
@@ -73,7 +73,7 @@ def create_pal24_flask_app(
|
||||
async def process() -> bool:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
return await payment_service.process_pal24_postback(db, parsed_payload)
|
||||
return await payment_service.process_pal24_callback(db, parsed_payload)
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
@@ -112,7 +112,7 @@ def create_pal24_flask_app(
|
||||
|
||||
|
||||
class Pal24WebhookServer:
|
||||
"""Threaded Flask server for Pal24 postbacks."""
|
||||
"""Threaded Flask server for Pal24 callbacks."""
|
||||
|
||||
def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None:
|
||||
self.app = create_pal24_flask_app(payment_service, loop)
|
||||
|
||||
Vendored
+268
-127
@@ -1,12 +1,16 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
import hashlib
|
||||
import hmac
|
||||
import base64
|
||||
from typing import Optional, Dict, Any
|
||||
from ipaddress import (
|
||||
IPv4Address,
|
||||
IPv4Network,
|
||||
IPv6Address,
|
||||
IPv6Network,
|
||||
ip_address,
|
||||
ip_network,
|
||||
)
|
||||
from typing import Iterable, Optional, Dict, Any, List, Union, Tuple
|
||||
from aiohttp import web
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.services.payment_service import PaymentService
|
||||
@@ -15,155 +19,292 @@ from app.database.database import get_db
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
IPAddress = Union[IPv4Address, IPv6Address]
|
||||
IPNetwork = Union[IPv4Network, IPv6Network]
|
||||
|
||||
YOOKASSA_ALLOWED_IP_NETWORKS: tuple[IPNetwork, ...] = (
|
||||
ip_network("185.71.76.0/27"),
|
||||
ip_network("185.71.77.0/27"),
|
||||
ip_network("77.75.153.0/25"),
|
||||
ip_network("77.75.154.128/25"),
|
||||
ip_network("77.75.156.11/32"),
|
||||
ip_network("77.75.156.35/32"),
|
||||
ip_network("2a02:5180::/32"),
|
||||
)
|
||||
|
||||
|
||||
CLOUDFLARE_TRUSTED_NETWORKS: tuple[IPNetwork, ...] = (
|
||||
ip_network("173.245.48.0/20"),
|
||||
ip_network("103.21.244.0/22"),
|
||||
ip_network("103.22.200.0/22"),
|
||||
ip_network("103.31.4.0/22"),
|
||||
ip_network("141.101.64.0/18"),
|
||||
ip_network("108.162.192.0/18"),
|
||||
ip_network("190.93.240.0/20"),
|
||||
ip_network("188.114.96.0/20"),
|
||||
ip_network("197.234.240.0/22"),
|
||||
ip_network("198.41.128.0/17"),
|
||||
ip_network("162.158.0.0/15"),
|
||||
ip_network("104.16.0.0/13"),
|
||||
ip_network("104.24.0.0/14"),
|
||||
ip_network("172.64.0.0/13"),
|
||||
ip_network("131.0.72.0/22"),
|
||||
ip_network("2400:cb00::/32"),
|
||||
ip_network("2606:4700::/32"),
|
||||
ip_network("2803:f800::/32"),
|
||||
ip_network("2405:b500::/32"),
|
||||
ip_network("2405:8100::/32"),
|
||||
ip_network("2a06:98c0::/29"),
|
||||
ip_network("2c0f:f248::/32"),
|
||||
)
|
||||
|
||||
|
||||
YOOKASSA_ALLOWED_EVENTS: tuple[str, ...] = (
|
||||
"payment.succeeded",
|
||||
"payment.waiting_for_capture",
|
||||
"payment.canceled",
|
||||
)
|
||||
|
||||
|
||||
def collect_yookassa_ip_candidates(*values: Optional[str]) -> List[str]:
|
||||
candidates: List[str] = []
|
||||
for value in values:
|
||||
if not value:
|
||||
continue
|
||||
for part in value.split(","):
|
||||
normalized = part.strip()
|
||||
if normalized:
|
||||
candidates.append(normalized)
|
||||
return candidates
|
||||
|
||||
|
||||
def _parse_candidate_ip(candidate: str) -> Optional[IPAddress]:
|
||||
value = candidate.strip()
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if value.startswith("[") and "]" in value:
|
||||
value = value[1:value.index("]")]
|
||||
|
||||
if "%" in value:
|
||||
value = value.split("%", 1)[0]
|
||||
|
||||
if value.count(":") == 1 and "." in value:
|
||||
host, _, port = value.rpartition(":")
|
||||
if port.isdigit():
|
||||
value = host
|
||||
|
||||
try:
|
||||
return ip_address(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _should_trust_forwarded_headers(remote_ip: Optional[IPAddress]) -> bool:
|
||||
if remote_ip is None:
|
||||
return True
|
||||
|
||||
if _is_trusted_proxy_ip(remote_ip):
|
||||
return True
|
||||
|
||||
return any(
|
||||
getattr(remote_ip, attribute)
|
||||
for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved")
|
||||
)
|
||||
|
||||
|
||||
_TRUSTED_PROXY_NETWORKS_CACHE: Tuple[str, Tuple[IPNetwork, ...]] = ("", ())
|
||||
|
||||
|
||||
def _get_trusted_proxy_networks() -> Tuple[IPNetwork, ...]:
|
||||
global _TRUSTED_PROXY_NETWORKS_CACHE
|
||||
|
||||
raw_value = getattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "") or ""
|
||||
cached_raw, cached_networks = _TRUSTED_PROXY_NETWORKS_CACHE
|
||||
|
||||
if raw_value == cached_raw:
|
||||
return cached_networks
|
||||
|
||||
networks: List[IPNetwork] = []
|
||||
for part in raw_value.split(","):
|
||||
candidate = part.strip()
|
||||
if not candidate:
|
||||
continue
|
||||
|
||||
try:
|
||||
networks.append(ip_network(candidate, strict=False))
|
||||
except ValueError:
|
||||
logger.warning("Неверная сеть доверенного прокси YooKassa: %s", candidate)
|
||||
|
||||
cached_networks = tuple(networks)
|
||||
_TRUSTED_PROXY_NETWORKS_CACHE = (raw_value, cached_networks)
|
||||
return cached_networks
|
||||
|
||||
|
||||
def _is_trusted_proxy_ip(ip_object: IPAddress) -> bool:
|
||||
if any(
|
||||
getattr(ip_object, attribute)
|
||||
for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved")
|
||||
):
|
||||
return True
|
||||
|
||||
if any(ip_object in network for network in CLOUDFLARE_TRUSTED_NETWORKS):
|
||||
return True
|
||||
|
||||
return any(ip_object in network for network in _get_trusted_proxy_networks())
|
||||
|
||||
|
||||
def resolve_yookassa_ip(
|
||||
candidates: Iterable[str],
|
||||
*,
|
||||
remote: Optional[str] = None,
|
||||
) -> Optional[IPAddress]:
|
||||
remote_ip = _parse_candidate_ip(remote) if remote else None
|
||||
|
||||
if (
|
||||
remote_ip is not None
|
||||
and remote_ip.is_global
|
||||
and not _is_trusted_proxy_ip(remote_ip)
|
||||
):
|
||||
return remote_ip
|
||||
|
||||
candidate_list = list(candidates)
|
||||
|
||||
if _should_trust_forwarded_headers(remote_ip):
|
||||
last_hop = remote_ip
|
||||
for candidate in reversed(candidate_list):
|
||||
ip_object = _parse_candidate_ip(candidate)
|
||||
if ip_object is not None:
|
||||
if last_hop is None or _is_trusted_proxy_ip(last_hop):
|
||||
if _is_trusted_proxy_ip(ip_object):
|
||||
last_hop = ip_object
|
||||
continue
|
||||
return ip_object
|
||||
break
|
||||
|
||||
if last_hop is not None and not _is_trusted_proxy_ip(last_hop):
|
||||
return last_hop
|
||||
|
||||
return remote_ip if remote_ip is not None else next(
|
||||
(ip for ip in (_parse_candidate_ip(value) for value in candidate_list) if ip is not None),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def is_yookassa_ip_allowed(ip_object: IPAddress) -> bool:
|
||||
return any(ip_object in network for network in YOOKASSA_ALLOWED_IP_NETWORKS)
|
||||
|
||||
|
||||
class YooKassaWebhookHandler:
|
||||
|
||||
@staticmethod
|
||||
def verify_webhook_signature(body: str, signature: str, secret: str) -> bool:
|
||||
try:
|
||||
signature_parts = signature.strip().split(' ')
|
||||
|
||||
if len(signature_parts) < 4:
|
||||
logger.error(f"Неверный формат подписи YooKassa: {signature}")
|
||||
return False
|
||||
|
||||
version = signature_parts[0]
|
||||
payment_id = signature_parts[1]
|
||||
timestamp = signature_parts[2]
|
||||
received_signature = signature_parts[3]
|
||||
|
||||
if version != "v1":
|
||||
logger.error(f"Неподдерживаемая версия подписи: {version}")
|
||||
return False
|
||||
|
||||
logger.info(f"Проверка подписи v1 для платежа {payment_id}, timestamp: {timestamp}")
|
||||
|
||||
|
||||
expected_signature_1 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
body.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_1_b64 = base64.b64encode(expected_signature_1).decode('utf-8')
|
||||
|
||||
signed_payload_2 = f"{payment_id}.{timestamp}.{body}"
|
||||
expected_signature_2 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
signed_payload_2.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_2_b64 = base64.b64encode(expected_signature_2).decode('utf-8')
|
||||
|
||||
signed_payload_3 = f"{timestamp}.{body}"
|
||||
expected_signature_3 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
signed_payload_3.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_3_b64 = base64.b64encode(expected_signature_3).decode('utf-8')
|
||||
|
||||
logger.debug(f"Получена подпись: {received_signature}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 1): {expected_signature_1_b64}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 2): {expected_signature_2_b64}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 3): {expected_signature_3_b64}")
|
||||
|
||||
is_valid = (
|
||||
hmac.compare_digest(received_signature, expected_signature_1_b64) or
|
||||
hmac.compare_digest(received_signature, expected_signature_2_b64) or
|
||||
hmac.compare_digest(received_signature, expected_signature_3_b64)
|
||||
)
|
||||
|
||||
if is_valid:
|
||||
logger.info("✅ Подпись YooKassa webhook проверена успешно")
|
||||
else:
|
||||
logger.warning("⚠️ Подпись YooKassa webhook не совпадает ни с одним вариантом")
|
||||
|
||||
return is_valid
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки подписи YooKassa: {e}")
|
||||
return False
|
||||
|
||||
def __init__(self, payment_service: PaymentService):
|
||||
self.payment_service = payment_service
|
||||
|
||||
|
||||
async def handle_webhook(self, request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
try:
|
||||
logger.info(f"📥 Получен YooKassa webhook: {request.method} {request.path}")
|
||||
logger.info(f"📋 Headers: {dict(request.headers)}")
|
||||
|
||||
|
||||
header_ip_candidates = collect_yookassa_ip_candidates(
|
||||
request.headers.get("X-Forwarded-For"),
|
||||
request.headers.get("X-Real-IP"),
|
||||
request.headers.get("Cf-Connecting-Ip"),
|
||||
)
|
||||
client_ip = resolve_yookassa_ip(
|
||||
header_ip_candidates,
|
||||
remote=request.remote,
|
||||
)
|
||||
|
||||
if client_ip is None:
|
||||
logger.warning(
|
||||
"🚫 Не удалось определить IP-адрес отправителя YooKassa webhook. Кандидаты: %s",
|
||||
header_ip_candidates + ([request.remote] if request.remote else []),
|
||||
)
|
||||
return web.Response(status=403, text="Forbidden")
|
||||
|
||||
if not is_yookassa_ip_allowed(client_ip):
|
||||
logger.warning(
|
||||
"🚫 YooKassa webhook отклонён: IP %s не входит в доверенные диапазоны (%s)",
|
||||
client_ip,
|
||||
", ".join(str(network) for network in YOOKASSA_ALLOWED_IP_NETWORKS),
|
||||
)
|
||||
return web.Response(status=403, text="Forbidden")
|
||||
|
||||
logger.info("🌐 IP-адрес YooKassa подтверждён: %s", client_ip)
|
||||
|
||||
body = await request.text()
|
||||
|
||||
|
||||
if not body:
|
||||
logger.warning("⚠️ Получен пустой webhook от YooKassa")
|
||||
return web.Response(status=400, text="Empty body")
|
||||
|
||||
|
||||
logger.info(f"📄 Body: {body}")
|
||||
|
||||
|
||||
signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature')
|
||||
|
||||
if settings.YOOKASSA_WEBHOOK_SECRET and signature:
|
||||
logger.info(f"🔐 Получена подпись: {signature}")
|
||||
|
||||
if not YooKassaWebhookHandler.verify_webhook_signature(body, signature, settings.YOOKASSA_WEBHOOK_SECRET):
|
||||
logger.warning("❌ Подпись не совпала, но продолжаем обработку (режим отладки)")
|
||||
else:
|
||||
logger.info("✅ Подпись webhook проверена успешно")
|
||||
|
||||
elif settings.YOOKASSA_WEBHOOK_SECRET and not signature:
|
||||
logger.warning("⚠️ Webhook без подписи, но секрет настроен")
|
||||
|
||||
elif signature and not settings.YOOKASSA_WEBHOOK_SECRET:
|
||||
logger.info("ℹ️ Подпись получена, но проверка отключена (YOOKASSA_WEBHOOK_SECRET не настроен)")
|
||||
|
||||
else:
|
||||
logger.info("ℹ️ Проверка подписи отключена")
|
||||
|
||||
if signature:
|
||||
logger.info("ℹ️ Получена подпись YooKassa: %s", signature)
|
||||
|
||||
try:
|
||||
webhook_data = json.loads(body)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"❌ Ошибка парсинга JSON webhook YooKassa: {e}")
|
||||
return web.Response(status=400, text="Invalid JSON")
|
||||
|
||||
|
||||
logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}")
|
||||
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")
|
||||
if not event_type:
|
||||
logger.warning("⚠️ Webhook YooKassa без типа события")
|
||||
return web.Response(status=400, text="No event type")
|
||||
|
||||
if event_type not in ["payment.succeeded", "payment.waiting_for_capture"]:
|
||||
|
||||
if event_type not in YOOKASSA_ALLOWED_EVENTS:
|
||||
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
|
||||
# Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования)
|
||||
from app.database.models import PaymentMethod
|
||||
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)
|
||||
|
||||
if existing_transaction and event_type == "payment.succeeded":
|
||||
logger.info(f"ℹ️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.")
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
|
||||
|
||||
if success:
|
||||
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type}")
|
||||
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
|
||||
return web.Response(status=200, text="OK")
|
||||
else:
|
||||
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type}")
|
||||
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
|
||||
return web.Response(status=500, text="Processing error")
|
||||
|
||||
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Критическая ошибка обработки webhook YooKassa: {e}", exc_info=True)
|
||||
return web.Response(status=500, text="Internal server error")
|
||||
|
||||
|
||||
def setup_routes(self, app: web.Application) -> None:
|
||||
|
||||
|
||||
webhook_path = settings.YOOKASSA_WEBHOOK_PATH
|
||||
app.router.add_post(webhook_path, self.handle_webhook)
|
||||
app.router.add_get(webhook_path, self._get_handler)
|
||||
app.router.add_options(webhook_path, self._options_handler)
|
||||
|
||||
app.router.add_get(webhook_path, self._get_handler)
|
||||
app.router.add_options(webhook_path, self._options_handler)
|
||||
|
||||
logger.info(f"✅ Настроен YooKassa webhook на пути: POST {webhook_path}")
|
||||
|
||||
|
||||
async def _get_handler(self, request: web.Request) -> web.Response:
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
@@ -172,7 +313,7 @@ class YooKassaWebhookHandler:
|
||||
"path": request.path,
|
||||
"note": "Use POST method for actual webhooks"
|
||||
})
|
||||
|
||||
|
||||
async def _options_handler(self, request: web.Request) -> web.Response:
|
||||
return web.Response(
|
||||
status=200,
|
||||
@@ -185,46 +326,46 @@ class YooKassaWebhookHandler:
|
||||
|
||||
|
||||
def create_yookassa_webhook_app(payment_service: PaymentService) -> web.Application:
|
||||
|
||||
|
||||
app = web.Application()
|
||||
|
||||
|
||||
webhook_handler = YooKassaWebhookHandler(payment_service)
|
||||
webhook_handler.setup_routes(app)
|
||||
|
||||
|
||||
async def health_check(request):
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"status": "ok",
|
||||
"service": "yookassa_webhook",
|
||||
"port": settings.YOOKASSA_WEBHOOK_PORT,
|
||||
"path": settings.YOOKASSA_WEBHOOK_PATH,
|
||||
"enabled": settings.is_yookassa_enabled()
|
||||
})
|
||||
|
||||
|
||||
app.router.add_get("/health", health_check)
|
||||
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def start_yookassa_webhook_server(payment_service: PaymentService) -> None:
|
||||
|
||||
|
||||
if not settings.is_yookassa_enabled():
|
||||
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
app = create_yookassa_webhook_app(payment_service)
|
||||
|
||||
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
|
||||
|
||||
site = web.TCPSite(
|
||||
runner,
|
||||
host=settings.YOOKASSA_WEBHOOK_HOST,
|
||||
port=settings.YOOKASSA_WEBHOOK_PORT
|
||||
)
|
||||
|
||||
|
||||
await site.start()
|
||||
|
||||
|
||||
logger.info(
|
||||
"✅ YooKassa webhook сервер запущен на %s:%s",
|
||||
settings.YOOKASSA_WEBHOOK_HOST,
|
||||
@@ -236,7 +377,7 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
|
||||
settings.YOOKASSA_WEBHOOK_PORT,
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
@@ -246,7 +387,7 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
|
||||
await site.stop()
|
||||
await runner.cleanup()
|
||||
logger.info("✅ YooKassa webhook сервер остановлен")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка запуска YooKassa webhook сервера: {e}", exc_info=True)
|
||||
raise
|
||||
raise
|
||||
@@ -61,7 +61,7 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
},
|
||||
"payments": {
|
||||
"title": "💳 Платежные системы",
|
||||
"description": "YooKassa, CryptoBot, Heleket, MulenPay, PAL24, Wata, Tribute и Telegram Stars.",
|
||||
"description": "YooKassa, CryptoBot, Heleket, MulenPay, PAL24, Wata, Platega, Tribute и Telegram Stars.",
|
||||
"icon": "💳",
|
||||
"categories": (
|
||||
"PAYMENT",
|
||||
@@ -72,6 +72,7 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"MULENPAY",
|
||||
"PAL24",
|
||||
"WATA",
|
||||
"PLATEGA",
|
||||
"TRIBUTE",
|
||||
"TELEGRAM",
|
||||
),
|
||||
@@ -253,6 +254,7 @@ def _get_group_status(group_key: str) -> Tuple[str, str]:
|
||||
payment_statuses = {
|
||||
"YooKassa": settings.is_yookassa_enabled(),
|
||||
"CryptoBot": settings.is_cryptobot_enabled(),
|
||||
"Platega": settings.is_platega_enabled(),
|
||||
"MulenPay": settings.is_mulenpay_enabled(),
|
||||
"PAL24": settings.is_pal24_enabled(),
|
||||
"Tribute": settings.TRIBUTE_ENABLED,
|
||||
|
||||
@@ -37,6 +37,8 @@ def _method_display(method: PaymentMethod) -> str:
|
||||
return "Heleket"
|
||||
if method == PaymentMethod.YOOKASSA:
|
||||
return "YooKassa"
|
||||
if method == PaymentMethod.PLATEGA:
|
||||
return "Platega"
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
return "CryptoBot"
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
@@ -90,6 +92,18 @@ def _status_info(
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.PLATEGA:
|
||||
mapping = {
|
||||
"pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"inprogress": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"confirmed": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"failed": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"cancelled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"expired": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.HELEKET:
|
||||
if status in {"pending", "created", "waiting", "check", "processing"}:
|
||||
return "⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
|
||||
@@ -136,6 +150,8 @@ def _is_checkable(record: PendingPayment) -> bool:
|
||||
return status in {"created", "processing", "hold"}
|
||||
if record.method == PaymentMethod.WATA:
|
||||
return status in {"opened", "pending", "processing", "inprogress", "in_progress"}
|
||||
if record.method == PaymentMethod.PLATEGA:
|
||||
return status in {"pending", "inprogress", "in_progress"}
|
||||
if record.method == PaymentMethod.HELEKET:
|
||||
return status not in {"paid", "paid_over", "cancel", "canceled", "fail", "failed", "expired"}
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
|
||||
@@ -96,6 +96,24 @@ TRIAL_ENTRIES: Tuple[SettingEntry, ...] = (
|
||||
label_en="📱 Device limit",
|
||||
action="input",
|
||||
),
|
||||
SettingEntry(
|
||||
key="TRIAL_PAYMENT_ENABLED",
|
||||
section="trial",
|
||||
label_ru="💳 Платная активация",
|
||||
label_en="💳 Paid activation",
|
||||
action="toggle",
|
||||
description_ru="Если включено — за активацию триала будет списываться указанная сумма.",
|
||||
description_en="When enabled, the configured amount is charged during trial activation.",
|
||||
),
|
||||
SettingEntry(
|
||||
key="TRIAL_ACTIVATION_PRICE",
|
||||
section="trial",
|
||||
label_ru="💰 Стоимость активации",
|
||||
label_en="💰 Activation price",
|
||||
action="price",
|
||||
description_ru="Указывается в копейках. 0 — бесплатная активация.",
|
||||
description_en="Amount in kopeks. 0 — free activation.",
|
||||
),
|
||||
SettingEntry(
|
||||
key="TRIAL_ADD_REMAINING_DAYS_TO_PAID",
|
||||
section="trial",
|
||||
@@ -105,15 +123,6 @@ TRIAL_ENTRIES: Tuple[SettingEntry, ...] = (
|
||||
description_ru="Если включено — при покупке платной подписки оставшиеся дни триала будут добавлены к сроку.",
|
||||
description_en="When enabled, remaining trial days are added to paid subscription duration.",
|
||||
),
|
||||
SettingEntry(
|
||||
key="TRIAL_SQUAD_UUID",
|
||||
section="trial",
|
||||
label_ru="🆔 Squad UUID",
|
||||
label_en="🆔 Squad UUID",
|
||||
action="input",
|
||||
description_ru="Можно оставить пустым, если не требуется назначать пробные подписки в конкретный Squad.",
|
||||
description_en="Leave empty if trial subscriptions shouldn't be bound to a specific Squad.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -290,11 +299,14 @@ def _format_trial_summary(lang_code: str) -> str:
|
||||
duration = settings.TRIAL_DURATION_DAYS
|
||||
traffic = settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
devices = settings.TRIAL_DEVICE_LIMIT
|
||||
price_note = ""
|
||||
if settings.is_trial_paid_activation_enabled():
|
||||
price_note = f", 💳 {settings.format_price(settings.get_trial_activation_price())}"
|
||||
|
||||
traffic_label = _format_traffic_label(traffic, lang_code, short=True)
|
||||
devices_label = f"{devices}📱" if lang_code == "ru" else f"{devices}📱"
|
||||
days_suffix = "д" if lang_code == "ru" else "d"
|
||||
return f"{duration}{days_suffix}, {traffic_label}, {devices_label}"
|
||||
return f"{duration}{days_suffix}, {traffic_label}, {devices_label}{price_note}"
|
||||
|
||||
|
||||
def _format_core_summary(lang_code: str) -> str:
|
||||
|
||||
@@ -3836,10 +3836,7 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
getattr(settings, "TRIAL_SQUAD_UUID", None),
|
||||
)
|
||||
trial_uuid = await get_random_trial_squad_uuid(db)
|
||||
if trial_uuid:
|
||||
trial_squads = [trial_uuid]
|
||||
except Exception as error:
|
||||
@@ -3848,8 +3845,6 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
|
||||
admin_id,
|
||||
error,
|
||||
)
|
||||
if getattr(settings, "TRIAL_SQUAD_UUID", None):
|
||||
trial_squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
|
||||
@@ -284,7 +284,16 @@ async def show_payment_methods(
|
||||
devices_discounted_per_month * months_in_period
|
||||
)
|
||||
|
||||
current_tariff_desc = f"📱 Подписка: {len(current_connected_squads)} серверов, {current_traffic} ГБ, {current_device_limit} устр."
|
||||
traffic_value = current_traffic or 0
|
||||
if traffic_value <= 0:
|
||||
traffic_display = texts.t("TRAFFIC_UNLIMITED_SHORT", "Безлимит")
|
||||
else:
|
||||
traffic_display = texts.format_traffic(traffic_value)
|
||||
|
||||
current_tariff_desc = (
|
||||
f"📱 Подписка: {len(current_connected_squads)} серверов, "
|
||||
f"{traffic_display}, {current_device_limit} устр."
|
||||
)
|
||||
estimated_price_info = f"💰 Стоимость продления (примерно): {texts.format_price(total_price)} за {duration_days} дней"
|
||||
|
||||
tariff_info = f"\n\n📋 <b>Ваш текущий тариф:</b>\n{current_tariff_desc}\n{estimated_price_info}"
|
||||
@@ -386,6 +395,10 @@ async def handle_successful_topup_with_cart(
|
||||
success_text = (
|
||||
f"✅ Баланс пополнен на {texts.format_price(amount_kopeks)}!\n\n"
|
||||
f"💰 Текущий баланс: {texts.format_price(user.balance_kopeks)}\n\n"
|
||||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса.\n\n"
|
||||
f"🛒 У вас есть сохраненная корзина подписки\n"
|
||||
f"Стоимость: {texts.format_price(total_price)}\n\n"
|
||||
f"Хотите продолжить оформление?"
|
||||
@@ -519,6 +532,14 @@ async def process_topup_amount(
|
||||
from .mulenpay import process_mulenpay_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "platega":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .platega import process_platega_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(
|
||||
message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "wata":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .wata import process_wata_payment_amount
|
||||
@@ -630,6 +651,14 @@ async def handle_quick_amount_selection(
|
||||
await process_mulenpay_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "platega":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .platega import process_platega_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "wata":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .wata import process_wata_payment_amount
|
||||
@@ -717,6 +746,13 @@ async def handle_topup_amount_callback(
|
||||
await process_mulenpay_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "platega":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .platega import process_platega_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .pal24 import process_pal24_payment_amount
|
||||
@@ -738,6 +774,13 @@ async def handle_topup_amount_callback(
|
||||
await process_heleket_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "wata":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .wata import process_wata_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_wata_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "stars":
|
||||
from .stars import process_stars_payment_amount
|
||||
await process_stars_payment_amount(
|
||||
@@ -821,6 +864,16 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
F.data.startswith("pal24_method_"),
|
||||
)
|
||||
|
||||
from .platega import start_platega_payment, handle_platega_method_selection
|
||||
dp.callback_query.register(
|
||||
start_platega_payment,
|
||||
F.data == "topup_platega"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
handle_platega_method_selection,
|
||||
F.data.startswith("platega_method_"),
|
||||
)
|
||||
|
||||
from .yookassa import check_yookassa_payment_status
|
||||
dp.callback_query.register(
|
||||
check_yookassa_payment_status,
|
||||
@@ -889,6 +942,12 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
F.data.startswith("check_pal24_")
|
||||
)
|
||||
|
||||
from .platega import check_platega_payment_status
|
||||
dp.callback_query.register(
|
||||
check_platega_payment_status,
|
||||
F.data.startswith("check_platega_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_payment_methods_unavailable,
|
||||
F.data == "payment_methods_unavailable"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import html
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -423,8 +425,77 @@ async def check_pal24_payment_status(
|
||||
if not isinstance(links_meta, dict):
|
||||
links_meta = {}
|
||||
|
||||
sbp_link = links_meta.get("sbp") or payment.link_url
|
||||
card_link = links_meta.get("card")
|
||||
links_info = status_info.get("links") or {}
|
||||
|
||||
def _extract_link(source: Any, keys: tuple[str, ...]) -> Optional[str]:
|
||||
stack: list[Any] = [source]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
if isinstance(current, dict):
|
||||
for key in keys:
|
||||
value = current.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
stack.extend(current.values())
|
||||
elif isinstance(current, list):
|
||||
stack.extend(current)
|
||||
return None
|
||||
|
||||
raw_response = metadata.get("raw_response") if isinstance(metadata, dict) else None
|
||||
remote_data = status_info.get("remote_data")
|
||||
transfer_keys = (
|
||||
"transfer_url",
|
||||
"transferUrl",
|
||||
"transfer_link",
|
||||
"transferLink",
|
||||
"transfer",
|
||||
"sbp_url",
|
||||
"sbpUrl",
|
||||
"sbp_link",
|
||||
"sbpLink",
|
||||
)
|
||||
card_keys = (
|
||||
"link_url",
|
||||
"linkUrl",
|
||||
"link",
|
||||
"card_url",
|
||||
"cardUrl",
|
||||
"card_link",
|
||||
"cardLink",
|
||||
"payment_url",
|
||||
"paymentUrl",
|
||||
"url",
|
||||
)
|
||||
|
||||
extra_sbp_link = (
|
||||
_extract_link(raw_response, transfer_keys)
|
||||
if raw_response
|
||||
else None
|
||||
)
|
||||
if not extra_sbp_link and remote_data:
|
||||
extra_sbp_link = _extract_link(remote_data, transfer_keys)
|
||||
|
||||
extra_card_link = (
|
||||
_extract_link(raw_response, card_keys)
|
||||
if raw_response
|
||||
else None
|
||||
)
|
||||
if not extra_card_link and remote_data:
|
||||
extra_card_link = _extract_link(remote_data, card_keys)
|
||||
|
||||
sbp_link = (
|
||||
links_info.get("sbp")
|
||||
or links_meta.get("sbp")
|
||||
or status_info.get("sbp_url")
|
||||
or extra_sbp_link
|
||||
or payment.link_url
|
||||
)
|
||||
card_link = (
|
||||
links_info.get("card")
|
||||
or links_meta.get("card")
|
||||
or status_info.get("card_url")
|
||||
or extra_card_link
|
||||
)
|
||||
|
||||
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
|
||||
card_link = payment.link_page_url
|
||||
@@ -459,10 +530,53 @@ async def check_pal24_payment_status(
|
||||
db_user = getattr(callback, 'db_user', None)
|
||||
texts = get_texts(db_user.language if db_user else 'ru') if db_user else get_texts('ru')
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), callback_data=f"check_pal24_{local_payment_id}")],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
pay_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if not payment.is_paid and payment.status in {"NEW", "PROCESS"}:
|
||||
default_sbp_text = texts.t(
|
||||
"PAL24_SBP_PAY_BUTTON",
|
||||
"🏦 Оплатить через PayPalych (СБП)",
|
||||
)
|
||||
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
|
||||
|
||||
if sbp_link and settings.is_pal24_sbp_button_visible():
|
||||
pay_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=sbp_button_text,
|
||||
url=sbp_link,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
default_card_text = texts.t(
|
||||
"PAL24_CARD_PAY_BUTTON",
|
||||
"💳 Оплатить банковской картой (PayPalych)",
|
||||
)
|
||||
card_button_text = settings.get_pal24_card_button_text(default_card_text)
|
||||
|
||||
if card_link and settings.is_pal24_card_button_visible():
|
||||
if not pay_rows or pay_rows[-1][0].url != card_link:
|
||||
pay_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=card_button_text,
|
||||
url=card_link,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard_rows = pay_rows + [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_pal24_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
await callback.answer()
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Handlers for Platega balance interactions."""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_active_methods() -> List[int]:
|
||||
methods = settings.get_platega_active_methods()
|
||||
return [code for code in methods if code in {2, 10, 11, 12, 13}]
|
||||
|
||||
|
||||
async def _prompt_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
method_code: int,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
method_name = settings.get_platega_method_display_title(method_code)
|
||||
|
||||
min_amount_label = settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)
|
||||
max_amount_kopeks = settings.PLATEGA_MAX_AMOUNT_KOPEKS
|
||||
max_amount_label = (
|
||||
settings.format_price(max_amount_kopeks)
|
||||
if max_amount_kopeks and max_amount_kopeks > 0
|
||||
else ""
|
||||
)
|
||||
|
||||
default_prompt_body = (
|
||||
"Введите сумму для пополнения от {min_amount} до {max_amount}.\n"
|
||||
if max_amount_kopeks and max_amount_kopeks > 0
|
||||
else "Введите сумму для пополнения от {min_amount}.\n"
|
||||
)
|
||||
|
||||
prompt_template = texts.t(
|
||||
"PLATEGA_TOPUP_PROMPT",
|
||||
(
|
||||
"💳 <b>Оплата через Platega ({method_name})</b>\n\n"
|
||||
f"{default_prompt_body}"
|
||||
"Оплата происходит через Platega."
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await message.edit_text(
|
||||
prompt_template.format(
|
||||
method_name=method_name,
|
||||
min_amount=min_amount_label,
|
||||
max_amount=max_amount_label,
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="platega", platega_method=method_code)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_platega_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_platega_enabled():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PLATEGA_TEMPORARILY_UNAVAILABLE",
|
||||
"❌ Оплата через Platega временно недоступна",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
active_methods = _get_active_methods()
|
||||
if not active_methods:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PLATEGA_METHODS_NOT_CONFIGURED",
|
||||
"⚠️ На стороне Platega нет доступных методов оплаты",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(payment_method="platega")
|
||||
|
||||
if len(active_methods) == 1:
|
||||
await _prompt_amount(callback.message, db_user, state, active_methods[0])
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
method_buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
for method_code in active_methods:
|
||||
label = settings.get_platega_method_display_title(method_code)
|
||||
method_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=label,
|
||||
callback_data=f"platega_method_{method_code}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
method_buttons.append(
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"PLATEGA_SELECT_PAYMENT_METHOD",
|
||||
"Выберите способ оплаты Platega:",
|
||||
),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
|
||||
)
|
||||
await state.set_state(BalanceStates.waiting_for_platega_method)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_platega_method_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
try:
|
||||
method_code = int(callback.data.rsplit("_", 1)[-1])
|
||||
except ValueError:
|
||||
await callback.answer("❌ Некорректный способ оплаты", show_alert=True)
|
||||
return
|
||||
|
||||
if method_code not in _get_active_methods():
|
||||
await callback.answer("⚠️ Этот способ сейчас недоступен", show_alert=True)
|
||||
return
|
||||
|
||||
await _prompt_amount(callback.message, db_user, state, method_code)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_platega_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_platega_enabled():
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_TEMPORARILY_UNAVAILABLE",
|
||||
"❌ Оплата через Platega временно недоступна",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
method_code = int(data.get("platega_method", 0))
|
||||
if method_code not in _get_active_methods():
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_METHOD_SELECTION_REQUIRED",
|
||||
"⚠️ Выберите способ оплаты Platega перед вводом суммы",
|
||||
)
|
||||
)
|
||||
await state.set_state(BalanceStates.waiting_for_platega_method)
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_AMOUNT_TOO_LOW",
|
||||
"Минимальная сумма для оплаты через Platega: {amount}",
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS))
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_AMOUNT_TOO_HIGH",
|
||||
"Максимальная сумма для оплаты через Platega: {amount}",
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS))
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
payment_result = await payment_service.create_platega_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=db_user.language,
|
||||
payment_method_code=method_code,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.exception("Ошибка создания платежа Platega: %s", error)
|
||||
payment_result = None
|
||||
|
||||
if not payment_result or not payment_result.get("redirect_url"):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа Platega. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
redirect_url = payment_result.get("redirect_url")
|
||||
local_payment_id = payment_result.get("local_payment_id")
|
||||
transaction_id = payment_result.get("transaction_id")
|
||||
method_title = settings.get_platega_method_display_title(method_code)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"PLATEGA_PAY_BUTTON",
|
||||
"💳 Оплатить через {method}",
|
||||
).format(method=method_title),
|
||||
url=redirect_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_platega_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
)
|
||||
|
||||
instructions_template = texts.t(
|
||||
"PLATEGA_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"💳 <b>Оплата через Platega ({method})</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID транзакции: {transaction}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n"
|
||||
"1. Нажмите кнопку «Оплатить»\n"
|
||||
"2. Следуйте подсказкам платёжной системы\n"
|
||||
"3. Подтвердите перевод\n"
|
||||
"4. Средства зачислятся автоматически\n\n"
|
||||
"❓ Если возникнут проблемы, обратитесь в {support}"
|
||||
),
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
instructions_template.format(
|
||||
method=method_title,
|
||||
amount=settings.format_price(amount_kopeks),
|
||||
transaction=transaction_id or local_payment_id,
|
||||
support=settings.get_support_contact_display_html(),
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_platega_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split("_")[-1])
|
||||
except ValueError:
|
||||
await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
|
||||
try:
|
||||
status_info = await payment_service.get_platega_payment_status(db, local_payment_id)
|
||||
except Exception as error:
|
||||
logger.exception("Ошибка проверки статуса Platega: %s", error)
|
||||
await callback.answer("⚠️ Ошибка проверки статуса", show_alert=True)
|
||||
return
|
||||
|
||||
if not status_info:
|
||||
await callback.answer("⚠️ Платёж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
payment = status_info.get("payment")
|
||||
status = status_info.get("status")
|
||||
is_paid = status_info.get("is_paid")
|
||||
|
||||
language = "ru"
|
||||
user = getattr(payment, "user", None)
|
||||
if user and getattr(user, "language", None):
|
||||
language = user.language
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
if is_paid:
|
||||
await callback.answer(texts.t("PLATEGA_PAYMENT_ALREADY_CONFIRMED", "✅ Платёж уже зачислен"), show_alert=True)
|
||||
else:
|
||||
await callback.answer(
|
||||
texts.t("PLATEGA_PAYMENT_STATUS", "Текущий статус платежа: {status}").format(status=status),
|
||||
show_alert=True,
|
||||
)
|
||||
@@ -1655,8 +1655,18 @@ async def check_simple_pal24_payment_status(
|
||||
if not isinstance(links_meta, dict):
|
||||
links_meta = {}
|
||||
|
||||
sbp_link = links_meta.get("sbp") or payment.link_url
|
||||
card_link = links_meta.get("card")
|
||||
links_info = status_info.get("links") or {}
|
||||
sbp_link = (
|
||||
links_info.get("sbp")
|
||||
or links_meta.get("sbp")
|
||||
or status_info.get("sbp_url")
|
||||
or payment.link_url
|
||||
)
|
||||
card_link = (
|
||||
links_info.get("card")
|
||||
or links_meta.get("card")
|
||||
or status_info.get("card_url")
|
||||
)
|
||||
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
|
||||
card_link = payment.link_page_url
|
||||
|
||||
@@ -1689,8 +1699,45 @@ async def check_simple_pal24_payment_status(
|
||||
f"❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}",
|
||||
]
|
||||
|
||||
pay_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if not payment.is_paid and payment.status in {"NEW", "PROCESS"}:
|
||||
default_sbp_text = texts.t(
|
||||
"PAL24_SBP_PAY_BUTTON",
|
||||
"🏦 Оплатить через PayPalych (СБП)",
|
||||
)
|
||||
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
|
||||
|
||||
if sbp_link and settings.is_pal24_sbp_button_visible():
|
||||
pay_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=sbp_button_text,
|
||||
url=sbp_link,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
default_card_text = texts.t(
|
||||
"PAL24_CARD_PAY_BUTTON",
|
||||
"💳 Оплатить банковской картой (PayPalych)",
|
||||
)
|
||||
card_button_text = settings.get_pal24_card_button_text(default_card_text)
|
||||
|
||||
if card_link and settings.is_pal24_card_button_visible():
|
||||
if not pay_rows or pay_rows[-1][0].url != card_link:
|
||||
pay_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=card_button_text,
|
||||
url=card_link,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
inline_keyboard=pay_rows
|
||||
+ [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
|
||||
@@ -130,6 +130,10 @@ async def handle_successful_payment(
|
||||
"⭐ Потрачено звезд: {stars_spent}\n"
|
||||
"💰 Зачислено на баланс: {amount} ₽\n"
|
||||
"🆔 ID транзакции: {transaction_id}...\n\n"
|
||||
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
"Обязательно активируйте подписку отдельно!\n\n"
|
||||
"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
"подписка будет приобретена автоматически после пополнения баланса.\n\n"
|
||||
"Спасибо за пополнение! 🚀",
|
||||
).format(
|
||||
stars_spent=payment.total_amount,
|
||||
|
||||
@@ -50,7 +50,7 @@ from app.keyboards.inline import (
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.remnawave_service import RemnaWaveConfigurationError, RemnaWaveService
|
||||
from app.services.subscription_checkout_service import (
|
||||
clear_subscription_checkout_draft,
|
||||
get_subscription_checkout_draft,
|
||||
@@ -58,6 +58,14 @@ from app.services.subscription_checkout_service import (
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.trial_activation_service import (
|
||||
TrialPaymentChargeFailed,
|
||||
TrialPaymentInsufficientFunds,
|
||||
charge_trial_activation_if_required,
|
||||
preview_trial_activation_charge,
|
||||
revert_trial_activation,
|
||||
rollback_trial_subscription_activation,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_markup(markup: Optional[InlineKeyboardMarkup]) -> Optional[Any]:
|
||||
@@ -405,10 +413,7 @@ async def show_trial_offer(
|
||||
|
||||
trial_server_name = texts.t("TRIAL_SERVER_DEFAULT_NAME", "🎯 Тестовый сервер")
|
||||
try:
|
||||
from app.database.crud.server_squad import (
|
||||
get_server_squad_by_uuid,
|
||||
get_trial_eligible_server_squads,
|
||||
)
|
||||
from app.database.crud.server_squad import get_trial_eligible_server_squads
|
||||
|
||||
trial_squads = await get_trial_eligible_server_squads(db, include_unavailable=True)
|
||||
|
||||
@@ -420,21 +425,18 @@ async def show_trial_offer(
|
||||
"TRIAL_SERVER_RANDOM_POOL",
|
||||
"🎲 Случайный из {count} серверов",
|
||||
).format(count=len(trial_squads))
|
||||
elif settings.TRIAL_SQUAD_UUID:
|
||||
trial_server = await get_server_squad_by_uuid(db, settings.TRIAL_SQUAD_UUID)
|
||||
if trial_server:
|
||||
trial_server_name = trial_server.display_name
|
||||
else:
|
||||
logger.warning(
|
||||
"Триальный сервер с UUID %s не найден в БД",
|
||||
settings.TRIAL_SQUAD_UUID,
|
||||
)
|
||||
else:
|
||||
logger.warning("Не настроены сквады для выдачи триалов")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения триального сервера: {e}")
|
||||
|
||||
trial_device_limit = settings.TRIAL_DEVICE_LIMIT
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is not None:
|
||||
trial_device_limit = forced_limit
|
||||
|
||||
devices_line = ""
|
||||
if settings.is_devices_selection_enabled():
|
||||
devices_line_template = texts.t(
|
||||
@@ -442,14 +444,25 @@ async def show_trial_offer(
|
||||
"\n📱 <b>Устройства:</b> {devices} шт.",
|
||||
)
|
||||
devices_line = devices_line_template.format(
|
||||
devices=settings.TRIAL_DEVICE_LIMIT,
|
||||
devices=trial_device_limit,
|
||||
)
|
||||
|
||||
price_line = ""
|
||||
if settings.is_trial_paid_activation_enabled():
|
||||
trial_price = settings.get_trial_activation_price()
|
||||
if trial_price > 0:
|
||||
price_line = texts.t(
|
||||
"TRIAL_PAYMENT_PRICE_LINE",
|
||||
"\n💳 <b>Стоимость активации:</b> {price}",
|
||||
).format(price=settings.format_price(trial_price))
|
||||
|
||||
trial_text = texts.TRIAL_AVAILABLE.format(
|
||||
days=settings.TRIAL_DURATION_DAYS,
|
||||
traffic=texts.format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB),
|
||||
devices=trial_device_limit if trial_device_limit is not None else "",
|
||||
devices_line=devices_line,
|
||||
server_name=trial_server_name
|
||||
server_name=trial_server_name,
|
||||
price_line=price_line,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -475,6 +488,33 @@ async def activate_trial(
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
preview_trial_activation_charge(db_user)
|
||||
except TrialPaymentInsufficientFunds as error:
|
||||
required_label = settings.format_price(error.required_amount)
|
||||
balance_label = settings.format_price(error.balance_amount)
|
||||
missing_label = settings.format_price(error.missing_amount)
|
||||
message = texts.t(
|
||||
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS",
|
||||
"⚠️ Недостаточно средств для активации триала.\n"
|
||||
"Необходимо: {required}\nНа балансе: {balance}\n"
|
||||
"Не хватает: {missing}\n\nПополните баланс и попробуйте снова.",
|
||||
).format(required=required_label, balance=balance_label, missing=missing_label)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=get_insufficient_balance_keyboard(
|
||||
db_user.language,
|
||||
amount_kopeks=error.required_amount,
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
charged_amount = 0
|
||||
subscription: Optional[Subscription] = None
|
||||
remnawave_user = None
|
||||
|
||||
try:
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
@@ -488,48 +528,197 @@ async def activate_trial(
|
||||
|
||||
await db.refresh(db_user)
|
||||
|
||||
try:
|
||||
charged_amount = await charge_trial_activation_if_required(
|
||||
db,
|
||||
db_user,
|
||||
description="Активация триала через бота",
|
||||
)
|
||||
except TrialPaymentInsufficientFunds as error:
|
||||
rollback_success = await rollback_trial_subscription_activation(db, subscription)
|
||||
await db.refresh(db_user)
|
||||
if not rollback_success:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"TRIAL_ROLLBACK_FAILED",
|
||||
"Не удалось отменить активацию триала. Попробуйте позже.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
logger.error(
|
||||
"Insufficient funds detected after trial creation for user %s: %s",
|
||||
db_user.id,
|
||||
error,
|
||||
)
|
||||
required_label = settings.format_price(error.required_amount)
|
||||
balance_label = settings.format_price(error.balance_amount)
|
||||
missing_label = settings.format_price(error.missing_amount)
|
||||
message = texts.t(
|
||||
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS",
|
||||
"⚠️ Недостаточно средств для активации триала.\n"
|
||||
"Необходимо: {required}\nНа балансе: {balance}\n"
|
||||
"Не хватает: {missing}\n\nПополните баланс и попробуйте снова.",
|
||||
).format(
|
||||
required=required_label,
|
||||
balance=balance_label,
|
||||
missing=missing_label,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=get_insufficient_balance_keyboard(
|
||||
db_user.language,
|
||||
amount_kopeks=error.required_amount,
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
except TrialPaymentChargeFailed:
|
||||
rollback_success = await rollback_trial_subscription_activation(db, subscription)
|
||||
await db.refresh(db_user)
|
||||
if not rollback_success:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"TRIAL_ROLLBACK_FAILED",
|
||||
"Не удалось отменить активацию триала. Попробуйте позже.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"TRIAL_PAYMENT_FAILED",
|
||||
"Не удалось списать средства для активации триала. Попробуйте позже.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
remnawave_user = await subscription_service.create_remnawave_user(
|
||||
db, subscription
|
||||
)
|
||||
try:
|
||||
remnawave_user = await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
)
|
||||
except RemnaWaveConfigurationError as error:
|
||||
logger.error("RemnaWave update skipped due to configuration error: %s", error)
|
||||
revert_result = await revert_trial_activation(
|
||||
db,
|
||||
db_user,
|
||||
subscription,
|
||||
charged_amount,
|
||||
refund_description="Возврат оплаты за активацию триала через бота",
|
||||
)
|
||||
if not revert_result.subscription_rolled_back:
|
||||
failure_text = texts.t(
|
||||
"TRIAL_ROLLBACK_FAILED",
|
||||
"Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.",
|
||||
)
|
||||
elif charged_amount > 0 and not revert_result.refunded:
|
||||
failure_text = texts.t(
|
||||
"TRIAL_REFUND_FAILED",
|
||||
"Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.",
|
||||
)
|
||||
else:
|
||||
failure_text = texts.t(
|
||||
"TRIAL_PROVISIONING_FAILED",
|
||||
"Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.",
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
failure_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Failed to create RemnaWave user for trial subscription %s: %s",
|
||||
getattr(subscription, "id", "<unknown>"),
|
||||
error,
|
||||
)
|
||||
revert_result = await revert_trial_activation(
|
||||
db,
|
||||
db_user,
|
||||
subscription,
|
||||
charged_amount,
|
||||
refund_description="Возврат оплаты за активацию триала через бота",
|
||||
)
|
||||
if not revert_result.subscription_rolled_back:
|
||||
failure_text = texts.t(
|
||||
"TRIAL_ROLLBACK_FAILED",
|
||||
"Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.",
|
||||
)
|
||||
elif charged_amount > 0 and not revert_result.refunded:
|
||||
failure_text = texts.t(
|
||||
"TRIAL_REFUND_FAILED",
|
||||
"Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.",
|
||||
)
|
||||
else:
|
||||
failure_text = texts.t(
|
||||
"TRIAL_PROVISIONING_FAILED",
|
||||
"Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.",
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
failure_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
await db.refresh(db_user)
|
||||
|
||||
try:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_trial_activation_notification(db, db_user, subscription)
|
||||
await notification_service.send_trial_activation_notification(
|
||||
db,
|
||||
db_user,
|
||||
subscription,
|
||||
charged_amount_kopeks=charged_amount,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о триале: {e}")
|
||||
|
||||
subscription_link = get_display_subscription_link(subscription)
|
||||
hide_subscription_link = settings.should_hide_subscription_link()
|
||||
|
||||
payment_note = ""
|
||||
if charged_amount > 0:
|
||||
payment_note = "\n\n" + texts.t(
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE",
|
||||
"💳 С вашего баланса списано {amount}.",
|
||||
).format(amount=settings.format_price(charged_amount))
|
||||
|
||||
if remnawave_user and subscription_link:
|
||||
if settings.is_happ_cryptolink_mode():
|
||||
trial_success_text = (
|
||||
f"{texts.TRIAL_ACTIVATED}\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_HAPP_LINK_PROMPT",
|
||||
"🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
|
||||
)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
|
||||
"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
|
||||
)
|
||||
f"{texts.TRIAL_ACTIVATED}\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_HAPP_LINK_PROMPT",
|
||||
"🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
|
||||
)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
|
||||
"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
|
||||
)
|
||||
)
|
||||
elif hide_subscription_link:
|
||||
trial_success_text = (
|
||||
f"{texts.TRIAL_ACTIVATED}\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_LINK_HIDDEN_NOTICE",
|
||||
"ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
|
||||
)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
|
||||
"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
|
||||
)
|
||||
f"{texts.TRIAL_ACTIVATED}\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_LINK_HIDDEN_NOTICE",
|
||||
"ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
|
||||
)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
|
||||
"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
|
||||
)
|
||||
)
|
||||
else:
|
||||
subscription_import_link = texts.t(
|
||||
@@ -543,6 +732,8 @@ async def activate_trial(
|
||||
f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}"
|
||||
)
|
||||
|
||||
trial_success_text += payment_note
|
||||
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
|
||||
if connect_mode == "miniapp_subscription":
|
||||
@@ -553,8 +744,12 @@ async def activate_trial(
|
||||
web_app=types.WebAppInfo(url=subscription_link),
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu")],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
])
|
||||
elif connect_mode == "miniapp_custom":
|
||||
if not settings.MINIAPP_CUSTOM_URL:
|
||||
@@ -574,22 +769,33 @@ async def activate_trial(
|
||||
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL),
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu")],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
])
|
||||
elif connect_mode == "link":
|
||||
rows = [
|
||||
[InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)]
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
url=subscription_link,
|
||||
)
|
||||
]
|
||||
]
|
||||
happ_row = get_happ_download_button_row(texts)
|
||||
if happ_row:
|
||||
rows.append(happ_row)
|
||||
rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu"
|
||||
)
|
||||
])
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
]
|
||||
)
|
||||
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
elif connect_mode == "happ_cryptolink":
|
||||
rows = [
|
||||
@@ -603,40 +809,86 @@ async def activate_trial(
|
||||
happ_row = get_happ_download_button_row(texts)
|
||||
if happ_row:
|
||||
rows.append(happ_row)
|
||||
rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu"
|
||||
)
|
||||
])
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
]
|
||||
)
|
||||
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
else:
|
||||
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
callback_data="subscription_connect")],
|
||||
[InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu")],
|
||||
])
|
||||
connect_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
callback_data="subscription_connect",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
trial_success_text,
|
||||
reply_markup=connect_keyboard,
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
trial_success_text = (
|
||||
f"{texts.TRIAL_ACTIVATED}\n\n⚠️ Ссылка генерируется, попробуйте перейти в раздел 'Моя подписка' через несколько секунд."
|
||||
)
|
||||
trial_success_text += payment_note
|
||||
await callback.message.edit_text(
|
||||
f"{texts.TRIAL_ACTIVATED}\n\n⚠️ Ссылка генерируется, попробуйте перейти в раздел 'Моя подписка' через несколько секунд.",
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
trial_success_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
|
||||
logger.info(f"✅ Активирована тестовая подписка для пользователя {db_user.telegram_id}")
|
||||
logger.info(
|
||||
f"✅ Активирована тестовая подписка для пользователя {db_user.telegram_id}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка активации триала: {e}")
|
||||
failure_text = texts.ERROR
|
||||
|
||||
if subscription and remnawave_user is None:
|
||||
revert_result = await revert_trial_activation(
|
||||
db,
|
||||
db_user,
|
||||
subscription,
|
||||
charged_amount,
|
||||
refund_description="Возврат оплаты за активацию триала через бота",
|
||||
)
|
||||
if not revert_result.subscription_rolled_back:
|
||||
failure_text = texts.t(
|
||||
"TRIAL_ROLLBACK_FAILED",
|
||||
"Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.",
|
||||
)
|
||||
elif charged_amount > 0 and not revert_result.refunded:
|
||||
failure_text = texts.t(
|
||||
"TRIAL_REFUND_FAILED",
|
||||
"Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.",
|
||||
)
|
||||
else:
|
||||
failure_text = texts.t(
|
||||
"TRIAL_PROVISIONING_FAILED",
|
||||
"Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.",
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.ERROR,
|
||||
failure_text,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -114,7 +114,11 @@ async def handle_successful_payment(message: types.Message):
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!"
|
||||
f"✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!\n\n"
|
||||
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса."
|
||||
)
|
||||
|
||||
logger.info(f"✅ Обработан Stars платеж: {payment.telegram_payment_charge_id}")
|
||||
|
||||
@@ -1159,6 +1159,14 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_platega_enabled() and settings.get_platega_active_methods():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_PLATEGA", "💳 Platega"),
|
||||
callback_data=_build_callback("platega"),
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
|
||||
@@ -1027,6 +1027,7 @@
|
||||
"PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)",
|
||||
"PAYMENT_CARD_WATA": "💳 Bank card (WATA)",
|
||||
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
|
||||
"PAYMENT_PLATEGA": "💳 Platega",
|
||||
"PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
|
||||
"PAYMENT_HELEKET": "🪙 Cryptocurrency (Heleket)",
|
||||
@@ -1060,6 +1061,8 @@
|
||||
"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_NAME": "💳 <b>Bank card (Platega)</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
|
||||
@@ -1078,6 +1081,18 @@
|
||||
"PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)",
|
||||
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
|
||||
"PAYMENT_VIA_SUPPORT": "🛠️ Via support",
|
||||
"PLATEGA_TOPUP_PROMPT": "💳 <b>Payment via Platega ({method_name})</b>\n\nEnter an amount from {min_amount} to {max_amount}.\nPayment is processed by Platega.",
|
||||
"PLATEGA_SELECT_PAYMENT_METHOD": "Choose a Platega payment method:",
|
||||
"PLATEGA_TEMPORARILY_UNAVAILABLE": "❌ Platega payments are temporarily unavailable",
|
||||
"PLATEGA_METHODS_NOT_CONFIGURED": "⚠️ No active Platega methods configured",
|
||||
"PLATEGA_METHOD_SELECTION_REQUIRED": "⚠️ Select a Platega payment method before entering the amount",
|
||||
"PLATEGA_AMOUNT_TOO_LOW": "Minimum amount for Platega: {amount}",
|
||||
"PLATEGA_AMOUNT_TOO_HIGH": "Maximum amount for Platega: {amount}",
|
||||
"PLATEGA_PAYMENT_ERROR": "❌ Failed to create Platega payment. Please try again later or contact support.",
|
||||
"PLATEGA_PAYMENT_INSTRUCTIONS": "💳 <b>Payment via Platega ({method})</b>\n\n💰 Amount: {amount}\n🆔 Transaction ID: {transaction}\n\n📱 <b>Instructions:</b>\n1. Tap the ‘Pay’ button\n2. Follow the payment provider instructions\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ If you have issues, contact {support}",
|
||||
"PLATEGA_PAY_BUTTON": "💳 Pay via {method}",
|
||||
"PLATEGA_PAYMENT_ALREADY_CONFIRMED": "✅ Payment already credited",
|
||||
"PLATEGA_PAYMENT_STATUS": "Current payment status: {status}",
|
||||
"PAY_NOW_BUTTON": "💳 Pay",
|
||||
"PAY_WITH_COINS_BUTTON": "🪙 Pay",
|
||||
"PENDING_CANCEL_BUTTON": "⌛ Cancel",
|
||||
@@ -1229,7 +1244,7 @@
|
||||
"SKIP_BUTTON": "Skip ➡️",
|
||||
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Failed to credit funds. Please contact support; the payment will be verified manually.",
|
||||
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Technical error processing the payment. Please contact support for assistance.",
|
||||
"STARS_PAYMENT_SUCCESS": "🎉 <b>Payment processed successfully!</b>\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\nThank you for topping up! 🚀",
|
||||
"STARS_PAYMENT_SUCCESS": "🎉 <b>Payment processed successfully!</b>\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\n⚠️ <b>Important:</b> Balance top-up does not automatically activate your subscription. Please activate your subscription separately!\n\n🔄 If you have a saved subscription cart and auto-purchase is enabled, your subscription will be automatically purchased after balance top-up.\n\nThank you for topping up! 🚀",
|
||||
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Error: user not found. Please contact support.",
|
||||
"STARS_PRECHECK_INVALID_PAYLOAD": "Payment validation error. Please try again.",
|
||||
"STARS_PRECHECK_TECHNICAL_ERROR": "Technical error. Please try again later.",
|
||||
@@ -1396,11 +1411,19 @@
|
||||
"TRAFFIC_NO_CHANGE": "ℹ️ Traffic limit was not changed",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured",
|
||||
"TRAFFIC_UNLIMITED": "📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"TRAFFIC_UNLIMITED_SHORT": "Unlimited",
|
||||
"TRIAL_ACTIVATED": "🎉 Trial subscription activated!",
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Activate",
|
||||
"TRIAL_ALREADY_USED": "❌ The trial subscription has already been used",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic}{devices_line}\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a trial plan{price_line}:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic}{devices_line}\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
|
||||
"TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 <b>Devices:</b> {devices} pcs",
|
||||
"TRIAL_PAYMENT_PRICE_LINE": "\n💳 <b>Activation price:</b> {price}",
|
||||
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS": "⚠️ Not enough funds to activate the trial.\nRequired: {required}\nOn balance: {balance}\nMissing: {missing}\n\nTop up your balance and try again.",
|
||||
"TRIAL_PAYMENT_FAILED": "We couldn't charge your balance to activate the trial. Please try again later.",
|
||||
"TRIAL_PROVISIONING_FAILED": "We couldn't finish setting up the trial. Any charge has been refunded. Please try again later.",
|
||||
"TRIAL_ROLLBACK_FAILED": "We couldn't cancel the trial activation after a payment error. Please contact support and try again later.",
|
||||
"TRIAL_REFUND_FAILED": "We couldn't refund the trial activation charge. Please contact support immediately.",
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 {amount} has been deducted from your balance.",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Access paused</b>\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
|
||||
"TRIAL_INACTIVE_1H": "⏳ <b>An hour has passed and we haven't seen any traffic yet</b>\n\nOpen the connection guide and follow the steps. We're always ready to help!",
|
||||
|
||||
@@ -1047,6 +1047,7 @@
|
||||
"PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)",
|
||||
"PAYMENT_CARD_WATA": "💳 Банковская карта (WATA)",
|
||||
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
|
||||
"PAYMENT_PLATEGA": "💳 Platega",
|
||||
"PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
|
||||
"PAYMENT_HELEKET": "🪙 Криптовалюта (Heleket)",
|
||||
@@ -1080,6 +1081,8 @@
|
||||
"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_NAME": "💳 <b>Банковская карта (Platega)</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
|
||||
@@ -1098,6 +1101,18 @@
|
||||
"PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)",
|
||||
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
|
||||
"PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку",
|
||||
"PLATEGA_TOPUP_PROMPT": "💳 <b>Оплата через Platega ({method_name})</b>\n\nВведите сумму для пополнения от {min_amount} до {max_amount}.\nОплата происходит через Platega.",
|
||||
"PLATEGA_SELECT_PAYMENT_METHOD": "Выберите способ оплаты Platega:",
|
||||
"PLATEGA_TEMPORARILY_UNAVAILABLE": "❌ Оплата через Platega временно недоступна",
|
||||
"PLATEGA_METHODS_NOT_CONFIGURED": "⚠️ На стороне Platega нет доступных методов оплаты",
|
||||
"PLATEGA_METHOD_SELECTION_REQUIRED": "⚠️ Выберите способ оплаты Platega перед вводом суммы",
|
||||
"PLATEGA_AMOUNT_TOO_LOW": "Минимальная сумма для оплаты через Platega: {amount}",
|
||||
"PLATEGA_AMOUNT_TOO_HIGH": "Максимальная сумма для оплаты через Platega: {amount}",
|
||||
"PLATEGA_PAYMENT_ERROR": "❌ Ошибка создания платежа Platega. Попробуйте позже или обратитесь в поддержку.",
|
||||
"PLATEGA_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через Platega ({method})</b>\n\n💰 Сумма: {amount}\n🆔 ID транзакции: {transaction}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку «Оплатить»\n2. Следуйте подсказкам платёжной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
|
||||
"PLATEGA_PAY_BUTTON": "💳 Оплатить через {method}",
|
||||
"PLATEGA_PAYMENT_ALREADY_CONFIRMED": "✅ Платёж уже зачислен",
|
||||
"PLATEGA_PAYMENT_STATUS": "Текущий статус платежа: {status}",
|
||||
"PAY_NOW_BUTTON": "💳 Оплатить",
|
||||
"PAY_WITH_COINS_BUTTON": "🪙 Оплатить",
|
||||
"PENDING_CANCEL_BUTTON": "⌛ Отмена",
|
||||
@@ -1249,7 +1264,7 @@
|
||||
"SKIP_BUTTON": "⏭️ Пропустить",
|
||||
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Произошла ошибка при зачислении средств. Обратитесь в поддержку, платеж будет проверен вручную.",
|
||||
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.",
|
||||
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платеж успешно обработан!</b>\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n🆔 ID транзакции: {transaction_id}...\n\nСпасибо за пополнение! 🚀",
|
||||
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платеж успешно обработан!</b>\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n\n🆔 ID транзакции: {transaction_id}...\n\n⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. Обязательно активируйте подписку отдельно!\n\n🔄 При наличии сохранённой корзины подписки и включенной автопокупке, подписка будет приобретена автоматически после пополнения баланса.\n\nСпасибо за пополнение! 🚀",
|
||||
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.",
|
||||
"STARS_PRECHECK_INVALID_PAYLOAD": "Ошибка валидации платежа. Попробуйте еще раз.",
|
||||
"STARS_PRECHECK_TECHNICAL_ERROR": "Техническая ошибка. Попробуйте позже.",
|
||||
@@ -1416,11 +1431,19 @@
|
||||
"TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены",
|
||||
"TRAFFIC_UNLIMITED": "📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"TRAFFIC_UNLIMITED_SHORT": "Безлимит",
|
||||
"TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!",
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Активировать",
|
||||
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic}{devices_line}\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить тестовую подписку{price_line}:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic}{devices_line}\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
|
||||
"TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 <b>Устройства:</b> {devices} шт.",
|
||||
"TRIAL_PAYMENT_PRICE_LINE": "\n💳 <b>Стоимость активации:</b> {price}",
|
||||
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS": "⚠️ Недостаточно средств для активации триала.\nНеобходимо: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nПополните баланс и попробуйте снова.",
|
||||
"TRIAL_PAYMENT_FAILED": "Не удалось списать средства для активации триала. Попробуйте позже.",
|
||||
"TRIAL_PROVISIONING_FAILED": "Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.",
|
||||
"TRIAL_ROLLBACK_FAILED": "Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.",
|
||||
"TRIAL_REFUND_FAILED": "Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.",
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 С вашего баланса списано {amount}.",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Доступ приостановлен</b>\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
|
||||
"TRIAL_INACTIVE_1H": "⏳ <b>Прошёл час, а подключение не выполнено</b>\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",
|
||||
|
||||
@@ -190,7 +190,9 @@ class AdminNotificationService:
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription
|
||||
subscription: Subscription,
|
||||
*,
|
||||
charged_amount_kopeks: Optional[int] = None,
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
@@ -210,6 +212,12 @@ class AdminNotificationService:
|
||||
else:
|
||||
trial_device_limit = settings.TRIAL_DEVICE_LIMIT
|
||||
|
||||
payment_block = ""
|
||||
if charged_amount_kopeks and charged_amount_kopeks > 0:
|
||||
payment_block = (
|
||||
f"\n💳 <b>Оплата за активацию:</b> {settings.format_price(charged_amount_kopeks)}"
|
||||
)
|
||||
|
||||
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
|
||||
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
@@ -224,6 +232,7 @@ class AdminNotificationService:
|
||||
📊 Трафик: {self._format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB)}
|
||||
📱 Устройства: {trial_device_limit}
|
||||
🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'}
|
||||
{payment_block}
|
||||
|
||||
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
|
||||
+794
-259
File diff suppressed because it is too large
Load Diff
@@ -129,10 +129,7 @@ class AdvertisingCampaignService:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
getattr(settings, "TRIAL_SQUAD_UUID", None),
|
||||
)
|
||||
trial_uuid = await get_random_trial_squad_uuid(db)
|
||||
if trial_uuid:
|
||||
squads = [trial_uuid]
|
||||
except Exception as error:
|
||||
@@ -141,8 +138,6 @@ class AdvertisingCampaignService:
|
||||
campaign.id,
|
||||
error,
|
||||
)
|
||||
if getattr(settings, "TRIAL_SQUAD_UUID", None):
|
||||
squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
new_subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
|
||||
@@ -288,12 +288,30 @@ class MaintenanceService:
|
||||
self._status.last_check = datetime.utcnow()
|
||||
|
||||
auth_params = settings.get_remnawave_auth_params()
|
||||
base_url = (auth_params.get("base_url") or "").strip()
|
||||
api_key = (auth_params.get("api_key") or "").strip()
|
||||
secret_key = (auth_params.get("secret_key") or "").strip() or None
|
||||
username = (auth_params.get("username") or "").strip() or None
|
||||
password = (auth_params.get("password") or "").strip() or None
|
||||
|
||||
if not base_url:
|
||||
logger.error("REMNAWAVE_API_URL не настроен, пропускаем проверку API")
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures = 0
|
||||
return False
|
||||
|
||||
if not api_key:
|
||||
logger.error("REMNAWAVE_API_KEY не настроен, пропускаем проверку API")
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures = 0
|
||||
return False
|
||||
|
||||
api = RemnaWaveAPI(
|
||||
base_url=auth_params["base_url"],
|
||||
api_key=auth_params["api_key"],
|
||||
secret_key=auth_params["secret_key"],
|
||||
username=auth_params["username"],
|
||||
password=auth_params["password"]
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
secret_key=secret_key,
|
||||
username=username,
|
||||
password=password
|
||||
)
|
||||
|
||||
attempts = settings.get_maintenance_retry_attempts()
|
||||
|
||||
@@ -937,7 +937,10 @@ class MonitoringService:
|
||||
if not user:
|
||||
continue
|
||||
|
||||
renewal_cost = settings.PRICE_30_DAYS
|
||||
# Правильный расчет стоимости продления с учетом всех параметров подписки
|
||||
renewal_cost = await self.subscription_service.calculate_renewal_price(
|
||||
subscription, 30, db, user=user
|
||||
)
|
||||
promo_discount_percent = self._get_user_promo_offer_discount_percent(user)
|
||||
charge_amount = renewal_cost
|
||||
promo_discount_value = 0
|
||||
|
||||
@@ -90,21 +90,21 @@ class Pal24Service:
|
||||
return await self.client.get_bill_payments(bill_id)
|
||||
|
||||
@staticmethod
|
||||
def parse_postback(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def parse_callback(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
required_fields = ["InvId", "OutSum", "Status", "SignatureValue"]
|
||||
missing = [field for field in required_fields if field not in payload]
|
||||
if missing:
|
||||
raise Pal24APIError(f"Pal24 postback missing fields: {', '.join(missing)}")
|
||||
raise Pal24APIError(f"Pal24 callback missing fields: {', '.join(missing)}")
|
||||
|
||||
inv_id = str(payload["InvId"])
|
||||
out_sum = str(payload["OutSum"])
|
||||
signature = str(payload["SignatureValue"])
|
||||
|
||||
if not Pal24Client.verify_signature(out_sum, inv_id, signature):
|
||||
raise Pal24APIError("Pal24 postback signature mismatch")
|
||||
raise Pal24APIError("Pal24 callback signature mismatch")
|
||||
|
||||
logger.info(
|
||||
"Получен Pal24 postback: InvId=%s, Status=%s, TrsId=%s",
|
||||
"Получен Pal24 callback: InvId=%s, Status=%s, TrsId=%s",
|
||||
inv_id,
|
||||
payload.get("Status"),
|
||||
payload.get("TrsId"),
|
||||
|
||||
@@ -12,6 +12,7 @@ from .cryptobot import CryptoBotPaymentMixin
|
||||
from .heleket import HeleketPaymentMixin
|
||||
from .mulenpay import MulenPayPaymentMixin
|
||||
from .pal24 import Pal24PaymentMixin
|
||||
from .platega import PlategaPaymentMixin
|
||||
from .wata import WataPaymentMixin
|
||||
|
||||
__all__ = [
|
||||
@@ -23,5 +24,6 @@ __all__ = [
|
||||
"HeleketPaymentMixin",
|
||||
"MulenPayPaymentMixin",
|
||||
"Pal24PaymentMixin",
|
||||
"PlategaPaymentMixin",
|
||||
"WataPaymentMixin",
|
||||
]
|
||||
|
||||
@@ -42,6 +42,7 @@ class PaymentCommonMixin:
|
||||
user and user.subscription and not user.subscription.is_trial and user.subscription.is_active
|
||||
)
|
||||
|
||||
# Создаем основную кнопку: если есть активная подписка - продлить, иначе купить
|
||||
first_button = build_miniapp_or_callback_button(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
@@ -53,7 +54,16 @@ class PaymentCommonMixin:
|
||||
),
|
||||
)
|
||||
|
||||
keyboard_rows: list[list[InlineKeyboardButton]] = [[first_button]]
|
||||
# Кнопка активации подписки (всегда отображается)
|
||||
activate_subscription_button = build_miniapp_or_callback_button(
|
||||
text="🚀 Активировать подписку",
|
||||
callback_data="menu_buy" # Используем ту же callback_data что и "Купить подписку"
|
||||
)
|
||||
|
||||
keyboard_rows: list[list[InlineKeyboardButton]] = [
|
||||
[first_button],
|
||||
[activate_subscription_button]
|
||||
]
|
||||
|
||||
# Если для пользователя есть незавершённый checkout, предлагаем вернуться к нему.
|
||||
if user:
|
||||
@@ -128,7 +138,11 @@ class PaymentCommonMixin:
|
||||
"✅ <b>Платеж успешно завершен!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"💳 Способ: {payment_method}\n\n"
|
||||
"Средства зачислены на ваш баланс!"
|
||||
"Средства зачислены на ваш баланс!\n\n"
|
||||
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса."
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Mixin с логикой обработки платежей CryptoBot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
from typing import Any, Dict, Optional
|
||||
@@ -10,6 +11,7 @@ from typing import Any, Dict, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
@@ -20,6 +22,33 @@ from app.utils.user_utils import format_referrer_info
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _AdminNotificationContext:
|
||||
user_id: int
|
||||
transaction_id: int
|
||||
old_balance: int
|
||||
topup_status: str
|
||||
referrer_info: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _UserNotificationPayload:
|
||||
telegram_id: int
|
||||
text: str
|
||||
parse_mode: Optional[str]
|
||||
reply_markup: Any
|
||||
amount_rubles: float
|
||||
asset: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _SavedCartNotificationPayload:
|
||||
telegram_id: int
|
||||
text: str
|
||||
reply_markup: Any
|
||||
user_id: int
|
||||
|
||||
|
||||
class CryptoBotPaymentMixin:
|
||||
"""Mixin, отвечающий за генерацию инвойсов CryptoBot и обработку webhook."""
|
||||
|
||||
@@ -149,14 +178,16 @@ class CryptoBotPaymentMixin:
|
||||
|
||||
try:
|
||||
amount_rubles = await currency_converter.usd_to_rub(amount_usd)
|
||||
amount_kopeks = int(amount_rubles * 100)
|
||||
amount_rubles_rounded = math.ceil(amount_rubles)
|
||||
amount_kopeks = int(amount_rubles_rounded * 100)
|
||||
conversion_rate = (
|
||||
amount_rubles / amount_usd if amount_usd > 0 else 0
|
||||
)
|
||||
logger.info(
|
||||
"Конвертация USD->RUB: $%s -> %s₽ (курс: %.2f)",
|
||||
"Конвертация USD->RUB: $%s -> %s₽ (округлено до %s₽, курс: %.2f)",
|
||||
amount_usd,
|
||||
amount_rubles,
|
||||
amount_rubles_rounded,
|
||||
conversion_rate,
|
||||
)
|
||||
except Exception as error:
|
||||
@@ -166,7 +197,8 @@ class CryptoBotPaymentMixin:
|
||||
error,
|
||||
)
|
||||
amount_rubles = amount_usd
|
||||
amount_kopeks = int(amount_usd * 100)
|
||||
amount_rubles_rounded = math.ceil(amount_rubles)
|
||||
amount_kopeks = int(amount_rubles_rounded * 100)
|
||||
conversion_rate = 1.0
|
||||
|
||||
if amount_kopeks <= 0:
|
||||
@@ -185,7 +217,7 @@ class CryptoBotPaymentMixin:
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=(
|
||||
"Пополнение через CryptoBot "
|
||||
f"({updated_payment.amount} {updated_payment.asset} → {amount_rubles:.2f}₽)"
|
||||
f"({updated_payment.amount} {updated_payment.asset} → {amount_rubles_rounded:.2f}₽)"
|
||||
),
|
||||
payment_method=PaymentMethod.CRYPTOBOT,
|
||||
external_id=invoice_id,
|
||||
@@ -211,8 +243,6 @@ class CryptoBotPaymentMixin:
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
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 "🔄 Пополнение"
|
||||
@@ -241,55 +271,41 @@ class CryptoBotPaymentMixin:
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
admin_notification: Optional[_AdminNotificationContext] = None
|
||||
user_notification: Optional[_UserNotificationPayload] = None
|
||||
saved_cart_notification: Optional[_SavedCartNotificationPayload] = None
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
|
||||
error,
|
||||
)
|
||||
bot_instance = getattr(self, "bot", None)
|
||||
if bot_instance:
|
||||
admin_notification = _AdminNotificationContext(
|
||||
user_id=user.id,
|
||||
transaction_id=transaction.id,
|
||||
old_balance=old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n"
|
||||
f"💱 Курс: 1 USD = {conversion_rate:.2f}₽\n"
|
||||
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
message_text = (
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n"
|
||||
f"💱 Курс: 1 USD = {conversion_rate:.2f}₽\n"
|
||||
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
)
|
||||
user_notification = _UserNotificationPayload(
|
||||
telegram_id=user.telegram_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
"✅ Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)",
|
||||
user.telegram_id,
|
||||
f"{amount_rubles:.2f}",
|
||||
updated_payment.asset,
|
||||
amount_rubles=amount_rubles_rounded,
|
||||
asset=updated_payment.asset,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
|
||||
"Ошибка подготовки уведомления о пополнении CryptoBot: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
@@ -305,7 +321,7 @@ class CryptoBotPaymentMixin:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
bot=bot_instance,
|
||||
)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
@@ -318,17 +334,14 @@ class CryptoBotPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
if has_saved_cart and bot_instance:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
|
||||
total_amount=settings.format_price(payment.amount_kopeks)
|
||||
)
|
||||
|
||||
# Создаем клавиатуру с кнопками
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
@@ -343,18 +356,35 @@ class CryptoBotPaymentMixin:
|
||||
callback_data="back_to_menu"
|
||||
)]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
|
||||
reply_markup=keyboard
|
||||
|
||||
saved_cart_notification = _SavedCartNotificationPayload(
|
||||
telegram_id=user.telegram_id,
|
||||
text=(
|
||||
f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
|
||||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}"
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
user_id=user.id,
|
||||
)
|
||||
logger.info(
|
||||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||||
user.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
|
||||
user.id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if admin_notification:
|
||||
await self._deliver_admin_topup_notification(admin_notification)
|
||||
|
||||
if user_notification and bot_instance:
|
||||
await self._deliver_user_topup_notification(user_notification)
|
||||
|
||||
if saved_cart_notification and bot_instance:
|
||||
await self._deliver_saved_cart_reminder(saved_cart_notification)
|
||||
|
||||
return True
|
||||
|
||||
@@ -364,6 +394,116 @@ class CryptoBotPaymentMixin:
|
||||
)
|
||||
return False
|
||||
|
||||
async def _deliver_admin_topup_notification(
|
||||
self, context: _AdminNotificationContext
|
||||
) -> None:
|
||||
bot_instance = getattr(self, "bot", None)
|
||||
if not bot_instance:
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.crud.transaction import get_transaction_by_id
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Не удалось импортировать зависимости для админ-уведомления CryptoBot: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
user = await get_user_by_id(session, context.user_id)
|
||||
transaction = await get_transaction_by_id(session, context.transaction_id)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка загрузки данных для админ-уведомления CryptoBot: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
await session.rollback()
|
||||
return
|
||||
|
||||
if not user or not transaction:
|
||||
logger.warning(
|
||||
"Пропущена отправка админ-уведомления CryptoBot: user=%s transaction=%s",
|
||||
bool(user),
|
||||
bool(transaction),
|
||||
)
|
||||
return
|
||||
|
||||
notification_service = AdminNotificationService(bot_instance)
|
||||
try:
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
context.old_balance,
|
||||
topup_status=context.topup_status,
|
||||
referrer_info=context.referrer_info,
|
||||
subscription=getattr(user, "subscription", None),
|
||||
promo_group=getattr(user, "promo_group", None),
|
||||
db=session,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки админ-уведомления о пополнении CryptoBot: %s",
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def _deliver_user_topup_notification(
|
||||
self, payload: _UserNotificationPayload
|
||||
) -> None:
|
||||
bot_instance = getattr(self, "bot", None)
|
||||
if not bot_instance:
|
||||
return
|
||||
|
||||
try:
|
||||
await bot_instance.send_message(
|
||||
payload.telegram_id,
|
||||
payload.text,
|
||||
parse_mode=payload.parse_mode,
|
||||
reply_markup=payload.reply_markup,
|
||||
)
|
||||
logger.info(
|
||||
"✅ Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)",
|
||||
payload.telegram_id,
|
||||
f"{payload.amount_rubles:.2f}",
|
||||
payload.asset,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
|
||||
error,
|
||||
)
|
||||
|
||||
async def _deliver_saved_cart_reminder(
|
||||
self, payload: _SavedCartNotificationPayload
|
||||
) -> None:
|
||||
bot_instance = getattr(self, "bot", None)
|
||||
if not bot_instance:
|
||||
return
|
||||
|
||||
try:
|
||||
await bot_instance.send_message(
|
||||
chat_id=payload.telegram_id,
|
||||
text=payload.text,
|
||||
reply_markup=payload.reply_markup,
|
||||
)
|
||||
logger.info(
|
||||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||||
payload.user_id,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка отправки уведомления о сохраненной корзине для пользователя %s: %s",
|
||||
payload.user_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def get_cryptobot_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
|
||||
@@ -389,7 +389,11 @@ class MulenPayPaymentMixin:
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
|
||||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
logger.info(
|
||||
|
||||
+183
-43
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Pal24PaymentMixin:
|
||||
"""Mixin с созданием счетов Pal24, обработкой postback и запросом статуса."""
|
||||
"""Mixin с созданием счетов Pal24, обработкой callback и запросом статуса."""
|
||||
|
||||
async def create_pal24_payment(
|
||||
self,
|
||||
@@ -197,12 +197,12 @@ class Pal24PaymentMixin:
|
||||
"payment_url": primary_link,
|
||||
}
|
||||
|
||||
async def process_pal24_postback(
|
||||
async def process_pal24_callback(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
postback: Dict[str, Any],
|
||||
callback: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Обрабатывает postback от Pal24 и начисляет баланс при успехе."""
|
||||
"""Обрабатывает callback от Pal24 и начисляет баланс при успехе."""
|
||||
try:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
@@ -213,26 +213,26 @@ class Pal24PaymentMixin:
|
||||
return None
|
||||
|
||||
payment_id = _first_non_empty(
|
||||
postback.get("id"),
|
||||
postback.get("TrsId"),
|
||||
postback.get("TrsID"),
|
||||
callback.get("id"),
|
||||
callback.get("TrsId"),
|
||||
callback.get("TrsID"),
|
||||
)
|
||||
bill_id = _first_non_empty(
|
||||
postback.get("bill_id"),
|
||||
postback.get("billId"),
|
||||
postback.get("BillId"),
|
||||
postback.get("BillID"),
|
||||
callback.get("bill_id"),
|
||||
callback.get("billId"),
|
||||
callback.get("BillId"),
|
||||
callback.get("BillID"),
|
||||
)
|
||||
order_id = _first_non_empty(
|
||||
postback.get("order_id"),
|
||||
postback.get("orderId"),
|
||||
postback.get("InvId"),
|
||||
postback.get("InvID"),
|
||||
callback.get("order_id"),
|
||||
callback.get("orderId"),
|
||||
callback.get("InvId"),
|
||||
callback.get("InvID"),
|
||||
)
|
||||
status = (postback.get("status") or postback.get("Status") or "").upper()
|
||||
status = (callback.get("status") or callback.get("Status") or "").upper()
|
||||
|
||||
if not bill_id and not order_id:
|
||||
logger.error("Pal24 postback без идентификаторов: %s", postback)
|
||||
logger.error("Pal24 callback без идентификаторов: %s", callback)
|
||||
return False
|
||||
|
||||
payment = None
|
||||
@@ -250,57 +250,65 @@ class Pal24PaymentMixin:
|
||||
return True
|
||||
|
||||
if status in {"PAID", "SUCCESS", "OVERPAID"}:
|
||||
metadata = getattr(payment, "metadata_json", {}) or {}
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
|
||||
payment = await payment_module.update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=status,
|
||||
is_paid=True,
|
||||
paid_at=datetime.utcnow(),
|
||||
postback_payload=postback,
|
||||
callback_payload=callback,
|
||||
payment_id=payment_id,
|
||||
payment_status=postback.get("Status") or status,
|
||||
payment_status=callback.get("Status") or status,
|
||||
payment_method=(
|
||||
postback.get("payment_method")
|
||||
or postback.get("PaymentMethod")
|
||||
or (payment.metadata_json or {}).get("selected_method")
|
||||
callback.get("payment_method")
|
||||
or callback.get("PaymentMethod")
|
||||
or metadata.get("selected_method")
|
||||
or getattr(payment, "payment_method", None)
|
||||
),
|
||||
balance_amount=postback.get("BalanceAmount")
|
||||
or postback.get("balance_amount"),
|
||||
balance_currency=postback.get("BalanceCurrency")
|
||||
or postback.get("balance_currency"),
|
||||
payer_account=postback.get("AccountNumber")
|
||||
or postback.get("account")
|
||||
or postback.get("Account"),
|
||||
balance_amount=callback.get("BalanceAmount")
|
||||
or callback.get("balance_amount"),
|
||||
balance_currency=callback.get("BalanceCurrency")
|
||||
or callback.get("balance_currency"),
|
||||
payer_account=callback.get("AccountNumber")
|
||||
or callback.get("account")
|
||||
or callback.get("Account"),
|
||||
)
|
||||
|
||||
return await self._finalize_pal24_payment(
|
||||
db,
|
||||
payment,
|
||||
payment_id=payment_id,
|
||||
trigger="postback",
|
||||
trigger="callback",
|
||||
)
|
||||
|
||||
metadata = getattr(payment, "metadata_json", {}) or {}
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
|
||||
await payment_module.update_pal24_payment_status(
|
||||
db,
|
||||
payment,
|
||||
status=status or "UNKNOWN",
|
||||
is_paid=False,
|
||||
postback_payload=postback,
|
||||
callback_payload=callback,
|
||||
payment_id=payment_id,
|
||||
payment_status=postback.get("Status") or status,
|
||||
payment_status=callback.get("Status") or status,
|
||||
payment_method=(
|
||||
postback.get("payment_method")
|
||||
or postback.get("PaymentMethod")
|
||||
callback.get("payment_method")
|
||||
or callback.get("PaymentMethod")
|
||||
or getattr(payment, "payment_method", None)
|
||||
),
|
||||
balance_amount=postback.get("BalanceAmount")
|
||||
or postback.get("balance_amount"),
|
||||
balance_currency=postback.get("BalanceCurrency")
|
||||
or postback.get("balance_currency"),
|
||||
payer_account=postback.get("AccountNumber")
|
||||
or postback.get("account")
|
||||
or postback.get("Account"),
|
||||
balance_amount=callback.get("BalanceAmount")
|
||||
or callback.get("balance_amount"),
|
||||
balance_currency=callback.get("BalanceCurrency")
|
||||
or callback.get("balance_currency"),
|
||||
payer_account=callback.get("AccountNumber")
|
||||
or callback.get("account")
|
||||
or callback.get("Account"),
|
||||
)
|
||||
logger.info(
|
||||
"Обновили Pal24 платеж %s до статуса %s",
|
||||
@@ -310,7 +318,7 @@ class Pal24PaymentMixin:
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка обработки Pal24 postback: %s", error, exc_info=True)
|
||||
logger.error("Ошибка обработки Pal24 callback: %s", error, exc_info=True)
|
||||
return False
|
||||
|
||||
async def _finalize_pal24_payment(
|
||||
@@ -489,7 +497,11 @@ class Pal24PaymentMixin:
|
||||
chat_id=user.telegram_id,
|
||||
text=(
|
||||
"✅ Баланс пополнен на "
|
||||
f"{settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}"
|
||||
f"{settings.format_price(payment.amount_kopeks)}!\n\n"
|
||||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}"
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
@@ -681,12 +693,34 @@ class Pal24PaymentMixin:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
links_map, selected_method = self._build_links_map(payment, remote_payloads)
|
||||
primary_url = (
|
||||
links_map.get(selected_method)
|
||||
or links_map.get("sbp")
|
||||
or links_map.get("page")
|
||||
or links_map.get("card")
|
||||
)
|
||||
secondary_url = (
|
||||
links_map.get("page")
|
||||
or links_map.get("card")
|
||||
or links_map.get("sbp")
|
||||
)
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
"status": payment.status,
|
||||
"is_paid": payment.is_paid,
|
||||
"remote_status": remote_status_for_return,
|
||||
"remote_data": remote_data,
|
||||
"links": links_map or None,
|
||||
"primary_url": primary_url,
|
||||
"secondary_url": secondary_url,
|
||||
"sbp_url": links_map.get("sbp"),
|
||||
"card_url": links_map.get("card"),
|
||||
"link_page_url": links_map.get("page")
|
||||
or getattr(payment, "link_page_url", None),
|
||||
"link_url": getattr(payment, "link_url", None),
|
||||
"selected_method": selected_method,
|
||||
}
|
||||
|
||||
except Exception as error:
|
||||
@@ -837,6 +871,112 @@ class Pal24PaymentMixin:
|
||||
normalized = payment_method.strip().lower()
|
||||
return mapping.get(normalized, "sbp")
|
||||
|
||||
@staticmethod
|
||||
def _pick_first(mapping: Dict[str, Any], *keys: str) -> Optional[str]:
|
||||
for key in keys:
|
||||
value = mapping.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _build_links_map(
|
||||
cls,
|
||||
payment: Any,
|
||||
remote_payloads: Dict[str, Any],
|
||||
) -> tuple[Dict[str, str], str]:
|
||||
links: Dict[str, str] = {}
|
||||
|
||||
metadata = getattr(payment, "metadata_json", {}) or {}
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
|
||||
if metadata:
|
||||
links_meta = metadata.get("links")
|
||||
if isinstance(links_meta, dict):
|
||||
for key, value in links_meta.items():
|
||||
if value:
|
||||
links[key] = str(value)
|
||||
|
||||
selected_method = cls._normalize_payment_method(
|
||||
(metadata.get("selected_method") if isinstance(metadata, dict) else None)
|
||||
or getattr(payment, "payment_method", None)
|
||||
)
|
||||
|
||||
def _visit(value: Any) -> List[Dict[str, Any]]:
|
||||
stack: List[Any] = [value]
|
||||
result: List[Dict[str, Any]] = []
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
if isinstance(current, dict):
|
||||
result.append(current)
|
||||
stack.extend(current.values())
|
||||
elif isinstance(current, list):
|
||||
stack.extend(current)
|
||||
return result
|
||||
|
||||
payload_sources: List[Any] = []
|
||||
if metadata:
|
||||
payload_sources.append(metadata.get("raw_response"))
|
||||
payload_sources.append(getattr(payment, "callback_payload", None))
|
||||
payload_sources.extend(remote_payloads.values())
|
||||
|
||||
sbp_keys = (
|
||||
"transfer_url",
|
||||
"transferUrl",
|
||||
"transfer_link",
|
||||
"transferLink",
|
||||
"transfer",
|
||||
"sbp_url",
|
||||
"sbpUrl",
|
||||
"sbp_link",
|
||||
"sbpLink",
|
||||
)
|
||||
card_keys = (
|
||||
"link_url",
|
||||
"linkUrl",
|
||||
"link",
|
||||
"card_url",
|
||||
"cardUrl",
|
||||
"card_link",
|
||||
"cardLink",
|
||||
"payment_url",
|
||||
"paymentUrl",
|
||||
"url",
|
||||
)
|
||||
page_keys = (
|
||||
"link_page_url",
|
||||
"linkPageUrl",
|
||||
"page_url",
|
||||
"pageUrl",
|
||||
)
|
||||
|
||||
for source in payload_sources:
|
||||
if not source:
|
||||
continue
|
||||
for candidate in _visit(source):
|
||||
sbp_url = cls._pick_first(candidate, *sbp_keys)
|
||||
if sbp_url and "sbp" not in links:
|
||||
links["sbp"] = sbp_url
|
||||
card_url = cls._pick_first(candidate, *card_keys)
|
||||
if card_url and "card" not in links:
|
||||
links["card"] = card_url
|
||||
page_url = cls._pick_first(candidate, *page_keys)
|
||||
if page_url and "page" not in links:
|
||||
links["page"] = page_url
|
||||
|
||||
if getattr(payment, "link_page_url", None):
|
||||
links.setdefault("page", str(payment.link_page_url))
|
||||
|
||||
if getattr(payment, "link_url", None):
|
||||
link_url_value = str(payment.link_url)
|
||||
if selected_method == "card":
|
||||
links.setdefault("card", link_url_value)
|
||||
else:
|
||||
links.setdefault("sbp", link_url_value)
|
||||
|
||||
return links, selected_method
|
||||
|
||||
@staticmethod
|
||||
def _map_api_payment_method(normalized_payment_method: str) -> Optional[str]:
|
||||
"""Преобразует нормализованный метод оплаты в значение для Pal24 API."""
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
"""Mixin для интеграции платежей Platega."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.platega_service import PlategaService
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlategaPaymentMixin:
|
||||
"""Логика создания и обработки платежей Platega."""
|
||||
|
||||
_SUCCESS_STATUSES = {"CONFIRMED"}
|
||||
_FAILED_STATUSES = {"FAILED", "CANCELED", "EXPIRED"}
|
||||
_PENDING_STATUSES = {"PENDING", "INPROGRESS"}
|
||||
|
||||
async def create_platega_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
language: str,
|
||||
payment_method_code: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
service: Optional[PlategaService] = getattr(self, "platega_service", None)
|
||||
if not service or not service.is_configured:
|
||||
logger.error("Platega сервис не инициализирован")
|
||||
return None
|
||||
|
||||
if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма Platega меньше минимальной: %s < %s",
|
||||
amount_kopeks,
|
||||
settings.PLATEGA_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма Platega больше максимальной: %s > %s",
|
||||
amount_kopeks,
|
||||
settings.PLATEGA_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
correlation_id = uuid.uuid4().hex
|
||||
payload_token = f"platega:{correlation_id}"
|
||||
|
||||
amount_value = amount_kopeks / 100
|
||||
|
||||
try:
|
||||
response = await service.create_payment(
|
||||
payment_method=payment_method_code,
|
||||
amount=amount_value,
|
||||
currency=settings.PLATEGA_CURRENCY,
|
||||
description=description,
|
||||
return_url=settings.get_platega_return_url(),
|
||||
failed_url=settings.get_platega_failed_url(),
|
||||
payload=payload_token,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - network errors
|
||||
logger.exception("Ошибка Platega при создании платежа: %s", error)
|
||||
return None
|
||||
|
||||
if not response:
|
||||
logger.error("Platega вернул пустой ответ при создании платежа")
|
||||
return None
|
||||
|
||||
transaction_id = response.get("transactionId") or response.get("id")
|
||||
redirect_url = response.get("redirect")
|
||||
status = str(response.get("status") or "PENDING").upper()
|
||||
expires_at = PlategaService.parse_expires_at(response.get("expiresIn"))
|
||||
|
||||
metadata = {
|
||||
"raw_response": response,
|
||||
"language": language,
|
||||
"selected_method": payment_method_code,
|
||||
}
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
payment = await payment_module.create_platega_payment(
|
||||
db,
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=settings.PLATEGA_CURRENCY,
|
||||
description=description,
|
||||
status=status,
|
||||
payment_method_code=payment_method_code,
|
||||
correlation_id=correlation_id,
|
||||
platega_transaction_id=transaction_id,
|
||||
redirect_url=redirect_url,
|
||||
return_url=settings.get_platega_return_url(),
|
||||
failed_url=settings.get_platega_failed_url(),
|
||||
payload=payload_token,
|
||||
metadata=metadata,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Создан Platega платеж %s для пользователя %s (метод %s, сумма %s₽)",
|
||||
transaction_id or payment.id,
|
||||
user_id,
|
||||
payment_method_code,
|
||||
amount_value,
|
||||
)
|
||||
|
||||
return {
|
||||
"local_payment_id": payment.id,
|
||||
"transaction_id": transaction_id,
|
||||
"redirect_url": redirect_url,
|
||||
"status": status,
|
||||
"expires_at": expires_at,
|
||||
"correlation_id": correlation_id,
|
||||
}
|
||||
|
||||
async def process_platega_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: Dict[str, Any],
|
||||
) -> bool:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
transaction_id = str(payload.get("id") or "").strip()
|
||||
payload_token = payload.get("payload")
|
||||
|
||||
payment = None
|
||||
if transaction_id:
|
||||
payment = await payment_module.get_platega_payment_by_transaction_id(
|
||||
db, transaction_id
|
||||
)
|
||||
if not payment and payload_token:
|
||||
payment = await payment_module.get_platega_payment_by_correlation_id(
|
||||
db, str(payload_token).replace("platega:", "")
|
||||
)
|
||||
|
||||
if not payment:
|
||||
logger.warning("Platega webhook: платеж не найден (id=%s)", transaction_id)
|
||||
return False
|
||||
|
||||
status_raw = str(payload.get("status") or "").upper()
|
||||
if not status_raw:
|
||||
logger.warning("Platega webhook без статуса для платежа %s", payment.id)
|
||||
return False
|
||||
|
||||
update_kwargs = {
|
||||
"status": status_raw,
|
||||
"callback_payload": payload,
|
||||
}
|
||||
|
||||
if transaction_id:
|
||||
update_kwargs["platega_transaction_id"] = transaction_id
|
||||
|
||||
if status_raw in self._SUCCESS_STATUSES:
|
||||
if payment.is_paid:
|
||||
logger.info(
|
||||
"Platega платеж %s уже помечен как оплачен", payment.correlation_id
|
||||
)
|
||||
await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
**update_kwargs,
|
||||
is_paid=True,
|
||||
)
|
||||
return True
|
||||
|
||||
payment = await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
**update_kwargs,
|
||||
)
|
||||
await self._finalize_platega_payment(db, payment, payload)
|
||||
return True
|
||||
|
||||
if status_raw in self._FAILED_STATUSES:
|
||||
await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
**update_kwargs,
|
||||
is_paid=False,
|
||||
)
|
||||
logger.info(
|
||||
"Platega платеж %s перешёл в статус %s", payment.correlation_id, status_raw
|
||||
)
|
||||
return True
|
||||
|
||||
await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
**update_kwargs,
|
||||
)
|
||||
return True
|
||||
|
||||
async def get_platega_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
local_payment_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
payment = await payment_module.get_platega_payment_by_id(db, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
service: Optional[PlategaService] = getattr(self, "platega_service", None)
|
||||
remote_status: Optional[str] = None
|
||||
remote_payload: Optional[Dict[str, Any]] = None
|
||||
|
||||
if service and payment.platega_transaction_id:
|
||||
try:
|
||||
remote_payload = await service.get_transaction(
|
||||
payment.platega_transaction_id
|
||||
)
|
||||
except Exception as error: # pragma: no cover - network errors
|
||||
logger.error(
|
||||
"Ошибка Platega при получении транзакции %s: %s",
|
||||
payment.platega_transaction_id,
|
||||
error,
|
||||
)
|
||||
|
||||
if remote_payload:
|
||||
remote_status = str(remote_payload.get("status") or "").upper()
|
||||
if remote_status and remote_status != payment.status:
|
||||
await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
status=remote_status,
|
||||
metadata={
|
||||
**(getattr(payment, "metadata_json", {}) or {}),
|
||||
"remote_status": remote_payload,
|
||||
},
|
||||
)
|
||||
payment = await payment_module.get_platega_payment_by_id(db, local_payment_id)
|
||||
|
||||
if (
|
||||
remote_status in self._SUCCESS_STATUSES
|
||||
and not payment.is_paid
|
||||
):
|
||||
payment = await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
status=remote_status,
|
||||
callback_payload=remote_payload,
|
||||
)
|
||||
await self._finalize_platega_payment(db, payment, remote_payload)
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
"status": payment.status,
|
||||
"is_paid": payment.is_paid,
|
||||
"remote": remote_payload,
|
||||
}
|
||||
|
||||
async def _finalize_platega_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
payload: Optional[Dict[str, Any]],
|
||||
) -> Any:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||
if payload is not None:
|
||||
metadata["webhook"] = payload
|
||||
|
||||
paid_at = None
|
||||
if isinstance(payload, dict):
|
||||
paid_at_raw = payload.get("paidAt") or payload.get("confirmedAt")
|
||||
if paid_at_raw:
|
||||
try:
|
||||
paid_at = datetime.fromisoformat(str(paid_at_raw))
|
||||
except ValueError:
|
||||
paid_at = None
|
||||
|
||||
payment = await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
status="CONFIRMED",
|
||||
is_paid=True,
|
||||
paid_at=paid_at,
|
||||
metadata=metadata,
|
||||
callback_payload=payload,
|
||||
)
|
||||
|
||||
locked_payment = await payment_module.get_platega_payment_by_id_for_update(
|
||||
db, payment.id
|
||||
)
|
||||
if locked_payment:
|
||||
payment = locked_payment
|
||||
|
||||
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||
balance_already_credited = bool(metadata.get("balance_credited"))
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
"Platega платеж %s уже связан с транзакцией %s",
|
||||
payment.correlation_id,
|
||||
payment.transaction_id,
|
||||
)
|
||||
return payment
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error("Пользователь %s не найден для Platega", payment.user_id)
|
||||
return payment
|
||||
|
||||
transaction_external_id = (
|
||||
str(payload.get("id"))
|
||||
if isinstance(payload, dict) and payload.get("id")
|
||||
else payment.platega_transaction_id
|
||||
)
|
||||
|
||||
existing_transaction = None
|
||||
if transaction_external_id:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id(
|
||||
db,
|
||||
transaction_external_id,
|
||||
PaymentMethod.PLATEGA,
|
||||
)
|
||||
|
||||
method_display = settings.get_platega_method_display_name(payment.payment_method_code)
|
||||
description = (
|
||||
f"Пополнение через Platega ({method_display})"
|
||||
if method_display
|
||||
else "Пополнение через Platega"
|
||||
)
|
||||
|
||||
transaction = existing_transaction
|
||||
created_transaction = False
|
||||
|
||||
if not transaction:
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.PLATEGA,
|
||||
external_id=transaction_external_id or payment.correlation_id,
|
||||
is_completed=True,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await payment_module.link_platega_payment_to_transaction(
|
||||
db, payment=payment, transaction_id=transaction.id
|
||||
)
|
||||
|
||||
should_credit_balance = created_transaction or not balance_already_credited
|
||||
|
||||
if not should_credit_balance:
|
||||
logger.info(
|
||||
"Platega платеж %s уже зачислил баланс ранее",
|
||||
payment.correlation_id,
|
||||
)
|
||||
return payment
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
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 "🔄 Пополнение"
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Ошибка обработки реферального пополнения Platega: %s", error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Ошибка отправки админ уведомления Platega: %s", error)
|
||||
|
||||
method_title = settings.get_platega_method_display_title(payment.payment_method_code)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
f"🦊 Способ: {method_title}\n"
|
||||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Ошибка отправки уведомления пользователю Platega: %s", error)
|
||||
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from aiogram import types
|
||||
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
auto_purchase_success = False
|
||||
if has_saved_cart:
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
"Ошибка автоматической покупки подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
|
||||
"🛒 У вас есть неоформленный заказ.\n\n"
|
||||
"Вы можете продолжить оформление с теми же параметрами.",
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
callback_data="menu_balance",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=(
|
||||
f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
|
||||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса.\n\n"
|
||||
f"{cart_message}"
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
|
||||
payment.user_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
metadata["balance_change"] = {
|
||||
"old_balance": old_balance,
|
||||
"new_balance": user.balance_kopeks,
|
||||
"credited_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
metadata["balance_credited"] = True
|
||||
|
||||
await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Platega платеж %s для пользователя %s",
|
||||
payment.correlation_id,
|
||||
payment.user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -604,7 +604,11 @@ class TelegramStarsMixin:
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}",
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n"
|
||||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
|
||||
@@ -514,6 +514,10 @@ class WataPaymentMixin:
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
"🦊 Способ: WATA\n"
|
||||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||||
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
"Обязательно активируйте подписку отдельно!\n\n"
|
||||
"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
"подписка будет приобретена автоматически после пополнения баланса.\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
|
||||
@@ -30,6 +30,66 @@ if TYPE_CHECKING:
|
||||
class YooKassaPaymentMixin:
|
||||
"""Mixin с операциями по созданию и подтверждению платежей YooKassa."""
|
||||
|
||||
@staticmethod
|
||||
def _format_amount_value(value: Any) -> str:
|
||||
"""Форматирует сумму для хранения в webhook-объекте."""
|
||||
|
||||
try:
|
||||
quantized = Decimal(str(value)).quantize(Decimal("0.00"))
|
||||
return format(quantized, "f")
|
||||
except (InvalidOperation, ValueError, TypeError):
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def _merge_remote_yookassa_payload(
|
||||
cls,
|
||||
event_object: Dict[str, Any],
|
||||
remote_data: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Объединяет локальные данные вебхука с ответом API YooKassa."""
|
||||
|
||||
merged: Dict[str, Any] = dict(event_object)
|
||||
|
||||
status = remote_data.get("status")
|
||||
if status:
|
||||
merged["status"] = status
|
||||
|
||||
if "paid" in remote_data:
|
||||
merged["paid"] = bool(remote_data.get("paid"))
|
||||
|
||||
if "refundable" in remote_data:
|
||||
merged["refundable"] = bool(remote_data.get("refundable"))
|
||||
|
||||
payment_method_type = remote_data.get("payment_method_type")
|
||||
if payment_method_type:
|
||||
payment_method = dict(merged.get("payment_method") or {})
|
||||
payment_method["type"] = payment_method_type
|
||||
merged["payment_method"] = payment_method
|
||||
|
||||
amount_value = remote_data.get("amount_value")
|
||||
amount_currency = remote_data.get("amount_currency")
|
||||
if amount_value is not None or amount_currency:
|
||||
merged_amount = dict(merged.get("amount") or {})
|
||||
if amount_value is not None:
|
||||
merged_amount["value"] = cls._format_amount_value(amount_value)
|
||||
if amount_currency:
|
||||
merged_amount["currency"] = str(amount_currency).upper()
|
||||
merged["amount"] = merged_amount
|
||||
|
||||
for datetime_field in ("captured_at", "created_at"):
|
||||
value = remote_data.get(datetime_field)
|
||||
if value:
|
||||
merged[datetime_field] = value
|
||||
|
||||
metadata = remote_data.get("metadata")
|
||||
if metadata:
|
||||
try:
|
||||
merged["metadata"] = dict(metadata) # type: ignore[arg-type]
|
||||
except TypeError:
|
||||
merged["metadata"] = metadata
|
||||
|
||||
return merged
|
||||
|
||||
async def create_yookassa_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -322,7 +382,47 @@ class YooKassaPaymentMixin:
|
||||
from sqlalchemy import select
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
payment_description = getattr(payment, "description", "YooKassa платеж")
|
||||
# Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования)
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined]
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
PaymentMethod.YOOKASSA,
|
||||
)
|
||||
|
||||
if existing_transaction:
|
||||
# Если транзакция уже существует, просто завершаем обработку
|
||||
logger.info(
|
||||
"Платеж YooKassa %s уже был обработан транзакцией %s. Пропускаем повторную обработку.",
|
||||
payment.yookassa_payment_id,
|
||||
existing_transaction.id,
|
||||
)
|
||||
|
||||
# Убедимся, что платеж связан с транзакцией
|
||||
if not getattr(payment, "transaction_id", None):
|
||||
try:
|
||||
linked_payment = await payment_module.link_yookassa_payment_to_transaction( # type: ignore[attr-defined]
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
existing_transaction.id,
|
||||
)
|
||||
if linked_payment:
|
||||
payment.transaction_id = getattr(
|
||||
linked_payment,
|
||||
"transaction_id",
|
||||
existing_transaction.id,
|
||||
)
|
||||
if hasattr(linked_payment, "transaction"):
|
||||
payment.transaction = linked_payment.transaction
|
||||
except Exception as link_error: # pragma: no cover - защитный лог
|
||||
logger.warning(
|
||||
"Не удалось привязать платеж YooKassa %s к существующей транзакции %s: %s",
|
||||
payment.yookassa_payment_id,
|
||||
existing_transaction.id,
|
||||
link_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
payment_metadata: Dict[str, Any] = {}
|
||||
try:
|
||||
@@ -337,6 +437,84 @@ class YooKassaPaymentMixin:
|
||||
except Exception as parse_error:
|
||||
logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}")
|
||||
|
||||
processing_completed = bool(payment_metadata.get("processing_completed"))
|
||||
|
||||
transaction = None
|
||||
|
||||
existing_transaction_id = getattr(payment, "transaction_id", None)
|
||||
if existing_transaction_id:
|
||||
try:
|
||||
from app.database.crud.transaction import get_transaction_by_id
|
||||
|
||||
transaction = await get_transaction_by_id(db, existing_transaction_id)
|
||||
except Exception as fetch_error: # pragma: no cover - диагностический лог
|
||||
logger.warning(
|
||||
"Не удалось получить транзакцию %s для платежа YooKassa %s: %s",
|
||||
existing_transaction_id,
|
||||
payment.yookassa_payment_id,
|
||||
fetch_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if transaction and processing_completed:
|
||||
logger.info(
|
||||
"Пропускаем повторную обработку платежа YooKassa %s: транзакция %s уже завершила начисление.",
|
||||
payment.yookassa_payment_id,
|
||||
existing_transaction_id,
|
||||
)
|
||||
return True
|
||||
|
||||
if transaction:
|
||||
logger.info(
|
||||
"Транзакция %s для платежа YooKassa %s найдена, но обработка ранее не была завершена — повторяем критические шаги.",
|
||||
existing_transaction_id,
|
||||
payment.yookassa_payment_id,
|
||||
)
|
||||
|
||||
if transaction is None:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined]
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
PaymentMethod.YOOKASSA,
|
||||
)
|
||||
|
||||
if existing_transaction:
|
||||
# Если транзакция уже существует, пропускаем обработку
|
||||
logger.info(
|
||||
"Платеж YooKassa %s уже был обработан транзакцией %s. Пропускаем повторную обработку.",
|
||||
payment.yookassa_payment_id,
|
||||
existing_transaction.id,
|
||||
)
|
||||
|
||||
# Убедимся, что платеж связан с транзакцией
|
||||
if not getattr(payment, "transaction_id", None):
|
||||
try:
|
||||
linked_payment = await payment_module.link_yookassa_payment_to_transaction( # type: ignore[attr-defined]
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
existing_transaction.id,
|
||||
)
|
||||
if linked_payment:
|
||||
payment.transaction_id = getattr(
|
||||
linked_payment,
|
||||
"transaction_id",
|
||||
existing_transaction.id,
|
||||
)
|
||||
if hasattr(linked_payment, "transaction"):
|
||||
payment.transaction = linked_payment.transaction
|
||||
except Exception as link_error: # pragma: no cover - защитный лог
|
||||
logger.warning(
|
||||
"Не удалось привязать платеж YooKassa %s к существующей транзакции %s: %s",
|
||||
payment.yookassa_payment_id,
|
||||
existing_transaction.id,
|
||||
link_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
payment_description = getattr(payment, "description", "YooKassa платеж")
|
||||
|
||||
payment_purpose = payment_metadata.get("payment_purpose", "")
|
||||
is_simple_subscription = payment_purpose == "simple_subscription_purchase"
|
||||
|
||||
@@ -351,27 +529,32 @@ class YooKassaPaymentMixin:
|
||||
else f"Пополнение через YooKassa: {payment_description}"
|
||||
)
|
||||
|
||||
transaction = await payment_module.create_transaction(
|
||||
db=db,
|
||||
user_id=payment.user_id,
|
||||
type=transaction_type,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=transaction_description,
|
||||
payment_method=PaymentMethod.YOOKASSA,
|
||||
external_id=payment.yookassa_payment_id,
|
||||
is_completed=True,
|
||||
)
|
||||
if transaction is None:
|
||||
transaction = await payment_module.create_transaction(
|
||||
db=db,
|
||||
user_id=payment.user_id,
|
||||
type=transaction_type,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=transaction_description,
|
||||
payment_method=PaymentMethod.YOOKASSA,
|
||||
external_id=payment.yookassa_payment_id,
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
linked_payment = await payment_module.link_yookassa_payment_to_transaction(
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
transaction.id,
|
||||
)
|
||||
if not getattr(payment, "transaction_id", None):
|
||||
linked_payment = await payment_module.link_yookassa_payment_to_transaction(
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
transaction.id,
|
||||
)
|
||||
|
||||
if linked_payment:
|
||||
payment.transaction_id = getattr(linked_payment, "transaction_id", transaction.id)
|
||||
if hasattr(linked_payment, "transaction"):
|
||||
payment.transaction = linked_payment.transaction
|
||||
if linked_payment:
|
||||
payment.transaction_id = getattr(linked_payment, "transaction_id", transaction.id)
|
||||
if hasattr(linked_payment, "transaction"):
|
||||
payment.transaction = linked_payment.transaction
|
||||
|
||||
critical_flow_completed = False
|
||||
processing_marked = False
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if user:
|
||||
@@ -413,6 +596,14 @@ class YooKassaPaymentMixin:
|
||||
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
)
|
||||
|
||||
payment_metadata = await self._mark_yookassa_payment_processing_completed(
|
||||
db,
|
||||
payment,
|
||||
payment_metadata,
|
||||
commit=False,
|
||||
)
|
||||
processing_marked = True
|
||||
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
@@ -554,7 +745,11 @@ class YooKassaPaymentMixin:
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}",
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
|
||||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
@@ -684,6 +879,31 @@ class YooKassaPaymentMixin:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка активации подписки для пользователя {user.id}: {e}", exc_info=True)
|
||||
|
||||
if not processing_marked:
|
||||
payment_metadata = await self._mark_yookassa_payment_processing_completed(
|
||||
db,
|
||||
payment,
|
||||
payment_metadata,
|
||||
commit=True,
|
||||
)
|
||||
processing_marked = True
|
||||
|
||||
critical_flow_completed = True
|
||||
else:
|
||||
logger.warning(
|
||||
"Пользователь %s для платежа YooKassa %s не найден — начисление баланса невозможно",
|
||||
payment.user_id,
|
||||
payment.yookassa_payment_id,
|
||||
)
|
||||
|
||||
if critical_flow_completed and not processing_marked:
|
||||
payment_metadata = await self._mark_yookassa_payment_processing_completed(
|
||||
db,
|
||||
payment,
|
||||
payment_metadata,
|
||||
commit=True,
|
||||
)
|
||||
|
||||
if is_simple_subscription:
|
||||
logger.info(
|
||||
"Успешно обработан платеж YooKassa %s как покупка подписки: пользователь %s, сумма %s₽",
|
||||
@@ -709,6 +929,46 @@ class YooKassaPaymentMixin:
|
||||
)
|
||||
return False
|
||||
|
||||
async def _mark_yookassa_payment_processing_completed(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: "YooKassaPayment",
|
||||
payment_metadata: Dict[str, Any],
|
||||
*,
|
||||
commit: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Отмечает платёж как полностью обработанный, чтобы избежать повторного начисления."""
|
||||
|
||||
if payment_metadata.get("processing_completed"):
|
||||
return payment_metadata
|
||||
|
||||
updated_metadata = dict(payment_metadata)
|
||||
updated_metadata["processing_completed"] = True
|
||||
|
||||
try:
|
||||
from sqlalchemy import update
|
||||
from app.database.models import YooKassaPayment as YooKassaPaymentModel
|
||||
|
||||
await db.execute(
|
||||
update(YooKassaPaymentModel)
|
||||
.where(YooKassaPaymentModel.id == payment.id)
|
||||
.values(metadata_json=updated_metadata, updated_at=datetime.utcnow())
|
||||
)
|
||||
if commit:
|
||||
await db.commit()
|
||||
else:
|
||||
await db.flush()
|
||||
payment.metadata_json = updated_metadata
|
||||
except Exception as mark_error: # pragma: no cover - защитный лог
|
||||
logger.warning(
|
||||
"Не удалось отметить платеж YooKassa %s как завершенный: %s",
|
||||
payment.yookassa_payment_id,
|
||||
mark_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return updated_metadata
|
||||
|
||||
async def process_yookassa_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -722,6 +982,32 @@ class YooKassaPaymentMixin:
|
||||
logger.warning("Webhook без payment id: %s", event)
|
||||
return False
|
||||
|
||||
remote_data: Optional[Dict[str, Any]] = None
|
||||
if getattr(self, "yookassa_service", None):
|
||||
try:
|
||||
remote_data = await self.yookassa_service.get_payment_info( # type: ignore[union-attr]
|
||||
yookassa_payment_id
|
||||
)
|
||||
except Exception as error: # pragma: no cover - диагностический лог
|
||||
logger.warning(
|
||||
"Не удалось запросить актуальный статус платежа YooKassa %s: %s",
|
||||
yookassa_payment_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if remote_data:
|
||||
previous_status = event_object.get("status")
|
||||
event_object = self._merge_remote_yookassa_payload(event_object, remote_data)
|
||||
if previous_status and event_object.get("status") != previous_status:
|
||||
logger.info(
|
||||
"Статус платежа YooKassa %s скорректирован по данным API: %s → %s",
|
||||
yookassa_payment_id,
|
||||
previous_status,
|
||||
event_object.get("status"),
|
||||
)
|
||||
event["object"] = event_object
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
payment = await payment_module.get_yookassa_payment_by_id(db, yookassa_payment_id)
|
||||
|
||||
@@ -15,11 +15,13 @@ from app.external.heleket import HeleketService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.services.mulenpay_service import MulenPayService
|
||||
from app.services.pal24_service import Pal24Service
|
||||
from app.services.platega_service import PlategaService
|
||||
from app.services.payment import (
|
||||
CryptoBotPaymentMixin,
|
||||
HeleketPaymentMixin,
|
||||
MulenPayPaymentMixin,
|
||||
Pal24PaymentMixin,
|
||||
PlategaPaymentMixin,
|
||||
PaymentCommonMixin,
|
||||
TelegramStarsMixin,
|
||||
TributePaymentMixin,
|
||||
@@ -65,6 +67,11 @@ async def create_transaction(*args, **kwargs):
|
||||
return await transaction_crud.create_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_transaction_by_external_id(*args, **kwargs):
|
||||
transaction_crud = import_module("app.database.crud.transaction")
|
||||
return await transaction_crud.get_transaction_by_external_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def add_user_balance(*args, **kwargs):
|
||||
user_crud = import_module("app.database.crud.user")
|
||||
return await user_crud.add_user_balance(*args, **kwargs)
|
||||
@@ -170,6 +177,41 @@ async def link_wata_payment_to_transaction(*args, **kwargs):
|
||||
return await wata_crud.link_wata_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_platega_payment(*args, **kwargs):
|
||||
platega_crud = import_module("app.database.crud.platega")
|
||||
return await platega_crud.create_platega_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_platega_payment_by_id(*args, **kwargs):
|
||||
platega_crud = import_module("app.database.crud.platega")
|
||||
return await platega_crud.get_platega_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_platega_payment_by_id_for_update(*args, **kwargs):
|
||||
platega_crud = import_module("app.database.crud.platega")
|
||||
return await platega_crud.get_platega_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_platega_payment_by_transaction_id(*args, **kwargs):
|
||||
platega_crud = import_module("app.database.crud.platega")
|
||||
return await platega_crud.get_platega_payment_by_transaction_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_platega_payment_by_correlation_id(*args, **kwargs):
|
||||
platega_crud = import_module("app.database.crud.platega")
|
||||
return await platega_crud.get_platega_payment_by_correlation_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_platega_payment(*args, **kwargs):
|
||||
platega_crud = import_module("app.database.crud.platega")
|
||||
return await platega_crud.update_platega_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_platega_payment_to_transaction(*args, **kwargs):
|
||||
platega_crud = import_module("app.database.crud.platega")
|
||||
return await platega_crud.link_platega_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_cryptobot_payment(*args, **kwargs):
|
||||
crypto_crud = import_module("app.database.crud.cryptobot")
|
||||
return await crypto_crud.create_cryptobot_payment(*args, **kwargs)
|
||||
@@ -224,6 +266,7 @@ class PaymentService(
|
||||
HeleketPaymentMixin,
|
||||
MulenPayPaymentMixin,
|
||||
Pal24PaymentMixin,
|
||||
PlategaPaymentMixin,
|
||||
WataPaymentMixin,
|
||||
):
|
||||
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
||||
@@ -248,11 +291,14 @@ class PaymentService(
|
||||
self.pal24_service = (
|
||||
Pal24Service() if settings.is_pal24_enabled() else None
|
||||
)
|
||||
self.platega_service = (
|
||||
PlategaService() if settings.is_platega_enabled() else None
|
||||
)
|
||||
self.wata_service = WataService() if settings.is_wata_enabled() else None
|
||||
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.debug(
|
||||
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, Heleket=%s, %s=%s, Pal24=%s, Wata=%s)",
|
||||
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, Heleket=%s, %s=%s, Pal24=%s, Platega=%s, Wata=%s)",
|
||||
bool(self.yookassa_service),
|
||||
bool(self.stars_service),
|
||||
bool(self.cryptobot_service),
|
||||
@@ -260,5 +306,6 @@ class PaymentService(
|
||||
mulenpay_name,
|
||||
bool(self.mulenpay_service),
|
||||
bool(self.pal24_service),
|
||||
bool(self.platega_service),
|
||||
bool(self.wata_service),
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.database.models import (
|
||||
HeleketPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PlategaPayment,
|
||||
PaymentMethod,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
@@ -62,6 +63,7 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
PaymentMethod.WATA,
|
||||
PaymentMethod.HELEKET,
|
||||
PaymentMethod.CRYPTOBOT,
|
||||
PaymentMethod.PLATEGA,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -73,6 +75,7 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
PaymentMethod.PAL24,
|
||||
PaymentMethod.WATA,
|
||||
PaymentMethod.CRYPTOBOT,
|
||||
PaymentMethod.PLATEGA,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -86,6 +89,8 @@ def method_display_name(method: PaymentMethod) -> str:
|
||||
return "YooKassa"
|
||||
if method == PaymentMethod.WATA:
|
||||
return "WATA"
|
||||
if method == PaymentMethod.PLATEGA:
|
||||
return "Platega"
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
return "CryptoBot"
|
||||
if method == PaymentMethod.HELEKET:
|
||||
@@ -104,6 +109,8 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
|
||||
return settings.is_pal24_enabled()
|
||||
if method == PaymentMethod.WATA:
|
||||
return settings.is_wata_enabled()
|
||||
if method == PaymentMethod.PLATEGA:
|
||||
return settings.is_platega_enabled()
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
return settings.is_cryptobot_enabled()
|
||||
if method == PaymentMethod.HELEKET:
|
||||
@@ -315,6 +322,13 @@ def _is_wata_pending(payment: WataPayment) -> bool:
|
||||
}
|
||||
|
||||
|
||||
def _is_platega_pending(payment: PlategaPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or "").lower()
|
||||
return status in {"pending", "inprogress", "in_progress"}
|
||||
|
||||
|
||||
def _is_heleket_pending(payment: HeleketPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
@@ -459,6 +473,33 @@ async def _fetch_wata_payments(db: AsyncSession, cutoff: datetime) -> List[Pendi
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_platega_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(PlategaPayment)
|
||||
.options(selectinload(PlategaPayment.user))
|
||||
.where(PlategaPayment.created_at >= cutoff)
|
||||
.order_by(desc(PlategaPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_platega_pending(payment):
|
||||
continue
|
||||
identifier = payment.platega_transaction_id or payment.correlation_id or str(payment.id)
|
||||
record = _build_record(
|
||||
PaymentMethod.PLATEGA,
|
||||
payment,
|
||||
identifier=identifier,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, "expires_at", None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_heleket_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(HeleketPayment)
|
||||
@@ -582,6 +623,7 @@ async def list_recent_pending_payments(
|
||||
await _fetch_pal24_payments(db, cutoff),
|
||||
await _fetch_mulenpay_payments(db, cutoff),
|
||||
await _fetch_wata_payments(db, cutoff),
|
||||
await _fetch_platega_payments(db, cutoff),
|
||||
await _fetch_heleket_payments(db, cutoff),
|
||||
await _fetch_cryptobot_payments(db, cutoff),
|
||||
await _fetch_stars_transactions(db, cutoff),
|
||||
@@ -648,6 +690,22 @@ async def get_payment_record(
|
||||
expires_at=getattr(payment, "expires_at", None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.PLATEGA:
|
||||
payment = await db.get(PlategaPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=["user"])
|
||||
identifier = payment.platega_transaction_id or payment.correlation_id or str(payment.id)
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=identifier,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, "expires_at", None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.HELEKET:
|
||||
payment = await db.get(HeleketPayment, local_payment_id)
|
||||
if not payment:
|
||||
@@ -732,6 +790,9 @@ async def run_manual_check(
|
||||
elif method == PaymentMethod.WATA:
|
||||
result = await payment_service.get_wata_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
elif method == PaymentMethod.PLATEGA:
|
||||
result = await payment_service.get_platega_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
elif method == PaymentMethod.HELEKET:
|
||||
payment = await payment_service.sync_heleket_payment_status(
|
||||
db, local_payment_id=local_payment_id
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""HTTP-интеграция с Platega API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlategaService:
|
||||
"""Обертка над Platega API с базовой повторной отправкой запросов."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.base_url = (settings.PLATEGA_BASE_URL or "https://app.platega.io").rstrip("/")
|
||||
self.merchant_id = settings.PLATEGA_MERCHANT_ID
|
||||
self.secret = settings.PLATEGA_SECRET
|
||||
self._timeout = aiohttp.ClientTimeout(total=30, connect=10, sock_read=25)
|
||||
self._max_retries = 3
|
||||
self._retry_delay = 0.5
|
||||
self._retryable_statuses = {500, 502, 503, 504}
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return settings.is_platega_enabled()
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
*,
|
||||
payment_method: int,
|
||||
amount: float,
|
||||
currency: str,
|
||||
description: Optional[str] = None,
|
||||
return_url: Optional[str] = None,
|
||||
failed_url: Optional[str] = None,
|
||||
payload: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
body: Dict[str, Any] = {
|
||||
"paymentMethod": payment_method,
|
||||
"paymentDetails": {
|
||||
"amount": round(amount, 2),
|
||||
"currency": currency,
|
||||
},
|
||||
}
|
||||
|
||||
if description:
|
||||
body["description"] = description
|
||||
if return_url:
|
||||
body["return"] = return_url
|
||||
if failed_url:
|
||||
body["failedUrl"] = failed_url
|
||||
if payload:
|
||||
body["payload"] = payload
|
||||
|
||||
return await self._request("POST", "/transaction/process", json_data=body)
|
||||
|
||||
async def get_transaction(self, transaction_id: str) -> Optional[Dict[str, Any]]:
|
||||
endpoint = f"/transaction/{transaction_id}"
|
||||
return await self._request("GET", endpoint)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
*,
|
||||
json_data: Optional[Dict[str, Any]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.is_configured:
|
||||
logger.error("Platega service is not configured")
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
headers = {
|
||||
"X-MerchantId": self.merchant_id or "",
|
||||
"X-Secret": self.secret or "",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
last_error: Optional[BaseException] = None
|
||||
|
||||
for attempt in range(1, self._max_retries + 1):
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=self._timeout) as session:
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
json=json_data,
|
||||
params=params,
|
||||
headers=headers,
|
||||
) as response:
|
||||
data, raw_text = await self._deserialize_response(response)
|
||||
|
||||
if response.status >= 400:
|
||||
logger.error(
|
||||
"Platega API error %s %s: %s",
|
||||
response.status,
|
||||
endpoint,
|
||||
raw_text,
|
||||
)
|
||||
if (
|
||||
response.status in self._retryable_statuses
|
||||
and attempt < self._max_retries
|
||||
):
|
||||
await asyncio.sleep(self._retry_delay * attempt)
|
||||
continue
|
||||
return None
|
||||
|
||||
return data
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Platega request cancelled: %s %s", method, endpoint)
|
||||
raise
|
||||
except asyncio.TimeoutError as error:
|
||||
last_error = error
|
||||
logger.warning(
|
||||
"Platega request timeout (%s %s) attempt %s/%s",
|
||||
method,
|
||||
endpoint,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
)
|
||||
except aiohttp.ClientError as error:
|
||||
last_error = error
|
||||
logger.warning(
|
||||
"Platega client error (%s %s) attempt %s/%s: %s",
|
||||
method,
|
||||
endpoint,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - safety
|
||||
logger.exception("Unexpected Platega error: %s", error)
|
||||
return None
|
||||
|
||||
if attempt < self._max_retries:
|
||||
await asyncio.sleep(self._retry_delay * attempt)
|
||||
|
||||
if last_error is not None:
|
||||
logger.error(
|
||||
"Platega request failed after %s attempts (%s %s): %s",
|
||||
self._max_retries,
|
||||
method,
|
||||
endpoint,
|
||||
last_error,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def _deserialize_response(
|
||||
response: aiohttp.ClientResponse,
|
||||
) -> tuple[Optional[Dict[str, Any]], str]:
|
||||
raw_text = await response.text()
|
||||
if not raw_text:
|
||||
return None, ""
|
||||
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
if "json" in content_type.lower() or not content_type:
|
||||
try:
|
||||
return json.loads(raw_text), raw_text
|
||||
except json.JSONDecodeError as error:
|
||||
logger.error(
|
||||
"Failed to decode Platega JSON response %s: %s",
|
||||
response.url,
|
||||
error,
|
||||
)
|
||||
return None, raw_text
|
||||
|
||||
return None, raw_text
|
||||
|
||||
@staticmethod
|
||||
def parse_expires_at(expires_in: Optional[str]) -> Optional[datetime]:
|
||||
if not expires_in:
|
||||
return None
|
||||
|
||||
try:
|
||||
hours, minutes, seconds = [int(part) for part in expires_in.split(":", 2)]
|
||||
delta = timedelta(hours=hours, minutes=minutes, seconds=seconds)
|
||||
return datetime.utcnow() + delta
|
||||
except Exception:
|
||||
logger.warning("Failed to parse Platega expiresIn value: %s", expires_in)
|
||||
return None
|
||||
@@ -162,10 +162,7 @@ class PromoCodeService:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
trial_uuid = await get_random_trial_squad_uuid(
|
||||
db,
|
||||
settings.TRIAL_SQUAD_UUID,
|
||||
)
|
||||
trial_uuid = await get_random_trial_squad_uuid(db)
|
||||
if trial_uuid:
|
||||
trial_squads = [trial_uuid]
|
||||
except Exception as error:
|
||||
@@ -174,8 +171,6 @@ class PromoCodeService:
|
||||
promocode.code,
|
||||
error,
|
||||
)
|
||||
if getattr(settings, 'TRIAL_SQUAD_UUID', None):
|
||||
trial_squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
|
||||
@@ -84,6 +84,7 @@ class BotConfigurationService:
|
||||
"CRYPTOBOT": "🪙 CryptoBot",
|
||||
"HELEKET": "🪙 Heleket",
|
||||
"YOOKASSA": "🟣 YooKassa",
|
||||
"PLATEGA": "💳 Platega",
|
||||
"TRIBUTE": "🎁 Tribute",
|
||||
"MULENPAY": "💰 {mulenpay_name}",
|
||||
"PAL24": "🏦 PAL24 / PayPalych",
|
||||
@@ -137,6 +138,7 @@ class BotConfigurationService:
|
||||
"YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
|
||||
"CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.",
|
||||
"HELEKET": "Heleket: криптоплатежи, ключи мерчанта и вебхуки.",
|
||||
"PLATEGA": "Platega: merchant ID, секрет, ссылки возврата и методы оплаты.",
|
||||
"MULENPAY": "Платежи {mulenpay_name} и параметры магазина.",
|
||||
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
|
||||
"TRIBUTE": "Tribute и донат-сервисы.",
|
||||
@@ -303,6 +305,7 @@ class BotConfigurationService:
|
||||
"YOOKASSA_": "YOOKASSA",
|
||||
"CRYPTOBOT_": "CRYPTOBOT",
|
||||
"HELEKET_": "HELEKET",
|
||||
"PLATEGA_": "PLATEGA",
|
||||
"MULENPAY_": "MULENPAY",
|
||||
"PAL24_": "PAL24",
|
||||
"PAYMENT_": "PAYMENT",
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import decrement_subscription_server_counts
|
||||
from app.database.crud.user import add_user_balance, subtract_user_balance
|
||||
from app.database.models import Subscription, TransactionType, User
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TrialPaymentError(Exception):
|
||||
"""Base exception for trial activation payment issues."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TrialPaymentInsufficientFunds(TrialPaymentError):
|
||||
required_amount: int
|
||||
balance_amount: int
|
||||
|
||||
@property
|
||||
def missing_amount(self) -> int:
|
||||
return max(0, self.required_amount - self.balance_amount)
|
||||
|
||||
|
||||
class TrialPaymentChargeFailed(TrialPaymentError):
|
||||
"""Raised when balance charge could not be completed."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TrialActivationReversionResult:
|
||||
refunded: bool = True
|
||||
subscription_rolled_back: bool = True
|
||||
|
||||
|
||||
def get_trial_activation_charge_amount() -> int:
|
||||
"""Returns the configured activation charge in kopeks if payment is enabled."""
|
||||
|
||||
if not settings.is_trial_paid_activation_enabled():
|
||||
return 0
|
||||
|
||||
try:
|
||||
price_kopeks = int(settings.get_trial_activation_price() or 0)
|
||||
except (TypeError, ValueError): # pragma: no cover - defensive
|
||||
price_kopeks = 0
|
||||
|
||||
return max(0, price_kopeks)
|
||||
|
||||
|
||||
def preview_trial_activation_charge(user: User) -> int:
|
||||
"""Validates that the user can afford the trial activation charge."""
|
||||
|
||||
price_kopeks = get_trial_activation_charge_amount()
|
||||
if price_kopeks <= 0:
|
||||
return 0
|
||||
|
||||
balance = int(getattr(user, "balance_kopeks", 0) or 0)
|
||||
if balance < price_kopeks:
|
||||
raise TrialPaymentInsufficientFunds(price_kopeks, balance)
|
||||
|
||||
return price_kopeks
|
||||
|
||||
|
||||
async def charge_trial_activation_if_required(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
*,
|
||||
description: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Charges the user's balance if paid trial activation is enabled.
|
||||
|
||||
Returns the charged amount in kopeks. If payment is not required or the
|
||||
configured price is zero, the function returns ``0``.
|
||||
"""
|
||||
|
||||
price_kopeks = preview_trial_activation_charge(user)
|
||||
if price_kopeks <= 0:
|
||||
return 0
|
||||
|
||||
charge_description = description or "Активация триальной подписки"
|
||||
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
price_kopeks,
|
||||
charge_description,
|
||||
)
|
||||
if not success:
|
||||
raise TrialPaymentChargeFailed()
|
||||
|
||||
# subtract_user_balance обновляет пользователя, но на всякий случай приводим к int
|
||||
return int(price_kopeks)
|
||||
|
||||
|
||||
async def refund_trial_activation_charge(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
*,
|
||||
description: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Refunds a previously charged trial activation amount back to the user."""
|
||||
|
||||
if amount_kopeks <= 0:
|
||||
return True
|
||||
|
||||
refund_description = description or "Возврат оплаты за активацию триальной подписки"
|
||||
|
||||
success = await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
amount_kopeks,
|
||||
refund_description,
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.error(
|
||||
"Failed to refund %s kopeks for user %s during trial activation rollback",
|
||||
amount_kopeks,
|
||||
getattr(user, "id", "<unknown>"),
|
||||
)
|
||||
|
||||
return success
|
||||
|
||||
|
||||
async def rollback_trial_subscription_activation(
|
||||
db: AsyncSession,
|
||||
subscription: Optional[Subscription],
|
||||
) -> bool:
|
||||
"""Attempts to undo a previously created trial subscription.
|
||||
|
||||
Returns ``True`` when the rollback succeeds or when ``subscription`` is
|
||||
falsy. In case of a database failure the function returns ``False`` after
|
||||
logging the error so callers can decide how to proceed.
|
||||
"""
|
||||
|
||||
if not subscription:
|
||||
return True
|
||||
|
||||
try:
|
||||
await decrement_subscription_server_counts(db, subscription)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Failed to decrement server counters during trial rollback for %s: %s",
|
||||
subscription.user_id,
|
||||
error,
|
||||
)
|
||||
|
||||
try:
|
||||
await db.delete(subscription)
|
||||
await db.commit()
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Failed to remove trial subscription %s after charge failure: %s",
|
||||
getattr(subscription, "id", "<unknown>"),
|
||||
error,
|
||||
)
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def revert_trial_activation(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Optional[Subscription],
|
||||
charged_amount: int,
|
||||
*,
|
||||
refund_description: Optional[str] = None,
|
||||
) -> TrialActivationReversionResult:
|
||||
"""Rolls back a trial subscription and refunds any charged amount."""
|
||||
|
||||
rollback_success = await rollback_trial_subscription_activation(db, subscription)
|
||||
refund_success = await refund_trial_activation_charge(
|
||||
db,
|
||||
user,
|
||||
charged_amount,
|
||||
description=refund_description,
|
||||
)
|
||||
|
||||
try:
|
||||
await db.refresh(user)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to refresh user %s after reverting trial activation: %s",
|
||||
getattr(user, "id", "<unknown>"),
|
||||
error,
|
||||
)
|
||||
|
||||
return TrialActivationReversionResult(
|
||||
refunded=refund_success,
|
||||
subscription_rolled_back=rollback_success,
|
||||
)
|
||||
@@ -257,6 +257,10 @@ class TributeService:
|
||||
f"💰 Сумма: {int(amount_rubles)} ₽\n"
|
||||
f"💳 Способ оплаты: Tribute\n"
|
||||
f"🎉 Средства зачислены на баланс!\n\n"
|
||||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса.\n\n"
|
||||
f"Спасибо за оплату! 🙏"
|
||||
)
|
||||
|
||||
@@ -318,7 +322,9 @@ class TributeService:
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}",
|
||||
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n"
|
||||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
f"Обязательно активируйте подписку отдельно!\n\n{cart_message}",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
logger.info(
|
||||
|
||||
@@ -25,6 +25,7 @@ class SubscriptionStates(StatesGroup):
|
||||
class BalanceStates(StatesGroup):
|
||||
waiting_for_amount = State()
|
||||
waiting_for_pal24_method = State()
|
||||
waiting_for_platega_method = State()
|
||||
waiting_for_stars_payment = State()
|
||||
waiting_for_support_request = State()
|
||||
|
||||
|
||||
@@ -90,7 +90,16 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
|
||||
"description": "через Heleket",
|
||||
"callback": "topup_heleket"
|
||||
})
|
||||
|
||||
|
||||
if settings.is_platega_enabled() and settings.get_platega_active_methods():
|
||||
methods.append({
|
||||
"id": "platega",
|
||||
"name": "Банковская карта",
|
||||
"icon": "💳",
|
||||
"description": "через Platega (карты + СБП)",
|
||||
"callback": "topup_platega",
|
||||
})
|
||||
|
||||
# Поддержка всегда доступна
|
||||
methods.append({
|
||||
"id": "support",
|
||||
@@ -174,6 +183,8 @@ def is_payment_method_available(method_id: str) -> bool:
|
||||
return settings.is_cryptobot_enabled()
|
||||
elif method_id == "heleket":
|
||||
return settings.is_heleket_enabled()
|
||||
elif method_id == "platega":
|
||||
return settings.is_platega_enabled() and bool(settings.get_platega_active_methods())
|
||||
elif method_id == "support":
|
||||
return True # Поддержка всегда доступна
|
||||
else:
|
||||
@@ -192,6 +203,7 @@ def get_payment_method_status() -> Dict[str, bool]:
|
||||
"pal24": settings.is_pal24_enabled(),
|
||||
"cryptobot": settings.is_cryptobot_enabled(),
|
||||
"heleket": settings.is_heleket_enabled(),
|
||||
"platega": settings.is_platega_enabled() and bool(settings.get_platega_active_methods()),
|
||||
"support": True
|
||||
}
|
||||
|
||||
@@ -216,4 +228,6 @@ def get_enabled_payment_methods_count() -> int:
|
||||
count += 1
|
||||
if settings.is_heleket_enabled():
|
||||
count += 1
|
||||
if settings.is_platega_enabled() and settings.get_platega_active_methods():
|
||||
count += 1
|
||||
return count
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, Security
|
||||
|
||||
from app.config import settings
|
||||
from app.database import db_manager, get_pool_metrics
|
||||
from app.services.version_service import version_service
|
||||
|
||||
from ..dependencies import require_api_token
|
||||
@@ -24,3 +25,17 @@ async def health_check(_: object = Security(require_api_token)) -> HealthCheckRe
|
||||
webhooks=bool(settings.WEBHOOK_URL),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health/database", tags=["health"])
|
||||
async def database_health(_: object = Security(require_api_token)) -> dict:
|
||||
"""Детальная информация о состоянии базы данных."""
|
||||
|
||||
return await db_manager.health_check()
|
||||
|
||||
|
||||
@router.get("/metrics/pool", tags=["health"])
|
||||
async def pool_metrics(_: object = Security(require_api_token)) -> dict:
|
||||
"""Метрики пула подключений к базе данных."""
|
||||
|
||||
return await get_pool_metrics()
|
||||
|
||||
@@ -67,6 +67,14 @@ from app.services.payment_service import PaymentService, get_wata_payment_by_lin
|
||||
from app.services.promo_offer_service import promo_offer_service
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.trial_activation_service import (
|
||||
TrialPaymentChargeFailed,
|
||||
TrialPaymentInsufficientFunds,
|
||||
charge_trial_activation_if_required,
|
||||
preview_trial_activation_charge,
|
||||
revert_trial_activation,
|
||||
rollback_trial_subscription_activation,
|
||||
)
|
||||
from app.services.subscription_purchase_service import (
|
||||
purchase_service,
|
||||
PurchaseBalanceError,
|
||||
@@ -1529,6 +1537,8 @@ async def _resolve_pal24_payment_status(
|
||||
if remote_status:
|
||||
message = f"Status: {remote_status}"
|
||||
|
||||
links_info = status_info.get("links") if status_info else {}
|
||||
|
||||
return MiniAppPaymentStatusResult(
|
||||
method="pal24",
|
||||
status=status,
|
||||
@@ -1548,6 +1558,14 @@ async def _resolve_pal24_payment_status(
|
||||
"payment_method": getattr(payment, "payment_method", None),
|
||||
"payload": query.payload,
|
||||
"started_at": query.started_at,
|
||||
"links": links_info or None,
|
||||
"sbp_url": status_info.get("sbp_url") if status_info else None,
|
||||
"card_url": status_info.get("card_url") if status_info else None,
|
||||
"link_url": status_info.get("link_url") if status_info else None,
|
||||
"link_page_url": status_info.get("link_page_url") if status_info else None,
|
||||
"primary_url": status_info.get("primary_url") if status_info else None,
|
||||
"secondary_url": status_info.get("secondary_url") if status_info else None,
|
||||
"selected_method": status_info.get("selected_method") if status_info else None,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2888,6 +2906,13 @@ async def get_subscription_details(
|
||||
trial_duration_days = (
|
||||
settings.TRIAL_DURATION_DAYS if settings.TRIAL_DURATION_DAYS > 0 else None
|
||||
)
|
||||
trial_price_kopeks = settings.get_trial_activation_price()
|
||||
trial_payment_required = (
|
||||
settings.is_trial_paid_activation_enabled() and trial_price_kopeks > 0
|
||||
)
|
||||
trial_price_label = (
|
||||
settings.format_price(trial_price_kopeks) if trial_payment_required else None
|
||||
)
|
||||
|
||||
subscription_missing_reason = None
|
||||
if subscription is None:
|
||||
@@ -2951,6 +2976,9 @@ async def get_subscription_details(
|
||||
trial_available=trial_available,
|
||||
trial_duration_days=trial_duration_days,
|
||||
trial_status="available" if trial_available else "unavailable",
|
||||
trial_payment_required=trial_payment_required,
|
||||
trial_price_kopeks=trial_price_kopeks if trial_payment_required else None,
|
||||
trial_price_label=trial_price_label,
|
||||
**autopay_extras,
|
||||
)
|
||||
|
||||
@@ -3103,6 +3131,20 @@ async def activate_subscription_trial_endpoint(
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
preview_trial_activation_charge(user)
|
||||
except TrialPaymentInsufficientFunds as error:
|
||||
missing = error.missing_amount
|
||||
raise HTTPException(
|
||||
status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
"code": "insufficient_funds",
|
||||
"message": "Not enough funds to activate the trial",
|
||||
"missing_amount_kopeks": missing,
|
||||
"required_amount_kopeks": error.required_amount,
|
||||
"balance_kopeks": error.balance_amount,
|
||||
},
|
||||
) from error
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
@@ -3127,6 +3169,61 @@ async def activate_subscription_trial_endpoint(
|
||||
},
|
||||
) from error
|
||||
|
||||
charged_amount = 0
|
||||
try:
|
||||
charged_amount = await charge_trial_activation_if_required(db, user)
|
||||
except TrialPaymentInsufficientFunds as error:
|
||||
rollback_success = await rollback_trial_subscription_activation(db, subscription)
|
||||
await db.refresh(user)
|
||||
if not rollback_success:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"code": "trial_rollback_failed",
|
||||
"message": "Failed to revert trial activation after charge error",
|
||||
},
|
||||
) from error
|
||||
|
||||
logger.error(
|
||||
"Balance check failed after trial creation for user %s: %s",
|
||||
user.id,
|
||||
error,
|
||||
)
|
||||
raise HTTPException(
|
||||
status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
"code": "insufficient_funds",
|
||||
"message": "Not enough funds to activate the trial",
|
||||
"missing_amount_kopeks": error.missing_amount,
|
||||
"required_amount_kopeks": error.required_amount,
|
||||
"balance_kopeks": error.balance_amount,
|
||||
},
|
||||
) from error
|
||||
except TrialPaymentChargeFailed as error:
|
||||
rollback_success = await rollback_trial_subscription_activation(db, subscription)
|
||||
await db.refresh(user)
|
||||
if not rollback_success:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"code": "trial_rollback_failed",
|
||||
"message": "Failed to revert trial activation after charge error",
|
||||
},
|
||||
) from error
|
||||
|
||||
logger.error(
|
||||
"Failed to charge balance for trial activation after subscription %s creation: %s",
|
||||
subscription.id,
|
||||
error,
|
||||
)
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"code": "charge_failed",
|
||||
"message": "Failed to charge balance for trial activation",
|
||||
},
|
||||
) from error
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
@@ -3134,13 +3231,75 @@ async def activate_subscription_trial_endpoint(
|
||||
try:
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
except RemnaWaveConfigurationError as error: # pragma: no cover - configuration issues
|
||||
logger.warning("RemnaWave update skipped: %s", error)
|
||||
logger.error("RemnaWave update skipped due to configuration error: %s", error)
|
||||
revert_result = await revert_trial_activation(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
charged_amount,
|
||||
refund_description="Возврат оплаты за активацию триала в мини-приложении",
|
||||
)
|
||||
if not revert_result.subscription_rolled_back:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"code": "trial_rollback_failed",
|
||||
"message": "Failed to revert trial activation after RemnaWave error",
|
||||
},
|
||||
) from error
|
||||
if charged_amount > 0 and not revert_result.refunded:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"code": "trial_refund_failed",
|
||||
"message": "Failed to refund trial activation charge after RemnaWave error",
|
||||
},
|
||||
) from error
|
||||
|
||||
raise HTTPException(
|
||||
status.HTTP_502_BAD_GATEWAY,
|
||||
detail={
|
||||
"code": "remnawave_configuration_error",
|
||||
"message": "Trial activation failed due to RemnaWave configuration. Charge refunded.",
|
||||
},
|
||||
) from error
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Failed to create RemnaWave user for trial subscription %s: %s",
|
||||
subscription.id,
|
||||
error,
|
||||
)
|
||||
revert_result = await revert_trial_activation(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
charged_amount,
|
||||
refund_description="Возврат оплаты за активацию триала в мини-приложении",
|
||||
)
|
||||
if not revert_result.subscription_rolled_back:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"code": "trial_rollback_failed",
|
||||
"message": "Failed to revert trial activation after RemnaWave error",
|
||||
},
|
||||
) from error
|
||||
if charged_amount > 0 and not revert_result.refunded:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"code": "trial_refund_failed",
|
||||
"message": "Failed to refund trial activation charge after RemnaWave error",
|
||||
},
|
||||
) from error
|
||||
|
||||
raise HTTPException(
|
||||
status.HTTP_502_BAD_GATEWAY,
|
||||
detail={
|
||||
"code": "remnawave_provisioning_failed",
|
||||
"message": "Trial activation failed due to RemnaWave provisioning. Charge refunded.",
|
||||
},
|
||||
) from error
|
||||
|
||||
await db.refresh(subscription)
|
||||
|
||||
@@ -3158,6 +3317,9 @@ async def activate_subscription_trial_endpoint(
|
||||
duration_days = settings.TRIAL_DURATION_DAYS
|
||||
|
||||
language_code = _normalize_language_code(user)
|
||||
charged_amount_label = (
|
||||
settings.format_price(charged_amount) if charged_amount > 0 else None
|
||||
)
|
||||
if language_code == "ru":
|
||||
if duration_days:
|
||||
message = f"Триал активирован на {duration_days} дн. Приятного пользования!"
|
||||
@@ -3169,8 +3331,19 @@ async def activate_subscription_trial_endpoint(
|
||||
else:
|
||||
message = "Trial activated successfully. Enjoy!"
|
||||
|
||||
if charged_amount_label:
|
||||
if language_code == "ru":
|
||||
message = f"{message}\n\n💳 С вашего баланса списано {charged_amount_label}."
|
||||
else:
|
||||
message = f"{message}\n\n💳 {charged_amount_label} has been deducted from your balance."
|
||||
|
||||
await _with_admin_notification_service(
|
||||
lambda service: service.send_trial_activation_notification(db, user, subscription)
|
||||
lambda service: service.send_trial_activation_notification(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
charged_amount_kopeks=charged_amount,
|
||||
)
|
||||
)
|
||||
|
||||
return MiniAppSubscriptionTrialResponse(
|
||||
@@ -3178,6 +3351,10 @@ async def activate_subscription_trial_endpoint(
|
||||
subscription_id=getattr(subscription, "id", None),
|
||||
trial_status="activated",
|
||||
trial_duration_days=duration_days,
|
||||
charged_amount_kopeks=charged_amount if charged_amount > 0 else None,
|
||||
charged_amount_label=charged_amount_label,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
balance_label=settings.format_price(user.balance_kopeks),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+43
-13
@@ -158,6 +158,12 @@ async def get_user(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserResponse:
|
||||
# First check if the provided ID is a telegram_id
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
if user:
|
||||
return _serialize_user(user)
|
||||
|
||||
# If not found as telegram_id, check as internal user ID
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
@@ -202,8 +208,15 @@ async def update_user_endpoint(
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> UserResponse:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
# First check if the provided ID is a telegram_id
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
if user:
|
||||
found_user = user
|
||||
else:
|
||||
# If not found as telegram_id, check as internal user ID
|
||||
found_user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not found_user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
updates: dict[str, Any] = {}
|
||||
@@ -234,18 +247,23 @@ async def update_user_endpoint(
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Promo group not found")
|
||||
updates["promo_group_id"] = promo_group.id
|
||||
|
||||
if payload.referral_code is not None and payload.referral_code != user.referral_code:
|
||||
if payload.referral_code is not None and payload.referral_code != found_user.referral_code:
|
||||
existing_code_owner = await get_user_by_referral_code(db, payload.referral_code)
|
||||
if existing_code_owner and existing_code_owner.id != user.id:
|
||||
if existing_code_owner and existing_code_owner.id != found_user.id:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Referral code already in use")
|
||||
updates["referral_code"] = payload.referral_code
|
||||
|
||||
if not updates:
|
||||
return _serialize_user(user)
|
||||
return _serialize_user(found_user)
|
||||
|
||||
user = await update_user(db, user, **updates)
|
||||
user = await get_user_by_id(db, user.id)
|
||||
return _serialize_user(user)
|
||||
found_user = await update_user(db, found_user, **updates)
|
||||
# Reload the user to ensure we have the latest data
|
||||
if found_user.telegram_id == user_id:
|
||||
found_user = await get_user_by_telegram_id(db, user_id)
|
||||
else:
|
||||
found_user = await get_user_by_id(db, found_user.id)
|
||||
|
||||
return _serialize_user(found_user)
|
||||
|
||||
|
||||
@router.post("/{user_id}/balance", response_model=UserResponse)
|
||||
@@ -258,13 +276,20 @@ async def update_balance(
|
||||
if payload.amount_kopeks == 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Amount must be non-zero")
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
# First check if the provided ID is a telegram_id
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
if user:
|
||||
found_user = user
|
||||
else:
|
||||
# If not found as telegram_id, check as internal user ID
|
||||
found_user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not found_user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||
|
||||
success = await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
found_user,
|
||||
amount_kopeks=payload.amount_kopeks,
|
||||
description=payload.description or "Корректировка через веб-API",
|
||||
create_transaction=payload.create_transaction,
|
||||
@@ -273,5 +298,10 @@ async def update_balance(
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to update balance")
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
return _serialize_user(user)
|
||||
# Reload the user to ensure we have the latest data
|
||||
if found_user.telegram_id == user_id:
|
||||
found_user = await get_user_by_telegram_id(db, user_id)
|
||||
else:
|
||||
found_user = await get_user_by_id(db, found_user.id)
|
||||
|
||||
return _serialize_user(found_user)
|
||||
|
||||
@@ -465,8 +465,11 @@ class MiniAppSubscriptionResponse(BaseModel):
|
||||
trial_available: bool = False
|
||||
trial_duration_days: Optional[int] = None
|
||||
trial_status: Optional[str] = None
|
||||
trial_payment_required: bool = Field(default=False, alias="trialPaymentRequired")
|
||||
trial_price_kopeks: Optional[int] = Field(default=None, alias="trialPriceKopeks")
|
||||
trial_price_label: Optional[str] = Field(default=None, alias="trialPriceLabel")
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
||||
|
||||
|
||||
class MiniAppSubscriptionServerOption(BaseModel):
|
||||
@@ -736,6 +739,10 @@ class MiniAppSubscriptionTrialResponse(BaseModel):
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
trial_status: Optional[str] = Field(default=None, alias="trialStatus")
|
||||
trial_duration_days: Optional[int] = Field(default=None, alias="trialDurationDays")
|
||||
charged_amount_kopeks: Optional[int] = Field(default=None, alias="chargedAmountKopeks")
|
||||
charged_amount_label: Optional[str] = Field(default=None, alias="chargedAmountLabel")
|
||||
balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks")
|
||||
balance_label: Optional[str] = Field(default=None, alias="balanceLabel")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
+90
-17
@@ -15,7 +15,7 @@ from aiogram import Bot
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.external.tribute import TributeService as TributeAPI
|
||||
from app.external.yookassa_webhook import YooKassaWebhookHandler
|
||||
from app.external import yookassa_webhook as yookassa_webhook_module
|
||||
from app.external.wata_webhook import WataWebhookHandler
|
||||
from app.external.heleket_webhook import HeleketWebhookHandler
|
||||
from app.external.pal24_client import Pal24APIError
|
||||
@@ -322,7 +322,6 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
routes_registered = True
|
||||
|
||||
if settings.is_yookassa_enabled():
|
||||
yookassa_secret = settings.YOOKASSA_WEBHOOK_SECRET or ""
|
||||
|
||||
@router.options(settings.YOOKASSA_WEBHOOK_PATH)
|
||||
async def yookassa_options() -> Response:
|
||||
@@ -347,6 +346,37 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
|
||||
@router.post(settings.YOOKASSA_WEBHOOK_PATH)
|
||||
async def yookassa_webhook(request: Request) -> JSONResponse:
|
||||
header_ip_candidates = yookassa_webhook_module.collect_yookassa_ip_candidates(
|
||||
request.headers.get("X-Forwarded-For"),
|
||||
request.headers.get("X-Real-IP"),
|
||||
request.headers.get("Cf-Connecting-Ip"),
|
||||
)
|
||||
remote_ip = request.client.host if request.client else None
|
||||
client_ip = yookassa_webhook_module.resolve_yookassa_ip(
|
||||
header_ip_candidates,
|
||||
remote=remote_ip,
|
||||
)
|
||||
|
||||
if client_ip is None:
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"reason": "unknown_ip",
|
||||
"candidates": header_ip_candidates + ([remote_ip] if remote_ip else []),
|
||||
},
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
if not yookassa_webhook_module.is_yookassa_ip_allowed(client_ip):
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "error",
|
||||
"reason": "forbidden_ip",
|
||||
"ip": str(client_ip),
|
||||
},
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
body_bytes = await request.body()
|
||||
if not body_bytes:
|
||||
return JSONResponse({"status": "error", "reason": "empty_body"}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -354,18 +384,8 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
body = body_bytes.decode("utf-8")
|
||||
|
||||
signature = request.headers.get("Signature") or request.headers.get("X-YooKassa-Signature")
|
||||
if yookassa_secret:
|
||||
if not signature:
|
||||
return JSONResponse(
|
||||
{"status": "error", "reason": "missing_signature"},
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if not YooKassaWebhookHandler.verify_webhook_signature(body, signature, yookassa_secret):
|
||||
return JSONResponse(
|
||||
{"status": "error", "reason": "invalid_signature"},
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
if signature:
|
||||
logger.info("ℹ️ Получена подпись YooKassa: %s", signature)
|
||||
|
||||
try:
|
||||
webhook_data = json.loads(body)
|
||||
@@ -382,7 +402,11 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if event_type not in {"payment.succeeded", "payment.waiting_for_capture"}:
|
||||
if event_type not in {
|
||||
"payment.succeeded",
|
||||
"payment.waiting_for_capture",
|
||||
"payment.canceled",
|
||||
}:
|
||||
return JSONResponse({"status": "ok", "ignored": event_type})
|
||||
|
||||
success = await _process_payment_service_callback(
|
||||
@@ -555,7 +579,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
)
|
||||
|
||||
try:
|
||||
parsed_payload = pal24_service.parse_postback(payload)
|
||||
parsed_payload = pal24_service.parse_callback(payload)
|
||||
except Pal24APIError as error:
|
||||
return JSONResponse(
|
||||
{"status": "error", "reason": str(error)},
|
||||
@@ -565,7 +589,55 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
parsed_payload,
|
||||
"process_pal24_postback",
|
||||
"process_pal24_callback",
|
||||
)
|
||||
if success:
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
return JSONResponse(
|
||||
{"status": "error", "reason": "not_processed"},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
if settings.is_platega_enabled():
|
||||
|
||||
@router.get(settings.PLATEGA_WEBHOOK_PATH)
|
||||
async def platega_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "platega_webhook",
|
||||
"enabled": settings.is_platega_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.PLATEGA_WEBHOOK_PATH)
|
||||
async def platega_webhook(request: Request) -> JSONResponse:
|
||||
merchant_id = request.headers.get("X-MerchantId", "")
|
||||
secret = request.headers.get("X-Secret", "")
|
||||
if (
|
||||
merchant_id != (settings.PLATEGA_MERCHANT_ID or "")
|
||||
or secret != (settings.PLATEGA_SECRET or "")
|
||||
):
|
||||
return JSONResponse(
|
||||
{"status": "error", "reason": "unauthorized"},
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
try:
|
||||
payload = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse(
|
||||
{"status": "error", "reason": "invalid_json"},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
"process_platega_webhook",
|
||||
)
|
||||
if success:
|
||||
return JSONResponse({"status": "ok"})
|
||||
@@ -590,6 +662,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
"wata_enabled": settings.is_wata_enabled(),
|
||||
"heleket_enabled": settings.is_heleket_enabled(),
|
||||
"pal24_enabled": settings.is_pal24_enabled(),
|
||||
"platega_enabled": settings.is_platega_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -170,8 +170,8 @@
|
||||
- `app/external/pal24_client.py` — Async client for PayPalych (Pal24) API.
|
||||
Классы: `Pal24APIError` — Base error for Pal24 API operations., `Pal24Response` (2 методов) — Wrapper for Pal24 API responses., `Pal24Client` (5 методов) — Async client implementing PayPalych API methods.
|
||||
Функции: нет
|
||||
- `app/external/pal24_webhook.py` — Flask webhook server for PayPalych postbacks.
|
||||
Классы: `Pal24WebhookServer` (3 методов) — Threaded Flask server for Pal24 postbacks.
|
||||
- `app/external/pal24_webhook.py` — Flask webhook server for PayPalych callbacks.
|
||||
Классы: `Pal24WebhookServer` (3 методов) — Threaded Flask server for Pal24 callbacks.
|
||||
Функции: `_normalize_payload`, `create_pal24_flask_app`
|
||||
- `app/external/remnawave_api.py` — Python-модуль
|
||||
Классы: `UserStatus`, `TrafficLimitStrategy`, `RemnaWaveUser`, `RemnaWaveInternalSquad`, `RemnaWaveNode`, `SubscriptionInfo`, `RemnaWaveAPIError` (1 методов), `RemnaWaveAPI` (8 методов)
|
||||
@@ -731,7 +731,7 @@
|
||||
Функции: `anyio_backend`, `_enable_service`, `test_is_configured`, `test_format_and_signature`
|
||||
- `tests/services/test_pal24_service_adapter.py` — Тесты Pal24Service и вспомогательных функций.
|
||||
Классы: `StubPal24Client` (1 методов)
|
||||
Функции: `_enable_pal24`, `anyio_backend`, `test_parse_postback_success`, `test_parse_postback_missing_fields`, `test_convert_to_kopeks_and_expiration`
|
||||
Функции: `_enable_pal24`, `anyio_backend`, `test_parse_callback_success`, `test_parse_callback_missing_fields`, `test_convert_to_kopeks_and_expiration`
|
||||
- `tests/services/test_payment_service_cryptobot.py` — Тесты сценариев CryptoBot в PaymentService.
|
||||
Классы: `DummySession` (2 методов), `DummyLocalPayment` (1 методов), `StubCryptoBotService` (1 методов)
|
||||
Функции: `anyio_backend`, `_make_service`
|
||||
|
||||
@@ -105,10 +105,10 @@ curl -X POST "http://127.0.0.1:8080/tokens" \
|
||||
| `PUT` | `/settings/{key}` | Обновить значение настройки.
|
||||
| `DELETE` | `/settings/{key}` | Сбросить настройку к значению по умолчанию.
|
||||
| `GET` | `/users` | Список пользователей с фильтрами и пагинацией.
|
||||
| `GET` | `/users/{id}` | Детали пользователя.
|
||||
| `GET` | `/users/{id}` | Детали пользователя. ID может быть как внутренним (user.id), так и Telegram ID (user.telegram_id).
|
||||
| `POST` | `/users` | Создать пользователя (например, для ручной выдачи доступа).
|
||||
| `PATCH` | `/users/{id}` | Обновить профиль пользователя или статус.
|
||||
| `POST` | `/users/{id}/balance` | Корректировка баланса с созданием транзакции.
|
||||
| `PATCH` | `/users/{id}` | Обновить профиль пользователя или статус. ID может быть как внутренним (user.id), так и Telegram ID (user.telegram_id).
|
||||
| `POST` | `/users/{id}/balance` | Корректировка баланса с созданием транзакции. ID может быть как внутренним (user.id), так и Telegram ID (user.telegram_id).
|
||||
| `GET` | `/subscriptions` | Список подписок с фильтрами.
|
||||
| `POST` | `/subscriptions` | Создать триальную или платную подписку.
|
||||
| `POST` | `/subscriptions/{id}/extend` | Продлить подписку на N дней.
|
||||
@@ -260,7 +260,7 @@ curl -X POST "http://127.0.0.1:8080/tokens" \
|
||||
1. **Health-check** — перед авторизацией UI вызывает `GET /health`, чтобы отобразить статус и версию бота.
|
||||
2. **Настройки UI** — подгружает категории через `GET /settings/categories`, далее выводит форму со значениями из `GET /settings`.
|
||||
3. **Статистика дашборда** — `GET /stats/overview` для карточек с показателями.
|
||||
4. **Раздел пользователи** — `GET /users` с поиском (`search`), фильтрами по статусу или промо-группе. Для детальной карточки использовать `GET /users/{id}`.
|
||||
4. **Раздел пользователи** — `GET /users` с поиском (`search`), фильтрами по статусу или промо-группе. Для детальной карточки использовать `GET /users/{id}` (в качестве ID можно использовать как внутренний user.id, так и telegram_id пользователя).
|
||||
5. **Операции с подпиской** — использовать `POST /subscriptions/{id}/...` эндпоинты для продления, выдачи трафика и устройств.
|
||||
6. **Поддержка** — список тикетов (`GET /tickets`), изменение статуса (`POST /tickets/{id}/status`), блокировка ответов (`POST /tickets/{id}/reply-block`).
|
||||
7. **История операций** — `GET /transactions` с фильтрами по пользователю, типу и периоду.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "2b3c1d4e5f6a"
|
||||
down_revision: Union[str, None] = "9f0f2d5a1c7b"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"platega_payments",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("platega_transaction_id", sa.String(length=255), nullable=True, unique=True),
|
||||
sa.Column("correlation_id", sa.String(length=64), nullable=False, unique=True),
|
||||
sa.Column("amount_kopeks", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"currency",
|
||||
sa.String(length=10),
|
||||
nullable=False,
|
||||
server_default="RUB",
|
||||
),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("payment_method_code", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.String(length=50),
|
||||
nullable=False,
|
||||
server_default="PENDING",
|
||||
),
|
||||
sa.Column(
|
||||
"is_paid",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
sa.Column("paid_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("redirect_url", sa.Text(), nullable=True),
|
||||
sa.Column("return_url", sa.Text(), nullable=True),
|
||||
sa.Column("failed_url", sa.Text(), nullable=True),
|
||||
sa.Column("payload", sa.String(length=255), nullable=True),
|
||||
sa.Column("metadata_json", sa.JSON(), nullable=True),
|
||||
sa.Column("callback_payload", sa.JSON(), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("transaction_id", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["transaction_id"], ["transactions.id"], ondelete="SET NULL"),
|
||||
)
|
||||
|
||||
op.create_index("ix_platega_payments_id", "platega_payments", ["id"])
|
||||
op.create_index("ix_platega_payments_user_id", "platega_payments", ["user_id"])
|
||||
op.create_index(
|
||||
"ix_platega_payments_platega_transaction_id",
|
||||
"platega_payments",
|
||||
["platega_transaction_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_platega_payments_correlation_id",
|
||||
"platega_payments",
|
||||
["correlation_id"],
|
||||
unique=True,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_platega_payments_transaction_id",
|
||||
"platega_payments",
|
||||
["transaction_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_platega_payments_transaction_id", table_name="platega_payments")
|
||||
op.drop_index("ix_platega_payments_correlation_id", table_name="platega_payments")
|
||||
op.drop_index(
|
||||
"ix_platega_payments_platega_transaction_id",
|
||||
table_name="platega_payments",
|
||||
)
|
||||
op.drop_index("ix_platega_payments_user_id", table_name="platega_payments")
|
||||
op.drop_index("ix_platega_payments_id", table_name="platega_payments")
|
||||
op.drop_table("platega_payments")
|
||||
@@ -1,703 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#2481cc">
|
||||
<title>Connecting to VPN...</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
:root {
|
||||
--tg-theme-bg-color: #ffffff;
|
||||
--tg-theme-text-color: #000000;
|
||||
--tg-theme-hint-color: #999999;
|
||||
--tg-theme-link-color: #2481cc;
|
||||
--tg-theme-button-color: #2481cc;
|
||||
--tg-theme-button-text-color: #ffffff;
|
||||
--tg-theme-secondary-bg-color: #f0f0f0;
|
||||
|
||||
--primary: var(--tg-theme-button-color);
|
||||
--primary-rgb: 36, 129, 204;
|
||||
--text-primary: var(--tg-theme-text-color);
|
||||
--text-secondary: var(--tg-theme-hint-color);
|
||||
--bg-primary: var(--tg-theme-bg-color);
|
||||
--bg-secondary: var(--tg-theme-secondary-bg-color);
|
||||
--border-color: rgba(0, 0, 0, 0.08);
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
--radius-sm: 8px;
|
||||
--radius: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--info: #3b82f6;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: rgba(30, 41, 59, 0.85);
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #94a3b8;
|
||||
--border-color: rgba(148, 163, 184, 0.25);
|
||||
--shadow-sm: 0 2px 8px rgba(2, 6, 23, 0.3);
|
||||
--shadow-md: 0 4px 16px rgba(2, 6, 23, 0.45);
|
||||
--shadow-lg: 0 8px 32px rgba(2, 6, 23, 0.55);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
line-height: 1.6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
to { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Language Switcher */
|
||||
.top-bar {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.language-select {
|
||||
padding: 8px 32px 8px 12px;
|
||||
border-radius: var(--radius);
|
||||
border: 2px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.language-select:hover {
|
||||
border-color: var(--primary);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* Theme Toggle */
|
||||
.theme-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius);
|
||||
border: 2px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
border-color: var(--primary);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 16px;
|
||||
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
|
||||
border-radius: var(--radius-xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow-md);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.logo-container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||
transform: rotate(45deg);
|
||||
animation: shimmer 3s infinite;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 40px;
|
||||
color: white;
|
||||
z-index: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 15px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Main Card */
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Error State */
|
||||
.card.error {
|
||||
border: 2px solid var(--danger);
|
||||
background: linear-gradient(135deg, var(--bg-secondary), rgba(239, 68, 68, 0.05));
|
||||
}
|
||||
|
||||
.card.error .logo-container {
|
||||
background: linear-gradient(135deg, var(--danger), rgba(239, 68, 68, 0.7));
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid var(--bg-secondary);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Timer */
|
||||
.timer-container {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
margin: 24px 0;
|
||||
border: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.timer-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timer-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.timer-number {
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.7));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.timer-unit {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary), rgba(var(--primary-rgb), 0.6));
|
||||
border-radius: 3px;
|
||||
transition: width 1s linear;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
/* Status Text */
|
||||
.status-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status-description {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 16px 24px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.6s, height 0.6s;
|
||||
}
|
||||
|
||||
.btn:active::before {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.8));
|
||||
color: white;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
/* Hidden */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Mobile Optimizations */
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.timer-number {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Mode Adjustments */
|
||||
:root[data-theme="dark"] .timer-container {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .btn-primary {
|
||||
box-shadow: 0 10px 30px rgba(37, 99, 235, 0.45);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Top Bar -->
|
||||
<div class="top-bar">
|
||||
<select id="languageSelect" class="language-select">
|
||||
<option value="en">🇬🇧 English</option>
|
||||
<option value="ru">🇷🇺 Русский</option>
|
||||
</select>
|
||||
<button class="theme-toggle" id="themeToggle">
|
||||
<svg class="icon-sun" width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 18a6 6 0 100-12 6 6 0 000 12zM12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||
</svg>
|
||||
<svg class="icon-moon" width="20" height="20" fill="currentColor" viewBox="0 0 24 24" style="display: none;">
|
||||
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<div class="header animate-in">
|
||||
<div class="logo-container">
|
||||
<div class="logo-icon" id="appIcon">⚡</div>
|
||||
</div>
|
||||
<div class="logo" data-i18n="app.name">VPN</div>
|
||||
<div class="subtitle" id="appSubtitle" data-i18n="app.connecting">Connecting to VPN...</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Card -->
|
||||
<div class="card animate-in" id="mainCard">
|
||||
<div class="loading" id="loadingState">
|
||||
<div class="spinner"></div>
|
||||
<div class="loading-text" id="statusText" data-i18n="status.connecting">Establishing secure connection...</div>
|
||||
|
||||
<div class="timer-container" id="timerContainer">
|
||||
<div class="timer-label" data-i18n="timer.label">Redirecting in</div>
|
||||
<div class="timer-display">
|
||||
<span class="timer-number" id="timer">10</span>
|
||||
<span class="timer-unit" data-i18n="timer.seconds">seconds</span>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" id="progressFill"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="status-description" data-i18n="status.manual">If nothing happens, click the button below.</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State (hidden by default) -->
|
||||
<div class="error-state hidden" id="errorState">
|
||||
<div class="status-text error-text" data-i18n="error.title">Connection Error</div>
|
||||
<p class="status-description" data-i18n="error.description">The connection link is missing or invalid.</p>
|
||||
</div>
|
||||
|
||||
<!-- Action Button -->
|
||||
<a class="btn btn-primary" id="actionButton" href="#" target="_blank" rel="noopener">
|
||||
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
<span data-i18n="button.connect">Connect to VPN</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// App schemes configuration
|
||||
const appSchemes = [
|
||||
{ scheme: 'happ://', id: 'happ', icon: 'H', name: { en: 'Happ', ru: 'Happ' } },
|
||||
{ scheme: 'flclash://', id: 'flclash', icon: 'F', name: { en: 'FlClash', ru: 'FlClash' } },
|
||||
{ scheme: 'clash://', id: 'clash-meta', icon: 'C', name: { en: 'Clash Meta', ru: 'Clash Meta' } },
|
||||
{ scheme: 'sing-box://', id: 'sing-box', icon: 'S', name: { en: 'Sing-box', ru: 'Sing-box' } },
|
||||
{ scheme: 'v2rayng://', id: 'v2rayng', icon: 'V', name: { en: 'v2rayNG', ru: 'v2rayNG' } },
|
||||
{ scheme: 'sub://', id: 'shadowrocket', icon: 'R', name: { en: 'Shadowrocket', ru: 'Shadowrocket' } },
|
||||
{ scheme: 'hiddify://', id: 'hiddify', icon: 'H', name: { en: 'Hiddify', ru: 'Hiddify' } }
|
||||
];
|
||||
|
||||
// Translations
|
||||
const translations = {
|
||||
en: {
|
||||
'app.name': 'VPN',
|
||||
'app.connecting': 'Connecting to VPN...',
|
||||
'status.connecting': 'Establishing secure connection...',
|
||||
'timer.label': 'Redirecting in',
|
||||
'timer.seconds': 'seconds',
|
||||
'status.manual': 'If nothing happens, click the button below.',
|
||||
'button.connect': 'Connect to VPN',
|
||||
'error.title': 'Connection Error',
|
||||
'error.description': 'The connection link is missing or invalid. Please contact support.'
|
||||
},
|
||||
ru: {
|
||||
'app.name': 'VPN',
|
||||
'app.connecting': 'Подключение к VPN...',
|
||||
'status.connecting': 'Установка безопасного соединения...',
|
||||
'timer.label': 'Перенаправление через',
|
||||
'timer.seconds': 'секунд',
|
||||
'status.manual': 'Если ничего не происходит, нажмите кнопку ниже.',
|
||||
'button.connect': 'Подключиться к VPN',
|
||||
'error.title': 'Ошибка подключения',
|
||||
'error.description': 'Ссылка для подключения отсутствует или недействительна. Обратитесь в поддержку.'
|
||||
}
|
||||
};
|
||||
|
||||
// Global variables
|
||||
let currentLanguage = 'en';
|
||||
let currentTheme = 'light';
|
||||
let timerValue = 10;
|
||||
let timerId = null;
|
||||
let redirectTo = '';
|
||||
let appInfo = null;
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadSettings();
|
||||
|
||||
// Get redirect URL from URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
redirectTo = urlParams.get('redirect_to') || '';
|
||||
|
||||
// Detect app from URL scheme
|
||||
if (redirectTo) {
|
||||
appInfo = appSchemes.find(a => redirectTo.startsWith(a.scheme));
|
||||
}
|
||||
|
||||
updateUI();
|
||||
|
||||
if (redirectTo) {
|
||||
startTimer();
|
||||
} else {
|
||||
showError();
|
||||
}
|
||||
});
|
||||
|
||||
// Load saved settings
|
||||
function loadSettings() {
|
||||
const savedTheme = localStorage.getItem('remnawave-miniapp-theme') || 'light';
|
||||
const savedLang = localStorage.getItem('remnawave-miniapp-language') || getBrowserLanguage();
|
||||
|
||||
currentTheme = savedTheme;
|
||||
currentLanguage = savedLang;
|
||||
|
||||
applyTheme(savedTheme);
|
||||
document.getElementById('languageSelect').value = currentLanguage;
|
||||
}
|
||||
|
||||
// Get browser language
|
||||
function getBrowserLanguage() {
|
||||
const lang = navigator.language || navigator.userLanguage;
|
||||
if (lang.startsWith('ru')) return 'ru';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
// Apply theme
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
updateThemeToggle();
|
||||
}
|
||||
|
||||
// Update theme toggle icon
|
||||
function updateThemeToggle() {
|
||||
const sunIcon = document.querySelector('.icon-sun');
|
||||
const moonIcon = document.querySelector('.icon-moon');
|
||||
|
||||
if (currentTheme === 'dark') {
|
||||
sunIcon.style.display = 'none';
|
||||
moonIcon.style.display = 'block';
|
||||
} else {
|
||||
sunIcon.style.display = 'block';
|
||||
moonIcon.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle theme
|
||||
document.getElementById('themeToggle').addEventListener('click', function() {
|
||||
currentTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
localStorage.setItem('remnawave-miniapp-theme', currentTheme);
|
||||
applyTheme(currentTheme);
|
||||
});
|
||||
|
||||
// Change language
|
||||
document.getElementById('languageSelect').addEventListener('change', function(e) {
|
||||
currentLanguage = e.target.value;
|
||||
localStorage.setItem('remnawave-miniapp-language', currentLanguage);
|
||||
updateUI();
|
||||
});
|
||||
|
||||
// Get translation
|
||||
function t(key) {
|
||||
return translations[currentLanguage]?.[key] || translations.en[key] || key;
|
||||
}
|
||||
|
||||
// Update UI with translations
|
||||
function updateUI() {
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
el.textContent = t(key);
|
||||
});
|
||||
|
||||
// Update app-specific info
|
||||
if (appInfo && redirectTo) {
|
||||
const appName = appInfo.name[currentLanguage] || appInfo.name.en;
|
||||
document.getElementById('appIcon').textContent = appInfo.icon;
|
||||
document.getElementById('appSubtitle').textContent = `${t('app.connecting').replace('VPN', appName)}`;
|
||||
|
||||
const statusText = document.getElementById('statusText');
|
||||
if (statusText) {
|
||||
statusText.textContent = `${t('status.connecting').replace('...', '')} ${appName}...`;
|
||||
}
|
||||
}
|
||||
|
||||
// Update action button
|
||||
const actionButton = document.getElementById('actionButton');
|
||||
if (actionButton && redirectTo) {
|
||||
actionButton.href = redirectTo;
|
||||
}
|
||||
}
|
||||
|
||||
// Start countdown timer
|
||||
function startTimer() {
|
||||
if (!redirectTo || timerId) return;
|
||||
|
||||
const timerEl = document.getElementById('timer');
|
||||
const progressEl = document.getElementById('progressFill');
|
||||
const totalTime = 10;
|
||||
|
||||
timerEl.textContent = timerValue;
|
||||
|
||||
timerId = setInterval(() => {
|
||||
timerValue--;
|
||||
timerEl.textContent = timerValue;
|
||||
|
||||
// Update progress bar
|
||||
const progress = ((totalTime - timerValue) / totalTime) * 100;
|
||||
progressEl.style.width = `${100 - progress}%`;
|
||||
|
||||
if (timerValue <= 0) {
|
||||
clearInterval(timerId);
|
||||
window.location.href = redirectTo;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Show error state
|
||||
function showError() {
|
||||
document.getElementById('loadingState').classList.add('hidden');
|
||||
document.getElementById('errorState').classList.remove('hidden');
|
||||
document.getElementById('actionButton').classList.add('hidden');
|
||||
document.getElementById('mainCard').classList.add('error');
|
||||
document.getElementById('appIcon').textContent = '!';
|
||||
|
||||
// Update subtitle
|
||||
document.getElementById('appSubtitle').textContent = t('error.title');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -14,6 +14,7 @@ redis==5.0.1
|
||||
PyYAML==6.0.2
|
||||
fastapi==0.115.6
|
||||
uvicorn==0.32.1
|
||||
python-multipart==0.0.9
|
||||
|
||||
# YooKassa SDK
|
||||
yookassa==3.7.0
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from app.config import settings
|
||||
from app.external.yookassa_webhook import (
|
||||
create_yookassa_webhook_app,
|
||||
resolve_yookassa_ip,
|
||||
)
|
||||
|
||||
|
||||
ALLOWED_IP = "185.71.76.10"
|
||||
|
||||
|
||||
class DummyDB:
|
||||
async def close(self) -> None: # pragma: no cover - simple stub
|
||||
pass
|
||||
@pytest.fixture(autouse=True)
|
||||
def configure_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
||||
monkeypatch.setattr(settings, "YOOKASSA_SHOP_ID", "shop", raising=False)
|
||||
monkeypatch.setattr(settings, "YOOKASSA_SECRET_KEY", "key", raising=False)
|
||||
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_PATH", "/yookassa-webhook", raising=False)
|
||||
monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "", raising=False)
|
||||
|
||||
|
||||
def _build_headers(**overrides: str) -> dict[str, str]:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Forwarded-For": ALLOWED_IP,
|
||||
"Cf-Connecting-Ip": ALLOWED_IP,
|
||||
}
|
||||
headers.update(overrides)
|
||||
return headers
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("remote", "expected"),
|
||||
(
|
||||
("185.71.76.10", "185.71.76.10"),
|
||||
("8.8.8.8", "8.8.8.8"),
|
||||
("10.0.0.5", "185.71.76.10"),
|
||||
(None, "185.71.76.10"),
|
||||
),
|
||||
)
|
||||
def test_resolve_yookassa_ip_trust_rules(remote: str | None, expected: str) -> None:
|
||||
candidates = [ALLOWED_IP]
|
||||
ip_object = resolve_yookassa_ip(candidates, remote=remote)
|
||||
|
||||
assert ip_object is not None
|
||||
assert str(ip_object) == expected
|
||||
|
||||
|
||||
def test_resolve_yookassa_ip_prefers_last_forwarded_candidate() -> None:
|
||||
candidates = ["185.71.76.10", "8.8.8.8"]
|
||||
|
||||
ip_object = resolve_yookassa_ip(candidates, remote="10.0.0.5")
|
||||
|
||||
assert ip_object is not None
|
||||
assert str(ip_object) == "8.8.8.8"
|
||||
|
||||
|
||||
def test_resolve_yookassa_ip_accepts_allowed_last_forwarded_candidate() -> None:
|
||||
candidates = ["8.8.8.8", ALLOWED_IP]
|
||||
|
||||
ip_object = resolve_yookassa_ip(candidates, remote="10.0.0.5")
|
||||
|
||||
assert ip_object is not None
|
||||
assert str(ip_object) == ALLOWED_IP
|
||||
|
||||
|
||||
def test_resolve_yookassa_ip_skips_trusted_proxy_hops(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "203.0.113.0/24", raising=False)
|
||||
|
||||
candidates = [ALLOWED_IP, "203.0.113.10"]
|
||||
|
||||
ip_object = resolve_yookassa_ip(candidates, remote="10.0.0.5")
|
||||
|
||||
assert ip_object is not None
|
||||
assert str(ip_object) == ALLOWED_IP
|
||||
|
||||
|
||||
def test_resolve_yookassa_ip_trusted_public_proxy(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "198.51.100.0/24", raising=False)
|
||||
|
||||
candidates = [ALLOWED_IP, "198.51.100.10"]
|
||||
|
||||
ip_object = resolve_yookassa_ip(candidates, remote="198.51.100.20")
|
||||
|
||||
assert ip_object is not None
|
||||
assert str(ip_object) == ALLOWED_IP
|
||||
|
||||
|
||||
def test_resolve_yookassa_ip_returns_none_when_no_candidates() -> None:
|
||||
assert resolve_yookassa_ip([], remote=None) is None
|
||||
|
||||
|
||||
async def _post_webhook(client: TestClient, payload: dict, **headers: str) -> web.Response:
|
||||
body = json.dumps(payload, ensure_ascii=False)
|
||||
return await client.post(
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
data=body.encode("utf-8"),
|
||||
headers=_build_headers(**headers),
|
||||
)
|
||||
|
||||
|
||||
def _patch_get_db(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_get_db():
|
||||
yield DummyDB()
|
||||
|
||||
monkeypatch.setattr("app.external.yookassa_webhook.get_db", fake_get_db)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_get_db(monkeypatch)
|
||||
|
||||
process_mock = AsyncMock(return_value=True)
|
||||
service = SimpleNamespace(process_yookassa_webhook=process_mock)
|
||||
|
||||
app = create_yookassa_webhook_app(service)
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
payload = {"event": "payment.succeeded"}
|
||||
body = json.dumps(payload, ensure_ascii=False)
|
||||
response = await client.post(
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
data=body.encode("utf-8"),
|
||||
headers=_build_headers(),
|
||||
)
|
||||
status = response.status
|
||||
text = await response.text()
|
||||
|
||||
assert status == 200
|
||||
assert text == "OK"
|
||||
process_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_webhook_trusts_cf_connecting_ip(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_get_db(monkeypatch)
|
||||
|
||||
process_mock = AsyncMock(return_value=True)
|
||||
service = SimpleNamespace(process_yookassa_webhook=process_mock)
|
||||
|
||||
app = create_yookassa_webhook_app(service)
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
payload = {"event": "payment.succeeded"}
|
||||
body = json.dumps(payload, ensure_ascii=False)
|
||||
headers = _build_headers()
|
||||
headers.pop("X-Forwarded-For")
|
||||
response = await client.post(
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
data=body.encode("utf-8"),
|
||||
headers=headers,
|
||||
)
|
||||
status = response.status
|
||||
text = await response.text()
|
||||
|
||||
assert status == 200
|
||||
assert text == "OK"
|
||||
process_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_webhook_with_optional_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_get_db(monkeypatch)
|
||||
|
||||
process_mock = AsyncMock(return_value=True)
|
||||
service = SimpleNamespace(process_yookassa_webhook=process_mock)
|
||||
|
||||
app = create_yookassa_webhook_app(service)
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
payload = {"event": "payment.succeeded"}
|
||||
body = json.dumps(payload, ensure_ascii=False)
|
||||
response = await client.post(
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
data=body.encode("utf-8"),
|
||||
headers=_build_headers(Signature="test-signature"),
|
||||
)
|
||||
status = response.status
|
||||
text = await response.text()
|
||||
|
||||
assert status == 200
|
||||
assert text == "OK"
|
||||
process_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_webhook_accepts_canceled_event(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_get_db(monkeypatch)
|
||||
|
||||
process_mock = AsyncMock(return_value=True)
|
||||
service = SimpleNamespace(process_yookassa_webhook=process_mock)
|
||||
|
||||
app = create_yookassa_webhook_app(service)
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
payload = {"event": "payment.canceled", "object": {"id": "yk_1"}}
|
||||
response = await client.post(
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers=_build_headers(),
|
||||
)
|
||||
|
||||
status = response.status
|
||||
|
||||
assert status == 200
|
||||
process_mock.assert_awaited_once()
|
||||
@@ -110,7 +110,7 @@ async def test_get_bill_payments(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert result == {"id": "BILL42", "payments": [{"id": "PAY-1"}]}
|
||||
|
||||
|
||||
def test_parse_postback_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_parse_callback_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_pal24(monkeypatch)
|
||||
sig = Pal24Client.calculate_signature("100.00", "INV1", api_token="sigsecret")
|
||||
payload = {
|
||||
@@ -119,14 +119,14 @@ def test_parse_postback_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"Status": "SUCCESS",
|
||||
"SignatureValue": sig,
|
||||
}
|
||||
result = Pal24Service.parse_postback(payload)
|
||||
result = Pal24Service.parse_callback(payload)
|
||||
assert result["InvId"] == "INV1"
|
||||
|
||||
|
||||
def test_parse_postback_missing_fields(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_parse_callback_missing_fields(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_pal24(monkeypatch)
|
||||
with pytest.raises(Pal24APIError):
|
||||
Pal24Service.parse_postback({"InvId": "1"})
|
||||
Pal24Service.parse_callback({"InvId": "1"})
|
||||
|
||||
|
||||
def test_convert_to_kopeks_and_expiration() -> None:
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Тесты для сценариев Platega в PaymentService."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
import app.services.payment_service as payment_service_module # noqa: E402
|
||||
from app.config import settings # noqa: E402
|
||||
from app.services.payment_service import PaymentService # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
class DummySession:
|
||||
async def commit(self) -> None: # pragma: no cover - no custom logic required
|
||||
return None
|
||||
|
||||
async def refresh(self, *_: Any) -> None: # pragma: no cover - no custom logic required
|
||||
return None
|
||||
|
||||
|
||||
class DummyLocalPayment:
|
||||
def __init__(self, payment_id: int = 101) -> None:
|
||||
self.id = payment_id
|
||||
self.created_at = datetime.utcnow()
|
||||
|
||||
|
||||
class StubPlategaService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
configured: bool = True,
|
||||
response: Optional[Dict[str, Any]] = None,
|
||||
transaction_payload: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
self.is_configured = configured
|
||||
self.response = response or {
|
||||
"transactionId": "trx-001",
|
||||
"redirect": "https://platega.example/pay",
|
||||
"status": "PENDING",
|
||||
"expiresIn": 900,
|
||||
}
|
||||
self.transaction_payload = transaction_payload
|
||||
self.calls: list[Dict[str, Any]] = []
|
||||
self.raise_error: Optional[Exception] = None
|
||||
|
||||
async def create_payment(self, **kwargs: Any) -> Optional[Dict[str, Any]]:
|
||||
self.calls.append(kwargs)
|
||||
if self.raise_error:
|
||||
raise self.raise_error
|
||||
return self.response
|
||||
|
||||
async def get_transaction(self, transaction_id: str) -> Optional[Dict[str, Any]]:
|
||||
self.calls.append({"transaction_lookup": transaction_id})
|
||||
return self.transaction_payload
|
||||
|
||||
|
||||
def _make_service(stub: Optional[StubPlategaService]) -> PaymentService:
|
||||
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
|
||||
service.bot = None
|
||||
service.platega_service = stub
|
||||
service.yookassa_service = None
|
||||
service.cryptobot_service = None
|
||||
service.heleket_service = None
|
||||
service.mulenpay_service = None
|
||||
service.pal24_service = None
|
||||
service.stars_service = None
|
||||
service.wata_service = None
|
||||
return service
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_platega_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
stub = StubPlategaService()
|
||||
service = _make_service(stub)
|
||||
db = DummySession()
|
||||
|
||||
captured_args: Dict[str, Any] = {}
|
||||
|
||||
async def fake_create_platega_payment(*args: Any, **kwargs: Any) -> DummyLocalPayment:
|
||||
if args:
|
||||
captured_args["db_arg"] = args[0]
|
||||
captured_args.update(kwargs)
|
||||
return DummyLocalPayment(payment_id=777)
|
||||
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"create_platega_payment",
|
||||
fake_create_platega_payment,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(settings, "PLATEGA_MIN_AMOUNT_KOPEKS", 10_000, raising=False)
|
||||
monkeypatch.setattr(settings, "PLATEGA_MAX_AMOUNT_KOPEKS", 500_000, raising=False)
|
||||
monkeypatch.setattr(settings, "PLATEGA_CURRENCY", "RUB", raising=False)
|
||||
monkeypatch.setattr(settings, "PLATEGA_RETURN_URL", "https://return", raising=False)
|
||||
monkeypatch.setattr(settings, "PLATEGA_FAILED_URL", "https://failed", raising=False)
|
||||
|
||||
result = await service.create_platega_payment(
|
||||
db=db,
|
||||
user_id=42,
|
||||
amount_kopeks=50_000,
|
||||
description="Пополнение счёта",
|
||||
language="ru",
|
||||
payment_method_code=10,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["local_payment_id"] == 777
|
||||
assert result["transaction_id"] == "trx-001"
|
||||
assert result["redirect_url"] == "https://platega.example/pay"
|
||||
assert result["status"] == "PENDING"
|
||||
assert "correlation_id" in result and len(result["correlation_id"]) == 32
|
||||
assert captured_args["user_id"] == 42
|
||||
assert captured_args["amount_kopeks"] == 50_000
|
||||
assert captured_args["payment_method_code"] == 10
|
||||
assert captured_args["metadata"]["selected_method"] == 10
|
||||
assert stub.calls and stub.calls[0]["payment_method"] == 10
|
||||
assert stub.calls[0]["amount"] == pytest.approx(500.0)
|
||||
assert stub.calls[0]["currency"] == "RUB"
|
||||
assert captured_args["metadata"]["language"] == "ru"
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_platega_payment_respects_limits_and_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
stub = StubPlategaService()
|
||||
service = _make_service(stub)
|
||||
db = DummySession()
|
||||
|
||||
monkeypatch.setattr(settings, "PLATEGA_MIN_AMOUNT_KOPEKS", 20_000, raising=False)
|
||||
monkeypatch.setattr(settings, "PLATEGA_MAX_AMOUNT_KOPEKS", 40_000, raising=False)
|
||||
|
||||
too_low = await service.create_platega_payment(
|
||||
db=db,
|
||||
user_id=1,
|
||||
amount_kopeks=10_000,
|
||||
description="Пополнение",
|
||||
language="ru",
|
||||
payment_method_code=2,
|
||||
)
|
||||
assert too_low is None
|
||||
|
||||
too_high = await service.create_platega_payment(
|
||||
db=db,
|
||||
user_id=1,
|
||||
amount_kopeks=100_000,
|
||||
description="Пополнение",
|
||||
language="ru",
|
||||
payment_method_code=2,
|
||||
)
|
||||
assert too_high is None
|
||||
|
||||
not_configured_service = _make_service(StubPlategaService(configured=False))
|
||||
result = await not_configured_service.create_platega_payment(
|
||||
db=db,
|
||||
user_id=1,
|
||||
amount_kopeks=30_000,
|
||||
description="Пополнение",
|
||||
language="ru",
|
||||
payment_method_code=2,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_platega_payment_handles_service_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
stub = StubPlategaService()
|
||||
stub.raise_error = RuntimeError("network down")
|
||||
service = _make_service(stub)
|
||||
db = DummySession()
|
||||
|
||||
async def fake_create_platega_payment(*_: Any, **__: Any) -> DummyLocalPayment:
|
||||
pytest.fail("local payment must not be created when Platega call fails")
|
||||
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"create_platega_payment",
|
||||
fake_create_platega_payment,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(settings, "PLATEGA_MIN_AMOUNT_KOPEKS", 1_000, raising=False)
|
||||
monkeypatch.setattr(settings, "PLATEGA_MAX_AMOUNT_KOPEKS", 1_000_000, raising=False)
|
||||
|
||||
result = await service.create_platega_payment(
|
||||
db=db,
|
||||
user_id=5,
|
||||
amount_kopeks=25_000,
|
||||
description="Пополнение",
|
||||
language="ru",
|
||||
payment_method_code=13,
|
||||
)
|
||||
assert result is None
|
||||
assert stub.calls and "payment_method" in stub.calls[0]
|
||||
|
||||
|
||||
def test_get_platega_active_methods_parses_and_filters(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
settings,
|
||||
"PLATEGA_ACTIVE_METHODS",
|
||||
" 2,10, 11 ;12,13,13,invalid ",
|
||||
raising=False,
|
||||
)
|
||||
|
||||
methods = settings.get_platega_active_methods()
|
||||
|
||||
assert methods == [2, 10, 11, 12, 13]
|
||||
|
||||
|
||||
def test_get_platega_active_methods_returns_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "PLATEGA_ACTIVE_METHODS", "", raising=False)
|
||||
|
||||
methods = settings.get_platega_active_methods()
|
||||
|
||||
assert methods == [2]
|
||||
|
||||
|
||||
def test_platega_method_display_helpers() -> None:
|
||||
assert settings.get_platega_method_display_name(10) == "Банковские карты (RUB)"
|
||||
assert settings.get_platega_method_display_title(10) == "💳 Карты (RUB)"
|
||||
assert settings.get_platega_method_display_name(999) == "Метод 999"
|
||||
assert settings.get_platega_method_display_title(999) == "Platega 999"
|
||||
@@ -17,6 +17,7 @@ if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
import app.services.payment_service as payment_service_module # noqa: E402
|
||||
import app.services.payment.cryptobot as cryptobot_module # noqa: E402
|
||||
from app.services.payment_service import PaymentService # noqa: E402
|
||||
from app.database.models import PaymentMethod # noqa: E402
|
||||
from app.config import settings # noqa: E402
|
||||
@@ -283,13 +284,21 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch
|
||||
monkeypatch.setitem(sys.modules, "app.database.crud.cryptobot", fake_cryptobot_module)
|
||||
|
||||
transactions: list[Dict[str, Any]] = []
|
||||
created_transaction: SimpleNamespace | None = None
|
||||
|
||||
async def fake_create_transaction(db, **kwargs):
|
||||
nonlocal created_transaction
|
||||
transactions.append(kwargs)
|
||||
return SimpleNamespace(id=888, **kwargs)
|
||||
created_transaction = SimpleNamespace(id=888, **kwargs)
|
||||
return created_transaction
|
||||
|
||||
fake_transaction_module = ModuleType("app.database.crud.transaction")
|
||||
fake_transaction_module.create_transaction = fake_create_transaction
|
||||
|
||||
async def fake_get_transaction_by_id(db, transaction_id):
|
||||
return created_transaction
|
||||
|
||||
fake_transaction_module.get_transaction_by_id = fake_get_transaction_by_id
|
||||
monkeypatch.setitem(sys.modules, "app.database.crud.transaction", fake_transaction_module)
|
||||
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
|
||||
|
||||
@@ -310,6 +319,10 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch
|
||||
|
||||
monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user_crypto)
|
||||
|
||||
fake_user_module = ModuleType("app.database.crud.user")
|
||||
fake_user_module.get_user_by_id = fake_get_user_crypto
|
||||
monkeypatch.setitem(sys.modules, "app.database.crud.user", fake_user_module)
|
||||
|
||||
referral_crypto = SimpleNamespace(process_referral_topup=AsyncMock())
|
||||
monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_crypto)
|
||||
|
||||
@@ -323,6 +336,18 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch
|
||||
admin_calls.append((args, kwargs))
|
||||
|
||||
monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService2(bot)))
|
||||
|
||||
class DummyAsyncSession:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def rollback(self): # pragma: no cover - defensive stub
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(cryptobot_module, "AsyncSessionLocal", lambda: DummyAsyncSession())
|
||||
monkeypatch.setattr(payment_service_module.currency_converter, "usd_to_rub", AsyncMock(return_value=140.0))
|
||||
monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False)
|
||||
service.build_topup_success_keyboard = AsyncMock(return_value=None)
|
||||
@@ -584,6 +609,171 @@ async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch)
|
||||
assert admin_calls
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_yookassa_webhook_uses_remote_status(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
bot = DummyBot()
|
||||
service = _make_service(bot)
|
||||
fake_session = FakeSession()
|
||||
payment = SimpleNamespace(
|
||||
yookassa_payment_id="yk_789",
|
||||
user_id=42,
|
||||
amount_kopeks=20000,
|
||||
transaction_id=None,
|
||||
status="pending",
|
||||
is_paid=False,
|
||||
)
|
||||
|
||||
async def fake_get_payment(db, payment_id):
|
||||
return payment
|
||||
|
||||
async def fake_update(db, payment_id, status, is_paid, is_captured, captured_at, payment_method_type):
|
||||
payment.status = status
|
||||
payment.is_paid = is_paid
|
||||
payment.captured_at = captured_at
|
||||
payment.payment_method_type = payment_method_type
|
||||
return payment
|
||||
|
||||
async def fake_link(db, payment_id, transaction_id):
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
yk_module = ModuleType("app.database.crud.yookassa")
|
||||
yk_module.get_yookassa_payment_by_id = fake_get_payment
|
||||
yk_module.update_yookassa_payment_status = fake_update
|
||||
yk_module.link_yookassa_payment_to_transaction = fake_link
|
||||
monkeypatch.setitem(sys.modules, "app.database.crud.yookassa", yk_module)
|
||||
|
||||
transactions: list[Dict[str, Any]] = []
|
||||
|
||||
async def fake_create_transaction(db, **kwargs):
|
||||
transactions.append(kwargs)
|
||||
return SimpleNamespace(id=555, **kwargs)
|
||||
|
||||
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
|
||||
|
||||
user = SimpleNamespace(
|
||||
id=42,
|
||||
telegram_id=4200,
|
||||
balance_kopeks=0,
|
||||
has_made_first_topup=False,
|
||||
promo_group=None,
|
||||
subscription=None,
|
||||
referred_by_id=None,
|
||||
referrer=None,
|
||||
)
|
||||
|
||||
async def fake_get_user(db, user_id):
|
||||
return user
|
||||
|
||||
monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user)
|
||||
monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False)
|
||||
|
||||
referral_mock = SimpleNamespace(process_referral_topup=AsyncMock())
|
||||
monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_mock)
|
||||
|
||||
admin_calls: list[Any] = []
|
||||
|
||||
class DummyAdminService:
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
async def send_balance_topup_notification(self, *args, **kwargs):
|
||||
admin_calls.append((args, kwargs))
|
||||
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"app.services.admin_notification_service",
|
||||
SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)),
|
||||
)
|
||||
|
||||
service.build_topup_success_keyboard = AsyncMock(return_value=None)
|
||||
|
||||
remote_payload = {
|
||||
"id": "yk_789",
|
||||
"status": "succeeded",
|
||||
"paid": True,
|
||||
"amount_value": 200.0,
|
||||
"amount_currency": "rub",
|
||||
"payment_method_type": "bank_card",
|
||||
"refundable": True,
|
||||
}
|
||||
|
||||
get_info_mock = AsyncMock(return_value=remote_payload)
|
||||
service.yookassa_service = SimpleNamespace(get_payment_info=get_info_mock)
|
||||
|
||||
payload = {
|
||||
"object": {
|
||||
"id": "yk_789",
|
||||
"status": "pending",
|
||||
"paid": False,
|
||||
}
|
||||
}
|
||||
|
||||
result = await service.process_yookassa_webhook(fake_session, payload)
|
||||
|
||||
assert result is True
|
||||
assert payment.status == "succeeded"
|
||||
assert payment.is_paid is True
|
||||
assert transactions and transactions[0]["amount_kopeks"] == 20000
|
||||
assert payment.transaction_id == 555
|
||||
get_info_mock.assert_awaited_once_with("yk_789")
|
||||
assert admin_calls
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_yookassa_webhook_handles_cancellation(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
bot = DummyBot()
|
||||
service = _make_service(bot)
|
||||
fake_session = FakeSession()
|
||||
payment = SimpleNamespace(
|
||||
yookassa_payment_id="yk_cancel",
|
||||
user_id=77,
|
||||
amount_kopeks=5000,
|
||||
transaction_id=None,
|
||||
status="pending",
|
||||
is_paid=False,
|
||||
captured_at=None,
|
||||
payment_method_type=None,
|
||||
)
|
||||
|
||||
async def fake_get_payment(db, payment_id):
|
||||
return payment
|
||||
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_yookassa_payment_by_id",
|
||||
fake_get_payment,
|
||||
)
|
||||
|
||||
get_info_mock = AsyncMock(
|
||||
return_value={
|
||||
"id": "yk_cancel",
|
||||
"status": "canceled",
|
||||
"paid": False,
|
||||
"amount_value": 50.0,
|
||||
"amount_currency": "RUB",
|
||||
}
|
||||
)
|
||||
service.yookassa_service = SimpleNamespace(get_payment_info=get_info_mock)
|
||||
|
||||
payload = {
|
||||
"object": {
|
||||
"id": "yk_cancel",
|
||||
"status": "pending",
|
||||
"paid": False,
|
||||
}
|
||||
}
|
||||
|
||||
result = await service.process_yookassa_webhook(fake_session, payload)
|
||||
|
||||
assert result is True
|
||||
assert payment.status == "canceled"
|
||||
assert payment.is_paid is False
|
||||
assert fake_session.commits == 1
|
||||
assert fake_session.refreshed and fake_session.refreshed[0] is payment
|
||||
assert bot.sent_messages == []
|
||||
get_info_mock.assert_awaited_once_with("yk_cancel")
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_yookassa_webhook_restores_missing_payment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -756,7 +946,7 @@ async def test_process_yookassa_webhook_missing_id(monkeypatch: pytest.MonkeyPat
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def test_process_pal24_callback_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
bot = DummyBot()
|
||||
service = _make_service(bot)
|
||||
service.pal24_service = SimpleNamespace(is_configured=True)
|
||||
@@ -879,7 +1069,7 @@ async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -
|
||||
"TrsId": "trs-1",
|
||||
}
|
||||
|
||||
result = await service.process_pal24_postback(fake_session, payload)
|
||||
result = await service.process_pal24_callback(fake_session, payload)
|
||||
|
||||
assert result is True
|
||||
assert payment.transaction_id == 654
|
||||
@@ -1044,7 +1234,7 @@ async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.Monkey
|
||||
assert transactions and transactions[0]["user_id"] == 91
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_pal24_postback_payment_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def test_process_pal24_callback_payment_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
bot = DummyBot()
|
||||
service = _make_service(bot)
|
||||
service.pal24_service = SimpleNamespace(is_configured=True)
|
||||
@@ -1071,5 +1261,5 @@ async def test_process_pal24_postback_payment_not_found(monkeypatch: pytest.Monk
|
||||
"Status": "SUCCESS",
|
||||
}
|
||||
|
||||
result = await service.process_pal24_postback(db, payload)
|
||||
result = await service.process_pal24_callback(db, payload)
|
||||
assert result is False
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.handlers.subscription.purchase import activate_trial
|
||||
from app.services.trial_activation_service import TrialPaymentInsufficientFunds
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trial_callback_query():
|
||||
callback = AsyncMock(spec=CallbackQuery)
|
||||
callback.message = AsyncMock(spec=Message)
|
||||
callback.message.edit_text = AsyncMock()
|
||||
callback.answer = AsyncMock()
|
||||
return callback
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trial_user():
|
||||
user = MagicMock(spec=User)
|
||||
user.subscription = None
|
||||
user.has_had_paid_subscription = False
|
||||
user.language = "ru"
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trial_db():
|
||||
return AsyncMock(spec=AsyncSession)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activate_trial_uses_trial_price_for_topup_redirect(
|
||||
trial_callback_query,
|
||||
trial_user,
|
||||
trial_db,
|
||||
):
|
||||
error = TrialPaymentInsufficientFunds(required_amount=15900, balance_amount=100)
|
||||
|
||||
mock_keyboard = InlineKeyboardMarkup(inline_keyboard=[])
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.handlers.subscription.purchase.preview_trial_activation_charge",
|
||||
side_effect=error,
|
||||
),
|
||||
patch(
|
||||
"app.handlers.subscription.purchase.get_texts",
|
||||
return_value=MagicMock(
|
||||
t=lambda key, default, **kwargs: default,
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"app.handlers.subscription.purchase.get_insufficient_balance_keyboard",
|
||||
return_value=mock_keyboard,
|
||||
) as insufficient_keyboard,
|
||||
):
|
||||
await activate_trial(trial_callback_query, trial_user, trial_db)
|
||||
|
||||
insufficient_keyboard.assert_called_once_with(
|
||||
trial_user.language,
|
||||
amount_kopeks=error.required_amount,
|
||||
)
|
||||
trial_callback_query.message.edit_text.assert_called_once()
|
||||
trial_callback_query.answer.assert_called_once()
|
||||
@@ -1,3 +1,4 @@
|
||||
import base64
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
@@ -11,8 +12,6 @@ from app.webserver.payments import create_payment_router
|
||||
|
||||
class DummyBot:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "TRIBUTE_ENABLED", False, raising=False)
|
||||
@@ -25,9 +24,9 @@ def reset_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", None, raising=False)
|
||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", False, raising=False)
|
||||
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_PATH", "/yookassa", raising=False)
|
||||
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_SECRET", None, raising=False)
|
||||
monkeypatch.setattr(settings, "YOOKASSA_SHOP_ID", "shop", raising=False)
|
||||
monkeypatch.setattr(settings, "YOOKASSA_SECRET_KEY", "key", raising=False)
|
||||
monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "", raising=False)
|
||||
monkeypatch.setattr(settings, "WEBHOOK_URL", "http://test", raising=False)
|
||||
|
||||
|
||||
@@ -38,7 +37,12 @@ def _get_route(router, path: str, method: str = "POST"):
|
||||
raise AssertionError(f"Route {path} with method {method} not found")
|
||||
|
||||
|
||||
def _build_request(path: str, body: bytes, headers: dict[str, str]) -> Request:
|
||||
def _build_request(
|
||||
path: str,
|
||||
body: bytes,
|
||||
headers: dict[str, str],
|
||||
client_ip: str | None = "185.71.76.1",
|
||||
) -> Request:
|
||||
scope = {
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0"},
|
||||
@@ -47,6 +51,9 @@ def _build_request(path: str, body: bytes, headers: dict[str, str]) -> Request:
|
||||
"headers": [(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in headers.items()],
|
||||
}
|
||||
|
||||
if client_ip is not None:
|
||||
scope["client"] = (client_ip, 12345)
|
||||
|
||||
async def receive() -> dict:
|
||||
return {"type": "http.request", "body": body, "more_body": False}
|
||||
|
||||
@@ -92,38 +99,12 @@ async def test_tribute_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_yookassa_invalid_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def test_yookassa_unknown_ip(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
||||
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_SECRET", "secret", raising=False)
|
||||
|
||||
class StubHandler:
|
||||
@staticmethod
|
||||
def verify_webhook_signature(body: str, signature: str, secret: str) -> bool: # noqa: D401
|
||||
return False
|
||||
service = SimpleNamespace(process_yookassa_webhook=AsyncMock())
|
||||
|
||||
monkeypatch.setattr("app.webserver.payments.YooKassaWebhookHandler", StubHandler)
|
||||
|
||||
router = create_payment_router(DummyBot(), SimpleNamespace())
|
||||
assert router is not None
|
||||
|
||||
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
|
||||
request = _build_request(
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
|
||||
headers={"Signature": "bad"},
|
||||
)
|
||||
|
||||
response = await route.endpoint(request)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_yookassa_missing_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
||||
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_SECRET", "secret", raising=False)
|
||||
|
||||
router = create_payment_router(DummyBot(), SimpleNamespace())
|
||||
router = create_payment_router(DummyBot(), service)
|
||||
assert router is not None
|
||||
|
||||
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
|
||||
@@ -131,13 +112,346 @@ async def test_yookassa_missing_signature(monkeypatch: pytest.MonkeyPatch) -> No
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
|
||||
headers={},
|
||||
client_ip=None,
|
||||
)
|
||||
|
||||
response = await route.endpoint(request)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.status_code == 403
|
||||
payload = json.loads(response.body.decode("utf-8"))
|
||||
assert payload["reason"] == "missing_signature"
|
||||
assert payload["reason"] == "unknown_ip"
|
||||
service.process_yookassa_webhook.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_yookassa_forbidden_ip(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
||||
|
||||
service = SimpleNamespace(process_yookassa_webhook=AsyncMock())
|
||||
|
||||
router = create_payment_router(DummyBot(), service)
|
||||
assert router is not None
|
||||
|
||||
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
|
||||
request = _build_request(
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
|
||||
headers={},
|
||||
client_ip="8.8.8.8",
|
||||
)
|
||||
|
||||
response = await route.endpoint(request)
|
||||
|
||||
assert response.status_code == 403
|
||||
payload = json.loads(response.body.decode("utf-8"))
|
||||
assert payload["reason"] == "forbidden_ip"
|
||||
assert payload["ip"] == "8.8.8.8"
|
||||
service.process_yookassa_webhook.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_yookassa_forbidden_ip_ignores_spoofed_header(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
||||
|
||||
service = SimpleNamespace(process_yookassa_webhook=AsyncMock())
|
||||
|
||||
router = create_payment_router(DummyBot(), service)
|
||||
assert router is not None
|
||||
|
||||
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
|
||||
request = _build_request(
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
|
||||
headers={"X-Forwarded-For": "185.71.76.10"},
|
||||
client_ip="8.8.8.8",
|
||||
)
|
||||
|
||||
response = await route.endpoint(request)
|
||||
|
||||
assert response.status_code == 403
|
||||
payload = json.loads(response.body.decode("utf-8"))
|
||||
assert payload["reason"] == "forbidden_ip"
|
||||
assert payload["ip"] == "8.8.8.8"
|
||||
service.process_yookassa_webhook.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_yookassa_forbidden_ip_ignores_spoofed_forwarded_chain(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
||||
|
||||
service = SimpleNamespace(process_yookassa_webhook=AsyncMock())
|
||||
|
||||
router = create_payment_router(DummyBot(), service)
|
||||
assert router is not None
|
||||
|
||||
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
|
||||
request = _build_request(
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
|
||||
headers={"X-Forwarded-For": "185.71.76.10, 8.8.8.8"},
|
||||
client_ip="10.0.0.5",
|
||||
)
|
||||
|
||||
response = await route.endpoint(request)
|
||||
|
||||
assert response.status_code == 403
|
||||
payload = json.loads(response.body.decode("utf-8"))
|
||||
assert payload["reason"] == "forbidden_ip"
|
||||
assert payload["ip"] == "8.8.8.8"
|
||||
service.process_yookassa_webhook.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_yookassa_allowed_ip(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
||||
|
||||
async def fake_get_db():
|
||||
yield SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db)
|
||||
|
||||
process_mock = AsyncMock(return_value=True)
|
||||
service = SimpleNamespace(process_yookassa_webhook=process_mock)
|
||||
|
||||
router = create_payment_router(DummyBot(), service)
|
||||
assert router is not None
|
||||
|
||||
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
|
||||
request = _build_request(
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
|
||||
headers={},
|
||||
client_ip="185.71.76.10",
|
||||
)
|
||||
|
||||
response = await route.endpoint(request)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = json.loads(response.body.decode("utf-8"))
|
||||
assert payload["status"] == "ok"
|
||||
process_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_yookassa_allowed_via_forwarded_header_when_proxy(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
||||
|
||||
async def fake_get_db():
|
||||
yield SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db)
|
||||
|
||||
process_mock = AsyncMock(return_value=True)
|
||||
service = SimpleNamespace(process_yookassa_webhook=process_mock)
|
||||
|
||||
router = create_payment_router(DummyBot(), service)
|
||||
assert router is not None
|
||||
|
||||
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
|
||||
request = _build_request(
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
|
||||
headers={"X-Forwarded-For": "185.71.76.10"},
|
||||
client_ip="10.0.0.5",
|
||||
)
|
||||
|
||||
response = await route.endpoint(request)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = json.loads(response.body.decode("utf-8"))
|
||||
assert payload["status"] == "ok"
|
||||
process_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_yookassa_allowed_via_cf_connecting_ip(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
||||
|
||||
async def fake_get_db():
|
||||
yield SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db)
|
||||
|
||||
process_mock = AsyncMock(return_value=True)
|
||||
service = SimpleNamespace(process_yookassa_webhook=process_mock)
|
||||
|
||||
router = create_payment_router(DummyBot(), service)
|
||||
assert router is not None
|
||||
|
||||
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
|
||||
request = _build_request(
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
|
||||
headers={"Cf-Connecting-Ip": "185.71.76.10"},
|
||||
client_ip="172.64.223.133",
|
||||
)
|
||||
|
||||
response = await route.endpoint(request)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = json.loads(response.body.decode("utf-8"))
|
||||
assert payload["status"] == "ok"
|
||||
process_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_yookassa_allowed_via_trusted_forwarded_chain(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
||||
monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "203.0.113.0/24", raising=False)
|
||||
|
||||
async def fake_get_db():
|
||||
yield SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db)
|
||||
|
||||
process_mock = AsyncMock(return_value=True)
|
||||
service = SimpleNamespace(process_yookassa_webhook=process_mock)
|
||||
|
||||
router = create_payment_router(DummyBot(), service)
|
||||
assert router is not None
|
||||
|
||||
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
|
||||
request = _build_request(
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
|
||||
headers={"X-Forwarded-For": "185.71.76.10, 203.0.113.10"},
|
||||
client_ip="10.0.0.5",
|
||||
)
|
||||
|
||||
response = await route.endpoint(request)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = json.loads(response.body.decode("utf-8"))
|
||||
assert payload["status"] == "ok"
|
||||
process_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_yookassa_allowed_via_trusted_public_proxy(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
||||
monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "198.51.100.0/24", raising=False)
|
||||
|
||||
async def fake_get_db():
|
||||
yield SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db)
|
||||
|
||||
process_mock = AsyncMock(return_value=True)
|
||||
service = SimpleNamespace(process_yookassa_webhook=process_mock)
|
||||
|
||||
router = create_payment_router(DummyBot(), service)
|
||||
assert router is not None
|
||||
|
||||
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
|
||||
request = _build_request(
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
|
||||
headers={"X-Forwarded-For": "185.71.76.10, 198.51.100.10"},
|
||||
client_ip="198.51.100.20",
|
||||
)
|
||||
|
||||
response = await route.endpoint(request)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = json.loads(response.body.decode("utf-8"))
|
||||
assert payload["status"] == "ok"
|
||||
process_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
||||
|
||||
async def fake_get_db():
|
||||
yield SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db)
|
||||
|
||||
process_mock = AsyncMock(return_value=True)
|
||||
service = SimpleNamespace(process_yookassa_webhook=process_mock)
|
||||
|
||||
router = create_payment_router(DummyBot(), service)
|
||||
assert router is not None
|
||||
|
||||
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
|
||||
payload = {"event": "payment.succeeded"}
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
request = _build_request(
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
body=body,
|
||||
headers={},
|
||||
)
|
||||
|
||||
response = await route.endpoint(request)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = json.loads(response.body.decode("utf-8"))
|
||||
assert payload["status"] == "ok"
|
||||
process_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_yookassa_webhook_cancellation(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
||||
|
||||
async def fake_get_db():
|
||||
yield SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db)
|
||||
|
||||
process_mock = AsyncMock(return_value=True)
|
||||
service = SimpleNamespace(process_yookassa_webhook=process_mock)
|
||||
|
||||
router = create_payment_router(DummyBot(), service)
|
||||
assert router is not None
|
||||
|
||||
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
|
||||
payload = {"event": "payment.canceled"}
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
request = _build_request(
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
body=body,
|
||||
headers={},
|
||||
)
|
||||
|
||||
response = await route.endpoint(request)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = json.loads(response.body.decode("utf-8"))
|
||||
assert payload["status"] == "ok"
|
||||
process_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_yookassa_webhook_with_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
||||
|
||||
async def fake_get_db():
|
||||
yield SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db)
|
||||
|
||||
process_mock = AsyncMock(return_value=True)
|
||||
service = SimpleNamespace(process_yookassa_webhook=process_mock)
|
||||
|
||||
router = create_payment_router(DummyBot(), service)
|
||||
assert router is not None
|
||||
|
||||
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
|
||||
payload = {"event": "payment.succeeded"}
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
request = _build_request(
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
body=body,
|
||||
headers={"Signature": "dummy"},
|
||||
)
|
||||
|
||||
response = await route.endpoint(request)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = json.loads(response.body.decode("utf-8"))
|
||||
assert payload["status"] == "ok"
|
||||
process_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
Reference in New Issue
Block a user