Compare commits

...

88 Commits

Author SHA1 Message Date
Egor d7aab2c3da Update Dockerfile 2025-10-15 06:48:18 +03:00
Egor f59e04b231 Update docker-registry.yml 2025-10-15 06:48:05 +03:00
Egor a2ac04a57a Update docker-hub.yml 2025-10-15 06:47:51 +03:00
Egor 4526df2342 Merge pull request #1352 from Fr1ngg/dev4
Dev4
2025-10-15 06:34:29 +03:00
Egor d0c65f201f Merge pull request #1351 from Fr1ngg/37rzsp-bedolaga/fix-top-up-balance-functionality
Fix Telegram Stars payment state and add missing YooKassa SBP locales
2025-10-15 06:30:26 +03:00
Egor 4fd736ecbf Fix Telegram Stars state import and add YooKassa SBP locale keys 2025-10-15 06:30:01 +03:00
Egor 351700f886 Merge pull request #1350 from Fr1ngg/dev4
Автосинк с ремной при старте + по таймеру
2025-10-15 06:21:50 +03:00
Egor 453d1dbf92 Merge pull request #1349 from Fr1ngg/lnnftq-bedolaga/add-auto-sync-feature-for-remnawave
Persist RemnaWave auto-sync schedule updates
2025-10-15 06:15:55 +03:00
Egor 0f358a9691 Persist RemnaWave auto-sync schedule updates 2025-10-15 06:10:44 +03:00
Egor ab890ea958 Merge pull request #1347 from Fr1ngg/revert-1346-r66y4z-bedolaga/add-auto-sync-feature-for-remnawave
Revert "Fix RemnaWave auto-sync callback handling"
2025-10-15 05:57:45 +03:00
Egor e0b0498f81 Revert "Fix RemnaWave auto-sync callback handling" 2025-10-15 05:57:34 +03:00
Egor af8dc119a5 Merge pull request #1346 from Fr1ngg/r66y4z-bedolaga/add-auto-sync-feature-for-remnawave
Fix RemnaWave auto-sync callback handling
2025-10-15 05:47:19 +03:00
Egor 7ad98ceebb Fix manual auto-sync callback timing 2025-10-15 05:42:27 +03:00
Egor 004aad4f06 Merge pull request #1345 from Fr1ngg/revert-1344-f5x8i3-bedolaga/add-auto-sync-feature-for-remnawave
Revert "Add coverage for RemnaWave auto-sync schedule helpers"
2025-10-15 05:41:27 +03:00
Egor ec5524d74f Revert "Add coverage for RemnaWave auto-sync schedule helpers" 2025-10-15 05:41:18 +03:00
Egor c1211ee025 Merge pull request #1344 from Fr1ngg/f5x8i3-bedolaga/add-auto-sync-feature-for-remnawave
Add coverage for RemnaWave auto-sync schedule helpers
2025-10-15 05:35:07 +03:00
Egor bf6528128f Add tests for RemnaWave auto-sync scheduling helpers 2025-10-15 05:34:02 +03:00
Egor 34c22b8955 Merge pull request #1343 from Fr1ngg/lpha75-bedolaga/-wata
Add localized labels for Wata payment method in mini app
2025-10-15 05:09:17 +03:00
Egor d48488338e Add localization for Wata top-up method 2025-10-15 05:09:01 +03:00
Egor a37fd89d8b Merge pull request #1342 from Fr1ngg/dev4
Wata
2025-10-15 04:58:19 +03:00
Egor 4553b1e038 Merge pull request #1341 from Fr1ngg/lej6x5-bedolaga/fix-wata-migration-error
Deduplicate Wata payment links before enforcing unique index
2025-10-15 04:51:34 +03:00
Egor 8c39f5aecf Deduplicate Wata payment links before enforcing unique index 2025-10-15 04:51:05 +03:00
Egor ce5ceb2f89 Merge pull request #1340 from Fr1ngg/revert-1339-32ftif-bedolaga/fix-wata-migration-error
Revert "Harden Wata payment link constraints"
2025-10-15 04:50:07 +03:00
Egor 8ca7750d25 Revert "Harden Wata payment link constraints" 2025-10-15 04:49:58 +03:00
Egor 1045cf42f9 Merge pull request #1339 from Fr1ngg/32ftif-bedolaga/fix-wata-migration-error
Harden Wata payment link constraints
2025-10-15 04:34:25 +03:00
Egor 427a8594d5 Harden Wata payment link constraints 2025-10-15 04:32:56 +03:00
Egor e007e8142a Merge pull request #1338 from Fr1ngg/revert-1337-c2jl2y-bedolaga/fix-wata-migration-error
Revert "Fix Wata auto-migration by ensuring required columns exist"
2025-10-15 04:32:31 +03:00
Egor 55e47e8e00 Revert "Fix Wata auto-migration by ensuring required columns exist" 2025-10-15 04:31:09 +03:00
Egor f84c54da03 Merge pull request #1337 from Fr1ngg/c2jl2y-bedolaga/fix-wata-migration-error
Fix Wata auto-migration by ensuring required columns exist
2025-10-15 04:21:05 +03:00
Egor 9e5279454c Fix Wata schema migration to add missing columns 2025-10-15 04:20:28 +03:00
Egor e935d13ccd Merge pull request #1336 from Fr1ngg/revert-1335-gr6o9l-bedolaga/modularize-miniapp-routes-by-functionality
Revert "Restore miniapp helpers and fix mulenpay status"
2025-10-15 04:15:33 +03:00
Egor 2c17ce5c3a Revert "Restore miniapp helpers and fix mulenpay status" 2025-10-15 04:15:24 +03:00
Egor 947ad48acd Merge pull request #1335 from Fr1ngg/gr6o9l-bedolaga/modularize-miniapp-routes-by-functionality
Restore miniapp helpers and fix mulenpay status
2025-10-15 04:13:48 +03:00
Egor aefae5818e Restore miniapp helpers and fix mulenpay status 2025-10-15 04:13:27 +03:00
Egor 47191342f6 Merge pull request #1334 from Fr1ngg/revert-1333-a5pqeb-bedolaga/modularize-miniapp-routes-by-functionality
Revert "Refine miniapp module exports"
2025-10-15 04:12:47 +03:00
Egor 0c841cbf86 Revert "Refine miniapp module exports" 2025-10-15 04:12:39 +03:00
Egor 8ef4c74b4a Merge pull request #1333 from Fr1ngg/a5pqeb-bedolaga/modularize-miniapp-routes-by-functionality
Refine miniapp module exports
2025-10-15 03:55:47 +03:00
Egor feb5e92fc2 Refine miniapp module exports 2025-10-15 03:54:04 +03:00
Egor 27338809c1 Merge pull request #1331 from Fr1ngg/uzp44y-bedolaga/refactor-bot-configuration-settings
Reassign miscellaneous bot settings to proper categories
2025-10-15 02:52:59 +03:00
Egor 53231ca074 Organize other settings into dedicated menu groups 2025-10-15 02:52:35 +03:00
Egor 1c177660c0 Merge pull request #1330 from Fr1ngg/bl2afh-bedolaga/update-bot-menu-configuration
Revamp bot configuration menu structure
2025-10-15 02:40:04 +03:00
Egor 041f1d09d7 Revamp bot configuration menu structure 2025-10-15 02:39:13 +03:00
Egor 8e13f644b0 Merge pull request #1329 from Fr1ngg/revert-1328-6k8mnf-bedolaga/update-bot-menu-configuration
Revert "refactor: regroup config categories"
2025-10-15 02:38:50 +03:00
Egor fc0b7591f9 Revert "refactor: regroup config categories" 2025-10-15 02:38:40 +03:00
Egor 308c645096 Merge pull request #1328 from Fr1ngg/6k8mnf-bedolaga/update-bot-menu-configuration
refactor: regroup config categories
2025-10-15 02:37:00 +03:00
Egor fd41d22322 refactor: regroup config categories 2025-10-15 02:36:11 +03:00
Egor 694d85ec10 Merge pull request #1327 from Fr1ngg/revert-1326-ogoewn-bedolaga/update-bot-menu-configuration
Revert "fix: group WATA payments in the admin menu"
2025-10-15 02:35:45 +03:00
Egor 790ffecade Revert "fix: group WATA payments in the admin menu" 2025-10-15 02:35:37 +03:00
Egor 5c5cc42af7 Merge pull request #1326 from Fr1ngg/ogoewn-bedolaga/update-bot-menu-configuration
fix: group WATA payments in the admin menu
2025-10-15 02:33:46 +03:00
Egor 0784f56f7c fix: organize payment settings and add wata test 2025-10-15 02:33:17 +03:00
Egor 775da62e00 Merge pull request #1325 from Fr1ngg/x280im-bedolaga/verify-wata-payment-method-integration
Add WATA payment support to miniapp API
2025-10-15 01:59:21 +03:00
Egor 436efcdd3e Add WATA payment support to miniapp API and tests 2025-10-15 01:50:34 +03:00
Egor 086660ebef Merge pull request #1324 from Fr1ngg/du2spj-bedolaga/fix-wata-payment-finalization-logic
Handle WATA Paid status during polling
2025-10-15 01:29:49 +03:00
Egor c546d10d93 Handle WATA Paid status during polling 2025-10-15 01:23:50 +03:00
Egor 169bdae0d1 Merge pull request #1323 from Fr1ngg/q1598f-bedolaga/add-new-balance-topping-method-to-bot
Fix missing logging import in WATA CRUD helpers
2025-10-15 01:21:28 +03:00
Egor 56e0ec4cd2 Fix logging import in WATA CRUD module 2025-10-15 01:14:43 +03:00
Egor f62e2794b2 Merge pull request #1322 from Fr1ngg/main
w
2025-10-15 00:43:41 +03:00
Egor f19db8f184 Merge pull request #1320 from Gy9vin/main
Фиксы ошибок запуска
2025-10-15 00:36:24 +03:00
Egor 41d4c9b507 Merge pull request #1321 from Fr1ngg/revert-1319-revert-1318-main
Revert "Revert "да все до кучи""
2025-10-15 00:36:11 +03:00
Egor 73e4abb29f Revert "Revert "да все до кучи"" 2025-10-15 00:35:58 +03:00
gy9vin f7cd8c9fc3 Фиксы ошибок запуска 2025-10-14 23:03:21 +03:00
Egor 6497f52a11 Merge pull request #1319 from Fr1ngg/revert-1318-main
Revert "да все до кучи"
2025-10-14 21:39:30 +03:00
Egor a4de6a8330 Revert "да все до кучи" 2025-10-14 21:39:20 +03:00
Egor d27e625ee6 Merge pull request #1318 from Gy9vin/main
да все до кучи
2025-10-14 21:35:11 +03:00
gy9vin e7a34152fc удаление бэкапов 2025-10-14 21:30:29 +03:00
Egor e332fc41fc Delete app/handlers/balance.py.backup 2025-10-14 21:20:29 +03:00
Egor 6bf0ac7fbf Delete app/handlers/balance.py.backup2 2025-10-14 21:19:27 +03:00
Egor 0effaa5240 Merge pull request #1317 from Fr1ngg/revert-1315-revert-1314-revert-1313-nv8l52-bedolaga/add-new-balance-top-up-method-to-bot
Revert "Revert "Revert "Complete Wata Pay integration"""
2025-10-14 21:18:03 +03:00
Egor 89b0544f86 Revert "Revert "Revert "Complete Wata Pay integration""" 2025-10-14 21:17:54 +03:00
Egor b1626d32cd Merge pull request #1315 from Fr1ngg/revert-1314-revert-1313-nv8l52-bedolaga/add-new-balance-top-up-method-to-bot
Revert "Revert "Complete Wata Pay integration""
2025-10-14 21:00:16 +03:00
gy9vin 8cdb4289c9 Модульность balance 2025-10-14 21:00:08 +03:00
gy9vin d4e3adddfc pal24 фиксы с кнопками СБП и карта 2025-10-14 20:21:52 +03:00
gy9vin 1baeb50322 кнопка назад при ручной проверки статуса платежа на палыче 2025-10-14 19:55:24 +03:00
Egor 7ed635600e Revert "Revert "Complete Wata Pay integration"" 2025-10-14 19:14:28 +03:00
Egor cb81fae3d8 Merge pull request #1311 from Fr1ngg/main
w
2025-10-14 19:13:49 +03:00
Egor 93cb99d50c Merge pull request #1314 from Fr1ngg/revert-1313-nv8l52-bedolaga/add-new-balance-top-up-method-to-bot
Revert "Complete Wata Pay integration"
2025-10-14 19:13:23 +03:00
Egor 5a4f59dfb2 Revert "Complete Wata Pay integration" 2025-10-14 19:13:11 +03:00
Egor 59f39e18a0 Merge pull request #1313 from Fr1ngg/nv8l52-bedolaga/add-new-balance-top-up-method-to-bot
Complete Wata Pay integration
2025-10-14 19:12:19 +03:00
Egor d3dd423042 Complete Wata Pay integration 2025-10-14 16:46:00 +03:00
Egor b90b270a9e Merge pull request #1306 from Gy9vin/main
Партнерка активация по env + убрано упоминание о vpn в русской локализации
2025-10-14 16:08:41 +03:00
gy9vin 7846b54a6f вернул докер 2025-10-14 11:16:19 +03:00
gy9vin 77083e8349 Фикс 2025-10-14 11:14:55 +03:00
gy9vin f121a5733b Прячем кнопки пополнения через env. оставляя только введения суммы вручную 2025-10-14 09:45:14 +03:00
Mikhail ad6bb95df7 Merge branch 'Fr1ngg:main' into main 2025-10-14 09:03:02 +03:00
gy9vin 58588e37d0 Реализация корзины пользователя! запоминает настройки подписки после пополнения баланса. 2025-10-14 09:02:16 +03:00
Egor 9717876fe7 Merge pull request #1307 from Fr1ngg/main
w
2025-10-14 00:55:29 +03:00
gy9vin badf44a9e7 Выпиливание упоминание про VPN из русской локализации 2025-10-13 23:02:25 +03:00
gy9vin a320de1cbb Скрытие кнопки партнерки через env 2025-10-13 23:00:06 +03:00
59 changed files with 6392 additions and 2034 deletions
+8
View File
@@ -136,6 +136,7 @@ TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,
PRICE_PER_DEVICE=5000
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
REFERRAL_PROGRAM_ENABLED=true
REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
REFERRAL_INVITER_BONUS_KOPEKS=10000
@@ -224,6 +225,9 @@ YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
# Быстрый выбор суммы пополнения через YooKassa
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
DISABLE_TOPUP_BUTTONS=false
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
# чтобы избежать блокировок платежных систем
@@ -273,6 +277,10 @@ PAL24_PAYMENT_DESCRIPTION="Пополнение баланса"
PAL24_MIN_AMOUNT_KOPEKS=10000
PAL24_MAX_AMOUNT_KOPEKS=100000000
PAL24_REQUEST_TIMEOUT=30
# Отображать кнопку СБП в PayPalych (true - отображать, false - скрывать)
PAL24_SBP_BUTTON_VISIBLE=true
# Отображать кнопку оплаты картой в PayPalych (true - отображать, false - скрывать)
PAL24_CARD_BUTTON_VISIBLE=true
# ===== ИНТЕРФЕЙС И UX =====
+3 -3
View File
@@ -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.5.1-$(git rev-parse --short HEAD)"
VERSION="v2.5.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.5.1-dev-$(git rev-parse --short HEAD)"
VERSION="v2.5.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.5.1-pr-$(git rev-parse --short HEAD)"
VERSION="v2.5.2-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.5.1-$(git rev-parse --short HEAD)"
VERSION="v2.5.2-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.5.1-dev-$(git rev-parse --short HEAD)"
VERSION="v2.5.2-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.5.1-pr-$(git rev-parse --short HEAD)"
VERSION="v2.5.2-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v2.5.1"
ARG VERSION="v2.5.2"
ARG BUILD_DATE
ARG VCS_REF
+1 -1
View File
@@ -127,7 +127,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
start.register_handlers(dp)
menu.register_handlers(dp)
subscription.register_handlers(dp)
balance.register_handlers(dp)
balance.register_balance_handlers(dp)
promocode.register_handlers(dp)
referral.register_handlers(dp)
support.register_handlers(dp)
+72
View File
@@ -71,6 +71,8 @@ class Settings(BaseSettings):
REMNAWAVE_AUTH_TYPE: str = "api_key"
REMNAWAVE_USER_DESCRIPTION_TEMPLATE: str = "Bot user: {full_name} {username}"
REMNAWAVE_USER_DELETE_MODE: str = "delete" # "delete" или "disable"
REMNAWAVE_AUTO_SYNC_ENABLED: bool = False
REMNAWAVE_AUTO_SYNC_TIMES: str = "03:00"
TRIAL_DURATION_DAYS: int = 3
TRIAL_TRAFFIC_LIMIT_GB: int = 10
@@ -134,6 +136,7 @@ class Settings(BaseSettings):
REFERRAL_INVITER_BONUS_KOPEKS: int = 10000
REFERRAL_COMMISSION_PERCENT: int = 25
REFERRAL_PROGRAM_ENABLED: bool = True
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS: int = 3
REFERRED_USER_REWARD: int = 0
@@ -178,6 +181,7 @@ class Settings(BaseSettings):
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
DISABLE_TOPUP_BUTTONS: bool = False
PAYMENT_BALANCE_DESCRIPTION: str = "Пополнение баланса"
PAYMENT_SUBSCRIPTION_DESCRIPTION: str = "Оплата подписки"
PAYMENT_SERVICE_NAME: str = "Интернет-сервис"
@@ -226,6 +230,21 @@ class Settings(BaseSettings):
PAL24_REQUEST_TIMEOUT: int = 30
PAL24_SBP_BUTTON_TEXT: Optional[str] = None
PAL24_CARD_BUTTON_TEXT: Optional[str] = None
PAL24_SBP_BUTTON_VISIBLE: bool = True
PAL24_CARD_BUTTON_VISIBLE: bool = True
WATA_ENABLED: bool = False
WATA_BASE_URL: str = "https://api.wata.pro/api/h2h"
WATA_ACCESS_TOKEN: Optional[str] = None
WATA_TERMINAL_PUBLIC_ID: Optional[str] = None
WATA_PAYMENT_DESCRIPTION: str = "Пополнение баланса"
WATA_PAYMENT_TYPE: str = "OneTime"
WATA_SUCCESS_REDIRECT_URL: Optional[str] = None
WATA_FAIL_REDIRECT_URL: Optional[str] = None
WATA_LINK_TTL_MINUTES: Optional[int] = None
WATA_MIN_AMOUNT_KOPEKS: int = 10000
WATA_MAX_AMOUNT_KOPEKS: int = 100000000
WATA_REQUEST_TIMEOUT: int = 30
MAIN_MENU_MODE: str = "default"
CONNECT_BUTTON_MODE: str = "guide"
@@ -458,6 +477,12 @@ class Settings(BaseSettings):
value = (self.PAL24_CARD_BUTTON_TEXT or "").strip()
return value or fallback
def is_pal24_sbp_button_visible(self) -> bool:
return self.PAL24_SBP_BUTTON_VISIBLE
def is_pal24_card_button_visible(self) -> bool:
return self.PAL24_CARD_BUTTON_VISIBLE
def get_remnawave_user_delete_mode(self) -> str:
"""Возвращает режим удаления пользователей: 'delete' или 'disable'"""
mode = self.REMNAWAVE_USER_DELETE_MODE.lower().strip()
@@ -490,6 +515,42 @@ class Settings(BaseSettings):
description = re.sub(r'\s+', ' ', description).strip()
return description
@staticmethod
def parse_daily_time_list(raw_value: Optional[str]) -> List[time]:
if not raw_value:
return []
segments = re.split(r"[\s,;]+", raw_value.strip())
seen: set[tuple[int, int]] = set()
parsed: List[time] = []
for segment in segments:
if not segment:
continue
try:
hours_str, minutes_str = segment.split(":", 1)
hours = int(hours_str)
minutes = int(minutes_str)
except (ValueError, AttributeError):
continue
if not (0 <= hours < 24 and 0 <= minutes < 60):
continue
key = (hours, minutes)
if key in seen:
continue
seen.add(key)
parsed.append(time(hour=hours, minute=minutes))
parsed.sort()
return parsed
def get_remnawave_auto_sync_times(self) -> List[time]:
return self.parse_daily_time_list(self.REMNAWAVE_AUTO_SYNC_TIMES)
def get_display_name_banned_keywords(self) -> List[str]:
raw_value = self.DISPLAY_NAME_BANNED_KEYWORDS
if raw_value is None:
@@ -727,6 +788,13 @@ class Settings(BaseSettings):
and self.PAL24_SHOP_ID is not None
)
def is_wata_enabled(self) -> bool:
return (
self.WATA_ENABLED
and self.WATA_ACCESS_TOKEN is not None
and self.WATA_TERMINAL_PUBLIC_ID is not None
)
def get_cryptobot_base_url(self) -> str:
if self.CRYPTOBOT_TESTNET:
return "https://testnet-pay.crypt.bot"
@@ -950,6 +1018,7 @@ class Settings(BaseSettings):
referred_reward_kopeks = self.get_referred_user_reward_kopeks()
return {
"program_enabled": self.is_referral_program_enabled(),
"minimum_topup_kopeks": self.REFERRAL_MINIMUM_TOPUP_KOPEKS,
"first_topup_bonus_kopeks": self.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
"inviter_bonus_kopeks": self.REFERRAL_INVITER_BONUS_KOPEKS,
@@ -959,6 +1028,9 @@ class Settings(BaseSettings):
"referred_user_reward_kopeks": referred_reward_kopeks,
}
def is_referral_program_enabled(self) -> bool:
return bool(self.REFERRAL_PROGRAM_ENABLED)
def is_referral_notifications_enabled(self) -> bool:
return self.REFERRAL_NOTIFICATIONS_ENABLED
+168
View File
@@ -0,0 +1,168 @@
"""CRUD helpers for WATA payment records."""
import logging
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import WataPayment
logger = logging.getLogger(__name__)
async def create_wata_payment(
db: AsyncSession,
*,
user_id: int,
payment_link_id: str,
amount_kopeks: int,
currency: str,
description: Optional[str],
status: str,
type_: Optional[str],
url: Optional[str],
order_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
expires_at: Optional[datetime] = None,
terminal_public_id: Optional[str] = None,
success_redirect_url: Optional[str] = None,
fail_redirect_url: Optional[str] = None,
) -> WataPayment:
payment = WataPayment(
user_id=user_id,
payment_link_id=payment_link_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
status=status,
type=type_,
url=url,
metadata_json=metadata or {},
expires_at=expires_at,
terminal_public_id=terminal_public_id,
success_redirect_url=success_redirect_url,
fail_redirect_url=fail_redirect_url,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(
"Создан Wata платеж #%s для пользователя %s: %s копеек (статус %s)",
payment.id,
user_id,
amount_kopeks,
status,
)
return payment
async def get_wata_payment_by_id(
db: AsyncSession,
payment_id: int,
) -> Optional[WataPayment]:
result = await db.execute(
select(WataPayment).where(WataPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def get_wata_payment_by_link_id(
db: AsyncSession,
payment_link_id: str,
) -> Optional[WataPayment]:
result = await db.execute(
select(WataPayment).where(WataPayment.payment_link_id == payment_link_id)
)
return result.scalar_one_or_none()
async def get_wata_payment_by_order_id(
db: AsyncSession,
order_id: str,
) -> Optional[WataPayment]:
result = await db.execute(
select(WataPayment).where(WataPayment.order_id == order_id)
)
return result.scalar_one_or_none()
async def update_wata_payment_status(
db: AsyncSession,
payment: WataPayment,
*,
status: Optional[str] = None,
is_paid: Optional[bool] = None,
paid_at: Optional[datetime] = None,
last_status: Optional[str] = None,
url: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
callback_payload: Optional[Dict[str, Any]] = None,
terminal_public_id: Optional[str] = None,
) -> WataPayment:
update_values: Dict[str, Any] = {}
if status is not None:
update_values["status"] = status
if is_paid is not None:
update_values["is_paid"] = is_paid
if paid_at is not None:
update_values["paid_at"] = paid_at
if last_status is not None:
update_values["last_status"] = last_status
if url is not None:
update_values["url"] = url
if metadata is not None:
update_values["metadata_json"] = metadata
if callback_payload is not None:
update_values["callback_payload"] = callback_payload
if terminal_public_id is not None:
update_values["terminal_public_id"] = terminal_public_id
if not update_values:
return payment
await db.execute(
update(WataPayment)
.where(WataPayment.id == payment.id)
.values(**update_values)
)
await db.commit()
await db.refresh(payment)
logger.info(
"Обновлен Wata платеж %s: статус=%s, is_paid=%s",
payment.payment_link_id,
payment.status,
payment.is_paid,
)
return payment
async def link_wata_payment_to_transaction(
db: AsyncSession,
payment: WataPayment,
transaction_id: int,
) -> WataPayment:
await db.execute(
update(WataPayment)
.where(WataPayment.id == payment.id)
.values(transaction_id=transaction_id)
)
await db.commit()
await db.refresh(payment)
logger.info(
"Wata платеж %s привязан к транзакции %s",
payment.payment_link_id,
transaction_id,
)
return payment
+51
View File
@@ -77,6 +77,7 @@ class PaymentMethod(Enum):
CRYPTOBOT = "cryptobot"
MULENPAY = "mulenpay"
PAL24 = "pal24"
WATA = "wata"
MANUAL = "manual"
@@ -293,6 +294,56 @@ class Pal24Payment(Base):
)
class WataPayment(Base):
__tablename__ = "wata_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
payment_link_id = Column(String(64), unique=True, nullable=False, index=True)
order_id = Column(String(255), nullable=True, index=True)
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default="RUB")
description = Column(Text, nullable=True)
type = Column(String(50), nullable=True)
status = Column(String(50), nullable=False, default="Opened")
is_paid = Column(Boolean, default=False)
paid_at = Column(DateTime, nullable=True)
last_status = Column(String(50), nullable=True)
terminal_public_id = Column(String(64), nullable=True)
url = Column(Text, nullable=True)
success_redirect_url = Column(Text, nullable=True)
fail_redirect_url = Column(Text, 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="wata_payments")
transaction = relationship("Transaction", backref="wata_payment")
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
def __repr__(self) -> str: # pragma: no cover - debug helper
return (
"<WataPayment(id={0}, link_id={1}, amount={2}₽, status={3})>".format(
self.id,
self.payment_link_id,
self.amount_rubles,
self.status,
)
)
class PromoGroup(Base):
__tablename__ = "promo_groups"
+482
View File
@@ -1,5 +1,6 @@
import logging
from datetime import datetime
from typing import List, Tuple
from sqlalchemy import inspect, select, text
from sqlalchemy.ext.asyncio import AsyncSession
@@ -268,6 +269,210 @@ async def check_index_exists(table_name: str, index_name: str) -> bool:
)
return False
async def fetch_duplicate_payment_links(conn) -> List[Tuple[str, int]]:
result = await conn.execute(
text(
"SELECT payment_link_id, COUNT(*) AS cnt "
"FROM wata_payments "
"WHERE payment_link_id IS NOT NULL AND payment_link_id <> '' "
"GROUP BY payment_link_id "
"HAVING COUNT(*) > 1"
)
)
return [(row[0], row[1]) for row in result.fetchall()]
def _build_dedup_suffix(base_suffix: str, record_id: int, max_length: int = 64) -> Tuple[str, int]:
suffix = f"{base_suffix}{record_id}"
trimmed_length = max_length - len(suffix)
if trimmed_length < 1:
# Fallback: use the record id only to stay within the limit.
suffix = f"dup-{record_id}"
trimmed_length = max_length - len(suffix)
return suffix, trimmed_length
async def resolve_duplicate_payment_links(conn, db_type: str) -> bool:
duplicates = await fetch_duplicate_payment_links(conn)
if not duplicates:
return True
logger.warning(
"Найдены дубликаты payment_link_id в wata_payments: %s",
", ".join(f"{link}×{count}" for link, count in duplicates[:5]),
)
for payment_link_id, _ in duplicates:
result = await conn.execute(
text(
"SELECT id, payment_link_id FROM wata_payments "
"WHERE payment_link_id = :payment_link_id "
"ORDER BY id"
),
{"payment_link_id": payment_link_id},
)
rows = result.fetchall()
if not rows:
continue
# Skip the first occurrence to preserve the original link value.
for duplicate_row in rows[1:]:
record_id = duplicate_row[0]
original_link = duplicate_row[1] or ""
suffix, trimmed_length = _build_dedup_suffix("-dup-", record_id)
new_base = original_link[:trimmed_length] if trimmed_length > 0 else ""
new_link = f"{new_base}{suffix}" if new_base else suffix
await conn.execute(
text(
"UPDATE wata_payments SET payment_link_id = :new_link "
"WHERE id = :record_id"
),
{"new_link": new_link, "record_id": record_id},
)
remaining_duplicates = await fetch_duplicate_payment_links(conn)
if remaining_duplicates:
logger.error(
"Не удалось устранить дубликаты payment_link_id: %s",
", ".join(f"{link}×{count}" for link, count in remaining_duplicates[:5]),
)
return False
logger.info("✅ Дубликаты payment_link_id устранены")
return True
async def enforce_wata_payment_link_constraints(
conn,
db_type: str,
unique_index_exists: bool,
legacy_index_exists: bool,
) -> Tuple[bool, bool]:
try:
if db_type == "sqlite":
await conn.execute(
text(
"UPDATE wata_payments "
"SET payment_link_id = 'legacy-' || id "
"WHERE payment_link_id IS NULL OR payment_link_id = ''"
)
)
if not await resolve_duplicate_payment_links(conn, db_type):
return unique_index_exists, legacy_index_exists
if not unique_index_exists:
await conn.execute(
text(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_wata_payment_link "
"ON wata_payments(payment_link_id)"
)
)
logger.info("✅ Создан уникальный индекс uq_wata_payment_link для payment_link_id")
unique_index_exists = True
else:
logger.info("ℹ️ Уникальный индекс для payment_link_id уже существует")
if legacy_index_exists and unique_index_exists:
await conn.execute(text("DROP INDEX IF EXISTS idx_wata_link_id"))
logger.info("ℹ️ Удалён устаревший индекс idx_wata_link_id")
legacy_index_exists = False
return unique_index_exists, legacy_index_exists
if db_type == "postgresql":
await conn.execute(
text(
"UPDATE wata_payments "
"SET payment_link_id = 'legacy-' || id::text "
"WHERE payment_link_id IS NULL OR payment_link_id = ''"
)
)
await conn.execute(
text(
"ALTER TABLE wata_payments "
"ALTER COLUMN payment_link_id SET NOT NULL"
)
)
logger.info("✅ Колонка payment_link_id теперь NOT NULL")
if not await resolve_duplicate_payment_links(conn, db_type):
return unique_index_exists, legacy_index_exists
if not unique_index_exists:
await conn.execute(
text(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_wata_payment_link "
"ON wata_payments(payment_link_id)"
)
)
logger.info("✅ Создан уникальный индекс uq_wata_payment_link для payment_link_id")
unique_index_exists = True
else:
logger.info("ℹ️ Уникальный индекс для payment_link_id уже существует")
if legacy_index_exists and unique_index_exists:
await conn.execute(text("DROP INDEX IF EXISTS idx_wata_link_id"))
logger.info("ℹ️ Удалён устаревший индекс idx_wata_link_id")
legacy_index_exists = False
return unique_index_exists, legacy_index_exists
if db_type == "mysql":
await conn.execute(
text(
"UPDATE wata_payments "
"SET payment_link_id = CONCAT('legacy-', id) "
"WHERE payment_link_id IS NULL OR payment_link_id = ''"
)
)
await conn.execute(
text(
"ALTER TABLE wata_payments "
"MODIFY COLUMN payment_link_id VARCHAR(64) NOT NULL"
)
)
logger.info("✅ Колонка payment_link_id теперь NOT NULL")
if not await resolve_duplicate_payment_links(conn, db_type):
return unique_index_exists, legacy_index_exists
if not unique_index_exists:
await conn.execute(
text(
"CREATE UNIQUE INDEX uq_wata_payment_link "
"ON wata_payments(payment_link_id)"
)
)
logger.info("✅ Создан уникальный индекс uq_wata_payment_link для payment_link_id")
unique_index_exists = True
else:
logger.info("ℹ️ Уникальный индекс для payment_link_id уже существует")
if legacy_index_exists and unique_index_exists:
await conn.execute(text("DROP INDEX idx_wata_link_id ON wata_payments"))
logger.info("ℹ️ Удалён устаревший индекс idx_wata_link_id")
legacy_index_exists = False
return unique_index_exists, legacy_index_exists
logger.warning(
"⚠️ Неизвестный тип БД %s — не удалось усилить ограничения payment_link_id", db_type
)
return unique_index_exists, legacy_index_exists
except Exception as e:
logger.error(f"Ошибка настройки ограничений payment_link_id: {e}")
return unique_index_exists, legacy_index_exists
async def create_cryptobot_payments_table():
table_exists = await check_table_exists('cryptobot_payments')
if table_exists:
@@ -706,6 +911,270 @@ async def create_pal24_payments_table():
return False
async def create_wata_payments_table():
table_exists = await check_table_exists('wata_payments')
if table_exists:
logger.info("Таблица wata_payments уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
create_sql = """
CREATE TABLE wata_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
payment_link_id VARCHAR(64) NOT NULL UNIQUE,
order_id VARCHAR(255) NULL,
amount_kopeks INTEGER NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
description TEXT NULL,
type VARCHAR(50) NULL,
status VARCHAR(50) NOT NULL DEFAULT 'Opened',
is_paid BOOLEAN NOT NULL DEFAULT 0,
paid_at DATETIME NULL,
last_status VARCHAR(50) NULL,
terminal_public_id VARCHAR(64) NULL,
url TEXT NULL,
success_redirect_url TEXT NULL,
fail_redirect_url TEXT NULL,
metadata_json JSON NULL,
callback_payload JSON NULL,
expires_at DATETIME NULL,
transaction_id INTEGER NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE UNIQUE INDEX idx_wata_link_id ON wata_payments(payment_link_id);
CREATE INDEX idx_wata_order_id ON wata_payments(order_id);
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE wata_payments (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
payment_link_id VARCHAR(64) NOT NULL UNIQUE,
order_id VARCHAR(255) NULL,
amount_kopeks INTEGER NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
description TEXT NULL,
type VARCHAR(50) NULL,
status VARCHAR(50) NOT NULL DEFAULT 'Opened',
is_paid BOOLEAN NOT NULL DEFAULT FALSE,
paid_at TIMESTAMP NULL,
last_status VARCHAR(50) NULL,
terminal_public_id VARCHAR(64) NULL,
url TEXT NULL,
success_redirect_url TEXT NULL,
fail_redirect_url TEXT NULL,
metadata_json JSON NULL,
callback_payload JSON NULL,
expires_at TIMESTAMP NULL,
transaction_id INTEGER NULL REFERENCES transactions(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_wata_link_id ON wata_payments(payment_link_id);
CREATE INDEX idx_wata_order_id ON wata_payments(order_id);
"""
elif db_type == 'mysql':
create_sql = """
CREATE TABLE wata_payments (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
payment_link_id VARCHAR(64) NOT NULL UNIQUE,
order_id VARCHAR(255) NULL,
amount_kopeks INT NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
description TEXT NULL,
type VARCHAR(50) NULL,
status VARCHAR(50) NOT NULL DEFAULT 'Opened',
is_paid BOOLEAN NOT NULL DEFAULT 0,
paid_at DATETIME NULL,
last_status VARCHAR(50) NULL,
terminal_public_id VARCHAR(64) NULL,
url TEXT NULL,
success_redirect_url TEXT NULL,
fail_redirect_url TEXT NULL,
metadata_json JSON NULL,
callback_payload JSON NULL,
expires_at DATETIME NULL,
transaction_id INT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE UNIQUE INDEX idx_wata_link_id ON wata_payments(payment_link_id);
CREATE INDEX idx_wata_order_id ON wata_payments(order_id);
"""
else:
logger.error(f"Неподдерживаемый тип БД для таблицы wata_payments: {db_type}")
return False
await conn.execute(text(create_sql))
logger.info("Таблица wata_payments успешно создана")
return True
except Exception as e:
logger.error(f"Ошибка создания таблицы wata_payments: {e}")
return False
async def ensure_wata_payment_schema() -> bool:
try:
table_exists = await check_table_exists("wata_payments")
if not table_exists:
logger.warning("⚠️ Таблица wata_payments отсутствует — создаём заново")
return await create_wata_payments_table()
db_type = await get_database_type()
legacy_link_index_exists = await check_index_exists(
"wata_payments", "idx_wata_link_id"
)
unique_link_index_exists = await check_index_exists(
"wata_payments", "uq_wata_payment_link"
)
builtin_unique_index_exists = await check_index_exists(
"wata_payments", "wata_payments_payment_link_id_key"
)
sqlite_auto_unique_exists = (
await check_index_exists("wata_payments", "sqlite_autoindex_wata_payments_1")
if db_type == "sqlite"
else False
)
order_index_exists = await check_index_exists("wata_payments", "idx_wata_order_id")
payment_link_column_exists = await check_column_exists(
"wata_payments", "payment_link_id"
)
order_id_column_exists = await check_column_exists("wata_payments", "order_id")
unique_index_exists = (
unique_link_index_exists
or builtin_unique_index_exists
or sqlite_auto_unique_exists
)
async with engine.begin() as conn:
if not payment_link_column_exists:
if db_type == "sqlite":
await conn.execute(
text(
"ALTER TABLE wata_payments "
"ADD COLUMN payment_link_id VARCHAR(64) NOT NULL DEFAULT ''"
)
)
payment_link_column_exists = True
unique_index_exists = False
elif db_type == "postgresql":
await conn.execute(
text(
"ALTER TABLE wata_payments "
"ADD COLUMN IF NOT EXISTS payment_link_id VARCHAR(64)"
)
)
payment_link_column_exists = True
elif db_type == "mysql":
await conn.execute(
text("ALTER TABLE wata_payments ADD COLUMN payment_link_id VARCHAR(64)")
)
payment_link_column_exists = True
else:
logger.warning(
"⚠️ Неизвестный тип БД %s — пропущено добавление payment_link_id",
db_type,
)
if payment_link_column_exists:
logger.info("✅ Добавлена колонка payment_link_id в wata_payments")
if payment_link_column_exists:
unique_index_exists, legacy_link_index_exists = (
await enforce_wata_payment_link_constraints(
conn,
db_type,
unique_index_exists,
legacy_link_index_exists,
)
)
if not order_id_column_exists:
if db_type == "sqlite":
await conn.execute(
text("ALTER TABLE wata_payments ADD COLUMN order_id VARCHAR(255)")
)
order_id_column_exists = True
elif db_type == "postgresql":
await conn.execute(
text(
"ALTER TABLE wata_payments "
"ADD COLUMN IF NOT EXISTS order_id VARCHAR(255)"
)
)
order_id_column_exists = True
elif db_type == "mysql":
await conn.execute(
text("ALTER TABLE wata_payments ADD COLUMN order_id VARCHAR(255)")
)
order_id_column_exists = True
else:
logger.warning(
"⚠️ Неизвестный тип БД %s — пропущено добавление order_id",
db_type,
)
if order_id_column_exists:
logger.info("✅ Добавлена колонка order_id в wata_payments")
if not order_index_exists:
if not order_id_column_exists:
logger.warning(
"⚠️ Пропущено создание индекса idx_wata_order_id — колонка order_id отсутствует"
)
else:
index_created = False
if db_type in {"sqlite", "postgresql"}:
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_wata_order_id ON wata_payments(order_id)"
)
)
index_created = True
elif db_type == "mysql":
await conn.execute(
text("CREATE INDEX idx_wata_order_id ON wata_payments(order_id)")
)
index_created = True
else:
logger.warning(
"⚠️ Неизвестный тип БД %s — пропущено создание индекса idx_wata_order_id",
db_type,
)
if index_created:
logger.info("✅ Создан индекс idx_wata_order_id")
else:
logger.info("️ Индекс idx_wata_order_id уже существует")
return True
except Exception as e:
logger.error(f"Ошибка обновления схемы wata_payments: {e}")
return False
async def create_discount_offers_table():
table_exists = await check_table_exists('discount_offers')
if table_exists:
@@ -2978,6 +3447,19 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей Pal24 payments")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ WATA ===")
wata_created = await create_wata_payments_table()
if wata_created:
logger.info("✅ Таблица Wata payments готова")
else:
logger.warning("⚠️ Проблемы с таблицей Wata payments")
wata_schema_ok = await ensure_wata_payment_schema()
if wata_schema_ok:
logger.info("✅ Схема Wata payments актуальна")
else:
logger.warning("⚠️ Не удалось обновить схему Wata payments")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ DISCOUNT_OFFERS ===")
discount_created = await create_discount_offers_table()
if discount_created:
+3
View File
@@ -0,0 +1,3 @@
from . import balance
__all__ = ["balance"]
+106 -62
View File
@@ -46,9 +46,18 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
},
"payments": {
"title": "💳 Платежные системы",
"description": "YooKassa, CryptoBot, MulenPay, PAL24, Tribute и Telegram Stars.",
"description": "YooKassa, CryptoBot, MulenPay, PAL24, Wata, Tribute и Telegram Stars.",
"icon": "💳",
"categories": ("PAYMENT", "YOOKASSA", "CRYPTOBOT", "MULENPAY", "PAL24", "TRIBUTE", "TELEGRAM"),
"categories": (
"PAYMENT",
"YOOKASSA",
"CRYPTOBOT",
"MULENPAY",
"PAL24",
"WATA",
"TRIBUTE",
"TELEGRAM",
),
},
"subscriptions": {
"title": "📅 Подписки и цены",
@@ -76,9 +85,19 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
},
"interface": {
"title": "🎨 Интерфейс и брендинг",
"description": "Логотип, тексты, языки, miniapp и deep links.",
"description": "Логотип, тексты, языки, главное меню, miniapp и deep links.",
"icon": "🎨",
"categories": ("INTERFACE_BRANDING", "INTERFACE_SUBSCRIPTION", "CONNECT_BUTTON", "MINIAPP", "HAPP", "SKIP", "LOCALIZATION", "ADDITIONAL"),
"categories": (
"INTERFACE",
"INTERFACE_BRANDING",
"INTERFACE_SUBSCRIPTION",
"CONNECT_BUTTON",
"MINIAPP",
"HAPP",
"SKIP",
"LOCALIZATION",
"ADDITIONAL",
),
},
"database": {
"title": "💾 База данных",
@@ -106,9 +125,9 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
},
"advanced": {
"title": "⚡ Расширенные",
"description": "Web API, webhook, логирование и режим отладки.",
"description": "Web API, webhook, логирование, модерация и режим отладки.",
"icon": "",
"categories": ("WEB_API", "WEBHOOK", "LOG", "DEBUG"),
"categories": ("WEB_API", "WEBHOOK", "LOG", "MODERATION", "DEBUG"),
},
"external_admin": {
"title": "🛡️ Внешняя админка",
@@ -353,25 +372,22 @@ def _render_dashboard_overview() -> str:
)
lines: List[str] = [
"⚙️ <b>Панель управления ботом</b>",
"⚙️ <b>ПАНЕЛЬ УПРАВЛЕНИЯ БОТОМ</b>",
"",
f"Всего параметров: <b>{total_settings}</b> • Переопределено: <b>{total_overrides}</b>",
"",
"Выберите категорию ниже или используйте быстрые действия:",
"<b>Группы настроек</b>",
"",
]
for group_key, title, items in grouped:
status_icon, status_text = _get_group_status(group_key)
description = _get_group_description(group_key) if group_key != CATEGORY_FALLBACK_KEY else "Настройки без категории."
total = sum(count for _, _, count in items)
lines.append(f"{status_icon} <b>{title}</b> — {status_text}")
if description:
lines.append(f" {description}")
lines.append(f" Настроек: {total}")
lines.append(f"└ Настроек: {total}")
lines.append("")
lines.append("🔍 Кнопка поиска поможет найти параметр по названию, описанию или ключу.")
lines.append("🔍 Используйте поиск, чтобы быстро найти нужный параметр по ключу или названию.")
return "\n".join(lines).strip()
@@ -1109,8 +1125,8 @@ def _build_groups_keyboard() -> types.InlineKeyboardMarkup:
for group_key, title, items in grouped:
total = sum(count for _, _, count in items)
status_icon, _ = _get_group_status(group_key)
button_text = f"{status_icon} {title} ({total})"
status_icon, status_text = _get_group_status(group_key)
button_text = f"{status_icon} {title} {status_text}"
rows.append(
[
types.InlineKeyboardButton(
@@ -1162,7 +1178,7 @@ def _build_groups_keyboard() -> types.InlineKeyboardMarkup:
rows.append(
[
types.InlineKeyboardButton(
text="⬅️ Назад",
text="⬅️ Назад в админку",
callback_data="admin_submenu_settings",
)
]
@@ -1185,19 +1201,6 @@ def _build_categories_keyboard(
sliced = categories[start:end]
rows: list[list[types.InlineKeyboardButton]] = []
status_icon, _status_text = (
_get_group_status(group_key)
if group_key != CATEGORY_FALLBACK_KEY
else ("", "Прочие настройки")
)
rows.append(
[
types.InlineKeyboardButton(
text=f"{status_icon} {group_title}",
callback_data="botcfg_group:noop",
)
]
)
buttons: List[types.InlineKeyboardButton] = []
for category_key, label, count in sliced:
@@ -1205,7 +1208,7 @@ def _build_categories_keyboard(
for definition in bot_configuration_service.get_settings_for_category(category_key):
if bot_configuration_service.has_override(definition.key):
overrides += 1
badge = "✳️" if overrides else ""
badge = "✳️" if overrides else ""
button_text = f"{badge} {label} ({count})"
buttons.append(
types.InlineKeyboardButton(
@@ -1228,7 +1231,7 @@ def _build_categories_keyboard(
)
nav_row.append(
types.InlineKeyboardButton(
text=f"{page}/{total_pages}",
text=f"[{page}/{total_pages}]",
callback_data="botcfg_group:noop",
)
)
@@ -1302,6 +1305,9 @@ def _build_settings_keyboard(
elif category_key == "MULENPAY":
label = texts.t("PAYMENT_CARD_MULENPAY", "💳 Банковская карта (Mulen Pay)")
test_payment_buttons.append([_test_button(f"{label} · тест", "mulenpay")])
elif category_key == "WATA":
label = texts.t("PAYMENT_CARD_WATA", "💳 Банковская карта (WATA)")
test_payment_buttons.append([_test_button(f"{label} · тест", "wata")])
elif category_key == "PAL24":
label = texts.t("PAYMENT_CARD_PAL24", "💳 Банковская карта (PayPalych)")
test_payment_buttons.append([_test_button(f"{label} · тест", "pal24")])
@@ -1350,7 +1356,7 @@ def _build_settings_keyboard(
)
nav_row.append(
types.InlineKeyboardButton(
text=f"{page}/{total_pages}", callback_data="botcfg_cat_page:noop"
text=f"[{page}/{total_pages}]", callback_data="botcfg_cat_page:noop"
)
)
if page < total_pages:
@@ -1462,42 +1468,57 @@ def _render_setting_text(key: str) -> str:
summary = bot_configuration_service.get_setting_summary(key)
guidance = bot_configuration_service.get_setting_guidance(key)
definition = bot_configuration_service.get_definition(key)
description = guidance.get("description") or ""
format_hint = guidance.get("format") or ""
example = guidance.get("example") or ""
warning = guidance.get("warning") or ""
dependencies = guidance.get("dependencies") or ""
type_label = guidance.get("type") or summary.get("type") or definition.type_label
lines = [
f"🧩 <b>{summary['name']}</b>",
f"🔑 <b>Ключ:</b> <code>{summary['key']}</code>",
f"📁 <b>Категория:</b> {summary['category_label']}",
f"📝 <b>Тип:</b> {guidance['type']}",
f"📌 <b>Текущее:</b> {summary['current']}",
f"📦 <b>По умолчанию:</b> {summary['original']}",
f"✳️ <b>Переопределено:</b> {'Да' if summary['has_override'] else 'Нет'}",
*(
["🔒 <b>Режим:</b> Только для чтения (управляется автоматически)"]
if summary.get("is_read_only")
else []
),
"",
f"📘 <b>Описание:</b> {guidance['description']}",
f"📐 <b>Формат:</b> {guidance['format']}",
f"💡 <b>Пример:</b> {guidance['example']}",
f"⚠️ <b>Важно:</b> {guidance['warning']}",
f"🔗 <b>Связанные настройки:</b> {guidance['dependencies']}",
f"🔑 Ключ: <code>{summary['key']}</code>",
f"📁 Категория: {summary['category_label']}",
f"📝 Тип: {type_label}",
f"📌 Текущее: {summary['current']}",
]
original_value = summary.get("original")
if original_value not in {None, ""}:
lines.append(f"📦 По умолчанию: {original_value}")
lines.append(f"✳️ Переопределено: {'Да' if summary['has_override'] else 'Нет'}")
if summary.get("is_read_only"):
lines.append("🔒 Режим: Только для чтения (управляется автоматически)")
lines.append("")
if description:
lines.append(f"📘 Описание: {description}")
if format_hint:
lines.append(f"📐 Формат: {format_hint}")
if example:
lines.append(f"💡 Пример: {example}")
if warning:
lines.append(f"⚠️ Важно: {warning}")
if dependencies:
lines.append(f"🔗 Связанные: {dependencies}")
choices = bot_configuration_service.get_choice_options(key)
if choices:
current_raw = bot_configuration_service.get_current_value(key)
lines.append("")
lines.append("📋 <b>Доступные значения:</b>")
lines.append("📋 Доступные значения:")
for option in choices:
marker = "" if current_raw == option.value else ""
value_display = bot_configuration_service.format_value_human(key, option.value)
description = option.description or ""
base_line = f"{marker} {option.label} — <code>{value_display}</code>"
if description:
lines.append(
f"{marker} {option.label} — <code>{value_display}</code>\n {description}"
)
else:
lines.append(f"{marker} {option.label} — <code>{value_display}</code>")
base_line += f"\n{description}"
lines.append(base_line)
return "\n".join(lines)
@@ -1540,13 +1561,25 @@ async def show_bot_config_group(
keyboard = _build_categories_keyboard(group_key, group_title, items, page)
status_icon, status_text = _get_group_status(group_key)
description = _get_group_description(group_key)
lines = [f"{status_icon} <b>{group_title}</b>"]
if description:
lines.append(description)
icon = _get_group_icon(group_key)
raw_title = str(group_title).strip()
clean_title = raw_title
if icon and raw_title.startswith(icon):
clean_title = raw_title[len(icon) :].strip()
elif " " in raw_title:
possible_icon, remainder = raw_title.split(" ", 1)
if possible_icon:
icon = possible_icon
clean_title = remainder.strip()
lines = [f"{icon} <b>{clean_title}</b>"]
if status_text:
lines.append(f"Статус: {status_text}")
lines.append(f"Статус: {status_icon} {status_text}")
lines.append(f"🏠 → {clean_title}")
if description:
lines.append("")
lines.append(description)
lines.append("")
lines.append("📂 Выберите категорию настроек:")
lines.append("📂 Категории группы:")
await callback.message.edit_text(
"\n".join(lines),
reply_markup=keyboard,
@@ -1575,6 +1608,17 @@ async def show_bot_config_category(
category_description = bot_configuration_service.get_category_description(category_key)
group_meta = _get_group_meta(group_key)
group_title = str(group_meta.get("title", group_key))
group_icon = _get_group_icon(group_key)
raw_group_title = group_title.strip()
if group_icon and raw_group_title.startswith(group_icon):
group_plain_title = raw_group_title[len(group_icon) :].strip()
elif " " in raw_group_title:
possible_icon, remainder = raw_group_title.split(" ", 1)
group_plain_title = remainder.strip()
if possible_icon:
group_icon = possible_icon
else:
group_plain_title = raw_group_title
keyboard = _build_settings_keyboard(
category_key,
group_key,
@@ -1584,12 +1628,12 @@ async def show_bot_config_category(
)
text_lines = [
f"🗂 <b>{category_label}</b>",
f"Навигация: 🏠 Главное{group_title}{category_label}",
f"🏠{group_plain_title}{category_label}",
]
if category_description:
text_lines.append(category_description)
text_lines.append("")
text_lines.append("📋 Выберите настройку для просмотра или редактирования:")
text_lines.append("📋 Список настроек категории:")
await callback.message.edit_text(
"\n".join(text_lines),
reply_markup=keyboard,
+466 -23
View File
@@ -1,8 +1,16 @@
import logging
import math
from datetime import datetime
from typing import Any, Dict, Optional
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from app.states import SquadRenameStates, SquadCreateStates, SquadMigrationStates
from app.states import (
RemnaWaveSyncStates,
SquadRenameStates,
SquadCreateStates,
SquadMigrationStates,
)
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -19,6 +27,11 @@ from app.keyboards.admin import (
)
from app.localization.texts import get_texts
from app.services.remnawave_service import RemnaWaveService, RemnaWaveConfigurationError
from app.services.remnawave_sync_service import (
RemnaWaveAutoSyncStatus,
remnawave_sync_service,
)
from app.services.system_settings_service import bot_configuration_service
from app.utils.decorators import admin_required, error_handler
from app.utils.formatters import format_bytes, format_datetime
@@ -30,6 +43,149 @@ squad_create_data = {}
MIGRATION_PAGE_SIZE = 8
def _format_duration(seconds: float) -> str:
if seconds < 1:
return "<1с"
minutes, sec = divmod(int(seconds), 60)
if minutes:
if sec:
return f"{minutes} мин {sec} с"
return f"{minutes} мин"
return f"{sec} с"
def _format_user_stats(stats: Optional[Dict[str, Any]]) -> str:
if not stats:
return ""
created = stats.get("created", 0)
updated = stats.get("updated", 0)
deleted = stats.get("deleted", stats.get("deactivated", 0))
errors = stats.get("errors", 0)
return (
f"• Создано: {created}\n"
f"• Обновлено: {updated}\n"
f"• Деактивировано: {deleted}\n"
f"• Ошибок: {errors}"
)
def _format_server_stats(stats: Optional[Dict[str, Any]]) -> str:
if not stats:
return ""
created = stats.get("created", 0)
updated = stats.get("updated", 0)
removed = stats.get("removed", 0)
total = stats.get("total", 0)
return (
f"• Создано: {created}\n"
f"• Обновлено: {updated}\n"
f"• Удалено: {removed}\n"
f"• Всего в панели: {total}"
)
def _build_auto_sync_view(status: RemnaWaveAutoSyncStatus) -> tuple[str, types.InlineKeyboardMarkup]:
times_text = ", ".join(t.strftime("%H:%M") for t in status.times) if status.times else ""
next_run_text = format_datetime(status.next_run) if status.next_run else ""
if status.last_run_finished_at:
finished_text = format_datetime(status.last_run_finished_at)
started_text = (
format_datetime(status.last_run_started_at)
if status.last_run_started_at
else ""
)
duration = (
status.last_run_finished_at - status.last_run_started_at
if status.last_run_started_at
else None
)
duration_text = f" ({_format_duration(duration.total_seconds())})" if duration else ""
reason_map = {
"manual": "вручную",
"auto": "по расписанию",
"immediate": "при включении",
}
reason_text = reason_map.get(status.last_run_reason or "", "")
result_icon = "" if status.last_run_success else ""
result_label = "успешно" if status.last_run_success else "с ошибками"
error_block = (
f"\n⚠️ Ошибка: {status.last_run_error}"
if status.last_run_error
else ""
)
last_run_text = (
f"{result_icon} {result_label}\n"
f"• Старт: {started_text}\n"
f"• Завершено: {finished_text}{duration_text}\n"
f"• Причина запуска: {reason_text}{error_block}"
)
elif status.last_run_started_at:
last_run_text = (
"⏳ Синхронизация началась, но еще не завершилась"
if status.is_running
else f"ℹ️ Последний запуск: {format_datetime(status.last_run_started_at)}"
)
else:
last_run_text = ""
running_text = "⏳ Выполняется сейчас" if status.is_running else "Ожидание"
toggle_text = "❌ Отключить" if status.enabled else "✅ Включить"
text = f"""🔄 <b>Автосинхронизация RemnaWave</b>
<b>Статус:</b> {'✅ Включена' if status.enabled else '❌ Отключена'}
🕒 <b>Расписание:</b> {times_text}
📅 <b>Следующий запуск:</b> {next_run_text if status.enabled else ''}
<b>Состояние:</b> {running_text}
📊 <b>Последний запуск:</b>
{last_run_text}
👥 <b>Пользователи:</b>
{_format_user_stats(status.last_user_stats)}
🌐 <b>Серверы:</b>
{_format_server_stats(status.last_server_stats)}
"""
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text="🔁 Запустить сейчас",
callback_data="remnawave_auto_sync_run",
)
],
[
types.InlineKeyboardButton(
text=toggle_text,
callback_data="remnawave_auto_sync_toggle",
)
],
[
types.InlineKeyboardButton(
text="🕒 Изменить расписание",
callback_data="remnawave_auto_sync_times",
)
],
[
types.InlineKeyboardButton(
text="⬅️ Назад",
callback_data="admin_rw_sync",
)
],
]
)
return text, keyboard
def _format_migration_server_label(texts, server) -> str:
status = (
texts.t("ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE", "✅ Доступен")
@@ -2096,34 +2252,310 @@ async def show_sync_options(
db_user: User,
db: AsyncSession
):
text = """
🔄 <b>Синхронизация с Remnawave</b>
status = remnawave_sync_service.get_status()
times_text = ", ".join(t.strftime("%H:%M") for t in status.times) if status.times else ""
next_run_text = format_datetime(status.next_run) if status.next_run else ""
last_result = ""
🔄 <b>Полная синхронизация выполняет:</b>
Создание новых пользователей из панели в боте
Обновление данных существующих пользователей
Деактивация подписок пользователей, отсутствующих в панели
Сохранение балансов пользователей
Время выполнения: 2-5 минут
if status.last_run_finished_at:
result_icon = "" if status.last_run_success else ""
result_label = "успешно" if status.last_run_success else "с ошибками"
finished_text = format_datetime(status.last_run_finished_at)
last_result = f"{result_icon} {result_label} ({finished_text})"
elif status.last_run_started_at:
last_result = f"⏳ Запущено {format_datetime(status.last_run_started_at)}"
<b>Важно:</b>
Во время синхронизации не выполняйте другие операции
При полной синхронизации подписки пользователей, отсутствующих в панели, будут деактивированы
Рекомендуется делать полную синхронизацию ежедневно
Баланс пользователей НЕ удаляется
"""
keyboard = [
[types.InlineKeyboardButton(text="🔄 Запустить полную синхронизацию", callback_data="sync_all_users")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")]
status_lines = [
f"⚙️ Статус: {'✅ Включена' if status.enabled else '❌ Отключена'}",
f"🕒 Расписание: {times_text}",
f"📅 Следующий запуск: {next_run_text if status.enabled else ''}",
f"📊 Последний запуск: {last_result}",
]
text = (
"🔄 <b>Синхронизация с Remnawave</b>\n\n"
"🔄 <b>Полная синхронизация выполняет:</b>\n"
"• Создание новых пользователей из панели в боте\n"
"• Обновление данных существующих пользователей\n"
"• Деактивация подписок пользователей, отсутствующих в панели\n"
"• Сохранение балансов пользователей\n"
"• ⏱️ Время выполнения: 2-5 минут\n\n"
"⚠️ <b>Важно:</b>\n"
"• Во время синхронизации не выполняйте другие операции\n"
"• При полной синхронизации подписки пользователей, отсутствующих в панели, будут деактивированы\n"
"• Рекомендуется делать полную синхронизацию ежедневно\n"
"• Баланс пользователей НЕ удаляется\n\n"
+ "\n".join(status_lines)
)
keyboard = [
[
types.InlineKeyboardButton(
text="🔄 Запустить полную синхронизацию",
callback_data="sync_all_users",
)
],
[
types.InlineKeyboardButton(
text="⚙️ Настройки автосинхронизации",
callback_data="admin_rw_auto_sync",
)
],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")],
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
@admin_required
@error_handler
async def show_auto_sync_settings(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await state.clear()
status = remnawave_sync_service.get_status()
text, keyboard = _build_auto_sync_view(status)
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="HTML",
)
await callback.answer()
@admin_required
@error_handler
async def toggle_auto_sync_setting(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await state.clear()
new_value = not bool(settings.REMNAWAVE_AUTO_SYNC_ENABLED)
await bot_configuration_service.set_value(
db,
"REMNAWAVE_AUTO_SYNC_ENABLED",
new_value,
)
await db.commit()
status = remnawave_sync_service.get_status()
text, keyboard = _build_auto_sync_view(status)
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="HTML",
)
await callback.answer(
f"Автосинхронизация {'включена' if new_value else 'отключена'}"
)
@admin_required
@error_handler
async def prompt_auto_sync_schedule(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
status = remnawave_sync_service.get_status()
current_schedule = ", ".join(t.strftime("%H:%M") for t in status.times) if status.times else ""
instructions = (
"🕒 <b>Настройка расписания автосинхронизации</b>\n\n"
"Укажите время запуска через запятую или с новой строки в формате HH:MM.\n"
f"Текущее расписание: <code>{current_schedule}</code>\n\n"
"Примеры: <code>03:00, 15:30</code> или <code>00:15\n06:00\n18:45</code>\n\n"
"Отправьте <b>отмена</b>, чтобы вернуться без изменений."
)
await state.set_state(RemnaWaveSyncStates.waiting_for_schedule)
await state.update_data(
auto_sync_message_id=callback.message.message_id,
auto_sync_message_chat_id=callback.message.chat.id,
)
await callback.message.edit_text(
instructions,
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text="❌ Отмена",
callback_data="remnawave_auto_sync_cancel",
)
]
]
),
)
await callback.answer()
@admin_required
@error_handler
async def cancel_auto_sync_schedule(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await state.clear()
status = remnawave_sync_service.get_status()
text, keyboard = _build_auto_sync_view(status)
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="HTML",
)
await callback.answer("Изменение расписания отменено")
@admin_required
@error_handler
async def run_auto_sync_now(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
if remnawave_sync_service.get_status().is_running:
await callback.answer("Синхронизация уже выполняется", show_alert=True)
return
await state.clear()
await callback.message.edit_text(
"🔄 Запуск автосинхронизации...\n\nПодождите, это может занять несколько минут.",
parse_mode="HTML",
)
await callback.answer("Автосинхронизация запущена")
result = await remnawave_sync_service.run_sync_now(reason="manual")
status = remnawave_sync_service.get_status()
base_text, keyboard = _build_auto_sync_view(status)
if not result.get("started"):
await callback.message.edit_text(
"⚠️ <b>Синхронизация уже выполняется</b>\n\n" + base_text,
reply_markup=keyboard,
parse_mode="HTML",
)
return
if result.get("success"):
user_stats = result.get("user_stats") or {}
server_stats = result.get("server_stats") or {}
summary = (
"✅ <b>Синхронизация завершена</b>\n"
f"👥 Пользователи: создано {user_stats.get('created', 0)}, обновлено {user_stats.get('updated', 0)}, "
f"деактивировано {user_stats.get('deleted', user_stats.get('deactivated', 0))}, ошибок {user_stats.get('errors', 0)}\n"
f"🌐 Серверы: создано {server_stats.get('created', 0)}, обновлено {server_stats.get('updated', 0)}, удалено {server_stats.get('removed', 0)}\n\n"
)
final_text = summary + base_text
await callback.message.edit_text(
final_text,
reply_markup=keyboard,
parse_mode="HTML",
)
else:
error_text = result.get("error") or "Неизвестная ошибка"
summary = (
"❌ <b>Синхронизация завершилась с ошибкой</b>\n"
f"Причина: {error_text}\n\n"
)
await callback.message.edit_text(
summary + base_text,
reply_markup=keyboard,
parse_mode="HTML",
)
@admin_required
@error_handler
async def save_auto_sync_schedule(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
text = (message.text or "").strip()
data = await state.get_data()
if text.lower() in {"отмена", "cancel"}:
await state.clear()
status = remnawave_sync_service.get_status()
view_text, keyboard = _build_auto_sync_view(status)
message_id = data.get("auto_sync_message_id")
chat_id = data.get("auto_sync_message_chat_id", message.chat.id)
if message_id:
await message.bot.edit_message_text(
view_text,
chat_id=chat_id,
message_id=message_id,
reply_markup=keyboard,
parse_mode="HTML",
)
else:
await message.answer(
view_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await message.answer("Настройка расписания отменена")
return
parsed_times = settings.parse_daily_time_list(text)
if not parsed_times:
await message.answer(
"❌ Не удалось распознать время. Используйте формат HH:MM, например 03:00 или 18:45.",
)
return
normalized_value = ", ".join(t.strftime("%H:%M") for t in parsed_times)
await bot_configuration_service.set_value(
db,
"REMNAWAVE_AUTO_SYNC_TIMES",
normalized_value,
)
await db.commit()
status = remnawave_sync_service.get_status()
view_text, keyboard = _build_auto_sync_view(status)
message_id = data.get("auto_sync_message_id")
chat_id = data.get("auto_sync_message_chat_id", message.chat.id)
if message_id:
await message.bot.edit_message_text(
view_text,
chat_id=chat_id,
message_id=message_id,
reply_markup=keyboard,
parse_mode="HTML",
)
else:
await message.answer(
view_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await state.clear()
await message.answer("✅ Расписание автосинхронизации обновлено")
@admin_required
@error_handler
async def sync_all_users(
@@ -2688,6 +3120,11 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(manage_node, F.data.startswith("node_restart_"))
dp.callback_query.register(restart_all_nodes, F.data == "admin_restart_all_nodes")
dp.callback_query.register(show_sync_options, F.data == "admin_rw_sync")
dp.callback_query.register(show_auto_sync_settings, F.data == "admin_rw_auto_sync")
dp.callback_query.register(toggle_auto_sync_setting, F.data == "remnawave_auto_sync_toggle")
dp.callback_query.register(prompt_auto_sync_schedule, F.data == "remnawave_auto_sync_times")
dp.callback_query.register(cancel_auto_sync_schedule, F.data == "remnawave_auto_sync_cancel")
dp.callback_query.register(run_auto_sync_now, F.data == "remnawave_auto_sync_run")
dp.callback_query.register(sync_all_users, F.data == "sync_all_users")
dp.callback_query.register(show_squad_migration_menu, F.data == "admin_rw_migration")
dp.callback_query.register(paginate_migration_source, F.data.startswith("admin_migration_source_page_"))
@@ -2716,13 +3153,19 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(finish_squad_creation, F.data == "create_squad_finish")
dp.message.register(
process_squad_new_name,
process_squad_new_name,
SquadRenameStates.waiting_for_new_name,
F.text
)
dp.message.register(
process_squad_name,
SquadCreateStates.waiting_for_name,
F.text
)
dp.message.register(
save_auto_sync_schedule,
RemnaWaveSyncStates.waiting_for_schedule,
F.text,
)
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
from .main import register_balance_handlers
__all__ = ["register_balance_handlers"]
+241
View File
@@ -0,0 +1,241 @@
import logging
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.utils.decorators import error_handler
from app.states import BalanceStates
logger = logging.getLogger(__name__)
@error_handler
async def start_cryptobot_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_cryptobot_enabled():
await callback.answer("❌ Оплата криптовалютой временно недоступна", show_alert=True)
return
from app.utils.currency_converter import currency_converter
try:
current_rate = await currency_converter.get_usd_to_rub_rate()
rate_text = f"💱 Текущий курс: 1 USD = {current_rate:.2f}"
except Exception as e:
logger.warning(f"Не удалось получить курс валют: {e}")
current_rate = 95.0
rate_text = f"💱 Курс: 1 USD ≈ {current_rate:.0f}"
available_assets = settings.get_cryptobot_assets()
assets_text = ", ".join(available_assets)
# Формируем текст сообщения в зависимости от настройки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
message_text = (
f"🪙 <b>Пополнение криптовалютой</b>\n\n"
f"Выберите сумму пополнения или введите вручную сумму "
f"от 100 до 100,000 ₽:\n\n"
f"💰 Доступные активы: {assets_text}\n"
f"⚡ Мгновенное зачисление на баланс\n"
f"🔒 Безопасная оплата через CryptoBot\n\n"
f"{rate_text}\n"
f"Сумма будет автоматически конвертирована в USD для оплаты."
)
else:
message_text = (
f"🪙 <b>Пополнение криптовалютой</b>\n\n"
f"Введите сумму для пополнения от 100 до 100,000 ₽:\n\n"
f"💰 Доступные активы: {assets_text}\n"
f"⚡ Мгновенное зачисление на баланс\n"
f"🔒 Безопасная оплата через CryptoBot\n\n"
f"{rate_text}\n"
f"Сумма будет автоматически конвертирована в USD для оплаты."
)
# Создаем клавиатуру
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)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="cryptobot", current_rate=current_rate)
await callback.answer()
@error_handler
async def process_cryptobot_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_cryptobot_enabled():
await message.answer("❌ Оплата криптовалютой временно недоступна")
return
amount_rubles = amount_kopeks / 100
if amount_rubles < 100:
await message.answer("Минимальная сумма пополнения: 100 ₽")
return
if amount_rubles > 100000:
await message.answer("Максимальная сумма пополнения: 100,000 ₽")
return
try:
data = await state.get_data()
current_rate = data.get('current_rate')
if not current_rate:
from app.utils.currency_converter import currency_converter
current_rate = await currency_converter.get_usd_to_rub_rate()
amount_usd = amount_rubles / current_rate
amount_usd = round(amount_usd, 2)
if amount_usd < 1:
await message.answer("❌ Минимальная сумма для оплаты в USD: 1.00 USD")
return
if amount_usd > 1000:
await message.answer("❌ Максимальная сумма для оплаты в USD: 1,000 USD")
return
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_cryptobot_payment(
db=db,
user_id=db_user.id,
amount_usd=amount_usd,
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
description=f"Пополнение баланса на {amount_rubles:.0f} ₽ ({amount_usd:.2f} USD)",
payload=f"balance_{db_user.id}_{amount_kopeks}"
)
if not payment_result:
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
return
bot_invoice_url = payment_result.get("bot_invoice_url")
mini_app_invoice_url = payment_result.get("mini_app_invoice_url")
payment_url = bot_invoice_url or mini_app_invoice_url
if not payment_url:
await message.answer("❌ Ошибка получения ссылки для оплаты. Обратитесь в поддержку.")
await state.clear()
return
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🪙 Оплатить", url=payment_url)],
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_cryptobot_{payment_result['local_payment_id']}")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await message.answer(
f"🪙 <b>Оплата криптовалютой</b>\n\n"
f"💰 Сумма к зачислению: {amount_rubles:.0f}\n"
f"💵 К оплате: {amount_usd:.2f} USD\n"
f"🪙 Актив: {payment_result['asset']}\n"
f"💱 Курс: 1 USD = {current_rate:.2f}\n"
f"🆔 ID платежа: {payment_result['invoice_id'][:8]}...\n\n"
f"📱 <b>Инструкция:</b>\n"
f"1. Нажмите кнопку 'Оплатить'\n"
f"2. Выберите удобный актив\n"
f"3. Переведите указанную сумму\n"
f"4. Деньги поступят на баланс автоматически\n\n"
f"🔒 Оплата проходит через защищенную систему CryptoBot\n"
f"⚡ Поддерживаемые активы: USDT, TON, BTC, ETH\n\n"
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
reply_markup=keyboard,
parse_mode="HTML"
)
await state.clear()
logger.info(f"Создан CryptoBot платеж для пользователя {db_user.telegram_id}: "
f"{amount_rubles:.0f} ₽ ({amount_usd:.2f} USD), ID: {payment_result['invoice_id']}")
except Exception as e:
logger.error(f"Ошибка создания CryptoBot платежа: {e}")
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
@error_handler
async def check_cryptobot_payment_status(
callback: types.CallbackQuery,
db: AsyncSession
):
try:
local_payment_id = int(callback.data.split('_')[-1])
from app.database.crud.cryptobot import get_cryptobot_payment_by_id
payment = await get_cryptobot_payment_by_id(db, local_payment_id)
if not payment:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
status_emoji = {
"active": "",
"paid": "",
"expired": ""
}
status_text = {
"active": "Ожидает оплаты",
"paid": "Оплачен",
"expired": "Истек"
}
emoji = status_emoji.get(payment.status, "")
status = status_text.get(payment.status, "Неизвестно")
message_text = (f"🪙 Статус платежа:\n\n"
f"🆔 ID: {payment.invoice_id[:8]}...\n"
f"💰 Сумма: {payment.amount} {payment.asset}\n"
f"📊 Статус: {emoji} {status}\n"
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n")
if payment.is_paid:
message_text += "\n✅ Платеж успешно завершен!\n\nСредства зачислены на баланс."
elif payment.is_pending:
message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше."
elif payment.is_expired:
message_text += (
f"\n❌ Платеж истек. Обратитесь в {settings.get_support_contact_display()}"
)
await callback.answer(message_text, show_alert=True)
except Exception as e:
logger.error(f"Ошибка проверки статуса CryptoBot платежа: {e}")
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
+728
View File
@@ -0,0 +1,728 @@
import html
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.states import BalanceStates
from app.database.crud.user import add_user_balance
from app.database.crud.transaction import (
get_user_transactions, get_user_transactions_count,
create_transaction
)
from app.database.models import User, TransactionType, PaymentMethod
from app.keyboards.inline import (
get_balance_keyboard, get_payment_methods_keyboard,
get_back_keyboard, get_pagination_keyboard
)
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.utils.pagination import paginate_list
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
TRANSACTIONS_PER_PAGE = 10
def get_quick_amount_buttons(language: str) -> list:
if not settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED or settings.DISABLE_TOPUP_BUTTONS:
return []
buttons = []
periods = settings.get_available_subscription_periods()
periods = periods[:6]
for period in periods:
price_attr = f"PRICE_{period}_DAYS"
if hasattr(settings, price_attr):
price_kopeks = getattr(settings, price_attr)
price_rubles = price_kopeks // 100
callback_data = f"quick_amount_{price_kopeks}"
buttons.append(
types.InlineKeyboardButton(
text=f"{price_rubles} ₽ ({period} дней)",
callback_data=callback_data
)
)
keyboard_rows = []
for i in range(0, len(buttons), 2):
keyboard_rows.append(buttons[i:i + 2])
return keyboard_rows
@error_handler
async def show_balance_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
balance_text = texts.BALANCE_INFO.format(
balance=texts.format_price(db_user.balance_kopeks)
)
await callback.message.edit_text(
balance_text,
reply_markup=get_balance_keyboard(db_user.language)
)
await callback.answer()
@error_handler
async def show_balance_history(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
page: int = 1
):
texts = get_texts(db_user.language)
offset = (page - 1) * TRANSACTIONS_PER_PAGE
raw_transactions = await get_user_transactions(
db, db_user.id,
limit=TRANSACTIONS_PER_PAGE * 3,
offset=offset
)
seen_transactions = set()
unique_transactions = []
for transaction in raw_transactions:
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
transaction_key = (
transaction.amount_kopeks,
transaction.description,
rounded_time
)
if transaction_key not in seen_transactions:
seen_transactions.add(transaction_key)
unique_transactions.append(transaction)
if len(unique_transactions) >= TRANSACTIONS_PER_PAGE:
break
all_transactions = await get_user_transactions(db, db_user.id, limit=1000)
seen_all = set()
total_unique = 0
for transaction in all_transactions:
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
transaction_key = (
transaction.amount_kopeks,
transaction.description,
rounded_time
)
if transaction_key not in seen_all:
seen_all.add(transaction_key)
total_unique += 1
if not unique_transactions:
await callback.message.edit_text(
"📊 История операций пуста",
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
return
text = "📊 <b>История операций</b>\n\n"
for transaction in unique_transactions:
emoji = "💰" if transaction.type == TransactionType.DEPOSIT.value else "💸"
amount_text = f"+{texts.format_price(transaction.amount_kopeks)}" if transaction.type == TransactionType.DEPOSIT.value else f"-{texts.format_price(transaction.amount_kopeks)}"
text += f"{emoji} {amount_text}\n"
text += f"📝 {transaction.description}\n"
text += f"📅 {transaction.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
keyboard = []
total_pages = (total_unique + TRANSACTIONS_PER_PAGE - 1) // TRANSACTIONS_PER_PAGE
if total_pages > 1:
pagination_row = get_pagination_keyboard(
page, total_pages, "balance_history", db_user.language
)
keyboard.extend(pagination_row)
keyboard.append([
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await callback.answer()
@error_handler
async def handle_balance_history_pagination(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
page = int(callback.data.split('_')[-1])
await show_balance_history(callback, db_user, db, page)
@error_handler
async def show_payment_methods(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
from app.utils.payment_utils import get_payment_methods_text
texts = get_texts(db_user.language)
payment_text = get_payment_methods_text(db_user.language)
await callback.message.edit_text(
payment_text,
reply_markup=get_payment_methods_keyboard(0, db_user.language),
parse_mode="HTML"
)
await callback.answer()
@error_handler
async def handle_payment_methods_unavailable(
callback: types.CallbackQuery,
db_user: User
):
texts = get_texts(db_user.language)
await callback.answer(
texts.t(
"PAYMENT_METHODS_UNAVAILABLE_ALERT",
"⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
),
show_alert=True
)
@error_handler
async def handle_successful_topup_with_cart(
user_id: int,
amount_kopeks: int,
bot,
db: AsyncSession
):
from app.database.crud.user import get_user_by_id
from aiogram.fsm.context import FSMContext
from aiogram.fsm.storage.base import StorageKey
from app.bot import dp
user = await get_user_by_id(db, user_id)
if not user:
return
storage = dp.storage
key = StorageKey(bot_id=bot.id, chat_id=user.telegram_id, user_id=user.telegram_id)
try:
state_data = await storage.get_data(key)
current_state = await storage.get_state(key)
if (current_state == "SubscriptionStates:cart_saved_for_topup" and
state_data.get('saved_cart')):
texts = get_texts(user.language)
total_price = state_data.get('total_price', 0)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text="🛒 Вернуться к оформлению подписки",
callback_data="return_to_saved_cart"
)],
[types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance"
)],
[types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu"
)]
])
success_text = (
f"✅ Баланс пополнен на {texts.format_price(amount_kopeks)}!\n\n"
f"💰 Текущий баланс: {texts.format_price(user.balance_kopeks)}\n\n"
f"🛒 У вас есть сохраненная корзина подписки\n"
f"Стоимость: {texts.format_price(total_price)}\n\n"
f"Хотите продолжить оформление?"
)
await bot.send_message(
chat_id=user.telegram_id,
text=success_text,
reply_markup=keyboard,
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка обработки успешного пополнения с корзиной: {e}")
@error_handler
async def request_support_topup(
callback: types.CallbackQuery,
db_user: User
):
texts = get_texts(db_user.language)
support_text = f"""
🛠 <b>Пополнение через поддержку</b>
Для пополнения баланса обратитесь в техподдержку:
{settings.get_support_contact_display_html()}
Укажите:
ID: {db_user.telegram_id}
Сумму пополнения
Способ оплаты
Время обработки: 1-24 часа
<b>Доступные способы:</b>
Криптовалюта
Переводы между банками
Другие платежные системы
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text="💬 Написать в поддержку",
url=settings.get_support_contact_url() or "https://t.me/"
)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await callback.message.edit_text(
support_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await callback.answer()
@error_handler
async def process_topup_amount(
message: types.Message,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
try:
if not message.text:
if message.successful_payment:
logger.info(
"Получено сообщение об успешном платеже без текста, "
"обработчик суммы пополнения завершает работу"
)
await state.clear()
return
await message.answer(
texts.INVALID_AMOUNT,
reply_markup=get_back_keyboard(db_user.language)
)
return
amount_text = message.text.strip()
if not amount_text:
await message.answer(
texts.INVALID_AMOUNT,
reply_markup=get_back_keyboard(db_user.language)
)
return
amount_rubles = float(amount_text.replace(',', '.'))
if amount_rubles < 1:
await message.answer("Минимальная сумма пополнения: 1 ₽")
return
if amount_rubles > 50000:
await message.answer("Максимальная сумма пополнения: 50,000 ₽")
return
amount_kopeks = int(amount_rubles * 100)
data = await state.get_data()
payment_method = data.get("payment_method", "stars")
if payment_method in ["yookassa", "yookassa_sbp"]:
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Минимальная сумма для оплаты через YooKassa: {min_rubles:.0f}")
return
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f}".replace(',', ' '))
return
if payment_method == "stars":
from .stars import process_stars_payment_amount
await process_stars_payment_amount(message, db_user, amount_kopeks, state)
elif payment_method == "yookassa":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "yookassa_sbp":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_sbp_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_sbp_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "mulenpay":
from app.database.database import AsyncSessionLocal
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 == "wata":
from app.database.database import AsyncSessionLocal
from .wata import process_wata_payment_amount
async with AsyncSessionLocal() as db:
await process_wata_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "pal24":
from app.database.database import AsyncSessionLocal
from .pal24 import process_pal24_payment_amount
async with AsyncSessionLocal() as db:
await process_pal24_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "cryptobot":
from app.database.database import AsyncSessionLocal
from .cryptobot import process_cryptobot_payment_amount
async with AsyncSessionLocal() as db:
await process_cryptobot_payment_amount(message, db_user, db, amount_kopeks, state)
else:
await message.answer("Неизвестный способ оплаты")
except ValueError:
await message.answer(
texts.INVALID_AMOUNT,
reply_markup=get_back_keyboard(db_user.language)
)
@error_handler
async def handle_sbp_payment(
callback: types.CallbackQuery,
db: AsyncSession
):
try:
local_payment_id = int(callback.data.split('_')[-1])
from app.database.crud.yookassa import get_yookassa_payment_by_local_id
payment = await get_yookassa_payment_by_local_id(db, local_payment_id)
if not payment:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
import json
metadata = json.loads(payment.metadata_json) if payment.metadata_json else {}
confirmation_token = metadata.get("confirmation_token")
if not confirmation_token:
await callback.answer("❌ Токен подтверждения не найден", show_alert=True)
return
await callback.message.answer(
f"Для оплаты через СБП откройте приложение вашего банка и подтвердите платеж.\\n\\n"
f"Если у вас не открылось банковское приложение автоматически, вы можете:\\n"
f"1. Скопировать этот токен: <code>{confirmation_token}</code>\\n"
f"2. Открыть приложение вашего банка\\n"
f"3. Найти функцию оплаты по токену\\n"
f"4. Вставить токен и подтвердить платеж",
parse_mode="HTML"
)
await callback.answer("Информация об оплате отправлена", show_alert=True)
except Exception as e:
logger.error(f"Ошибка обработки embedded платежа СБП: {e}")
await callback.answer("❌ Ошибка обработки платежа", show_alert=True)
@error_handler
async def handle_quick_amount_selection(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
"""
Обработчик выбора суммы через кнопки быстрого выбора
"""
# Извлекаем сумму из callback_data
try:
amount_kopeks = int(callback.data.split('_')[-1])
amount_rubles = amount_kopeks / 100
# Получаем метод оплаты из состояния
data = await state.get_data()
payment_method = data.get("payment_method", "yookassa")
# Проверяем, какой метод оплаты был выбран и вызываем соответствующий обработчик
if payment_method == "yookassa":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "yookassa_sbp":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_sbp_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_sbp_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "mulenpay":
from app.database.database import AsyncSessionLocal
from .mulenpay import process_mulenpay_payment_amount
async with AsyncSessionLocal() as db:
await process_mulenpay_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
async with AsyncSessionLocal() as db:
await process_wata_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "pal24":
from app.database.database import AsyncSessionLocal
from .pal24 import process_pal24_payment_amount
async with AsyncSessionLocal() as db:
await process_pal24_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
else:
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
return
except ValueError:
await callback.answer("❌ Ошибка обработки суммы", show_alert=True)
except Exception as e:
logger.error(f"Ошибка обработки быстрого выбора суммы: {e}")
await callback.answer("❌ Ошибка обработки запроса", show_alert=True)
@error_handler
async def handle_topup_amount_callback(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
try:
_, method, amount_str = callback.data.split("|", 2)
amount_kopeks = int(amount_str)
except ValueError:
await callback.answer("❌ Некорректный запрос", show_alert=True)
return
if amount_kopeks <= 0:
await callback.answer("❌ Некорректная сумма", show_alert=True)
return
try:
if method == "yookassa":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "yookassa_sbp":
from app.database.database import AsyncSessionLocal
from .yookassa import process_yookassa_sbp_payment_amount
async with AsyncSessionLocal() as db:
await process_yookassa_sbp_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "mulenpay":
from app.database.database import AsyncSessionLocal
from .mulenpay import process_mulenpay_payment_amount
async with AsyncSessionLocal() as db:
await process_mulenpay_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
async with AsyncSessionLocal() as db:
await process_pal24_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "cryptobot":
from app.database.database import AsyncSessionLocal
from .cryptobot import process_cryptobot_payment_amount
async with AsyncSessionLocal() as db:
await process_cryptobot_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(
callback.message, db_user, amount_kopeks, state
)
elif method == "tribute":
from .tribute import start_tribute_payment
await start_tribute_payment(callback, db_user)
return
else:
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
return
await callback.answer()
except Exception as error:
logger.error(f"Ошибка быстрого пополнения: {error}")
await callback.answer("❌ Ошибка обработки запроса", show_alert=True)
def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(
show_balance_menu,
F.data == "menu_balance"
)
dp.callback_query.register(
show_balance_history,
F.data == "balance_history"
)
dp.callback_query.register(
handle_balance_history_pagination,
F.data.startswith("balance_history_page_")
)
dp.callback_query.register(
show_payment_methods,
F.data == "balance_topup"
)
from .stars import start_stars_payment
dp.callback_query.register(
start_stars_payment,
F.data == "topup_stars"
)
from .yookassa import start_yookassa_payment
dp.callback_query.register(
start_yookassa_payment,
F.data == "topup_yookassa"
)
from .yookassa import start_yookassa_sbp_payment
dp.callback_query.register(
start_yookassa_sbp_payment,
F.data == "topup_yookassa_sbp"
)
from .mulenpay import start_mulenpay_payment
dp.callback_query.register(
start_mulenpay_payment,
F.data == "topup_mulenpay"
)
from .wata import start_wata_payment
dp.callback_query.register(
start_wata_payment,
F.data == "topup_wata"
)
from .pal24 import start_pal24_payment
dp.callback_query.register(
start_pal24_payment,
F.data == "topup_pal24"
)
from .yookassa import check_yookassa_payment_status
dp.callback_query.register(
check_yookassa_payment_status,
F.data.startswith("check_yookassa_")
)
from .tribute import start_tribute_payment
dp.callback_query.register(
start_tribute_payment,
F.data == "topup_tribute"
)
dp.callback_query.register(
request_support_topup,
F.data == "topup_support"
)
from .yookassa import check_yookassa_payment_status
dp.callback_query.register(
check_yookassa_payment_status,
F.data.startswith("check_yookassa_")
)
dp.message.register(
process_topup_amount,
BalanceStates.waiting_for_amount
)
from .cryptobot import start_cryptobot_payment
dp.callback_query.register(
start_cryptobot_payment,
F.data == "topup_cryptobot"
)
from .cryptobot import check_cryptobot_payment_status
dp.callback_query.register(
check_cryptobot_payment_status,
F.data.startswith("check_cryptobot_")
)
from .mulenpay import check_mulenpay_payment_status
dp.callback_query.register(
check_mulenpay_payment_status,
F.data.startswith("check_mulenpay_")
)
from .wata import check_wata_payment_status
dp.callback_query.register(
check_wata_payment_status,
F.data.startswith("check_wata_")
)
from .pal24 import check_pal24_payment_status
dp.callback_query.register(
check_pal24_payment_status,
F.data.startswith("check_pal24_")
)
dp.callback_query.register(
handle_payment_methods_unavailable,
F.data == "payment_methods_unavailable"
)
# Регистрируем обработчик для кнопок быстрого выбора суммы
dp.callback_query.register(
handle_quick_amount_selection,
F.data.startswith("quick_amount_")
)
dp.callback_query.register(
handle_topup_amount_callback,
F.data.startswith("topup_amount|")
)
+238
View File
@@ -0,0 +1,238 @@
import logging
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.utils.decorators import error_handler
from app.states import BalanceStates
logger = logging.getLogger(__name__)
@error_handler
async def start_mulenpay_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_mulenpay_enabled():
await callback.answer("❌ Оплата через Mulen Pay временно недоступна", show_alert=True)
return
message_text = texts.t(
"MULENPAY_TOPUP_PROMPT",
(
"💳 <b>Оплата через Mulen Pay</b>\n\n"
"Введите сумму для пополнения от 100 до 100 000 ₽.\n"
"Оплата происходит через защищенную платформу Mulen Pay."
),
)
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)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="mulenpay")
await callback.answer()
@error_handler
async def process_mulenpay_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_mulenpay_enabled():
await message.answer("❌ Оплата через Mulen Pay временно недоступна")
return
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
await message.answer(
f"Минимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MIN_AMOUNT_KOPEKS)}"
)
return
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
await message.answer(
f"Максимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MAX_AMOUNT_KOPEKS)}"
)
return
amount_rubles = amount_kopeks / 100
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_mulenpay_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
language=db_user.language,
)
if not payment_result or not payment_result.get("payment_url"):
await message.answer(
texts.t(
"MULENPAY_PAYMENT_ERROR",
"❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
return
payment_url = payment_result.get("payment_url")
mulen_payment_id = payment_result.get("mulen_payment_id")
local_payment_id = payment_result.get("local_payment_id")
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"MULENPAY_PAY_BUTTON",
"💳 Оплатить через Mulen Pay",
),
url=payment_url,
)
],
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_mulenpay_{local_payment_id}",
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
)
payment_id_display = mulen_payment_id if mulen_payment_id is not None else local_payment_id
message_template = texts.t(
"MULENPAY_PAYMENT_INSTRUCTIONS",
(
"💳 <b>Оплата через Mulen Pay</b>\n\n"
"💰 Сумма: {amount}\n"
"🆔 ID платежа: {payment_id}\n\n"
"📱 <b>Инструкция:</b>\n"
"1. Нажмите кнопку 'Оплатить через Mulen Pay'\n"
"2. Следуйте подсказкам платежной системы\n"
"3. Подтвердите перевод\n"
"4. Средства зачислятся автоматически\n\n"
"❓ Если возникнут проблемы, обратитесь в {support}"
),
)
message_text = message_template.format(
amount=settings.format_price(amount_kopeks),
payment_id=payment_id_display,
support=settings.get_support_contact_display_html(),
)
await message.answer(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await state.clear()
logger.info(
"Создан MulenPay платеж для пользователя %s: %s₽, ID: %s",
db_user.telegram_id,
amount_rubles,
payment_id_display,
)
except Exception as e:
logger.error(f"Ошибка создания MulenPay платежа: {e}")
await message.answer(
texts.t(
"MULENPAY_PAYMENT_ERROR",
"❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
@error_handler
async def check_mulenpay_payment_status(
callback: types.CallbackQuery,
db: AsyncSession
):
try:
local_payment_id = int(callback.data.split('_')[-1])
payment_service = PaymentService(callback.bot)
status_info = await payment_service.get_mulenpay_payment_status(db, local_payment_id)
if not status_info:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
payment = status_info["payment"]
status_labels = {
"created": ("", "Ожидает оплаты"),
"processing": ("", "Обрабатывается"),
"success": ("", "Оплачен"),
"canceled": ("", "Отменен"),
"error": ("⚠️", "Ошибка"),
"hold": ("🔒", "Холд"),
"unknown": ("", "Неизвестно"),
}
emoji, status_text = status_labels.get(payment.status, ("", "Неизвестно"))
message_lines = [
"💳 Статус платежа Mulen Pay:\n\n",
f"🆔 ID: {payment.mulen_payment_id or payment.id}\n",
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n",
f"📊 Статус: {emoji} {status_text}\n",
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n",
]
if payment.is_paid:
message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.")
elif payment.status in {"created", "processing"}:
message_lines.append(
"\n⏳ Платеж еще не завершен. Завершите оплату по ссылке и проверьте статус позже."
)
if payment.payment_url:
message_lines.append(f"\n🔗 Ссылка на оплату: {payment.payment_url}")
elif payment.status in {"canceled", "error"}:
message_lines.append(
f"\n❌ Платеж не был завершен. Попробуйте создать новый платеж или обратитесь в {settings.get_support_contact_display()}"
)
message_text = "".join(message_lines)
if len(message_text) > 190:
await callback.message.answer(message_text)
await callback.answer("ℹ️ Статус платежа отправлен в чат", show_alert=True)
else:
await callback.answer(message_text, show_alert=True)
except Exception as e:
logger.error(f"Ошибка проверки статуса MulenPay: {e}")
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
+364
View File
@@ -0,0 +1,364 @@
import html
import logging
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.utils.decorators import error_handler
from app.states import BalanceStates
logger = logging.getLogger(__name__)
@error_handler
async def start_pal24_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_pal24_enabled():
await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True)
return
# Формируем текст сообщения в зависимости от доступных способов оплаты
if settings.is_pal24_sbp_button_visible() and settings.is_pal24_card_button_visible():
payment_methods_text = "СБП и банковской картой"
elif settings.is_pal24_sbp_button_visible():
payment_methods_text = "СБП"
elif settings.is_pal24_card_button_visible():
payment_methods_text = "банковской картой"
else:
# Если обе кнопки отключены, используем общий текст
payment_methods_text = "доступными способами"
message_text = texts.t(
"PAL24_TOPUP_PROMPT",
(
f"🏦 <b>Оплата через PayPalych ({payment_methods_text})</b>\n\n"
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
f"Оплата проходит через PayPalych ({payment_methods_text})."
),
)
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)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="pal24")
await callback.answer()
@error_handler
async def process_pal24_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_pal24_enabled():
await message.answer("❌ Оплата через PayPalych временно недоступна")
return
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
min_rubles = settings.PAL24_MIN_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f}")
return
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
max_rubles = settings.PAL24_MAX_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f}".replace(',', ' '))
return
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_pal24_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
language=db_user.language,
)
if not payment_result:
await message.answer(
texts.t(
"PAL24_PAYMENT_ERROR",
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
return
sbp_url = (
payment_result.get("sbp_url")
or payment_result.get("transfer_url")
)
card_url = payment_result.get("card_url")
fallback_url = (
payment_result.get("link_page_url")
or payment_result.get("link_url")
)
if not (sbp_url or card_url or fallback_url):
await message.answer(
texts.t(
"PAL24_PAYMENT_ERROR",
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
return
if not sbp_url:
sbp_url = fallback_url
bill_id = payment_result.get("bill_id")
local_payment_id = payment_result.get("local_payment_id")
pay_buttons: list[list[types.InlineKeyboardButton]] = []
steps: list[str] = []
step_counter = 1
default_sbp_text = texts.t(
"PAL24_SBP_PAY_BUTTON",
"🏦 Оплатить через PayPalych (СБП)",
)
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
if sbp_url and settings.is_pal24_sbp_button_visible():
pay_buttons.append(
[
types.InlineKeyboardButton(
text=sbp_button_text,
url=sbp_url,
)
]
)
steps.append(
texts.t(
"PAL24_INSTRUCTION_BUTTON",
"{step}. Нажмите кнопку «{button}»",
).format(step=step_counter, button=html.escape(sbp_button_text))
)
step_counter += 1
default_card_text = texts.t(
"PAL24_CARD_PAY_BUTTON",
"💳 Оплатить банковской картой (PayPalych)",
)
card_button_text = settings.get_pal24_card_button_text(default_card_text)
if card_url and card_url != sbp_url and settings.is_pal24_card_button_visible():
pay_buttons.append(
[
types.InlineKeyboardButton(
text=card_button_text,
url=card_url,
)
]
)
steps.append(
texts.t(
"PAL24_INSTRUCTION_BUTTON",
"{step}. Нажмите кнопку «{button}»",
).format(step=step_counter, button=html.escape(card_button_text))
)
step_counter += 1
if not pay_buttons and fallback_url and settings.is_pal24_sbp_button_visible():
pay_buttons.append(
[
types.InlineKeyboardButton(
text=sbp_button_text,
url=fallback_url,
)
]
)
steps.append(
texts.t(
"PAL24_INSTRUCTION_BUTTON",
"{step}. Нажмите кнопку «{button}»",
).format(step=step_counter, button=html.escape(sbp_button_text))
)
step_counter += 1
follow_template = texts.t(
"PAL24_INSTRUCTION_FOLLOW",
"{step}. Следуйте подсказкам платёжной системы",
)
steps.append(follow_template.format(step=step_counter))
step_counter += 1
confirm_template = texts.t(
"PAL24_INSTRUCTION_CONFIRM",
"{step}. Подтвердите перевод",
)
steps.append(confirm_template.format(step=step_counter))
step_counter += 1
success_template = texts.t(
"PAL24_INSTRUCTION_COMPLETE",
"{step}. Средства зачислятся автоматически",
)
steps.append(success_template.format(step=step_counter))
message_template = texts.t(
"PAL24_PAYMENT_INSTRUCTIONS",
(
"🏦 <b>Оплата через PayPalych</b>\n\n"
"💰 Сумма: {amount}\n"
"🆔 ID счета: {bill_id}\n\n"
"📱 <b>Инструкция:</b>\n{steps}\n\n"
"❓ Если возникнут проблемы, обратитесь в {support}"
),
)
keyboard_rows = pay_buttons + [
[
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)
message_text = message_template.format(
amount=settings.format_price(amount_kopeks),
bill_id=bill_id,
steps="\n".join(steps),
support=settings.get_support_contact_display_html(),
)
await message.answer(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await state.clear()
logger.info(
"Создан PayPalych счет для пользователя %s: %s₽, ID: %s",
db_user.telegram_id,
amount_kopeks / 100,
bill_id,
)
except Exception as e:
logger.error(f"Ошибка создания PayPalych платежа: {e}")
await message.answer(
texts.t(
"PAL24_PAYMENT_ERROR",
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
@error_handler
async def check_pal24_payment_status(
callback: types.CallbackQuery,
db: AsyncSession,
):
try:
local_payment_id = int(callback.data.split('_')[-1])
payment_service = PaymentService(callback.bot)
status_info = await payment_service.get_pal24_payment_status(db, local_payment_id)
if not status_info:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
payment = status_info["payment"]
status_labels = {
"NEW": ("", "Ожидает оплаты"),
"PROCESS": ("", "Обрабатывается"),
"SUCCESS": ("", "Оплачен"),
"FAIL": ("", "Отменен"),
"UNDERPAID": ("⚠️", "Недоплата"),
"OVERPAID": ("⚠️", "Переплата"),
}
emoji, status_text = status_labels.get(payment.status, ("", "Неизвестно"))
metadata = payment.metadata_json or {}
links_meta = metadata.get("links") if isinstance(metadata, dict) else None
if not isinstance(links_meta, dict):
links_meta = {}
sbp_link = links_meta.get("sbp") or payment.link_url
card_link = links_meta.get("card")
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
card_link = payment.link_page_url
message_lines = [
"🏦 Статус платежа PayPalych:",
"",
f"🆔 ID счета: {payment.bill_id}",
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}",
f"📊 Статус: {emoji} {status_text}",
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}",
]
if payment.is_paid:
message_lines.append("")
message_lines.append("✅ Платеж успешно завершен! Средства уже на балансе.")
elif payment.status in {"NEW", "PROCESS"}:
message_lines.append("")
message_lines.append("⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.")
if sbp_link:
message_lines.append("")
message_lines.append(f"🏦 СБП: {sbp_link}")
if card_link and card_link != sbp_link:
message_lines.append(f"💳 Банковская карта: {card_link}")
elif payment.status in {"FAIL", "UNDERPAID", "OVERPAID"}:
message_lines.append("")
message_lines.append(
f"❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}"
)
from app.localization.texts import get_texts
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")]
])
await callback.answer()
await callback.message.edit_text(
"\n".join(message_lines),
reply_markup=keyboard,
disable_web_page_preview=True,
)
except Exception as e:
logger.error(f"Ошибка проверки статуса PayPalych: {e}")
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
+104
View File
@@ -0,0 +1,104 @@
import logging
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, get_payment_methods_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
from app.external.telegram_stars import TelegramStarsService
logger = logging.getLogger(__name__)
@error_handler
async def start_stars_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.TELEGRAM_STARS_ENABLED:
await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True)
return
# Формируем текст сообщения в зависимости от настройки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
message_text = (
f"⭐ <b>Пополнение через Telegram Stars</b>\n\n"
f"Выберите сумму пополнения или введите вручную:"
)
else:
message_text = texts.TOP_UP_AMOUNT
# Создаем клавиатуру
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)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="stars")
await callback.answer()
@error_handler
async def process_stars_payment_amount(
message: types.Message,
db_user: User,
amount_kopeks: int,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.TELEGRAM_STARS_ENABLED:
await message.answer("⚠️ Оплата Stars временно недоступна")
return
try:
amount_rubles = amount_kopeks / 100
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
stars_rate = settings.get_stars_rate()
payment_service = PaymentService(message.bot)
invoice_link = await payment_service.create_stars_invoice(
amount_kopeks=amount_kopeks,
description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}",
payload=f"balance_{db_user.id}_{amount_kopeks}"
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await message.answer(
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
f"К оплате: {stars_amount} звезд\n"
f"📊 Курс: {stars_rate}₽ за звезду\n\n"
f"Нажмите кнопку ниже для оплаты:",
reply_markup=keyboard,
parse_mode="HTML"
)
await state.clear()
except Exception as e:
logger.error(f"Ошибка создания Stars invoice: {e}")
await message.answer("⚠️ Ошибка создания платежа")
+59
View File
@@ -0,0 +1,59 @@
import logging
from aiogram import types
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.utils.decorators import error_handler
logger = logging.getLogger(__name__)
@error_handler
async def start_tribute_payment(
callback: types.CallbackQuery,
db_user: User
):
texts = get_texts(db_user.language)
if not settings.TRIBUTE_ENABLED:
await callback.answer("❌ Оплата картой временно недоступна", show_alert=True)
return
try:
from app.services.tribute_service import TributeService
tribute_service = TributeService(callback.bot)
payment_url = await tribute_service.create_payment_link(
user_id=db_user.telegram_id,
amount_kopeks=0,
description="Пополнение баланса VPN"
)
if not payment_url:
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
return
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await callback.message.edit_text(
f"💳 <b>Пополнение банковской картой</b>\n\n"
f"• Введите любую сумму от 100₽\n"
f"• Безопасная оплата через Tribute\n"
f"• Мгновенное зачисление на баланс\n"
f"• Принимаем карты Visa, MasterCard, МИР\n\n"
f"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n"
f"Нажмите кнопку для перехода к оплате:",
reply_markup=keyboard,
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка создания Tribute платежа: {e}")
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
await callback.answer()
+234
View File
@@ -0,0 +1,234 @@
import logging
from typing import Dict
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, get_user_by_id as fetch_user_by_id
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
@error_handler
async def start_wata_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_wata_enabled():
await callback.answer("❌ Оплата через WATA временно недоступна", show_alert=True)
return
message_text = texts.t(
"WATA_TOPUP_PROMPT",
(
"💳 <b>Оплата через WATA</b>\n\n"
"Введите сумму пополнения. Минимальная сумма — {min_amount}, максимальная — {max_amount}.\n"
"Оплата происходит через защищенную форму WATA."
),
).format(
min_amount=settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS),
max_amount=settings.format_price(settings.WATA_MAX_AMOUNT_KOPEKS),
)
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)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="wata")
await callback.answer()
@error_handler
async def process_wata_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_wata_enabled():
await message.answer("❌ Оплата через WATA временно недоступна")
return
if amount_kopeks < settings.WATA_MIN_AMOUNT_KOPEKS:
await message.answer(
texts.t(
"WATA_AMOUNT_TOO_LOW",
"Минимальная сумма пополнения: {amount}",
).format(amount=settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS))
)
return
if amount_kopeks > settings.WATA_MAX_AMOUNT_KOPEKS:
await message.answer(
texts.t(
"WATA_AMOUNT_TOO_HIGH",
"Максимальная сумма пополнения: {amount}",
).format(amount=settings.format_price(settings.WATA_MAX_AMOUNT_KOPEKS))
)
return
payment_service = PaymentService(message.bot)
try:
result = await payment_service.create_wata_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
language=db_user.language,
)
except Exception as error: # pragma: no cover - handled by decorator logs
logger.exception("Ошибка создания WATA платежа: %s", error)
result = None
if not result or not result.get("payment_url"):
await message.answer(
texts.t(
"WATA_PAYMENT_ERROR",
"❌ Ошибка создания платежа WATA. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
return
payment_url = result["payment_url"]
payment_link_id = result["payment_link_id"]
local_payment_id = result["local_payment_id"]
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t("WATA_PAY_BUTTON", "💳 Оплатить через WATA"),
url=payment_url,
)
],
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_wata_{local_payment_id}",
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
)
message_template = texts.t(
"WATA_PAYMENT_INSTRUCTIONS",
(
"💳 <b>Оплата через WATA</b>\n\n"
"💰 Сумма: {amount}\n"
"🆔 ID платежа: {payment_id}\n\n"
"📱 <b>Инструкция:</b>\n"
"1. Нажмите кнопку 'Оплатить через WATA'\n"
"2. Следуйте подсказкам платежной системы\n"
"3. Подтвердите перевод\n"
"4. Средства зачислятся автоматически\n\n"
"❓ Если возникнут проблемы, обратитесь в {support}"
),
)
message_text = message_template.format(
amount=settings.format_price(amount_kopeks),
payment_id=payment_link_id,
support=settings.get_support_contact_display_html(),
)
await message.answer(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await state.clear()
logger.info(
"Создан WATA платеж для пользователя %s: %s₽, ссылка: %s",
db_user.telegram_id,
amount_kopeks / 100,
payment_link_id,
)
@error_handler
async def check_wata_payment_status(
callback: types.CallbackQuery,
db: AsyncSession,
):
try:
local_payment_id = int(callback.data.split("_")[-1])
except (ValueError, IndexError):
await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True)
return
payment_service = PaymentService(callback.bot)
status_info = await payment_service.get_wata_payment_status(db, local_payment_id)
if not status_info:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
payment = status_info["payment"]
user_language = "ru"
try:
user = await fetch_user_by_id(db, payment.user_id)
if user and getattr(user, "language", None):
user_language = user.language
except Exception as error:
logger.debug("Не удалось получить пользователя для WATA статуса: %s", error)
texts = get_texts(user_language)
status_labels: Dict[str, Dict[str, str]] = {
"Opened": {"emoji": "", "label": texts.t("WATA_STATUS_OPENED", "Ожидает оплаты")},
"Closed": {"emoji": "", "label": texts.t("WATA_STATUS_CLOSED", "Обрабатывается")},
"Paid": {"emoji": "", "label": texts.t("WATA_STATUS_PAID", "Оплачен")},
"Declined": {"emoji": "", "label": texts.t("WATA_STATUS_DECLINED", "Отклонен")},
}
label_info = status_labels.get(payment.status, {"emoji": "", "label": texts.t("WATA_STATUS_UNKNOWN", "Неизвестно")})
message_lines = [
texts.t("WATA_STATUS_TITLE", "💳 <b>Статус платежа WATA</b>"),
"",
f"🆔 ID: {payment.payment_link_id}",
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}",
f"📊 Статус: {label_info['emoji']} {label_info['label']}",
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M') if payment.created_at else ''}",
]
if payment.is_paid:
message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.")
elif payment.status in {"Opened", "Closed"}:
message_lines.append(
"\n⏳ Платеж еще не завершен. Завершите оплату по ссылке и проверьте статус позже."
)
await callback.message.answer("\n".join(message_lines), parse_mode="HTML")
await callback.answer()
+340
View File
@@ -0,0 +1,340 @@
import logging
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.utils.decorators import error_handler
from app.states import BalanceStates
logger = logging.getLogger(__name__)
@error_handler
async def start_yookassa_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled():
await callback.answer("❌ Оплата картой через YooKassa временно недоступна", show_alert=True)
return
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
# Формируем текст сообщения в зависимости от настройки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
message_text = (
f"💳 <b>Оплата банковской картой</b>\n\n"
f"Выберите сумму пополнения или введите вручную сумму "
f"от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:"
)
else:
message_text = (
f"💳 <b>Оплата банковской картой</b>\n\n"
f"Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:"
)
# Создаем клавиатуру
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)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="yookassa")
await callback.answer()
@error_handler
async def start_yookassa_sbp_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED:
await callback.answer("❌ Оплата через СБП временно недоступна", show_alert=True)
return
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
# Формируем текст сообщения в зависимости от настройки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
message_text = (
f"🏦 <b>Оплата через СБП</b>\n\n"
f"Выберите сумму пополнения или введите вручную сумму "
f"от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:"
)
else:
message_text = (
f"🏦 <b>Оплата через СБП</b>\n\n"
f"Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:"
)
# Создаем клавиатуру
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)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="yookassa_sbp")
await callback.answer()
@error_handler
async def process_yookassa_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_yookassa_enabled():
await message.answer("❌ Оплата через YooKassa временно недоступна")
return
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Минимальная сумма для оплаты картой: {min_rubles:.0f}")
return
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Максимальная сумма для оплаты картой: {max_rubles:,.0f}".replace(',', ' '))
return
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_yookassa_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
receipt_email=None,
receipt_phone=None,
metadata={
"user_telegram_id": str(db_user.telegram_id),
"user_username": db_user.username or "",
"purpose": "balance_topup"
}
)
if not payment_result:
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
return
confirmation_url = payment_result.get("confirmation_url")
if not confirmation_url:
await message.answer("❌ Ошибка получения ссылки для оплаты. Обратитесь в поддержку.")
await state.clear()
return
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="💳 Оплатить картой", url=confirmation_url)],
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await message.answer(
f"💳 <b>Оплата банковской картой</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
f"📱 <b>Инструкция:</b>\n"
f"1. Нажмите кнопку 'Оплатить картой'\n"
f"2. Введите данные вашей карты\n"
f"3. Подтвердите платеж\n"
f"4. Деньги поступят на баланс автоматически\n\n"
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
f"✅ Принимаем карты: Visa, MasterCard, МИР\n\n"
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
reply_markup=keyboard,
parse_mode="HTML"
)
await state.clear()
logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: "
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
except Exception as e:
logger.error(f"Ошибка создания YooKassa платежа: {e}")
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
@error_handler
async def process_yookassa_sbp_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_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED:
await message.answer("❌ Оплата через СБП временно недоступна")
return
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Минимальная сумма для оплаты через СБП: {min_rubles:.0f}")
return
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
await message.answer(f"❌ Максимальная сумма для оплаты через СБП: {max_rubles:,.0f}".replace(',', ' '))
return
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_yookassa_sbp_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
receipt_email=None,
receipt_phone=None,
metadata={
"user_telegram_id": str(db_user.telegram_id),
"user_username": db_user.username or "",
"purpose": "balance_topup_sbp"
}
)
if not payment_result:
await message.answer("❌ Ошибка создания платежа через СБП. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
return
confirmation_url = payment_result.get("confirmation_url")
if not confirmation_url:
await message.answer("❌ Ошибка получения ссылки для оплаты через СБП. Обратитесь в поддержку.")
await state.clear()
return
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🏦 Оплатить через СБП", url=confirmation_url)],
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await message.answer(
f"🏦 <b>Оплата через СБП</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
f"📱 <b>Инструкция:</b>\n"
f"1. Нажмите кнопку 'Оплатить через СБП'\n"
f"2. Вас перенаправит в приложение вашего банка\n"
f"3. Подтвердите платеж через СБП\n"
f"4. Деньги поступят на баланс автоматически\n\n"
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
f"✅ Принимаем СБП от всех банков-участников\n\n"
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
reply_markup=keyboard,
parse_mode="HTML"
)
await state.clear()
logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: "
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
except Exception as e:
logger.error(f"Ошибка создания YooKassa СБП платежа: {e}")
await message.answer("❌ Ошибка создания платежа через СБП. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
@error_handler
async def check_yookassa_payment_status(
callback: types.CallbackQuery,
db: AsyncSession
):
try:
local_payment_id = int(callback.data.split('_')[-1])
from app.database.crud.yookassa import get_yookassa_payment_by_local_id
payment = await get_yookassa_payment_by_local_id(db, local_payment_id)
if not payment:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
status_emoji = {
"pending": "",
"waiting_for_capture": "",
"succeeded": "",
"canceled": "",
"failed": ""
}
status_text = {
"pending": "Ожидает оплаты",
"waiting_for_capture": "Ожидает подтверждения",
"succeeded": "Оплачен",
"canceled": "Отменен",
"failed": "Ошибка"
}
emoji = status_emoji.get(payment.status, "")
status = status_text.get(payment.status, "Неизвестно")
message_text = (f"💳 Статус платежа:\n\n"
f"🆔 ID: {payment.yookassa_payment_id[:8]}...\n"
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
f"📊 Статус: {emoji} {status}\n"
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n")
if payment.is_succeeded:
message_text += "\n✅ Платеж успешно завершен!\n\nСредства зачислены на баланс."
elif payment.is_pending:
message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше."
elif payment.is_failed:
message_text += (
f"\n❌ Платеж не прошел. Обратитесь в {settings.get_support_contact_display()}"
)
await callback.answer(message_text, show_alert=True)
except Exception as e:
logger.error(f"Ошибка проверки статуса платежа: {e}")
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
+17
View File
@@ -30,6 +30,7 @@ from app.services.subscription_checkout_service import (
from app.utils.photo_message import edit_or_answer_photo
from app.services.support_settings_service import SupportSettingsService
from app.services.main_menu_button_service import MainMenuButtonService
from app.services.user_cart_service import user_cart_service
from app.utils.promo_offer import (
build_promo_offer_hint,
build_test_access_hint,
@@ -162,6 +163,13 @@ async def show_main_menu(
draft_exists = await has_subscription_checkout_draft(db_user.id)
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
# Проверяем наличие сохраненной корзины в Redis
try:
has_saved_cart = await user_cart_service.has_user_cart(db_user.id)
except Exception as e:
logger.error(f"Ошибка проверки сохраненной корзины для пользователя {db_user.id}: {e}")
has_saved_cart = False
is_admin = settings.is_admin(db_user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
@@ -190,6 +198,7 @@ async def show_main_menu(
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине
custom_buttons=custom_buttons,
),
parse_mode="HTML",
@@ -893,6 +902,13 @@ async def handle_back_to_menu(
draft_exists = await has_subscription_checkout_draft(db_user.id)
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
# Проверяем наличие сохраненной корзины в Redis
try:
has_saved_cart = await user_cart_service.has_user_cart(db_user.id)
except Exception as e:
logger.error(f"Ошибка проверки сохраненной корзины для пользователя {db_user.id}: {e}")
has_saved_cart = False
is_admin = settings.is_admin(db_user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
@@ -921,6 +937,7 @@ async def handle_back_to_menu(
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине
custom_buttons=custom_buttons,
),
parse_mode="HTML",
+51 -16
View File
@@ -46,6 +46,7 @@ from app.keyboards.inline import (
get_subscription_confirm_keyboard_with_cart,
get_insufficient_balance_keyboard_with_cart
)
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
@@ -563,13 +564,16 @@ async def save_cart_and_redirect_to_topup(
texts = get_texts(db_user.language)
data = await state.get_data()
await state.set_state(SubscriptionStates.cart_saved_for_topup)
await state.update_data({
# Сохраняем данные корзины в Redis
cart_data = {
**data,
'saved_cart': True,
'missing_amount': missing_amount,
'return_to_cart': True
})
'return_to_cart': True,
'user_id': db_user.id
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
await callback.message.edit_text(
f"💰 Недостаточно средств для оформления подписки\n\n"
@@ -591,14 +595,15 @@ async def return_to_saved_cart(
db_user: User,
db: AsyncSession
):
data = await state.get_data()
texts = get_texts(db_user.language)
if not data.get('saved_cart'):
# Получаем данные корзины из Redis
cart_data = await user_cart_service.get_user_cart(db_user.id)
if not cart_data:
await callback.answer("❌ Сохраненная корзина не найдена", show_alert=True)
return
total_price = data.get('total_price', 0)
texts = get_texts(db_user.language)
total_price = cart_data.get('total_price', 0)
if db_user.balance_kopeks < total_price:
missing_amount = total_price - db_user.balance_kopeks
@@ -617,35 +622,38 @@ async def return_to_saved_cart(
countries = await _get_available_countries(db_user.promo_group_id)
selected_countries_names = []
months_in_period = calculate_months_from_days(data['period_days'])
period_display = format_period_description(data['period_days'], db_user.language)
months_in_period = calculate_months_from_days(cart_data['period_days'])
period_display = format_period_description(cart_data['period_days'], db_user.language)
for country in countries:
if country['uuid'] in data['countries']:
if country['uuid'] in cart_data['countries']:
selected_countries_names.append(country['name'])
if settings.is_traffic_fixed():
traffic_display = "Безлимитный" if data['traffic_gb'] == 0 else f"{data['traffic_gb']} ГБ"
traffic_display = "Безлимитный" if cart_data['traffic_gb'] == 0 else f"{cart_data['traffic_gb']} ГБ"
else:
traffic_display = "Безлимитный" if data['traffic_gb'] == 0 else f"{data['traffic_gb']} ГБ"
traffic_display = "Безлимитный" if cart_data['traffic_gb'] == 0 else f"{cart_data['traffic_gb']} ГБ"
summary_text = (
"🛒 Восстановленная корзина\n\n"
f"📅 Период: {period_display}\n"
f"📊 Трафик: {traffic_display}\n"
f"🌍 Страны: {', '.join(selected_countries_names)}\n"
f"📱 Устройства: {data['devices']}\n\n"
f"📱 Устройства: {cart_data['devices']}\n\n"
f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n"
"Подтверждаете покупку?"
)
# Устанавливаем данные в FSM для продолжения процесса
await state.set_data(cart_data)
await state.set_state(SubscriptionStates.confirming_purchase)
await callback.message.edit_text(
summary_text,
reply_markup=get_subscription_confirm_keyboard_with_cart(db_user.language),
parse_mode="HTML"
)
await state.set_state(SubscriptionStates.confirming_purchase)
await callback.answer("✅ Корзина восстановлена!")
async def handle_extend_subscription(
@@ -964,11 +972,24 @@ async def confirm_extend_subscription(
missing=texts.format_price(missing_kopeks),
)
# Подготовим данные для сохранения в корзину
cart_data = {
'period_days': days,
'total_price': price,
'user_id': db_user.id,
'saved_cart': True,
'missing_amount': missing_kopeks,
'return_to_cart': True
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
await callback.message.edit_text(
message_text,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
amount_kopeks=missing_kopeks,
has_saved_cart=True # Указываем, что есть сохраненная корзина
),
parse_mode="HTML",
)
@@ -1481,12 +1502,24 @@ async def confirm_purchase(
missing=texts.format_price(missing_kopeks),
)
# Сохраняем данные корзины в Redis перед переходом к пополнению
cart_data = {
**data,
'saved_cart': True,
'missing_amount': missing_kopeks,
'return_to_cart': True,
'user_id': db_user.id
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
await callback.message.edit_text(
message_text,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
resume_callback=resume_callback,
amount_kopeks=missing_kopeks,
has_saved_cart=True # Указываем, что есть сохраненная корзина
),
parse_mode="HTML",
)
@@ -1937,7 +1970,9 @@ async def clear_saved_cart(
db_user: User,
db: AsyncSession
):
# Очищаем как FSM, так и Redis
await state.clear()
await user_cart_service.delete_user_cart(db_user.id)
from app.handlers.menu import show_main_menu
await show_main_menu(callback, db_user, db)
+48 -10
View File
@@ -231,6 +231,7 @@ def get_main_menu_keyboard(
balance_kopeks: int = 0,
subscription=None,
show_resume_checkout: bool = False,
has_saved_cart: bool = False, # Новый параметр для отображения уведомления о сохраненной корзине
*,
is_moderator: bool = False,
custom_buttons: Optional[list[InlineKeyboardButton]] = None,
@@ -343,7 +344,7 @@ def get_main_menu_keyboard(
else:
keyboard.append([subscription_buttons[0]])
if show_resume_checkout:
if show_resume_checkout or has_saved_cart:
keyboard.append([
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
@@ -356,12 +357,18 @@ def get_main_menu_keyboard(
if isinstance(button, InlineKeyboardButton):
keyboard.append([button])
keyboard.extend([
[
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode"),
# Добавляем кнопки промокода и рефералов, учитывая настройки
additional_buttons = [
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode")
]
# Добавляем кнопку рефералов только если программа включена
if settings.is_referral_program_enabled():
additional_buttons.append(
InlineKeyboardButton(text=texts.MENU_REFERRALS, callback_data="menu_referrals")
]
])
)
keyboard.append(additional_buttons)
# Support button is configurable (runtime via service)
try:
@@ -624,6 +631,7 @@ def get_insufficient_balance_keyboard(
language: str = DEFAULT_LANGUAGE,
resume_callback: str | None = None,
amount_kopeks: int | None = None,
has_saved_cart: bool = False, # Новый параметр для указания наличия сохраненной корзины
) -> InlineKeyboardMarkup:
texts = get_texts(language)
@@ -644,14 +652,23 @@ def get_insufficient_balance_keyboard(
)
back_row_index = len(keyboard.inline_keyboard) - 1
if resume_callback:
# Если есть сохраненная корзина, добавляем кнопку возврата к оформлению
if has_saved_cart:
return_row = [
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout",
)
]
insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard)
keyboard.inline_keyboard.insert(insert_index, return_row)
elif resume_callback:
return_row = [
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data=resume_callback,
)
]
insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard)
keyboard.inline_keyboard.insert(insert_index, return_row)
@@ -757,6 +774,7 @@ def get_payment_methods_keyboard_with_cart(
language: str = "ru",
amount_kopeks: int = 0,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = get_payment_methods_keyboard(amount_kopeks, language)
# Добавляем кнопку "Очистить корзину"
@@ -767,9 +785,18 @@ def get_payment_methods_keyboard_with_cart(
)
])
# Добавляем кнопку возврата к оформлению подписки
keyboard.inline_keyboard.insert(-1, [ # Вставляем перед кнопкой "назад"
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout"
)
])
return keyboard
def get_subscription_confirm_keyboard_with_cart(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="✅ Подтвердить покупку",
@@ -780,8 +807,8 @@ def get_subscription_confirm_keyboard_with_cart(language: str = "ru") -> InlineK
callback_data="clear_saved_cart"
)],
[InlineKeyboardButton(
text="🔙 Назад",
callback_data="back_to_menu"
text=texts.BACK,
callback_data="subscription_config_back" # Изменили на возврат к настройке
)]
])
@@ -789,11 +816,14 @@ def get_insufficient_balance_keyboard_with_cart(
language: str = "ru",
amount_kopeks: int = 0,
) -> InlineKeyboardMarkup:
# Используем обновленную версию с флагом has_saved_cart=True
keyboard = get_insufficient_balance_keyboard(
language,
amount_kopeks=amount_kopeks,
has_saved_cart=True,
)
# Добавляем кнопку очистки корзины в начало
keyboard.inline_keyboard.insert(
0,
[
@@ -1066,6 +1096,14 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
])
if settings.is_wata_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_CARD_WATA", "💳 Банковская карта (WATA)"),
callback_data=_build_callback("wata")
)
])
if settings.is_pal24_enabled():
keyboard.append([
InlineKeyboardButton(
+23 -14
View File
@@ -2,26 +2,35 @@ from aiogram.types import ReplyKeyboardMarkup, KeyboardButton, ReplyKeyboardRemo
from typing import List
from app.localization.texts import get_texts
from app.config import settings
def get_main_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
texts = get_texts(language)
keyboard = [
[
KeyboardButton(text=texts.MENU_BALANCE),
KeyboardButton(text=texts.MENU_SUBSCRIPTION)
]
]
# Добавляем кнопки промокода и рефералов, учитывая настройки
second_row = [KeyboardButton(text=texts.MENU_PROMOCODE)]
# Добавляем кнопку рефералов только если программа включена
if settings.is_referral_program_enabled():
second_row.append(KeyboardButton(text=texts.MENU_REFERRALS))
keyboard.append(second_row)
keyboard.append([
KeyboardButton(text=texts.MENU_SUPPORT),
KeyboardButton(text=texts.MENU_RULES)
])
return ReplyKeyboardMarkup(
keyboard=[
[
KeyboardButton(text=texts.MENU_BALANCE),
KeyboardButton(text=texts.MENU_SUBSCRIPTION)
],
[
KeyboardButton(text=texts.MENU_PROMOCODE),
KeyboardButton(text=texts.MENU_REFERRALS)
],
[
KeyboardButton(text=texts.MENU_SUPPORT),
KeyboardButton(text=texts.MENU_RULES)
]
],
keyboard=keyboard,
resize_keyboard=True,
one_time_keyboard=False
)
+15
View File
@@ -621,11 +621,26 @@
"PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
"PAL24_TOPUP_PROMPT": "🏦 <b>PayPalych (SBP) payment</b>\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed via the PayPalych Faster Payments System.",
"PAYMENT_CARD_MULENPAY": "💳 Bank card (Mulen Pay)",
"PAYMENT_CARD_WATA": "💳 Bank card (WATA)",
"PAYMENT_CARD_PAL24": "🏦 SBP (PayPalych)",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via Mulen Pay",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card (Mulen Pay)</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "via WATA",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Bank card (WATA)</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>SBP (PayPalych)</b>",
"WATA_TOPUP_PROMPT": "💳 <b>Pay via WATA</b>\n\nEnter the amount to top up. Minimum amount — {min_amount}, maximum — {max_amount}.\nPayments are processed through the secure WATA form.",
"WATA_AMOUNT_TOO_LOW": "Minimum top-up amount: {amount}",
"WATA_AMOUNT_TOO_HIGH": "Maximum top-up amount: {amount}",
"WATA_PAYMENT_ERROR": "❌ Failed to create WATA payment. Please try again later or contact support.",
"WATA_PAY_BUTTON": "💳 Pay via WATA",
"WATA_PAYMENT_INSTRUCTIONS": "💳 <b>Pay via WATA</b>\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 <b>How to pay:</b>\n1. Tap Pay via WATA\n2. Follow the gateway prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
"WATA_STATUS_TITLE": "💳 <b>WATA payment status</b>",
"WATA_STATUS_OPENED": "Waiting for payment",
"WATA_STATUS_CLOSED": "Processing",
"WATA_STATUS_PAID": "Paid",
"WATA_STATUS_DECLINED": "Declined",
"WATA_STATUS_UNKNOWN": "Unknown",
"REPLY_TO_TICKET": "💬 Reply",
"REPORT_CLOSE": "❌ Close",
"REPORT_CLOSED": "✅ Report closed.",
+15
View File
@@ -629,11 +629,26 @@
"PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
"PAL24_TOPUP_PROMPT": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.",
"PAYMENT_CARD_MULENPAY": "💳 Банковская карта (Mulen Pay)",
"PAYMENT_CARD_WATA": "💳 Банковская карта (WATA)",
"PAYMENT_CARD_PAL24": "🏦 СБП (PayPalych)",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта (Mulen Pay)</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Банковская карта (WATA)</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
"WATA_TOPUP_PROMPT": "💳 <b>Оплата через WATA</b>\n\nВведите сумму пополнения. Минимальная сумма — {min_amount}, максимальная — {max_amount}.\nОплата происходит через защищенную форму WATA.",
"WATA_AMOUNT_TOO_LOW": "Минимальная сумма пополнения: {amount}",
"WATA_AMOUNT_TOO_HIGH": "Максимальная сумма пополнения: {amount}",
"WATA_PAYMENT_ERROR": "❌ Ошибка создания платежа WATA. Попробуйте позже или обратитесь в поддержку.",
"WATA_PAY_BUTTON": "💳 Оплатить через WATA",
"WATA_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через WATA</b>\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку 'Оплатить через WATA'\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
"WATA_STATUS_TITLE": "💳 <b>Статус платежа WATA</b>",
"WATA_STATUS_OPENED": "Ожидает оплаты",
"WATA_STATUS_CLOSED": "Обрабатывается",
"WATA_STATUS_PAID": "Оплачен",
"WATA_STATUS_DECLINED": "Отклонен",
"WATA_STATUS_UNKNOWN": "Неизвестно",
"REPLY_TO_TICKET": "💬 Ответить",
"REPORT_CLOSE": "❌ Закрыть",
"REPORT_CLOSED": "✅ Отчет закрыт.",
+53 -14
View File
@@ -408,6 +408,16 @@ class AdminNotificationService:
return refreshed_user, refreshed_transaction, subscription, promo_group
def _is_lazy_loading_error(self, error: Exception) -> bool:
message = str(error).lower()
return (
isinstance(error, MissingGreenlet)
or "greenlet_spawn" in message
or "await_only" in message
or "missinggreenlet" in message
)
async def send_balance_topup_notification(
self,
user: User,
@@ -423,7 +433,10 @@ class AdminNotificationService:
if not self._is_enabled():
return False
logger.info("Начинаем отправку уведомления о пополнении баланса")
try:
logger.info("Пытаемся создать сообщение уведомления")
message = self._build_balance_topup_message(
user,
transaction,
@@ -433,21 +446,49 @@ class AdminNotificationService:
subscription=subscription,
promo_group=promo_group,
)
except MissingGreenlet as missing_greenlet:
if db is None:
logger.info("Сообщение уведомления создано успешно")
except Exception as error:
logger.info(f"Перехвачена ошибка при создании сообщения уведомления: {type(error).__name__}: {error}")
if not self._is_lazy_loading_error(error):
logger.error(
"Недостаточно данных для уведомления о пополнении и отсутствует доступ к БД: %s",
missing_greenlet,
"Ошибка подготовки уведомления о пополнении: %s",
error,
exc_info=True,
)
return False
if db is None:
logger.error(
"Недостаточно данных для уведомления о пополнении и отсутствует доступ к БД: %s",
error,
exc_info=True,
)
return False
logger.warning(
"Повторная загрузка данных для уведомления о пополнении после ошибки ленивой загрузки: %s",
error,
)
try:
logger.info("Пытаемся перезагрузить данные для уведомления")
(
user,
transaction,
subscription,
promo_group,
) = await self._reload_topup_notification_entities(db, user, transaction)
logger.info("Данные успешно перезагружены")
except Exception as reload_error:
logger.error(
"Ошибка повторной загрузки данных для уведомления о пополнении: %s",
reload_error,
exc_info=True,
)
return False
try:
logger.info("Пытаемся создать сообщение после перезагрузки данных")
message = self._build_balance_topup_message(
user,
transaction,
@@ -457,24 +498,22 @@ class AdminNotificationService:
subscription=subscription,
promo_group=promo_group,
)
except Exception as reload_error:
logger.info("Сообщение успешно создано после перезагрузки данных")
except Exception as rebuild_error:
logger.error(
"Ошибка повторной загрузки данных для уведомления о пополнении: %s",
reload_error,
"Ошибка повторной подготовки уведомления о пополнении после повторной загрузки: %s",
rebuild_error,
exc_info=True,
)
return False
except Exception as e:
logger.error(
f"Ошибка подготовки уведомления о пополнении: {e}",
exc_info=True,
)
return False
try:
return await self._send_message(message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о пополнении: {e}")
logger.error(
f"Ошибка отправки уведомления о пополнении: {e}",
exc_info=True,
)
return False
async def send_subscription_extension_notification(
+14
View File
@@ -14,6 +14,7 @@ from app.database.models import (
MainMenuButtonActionType,
MainMenuButtonVisibility,
)
from app.config import settings
@dataclass(frozen=True)
@@ -105,6 +106,19 @@ class MainMenuButtonService:
if item.visibility == MainMenuButtonVisibility.SUBSCRIBERS and not has_subscription:
continue
# Проверка реферальной программы: скрыть кнопки, связанные с рефералами, если программа отключена
if (
not settings.is_referral_program_enabled()
and (
"partner" in item.text.lower()
or "referr" in item.text.lower()
or "партнер" in item.text.lower()
or "реферал" in item.text.lower()
or "referral" in item.action_value.lower()
)
):
continue
button = cls._build_button(item)
if button:
buttons.append(button)
+2
View File
@@ -11,6 +11,7 @@ from .tribute import TributePaymentMixin
from .cryptobot import CryptoBotPaymentMixin
from .mulenpay import MulenPayPaymentMixin
from .pal24 import Pal24PaymentMixin
from .wata import WataPaymentMixin
__all__ = [
"PaymentCommonMixin",
@@ -20,4 +21,5 @@ __all__ = [
"CryptoBotPaymentMixin",
"MulenPayPaymentMixin",
"Pal24PaymentMixin",
"WataPaymentMixin",
]
+40
View File
@@ -290,6 +290,46 @@ class CryptoBotPaymentMixin:
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)
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
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,
callback_data="subscription_resume_checkout"
)],
[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{cart_message}",
reply_markup=keyboard
)
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
except Exception as e:
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
return True
except Exception as error:
+42
View File
@@ -286,6 +286,48 @@ class MulenPayPaymentMixin:
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)
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="subscription_resume_checkout"
)],
[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{cart_message}",
reply_markup=keyboard
)
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
except Exception as e:
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
logger.info(
"✅ Обработан MulenPay платеж %s для пользователя %s",
payment.uuid,
+42
View File
@@ -353,6 +353,48 @@ class Pal24PaymentMixin:
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)
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="subscription_resume_checkout"
)],
[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{cart_message}",
reply_markup=keyboard
)
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
except Exception as e:
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
logger.info(
"✅ Обработан Pal24 платеж %s для пользователя %s",
payment.bill_id,
+43
View File
@@ -206,6 +206,7 @@ class TelegramStarsMixin:
logger.error(
"Ошибка отправки уведомления о пополнении Stars: %s",
error,
exc_info=True
)
if getattr(self, "bot", None):
@@ -236,6 +237,48 @@ class TelegramStarsMixin:
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)
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="subscription_resume_checkout"
)],
[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(amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard
)
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
except Exception as e:
logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
logger.info(
"✅ Обработан Stars платеж: пользователь %s, %s звезд → %s",
user_id,
+358
View File
@@ -0,0 +1,358 @@
"""Mixin for integrating WATA payment links into the payment service."""
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.wata_service import WataAPIError, WataService
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
class WataPaymentMixin:
"""Encapsulates creation and status handling for WATA payment links."""
async def create_wata_payment(
self,
db: AsyncSession,
user_id: int,
amount_kopeks: int,
description: str,
*,
language: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
if not getattr(self, "wata_service", None):
logger.error("WATA service is not initialised")
return None
if amount_kopeks < settings.WATA_MIN_AMOUNT_KOPEKS:
logger.warning(
"Сумма WATA меньше минимальной: %s < %s",
amount_kopeks,
settings.WATA_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.WATA_MAX_AMOUNT_KOPEKS:
logger.warning(
"Сумма WATA больше максимальной: %s > %s",
amount_kopeks,
settings.WATA_MAX_AMOUNT_KOPEKS,
)
return None
payment_module = import_module("app.services.payment_service")
order_id = f"wata_{user_id}_{uuid.uuid4().hex[:12]}"
try:
response = await self.wata_service.create_payment_link( # type: ignore[union-attr]
amount_kopeks=amount_kopeks,
currency="RUB",
description=description,
order_id=order_id,
)
except WataAPIError as error:
logger.error("Ошибка создания WATA платежа: %s", error)
return None
except Exception as error: # pragma: no cover - safety net
logger.exception("Непредвиденная ошибка при создании WATA платежа: %s", error)
return None
payment_link_id = response.get("id")
payment_url = response.get("url") or response.get("paymentUrl")
status = response.get("status") or "Opened"
terminal_public_id = response.get("terminalPublicId")
success_url = response.get("successRedirectUrl")
fail_url = response.get("failRedirectUrl")
if not payment_link_id:
logger.error("WATA API не вернула идентификатор платежной ссылки: %s", response)
return None
expiration_raw = response.get("expirationDateTime")
expires_at = WataService._parse_datetime(expiration_raw)
metadata = {
"response": response,
"language": language or settings.DEFAULT_LANGUAGE,
}
local_payment = await payment_module.create_wata_payment(
db=db,
user_id=user_id,
payment_link_id=payment_link_id,
amount_kopeks=amount_kopeks,
currency="RUB",
description=description,
status=status,
type_=response.get("type"),
url=payment_url,
order_id=order_id,
metadata=metadata,
expires_at=expires_at,
terminal_public_id=terminal_public_id,
success_redirect_url=success_url,
fail_redirect_url=fail_url,
)
logger.info(
"Создан WATA платеж %s на %s₽ для пользователя %s",
payment_link_id,
amount_kopeks / 100,
user_id,
)
return {
"local_payment_id": local_payment.id,
"payment_link_id": payment_link_id,
"payment_url": payment_url,
"status": status,
"order_id": order_id,
}
async def get_wata_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_wata_payment_by_id(db, local_payment_id)
if not payment:
return None
remote_link: Optional[Dict[str, Any]] = None
transaction_payload: Optional[Dict[str, Any]] = None
if getattr(self, "wata_service", None) and payment.payment_link_id:
try:
remote_link = await self.wata_service.get_payment_link(payment.payment_link_id) # type: ignore[union-attr]
except WataAPIError as error:
logger.error("Ошибка получения WATA ссылки %s: %s", payment.payment_link_id, error)
except Exception as error: # pragma: no cover - safety net
logger.exception("Непредвиденная ошибка при запросе WATA ссылки: %s", error)
if remote_link:
remote_status = remote_link.get("status") or payment.status
if remote_status != payment.status:
existing_metadata = dict(getattr(payment, "metadata_json", {}) or {})
existing_metadata["link"] = remote_link
await payment_module.update_wata_payment_status(
db,
payment=payment,
status=remote_status,
last_status=remote_status,
url=remote_link.get("url") or remote_link.get("paymentUrl"),
metadata=existing_metadata,
terminal_public_id=remote_link.get("terminalPublicId"),
)
payment = await payment_module.get_wata_payment_by_id(db, local_payment_id)
remote_status_normalized = (remote_status or "").lower()
if remote_status_normalized in {"closed", "paid"} and not payment.is_paid:
try:
tx_response = await self.wata_service.search_transactions( # type: ignore[union-attr]
order_id=payment.order_id,
payment_link_id=payment.payment_link_id,
status="Paid",
limit=5,
)
items = tx_response.get("items") or []
for item in items:
if (item or {}).get("status") == "Paid":
transaction_payload = item
break
except WataAPIError as error:
logger.error(
"Ошибка поиска WATA транзакций для %s: %s",
payment.payment_link_id,
error,
)
except Exception as error: # pragma: no cover - safety net
logger.exception("Непредвиденная ошибка при поиске WATA транзакции: %s", error)
if transaction_payload and not payment.is_paid:
payment = await self._finalize_wata_payment(db, payment, transaction_payload)
return {
"payment": payment,
"status": payment.status,
"is_paid": payment.is_paid,
"remote_link": remote_link,
"transaction": transaction_payload,
}
async def _finalize_wata_payment(
self,
db: AsyncSession,
payment: Any,
transaction_payload: Dict[str, Any],
) -> Any:
payment_module = import_module("app.services.payment_service")
paid_at = WataService._parse_datetime(transaction_payload.get("paymentTime"))
existing_metadata = dict(getattr(payment, "metadata_json", {}) or {})
existing_metadata["transaction"] = transaction_payload
await payment_module.update_wata_payment_status(
db,
payment=payment,
status="Paid",
is_paid=True,
paid_at=paid_at,
callback_payload=transaction_payload,
metadata=existing_metadata,
)
if payment.transaction_id:
logger.info(
"WATA платеж %s уже привязан к транзакции %s",
payment.payment_link_id,
payment.transaction_id,
)
return payment
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error("Пользователь %s не найден при обработке WATA", payment.user_id)
return payment
transaction_external_id = str(transaction_payload.get("id") or transaction_payload.get("transactionId") or "")
description = f"Пополнение через WATA ({payment.payment_link_id})"
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.WATA,
external_id=transaction_external_id or payment.payment_link_id,
is_completed=True,
)
await payment_module.link_wata_payment_to_transaction(db, payment, transaction.id)
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 = getattr(user, "promo_group", None)
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("Ошибка обработки реферального пополнения WATA: %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("Ошибка отправки админ уведомления WATA: %s", error)
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"
"🦊 Способ: WATA\n"
f"🆔 Транзакция: {transaction.id}\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as error:
logger.error("Ошибка отправки уведомления пользователю WATA: %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)
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="subscription_resume_checkout",
)
],
[
types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance",
)
],
[
types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu",
)
],
]
)
await self.bot.send_message(
user.telegram_id,
cart_message,
reply_markup=keyboard,
)
except Exception as error:
logger.debug("Не удалось отправить напоминание о корзине после WATA: %s", error)
return payment
+63 -2
View File
@@ -13,6 +13,7 @@ from typing import Any, Dict, Optional, TYPE_CHECKING
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.utils.user_utils import format_referrer_info
@@ -268,6 +269,7 @@ class YooKassaPaymentMixin:
await db.refresh(user)
# Отправляем уведомления админам
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
@@ -285,26 +287,85 @@ class YooKassaPaymentMixin:
promo_group=promo_group,
db=db,
)
logger.info("Уведомление админам о пополнении отправлено успешно")
except Exception as error:
logger.error(
"Ошибка отправки уведомления админам о YooKassa пополнении: %s",
error,
exc_info=True # Добавляем полный стек вызовов для отладки
)
# Отправляем уведомление пользователю
if getattr(self, "bot", None):
try:
# Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой
await self._send_payment_success_notification(
user.telegram_id,
payment.amount_kopeks,
user=user,
user=None, # Передаем None, чтобы _ensure_user_snapshot загрузил данные сам
db=db,
payment_method_title="Банковская карта (YooKassa)",
)
logger.info("Уведомление пользователю о платеже отправлено успешно")
except Exception as error:
logger.error(
"Ошибка отправки уведомления о платеже: %s", error
"Ошибка отправки уведомления о платеже: %s",
error,
exc_info=True # Добавляем полный стек вызовов для отладки
)
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
# ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях
logger.info(f"Проверяем наличие сохраненной корзины для пользователя {user.id}")
from app.services.user_cart_service import user_cart_service
try:
has_saved_cart = await user_cart_service.has_user_cart(user.id)
logger.info(f"Результат проверки корзины для пользователя {user.id}: {has_saved_cart}")
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
from aiogram import types
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,
callback_data="subscription_resume_checkout"
)],
[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{cart_message}",
reply_markup=keyboard
)
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}")
else:
logger.info(f"У пользователя {user.id} нет сохраненной корзины или бот недоступен")
except Exception as e:
logger.error(f"Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True)
logger.info(
"Успешно обработан платеж YooKassa %s: пользователь %s получил %s",
payment.yookassa_payment_id,
payment.user_id,
payment.amount_kopeks / 100,
)
logger.info(
"Успешно обработан платеж YooKassa %s: пользователь %s получил %s",
payment.yookassa_payment_id,
+36 -1
View File
@@ -22,8 +22,10 @@ from app.services.payment import (
TelegramStarsMixin,
TributePaymentMixin,
YooKassaPaymentMixin,
WataPaymentMixin,
)
from app.services.yookassa_service import YooKassaService
from app.services.wata_service import WataService
logger = logging.getLogger(__name__)
@@ -126,6 +128,36 @@ async def link_pal24_payment_to_transaction(*args, **kwargs):
return await pal_crud.link_pal24_payment_to_transaction(*args, **kwargs)
async def create_wata_payment(*args, **kwargs):
wata_crud = import_module("app.database.crud.wata")
return await wata_crud.create_wata_payment(*args, **kwargs)
async def get_wata_payment_by_link_id(*args, **kwargs):
wata_crud = import_module("app.database.crud.wata")
return await wata_crud.get_wata_payment_by_link_id(*args, **kwargs)
async def get_wata_payment_by_id(*args, **kwargs):
wata_crud = import_module("app.database.crud.wata")
return await wata_crud.get_wata_payment_by_id(*args, **kwargs)
async def get_wata_payment_by_order_id(*args, **kwargs):
wata_crud = import_module("app.database.crud.wata")
return await wata_crud.get_wata_payment_by_order_id(*args, **kwargs)
async def update_wata_payment_status(*args, **kwargs):
wata_crud = import_module("app.database.crud.wata")
return await wata_crud.update_wata_payment_status(*args, **kwargs)
async def link_wata_payment_to_transaction(*args, **kwargs):
wata_crud = import_module("app.database.crud.wata")
return await wata_crud.link_wata_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)
@@ -154,6 +186,7 @@ class PaymentService(
CryptoBotPaymentMixin,
MulenPayPaymentMixin,
Pal24PaymentMixin,
WataPaymentMixin,
):
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
@@ -174,13 +207,15 @@ class PaymentService(
self.pal24_service = (
Pal24Service() if settings.is_pal24_enabled() else None
)
self.wata_service = WataService() if settings.is_wata_enabled() else None
logger.debug(
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, "
"MulenPay=%s, Pal24=%s)",
"MulenPay=%s, Pal24=%s, Wata=%s)",
bool(self.yookassa_service),
bool(self.stars_service),
bool(self.cryptobot_service),
bool(self.mulenpay_service),
bool(self.pal24_service),
bool(self.wata_service),
)
+282
View File
@@ -0,0 +1,282 @@
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, time
from typing import Any, Callable, Dict, List, Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.crud.server_squad import sync_with_remnawave
from app.services.remnawave_service import (
RemnaWaveConfigurationError,
RemnaWaveService,
)
from app.utils.cache import cache
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class RemnaWaveAutoSyncStatus:
enabled: bool
times: List[time]
next_run: Optional[datetime]
last_run_started_at: Optional[datetime]
last_run_finished_at: Optional[datetime]
last_run_success: Optional[bool]
last_run_reason: Optional[str]
last_run_error: Optional[str]
last_user_stats: Optional[Dict[str, Any]]
last_server_stats: Optional[Dict[str, Any]]
is_running: bool
class RemnaWaveAutoSyncService:
def __init__(
self,
service_factory: Callable[[], RemnaWaveService] = RemnaWaveService,
) -> None:
self._scheduler_task: Optional[asyncio.Task] = None
self._scheduler_lock = asyncio.Lock()
self._sync_lock = asyncio.Lock()
self._service_factory = service_factory
self._service = self._service_factory()
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._initialized = False
self._pending_refresh = False
self._pending_run_immediately = False
self._next_run: Optional[datetime] = None
self._last_run_started_at: Optional[datetime] = None
self._last_run_finished_at: Optional[datetime] = None
self._last_run_success: Optional[bool] = None
self._last_run_reason: Optional[str] = None
self._last_run_error: Optional[str] = None
self._last_user_stats: Optional[Dict[str, Any]] = None
self._last_server_stats: Optional[Dict[str, Any]] = None
async def initialize(self) -> None:
self._loop = asyncio.get_running_loop()
self._initialized = True
run_immediately = self._pending_run_immediately
if self._pending_refresh:
self._pending_refresh = False
self._pending_run_immediately = False
await self.refresh_schedule(run_immediately=run_immediately)
else:
await self.refresh_schedule()
def refresh_configuration(self) -> None:
"""Rebuild the RemnaWave service instance using current settings."""
self._refresh_service()
async def refresh_schedule(self, *, run_immediately: bool = False) -> None:
async with self._scheduler_lock:
if self._scheduler_task and not self._scheduler_task.done():
self._scheduler_task.cancel()
try:
await self._scheduler_task
except asyncio.CancelledError:
pass
finally:
self._scheduler_task = None
if not settings.REMNAWAVE_AUTO_SYNC_ENABLED:
self._next_run = None
return
times = settings.get_remnawave_auto_sync_times()
if not times:
logger.warning(
"⚠️ Автосинхронизация включена, но расписание пустое. Укажите время запуска."
)
self._next_run = None
return
self._scheduler_task = asyncio.create_task(self._run_scheduler(times))
if run_immediately:
asyncio.create_task(self.run_sync_now(reason="immediate"))
def schedule_refresh(self, *, run_immediately: bool = False) -> None:
if not self._initialized:
self._pending_refresh = True
if run_immediately:
self._pending_run_immediately = True
return
loop = self._loop or asyncio.get_running_loop()
loop.create_task(self.refresh_schedule(run_immediately=run_immediately))
async def stop(self) -> None:
async with self._scheduler_lock:
if self._scheduler_task and not self._scheduler_task.done():
self._scheduler_task.cancel()
try:
await self._scheduler_task
except asyncio.CancelledError:
pass
self._scheduler_task = None
self._next_run = None
async def run_sync_now(self, *, reason: str = "manual") -> Dict[str, Any]:
if self._sync_lock.locked():
return {"started": False, "reason": "already_running"}
async with self._sync_lock:
self._last_run_started_at = datetime.utcnow()
self._last_run_finished_at = None
self._last_run_reason = reason
self._last_run_error = None
self._last_run_success = None
try:
user_stats, server_stats = await self._perform_sync()
except RemnaWaveConfigurationError as error:
message = str(error)
self._last_run_error = message
self._last_run_success = False
self._last_user_stats = None
self._last_server_stats = None
self._last_run_finished_at = datetime.utcnow()
logger.error("❌ Автосинхронизация RemnaWave: %s", message)
return {
"started": True,
"success": False,
"error": message,
"user_stats": None,
"server_stats": None,
}
except Exception as error:
message = str(error)
self._last_run_error = message
self._last_run_success = False
self._last_user_stats = None
self._last_server_stats = None
self._last_run_finished_at = datetime.utcnow()
logger.exception("❌ Ошибка автосинхронизации RemnaWave: %s", error)
return {
"started": True,
"success": False,
"error": message,
"user_stats": None,
"server_stats": None,
}
self._last_run_success = True
self._last_run_error = None
self._last_user_stats = user_stats
self._last_server_stats = server_stats
self._last_run_finished_at = datetime.utcnow()
return {
"started": True,
"success": True,
"error": None,
"user_stats": user_stats,
"server_stats": server_stats,
}
def get_status(self) -> RemnaWaveAutoSyncStatus:
times = settings.get_remnawave_auto_sync_times()
enabled = settings.REMNAWAVE_AUTO_SYNC_ENABLED and bool(times)
return RemnaWaveAutoSyncStatus(
enabled=enabled,
times=times,
next_run=self._next_run,
last_run_started_at=self._last_run_started_at,
last_run_finished_at=self._last_run_finished_at,
last_run_success=self._last_run_success,
last_run_reason=self._last_run_reason,
last_run_error=self._last_run_error,
last_user_stats=self._last_user_stats,
last_server_stats=self._last_server_stats,
is_running=self._sync_lock.locked(),
)
async def _run_scheduler(self, times: List[time]) -> None:
try:
while True:
next_run = self._calculate_next_run(times)
self._next_run = next_run
delay = (next_run - datetime.utcnow()).total_seconds()
if delay > 0:
await asyncio.sleep(delay)
await self.run_sync_now(reason="auto")
except asyncio.CancelledError:
raise
finally:
self._next_run = None
def _refresh_service(self) -> RemnaWaveService:
self._service = self._service_factory()
return self._service
async def _perform_sync(self) -> Tuple[Dict[str, Any], Dict[str, Any]]:
service = self._refresh_service()
if not service.is_configured:
raise RemnaWaveConfigurationError(
service.configuration_error or "RemnaWave API не настроен"
)
async with AsyncSessionLocal() as session:
user_stats = await service.sync_users_from_panel(session, "all")
server_stats = await self._sync_servers(session, service)
return user_stats, server_stats
async def _sync_servers(
self,
session: AsyncSession,
service: RemnaWaveService,
) -> Dict[str, Any]:
squads = await service.get_all_squads()
if not squads:
logger.warning("⚠️ Не удалось получить сквады из RemnaWave для автосинхронизации")
return {"created": 0, "updated": 0, "removed": 0, "total": 0}
created, updated, removed = await sync_with_remnawave(session, squads)
try:
await cache.delete_pattern("available_countries*")
except Exception as error:
logger.warning("⚠️ Не удалось очистить кеш стран после автосинхронизации: %s", error)
return {
"created": created,
"updated": updated,
"removed": removed,
"total": len(squads),
}
@staticmethod
def _calculate_next_run(times: List[time]) -> datetime:
now = datetime.utcnow()
today = now.date()
for scheduled in sorted(times):
candidate = datetime.combine(today, scheduled)
if candidate > now:
return candidate
first_time = sorted(times)[0]
next_day = today + timedelta(days=1)
return datetime.combine(next_day, first_time)
def _create_service() -> RemnaWaveAutoSyncService:
service = RemnaWaveAutoSyncService()
return service
remnawave_sync_service = _create_service()
+50
View File
@@ -78,6 +78,7 @@ class BotConfigurationService:
"TRIBUTE": "🎁 Tribute",
"MULENPAY": "💰 MulenPay",
"PAL24": "🏦 PAL24 / PayPalych",
"WATA": "💠 Wata",
"EXTERNAL_ADMIN": "🛡️ Внешняя админка",
"SUBSCRIPTIONS_CORE": "📅 Подписки и лимиты",
"PERIODS": "📆 Периоды подписок",
@@ -127,6 +128,7 @@ class BotConfigurationService:
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
"TRIBUTE": "Tribute и донат-сервисы.",
"TELEGRAM": "Telegram Stars и их стоимость.",
"WATA": "Wata: токен доступа, тип платежа и пределы сумм.",
"EXTERNAL_ADMIN": "Токен внешней админки для проверки запросов.",
"SUBSCRIPTIONS_CORE": "Лимиты устройств, трафика и базовые цены подписок.",
"PERIODS": "Доступные периоды подписок и продлений.",
@@ -171,6 +173,7 @@ class BotConfigurationService:
"CHANNEL_SUB_ID": "CHANNEL",
"CHANNEL_LINK": "CHANNEL",
"CHANNEL_IS_REQUIRED_SUB": "CHANNEL",
"BOT_USERNAME": "CORE",
"DEFAULT_LANGUAGE": "LOCALIZATION",
"AVAILABLE_LANGUAGES": "LOCALIZATION",
"LANGUAGE_SELECTION_ENABLED": "LOCALIZATION",
@@ -219,6 +222,7 @@ class BotConfigurationService:
"PAYMENT_SUBSCRIPTION_DESCRIPTION": "PAYMENT",
"PAYMENT_BALANCE_TEMPLATE": "PAYMENT",
"PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT",
"DISABLE_TOPUP_BUTTONS": "PAYMENT",
"ENABLE_NOTIFICATIONS": "NOTIFICATIONS",
"NOTIFICATION_RETRY_ATTEMPTS": "NOTIFICATIONS",
"NOTIFICATION_CACHE_HOURS": "NOTIFICATIONS",
@@ -244,6 +248,8 @@ class BotConfigurationService:
"VERSION_CHECK_INTERVAL_HOURS": "VERSION",
"TELEGRAM_STARS_RATE_RUB": "TELEGRAM",
"REMNAWAVE_USER_DESCRIPTION_TEMPLATE": "REMNAWAVE",
"REMNAWAVE_AUTO_SYNC_ENABLED": "REMNAWAVE",
"REMNAWAVE_AUTO_SYNC_TIMES": "REMNAWAVE",
}
CATEGORY_PREFIX_OVERRIDES: Dict[str, str] = {
@@ -268,6 +274,7 @@ class BotConfigurationService:
"MULENPAY_": "MULENPAY",
"PAL24_": "PAL24",
"PAYMENT_": "PAYMENT",
"WATA_": "WATA",
"EXTERNAL_ADMIN_": "EXTERNAL_ADMIN",
"CONNECT_BUTTON_HAPP": "HAPP",
"HAPP_": "HAPP",
@@ -420,6 +427,20 @@ class BotConfigurationService:
"warning": "Недоступный адрес приведет к ошибкам при управлении VPN-учетками.",
"dependencies": "REMNAWAVE_API_KEY или REMNAWAVE_USERNAME/REMNAWAVE_PASSWORD",
},
"REMNAWAVE_AUTO_SYNC_ENABLED": {
"description": "Автоматически запускает синхронизацию пользователей и серверов с панелью RemnaWave.",
"format": "Булево значение.",
"example": "Включено при корректно настроенных API-ключах.",
"warning": "При включении без расписания синхронизация не будет выполнена.",
"dependencies": "REMNAWAVE_AUTO_SYNC_TIMES",
},
"REMNAWAVE_AUTO_SYNC_TIMES": {
"description": "Список времени в формате HH:MM, когда запускается автосинхронизация в течение суток.",
"format": "Перечислите время через запятую или с новой строки (например, 03:00, 15:00).",
"example": "03:00, 15:00",
"warning": "Минимальный интервал между запусками не ограничен, но слишком частые синхронизации нагружают панель.",
"dependencies": "REMNAWAVE_AUTO_SYNC_ENABLED",
},
"EXTERNAL_ADMIN_TOKEN": {
"description": "Приватный токен, который использует внешняя админка для проверки запросов.",
"format": "Значение генерируется автоматически из username бота и его токена и доступно только для чтения.",
@@ -959,6 +980,35 @@ class BotConfigurationService:
refresh_period_prices()
elif key.startswith("PRICE_TRAFFIC_") or key == "TRAFFIC_PACKAGES_CONFIG":
refresh_traffic_prices()
elif key in {"REMNAWAVE_AUTO_SYNC_ENABLED", "REMNAWAVE_AUTO_SYNC_TIMES"}:
try:
from app.services.remnawave_sync_service import remnawave_sync_service
remnawave_sync_service.schedule_refresh(
run_immediately=(key == "REMNAWAVE_AUTO_SYNC_ENABLED" and bool(value))
)
except Exception as error:
logger.error(
"Не удалось обновить сервис автосинхронизации RemnaWave: %s",
error,
)
elif key in {
"REMNAWAVE_API_URL",
"REMNAWAVE_API_KEY",
"REMNAWAVE_SECRET_KEY",
"REMNAWAVE_USERNAME",
"REMNAWAVE_PASSWORD",
"REMNAWAVE_AUTH_TYPE",
}:
try:
from app.services.remnawave_sync_service import remnawave_sync_service
remnawave_sync_service.refresh_configuration()
except Exception as error:
logger.error(
"Не удалось обновить конфигурацию сервиса автосинхронизации RemnaWave: %s",
error,
)
except Exception as error:
logger.error("Не удалось применить значение %s=%s: %s", key, value, error)
+40 -2
View File
@@ -167,6 +167,7 @@ class TributeService:
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=session,
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о Tribute пополнении: {e}")
@@ -234,6 +235,7 @@ class TributeService:
except Exception as e:
logger.error(f"Ошибка обработки возврата Tribute: {e}")
async def _send_success_notification(self, user_id: int, amount_kopeks: int):
try:
@@ -243,6 +245,7 @@ class TributeService:
user = await get_user_by_telegram_id(session, user_id)
break
# Сначала отправляем стандартное уведомление
payment_service = PaymentService(self.bot)
keyboard = await payment_service.build_topup_success_keyboard(user)
@@ -261,10 +264,45 @@ class TributeService:
parse_mode="Markdown"
)
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
from app.services.user_cart_service import user_cart_service
has_saved_cart = await user_cart_service.has_user_cart(user.id)
if has_saved_cart and self.bot:
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
from aiogram import types
texts = get_texts(user.language)
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
total_amount=settings.format_price(amount_kopeks)
)
# Создаем клавиатуру с кнопками
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout"
)],
[types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance"
)],
[types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu"
)]
])
await self.bot.send_message(
chat_id=user_id,
text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n{cart_message}",
reply_markup=keyboard
)
logger.info(f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user_id}")
except Exception as e:
logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}")
async def _send_failure_notification(self, user_id: int):
try:
+112
View File
@@ -0,0 +1,112 @@
import json
import logging
from typing import Optional, Dict, Any
from datetime import timedelta
import redis.asyncio as redis
from app.config import settings
logger = logging.getLogger(__name__)
class UserCartService:
"""
Сервис для работы с корзиной пользователя через Redis
"""
def __init__(self):
self.redis_client = None
self._setup_redis()
def _setup_redis(self):
"""Инициализация Redis клиента"""
try:
self.redis_client = redis.from_url(settings.REDIS_URL)
except Exception as e:
logger.error(f"Ошибка подключения к Redis: {e}")
raise
async def save_user_cart(self, user_id: int, cart_data: Dict[str, Any], ttl: int = 3600) -> bool:
"""
Сохранить корзину пользователя в Redis
Args:
user_id: ID пользователя
cart_data: Данные корзины (параметры подписки)
ttl: Время жизни ключа в секундах (по умолчанию 1 час)
Returns:
bool: Успешность сохранения
"""
try:
key = f"user_cart:{user_id}"
json_data = json.dumps(cart_data, ensure_ascii=False)
await self.redis_client.setex(key, ttl, json_data)
logger.info(f"Корзина пользователя {user_id} сохранена в Redis")
return True
except Exception as e:
logger.error(f"Ошибка сохранения корзины пользователя {user_id}: {e}")
return False
async def get_user_cart(self, user_id: int) -> Optional[Dict[str, Any]]:
"""
Получить корзину пользователя из Redis
Args:
user_id: ID пользователя
Returns:
dict: Данные корзины или None
"""
try:
key = f"user_cart:{user_id}"
json_data = await self.redis_client.get(key)
if json_data:
cart_data = json.loads(json_data)
logger.info(f"Корзина пользователя {user_id} загружена из Redis")
return cart_data
return None
except Exception as e:
logger.error(f"Ошибка получения корзины пользователя {user_id}: {e}")
return None
async def delete_user_cart(self, user_id: int) -> bool:
"""
Удалить корзину пользователя из Redis
Args:
user_id: ID пользователя
Returns:
bool: Успешность удаления
"""
try:
key = f"user_cart:{user_id}"
result = await self.redis_client.delete(key)
if result:
logger.info(f"Корзина пользователя {user_id} удалена из Redis")
return bool(result)
except Exception as e:
logger.error(f"Ошибка удаления корзины пользователя {user_id}: {e}")
return False
async def has_user_cart(self, user_id: int) -> bool:
"""
Проверить наличие корзины у пользователя
Args:
user_id: ID пользователя
Returns:
bool: Наличие корзины
"""
try:
key = f"user_cart:{user_id}"
exists = await self.redis_client.exists(key)
return bool(exists)
except Exception as e:
logger.error(f"Ошибка проверки наличия корзины пользователя {user_id}: {e}")
return False
# Глобальный экземпляр сервиса
user_cart_service = UserCartService()
+201
View File
@@ -0,0 +1,201 @@
"""High level service for interacting with WATA payment API."""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
import aiohttp
from app.config import settings
logger = logging.getLogger(__name__)
class WataAPIError(RuntimeError):
"""Raised when the WATA API returns an error response."""
class WataService:
"""Thin wrapper around the WATA REST API used for balance top-ups."""
def __init__(
self,
*,
base_url: Optional[str] = None,
access_token: Optional[str] = None,
request_timeout: Optional[int] = None,
) -> None:
self.base_url = (base_url or settings.WATA_BASE_URL or "").rstrip("/")
self.access_token = access_token or settings.WATA_ACCESS_TOKEN
self.request_timeout = request_timeout or int(settings.WATA_REQUEST_TIMEOUT)
@property
def is_configured(self) -> bool:
return bool(
settings.is_wata_enabled()
and self.base_url
and self.access_token
)
def _build_url(self, path: str) -> str:
return f"{self.base_url}/{path.lstrip('/')}"
def _build_headers(self) -> Dict[str, str]:
if not self.access_token:
raise WataAPIError("WATA access token is not configured")
return {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
}
async def _request(
self,
method: str,
path: str,
*,
json: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
if not self.is_configured:
raise WataAPIError("WATA service is not configured")
url = self._build_url(path)
timeout = aiohttp.ClientTimeout(total=self.request_timeout)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.request(
method,
url,
json=json,
params=params,
headers=self._build_headers(),
) as response:
response_text = await response.text()
if response.status >= 400:
logger.error(
"WATA API error %s: %s", response.status, response_text
)
raise WataAPIError(
f"WATA API returned status {response.status}: {response_text}"
)
if not response_text:
return {}
try:
data = await response.json()
except aiohttp.ContentTypeError as error:
logger.error("WATA API returned non-JSON response: %s", error)
raise WataAPIError("WATA API returned invalid JSON") from error
return data
except aiohttp.ClientError as error:
logger.error("Error communicating with WATA API: %s", error)
raise WataAPIError("Failed to communicate with WATA API") from error
@staticmethod
def _amount_from_kopeks(amount_kopeks: int) -> float:
return round(amount_kopeks / 100, 2)
@staticmethod
def _format_datetime(value: datetime) -> str:
aware = value.astimezone(timezone.utc)
return aware.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
@staticmethod
def _parse_datetime(raw: Optional[str]) -> Optional[datetime]:
if not raw:
return None
try:
normalized = raw.replace("Z", "+00:00")
return datetime.fromisoformat(normalized)
except (ValueError, TypeError):
logger.debug("Failed to parse WATA datetime: %s", raw)
return None
async def create_payment_link(
self,
*,
amount_kopeks: int,
currency: str,
description: str,
order_id: str,
success_url: Optional[str] = None,
fail_url: Optional[str] = None,
link_type: Optional[str] = None,
expiration_minutes: Optional[int] = None,
allow_arbitrary_amount: bool = False,
arbitrary_amount_prompts: Optional[list[int]] = None,
) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"amount": self._amount_from_kopeks(amount_kopeks),
"currency": currency,
"description": description,
"orderId": order_id,
}
payload["type"] = link_type or settings.WATA_PAYMENT_TYPE or "OneTime"
if success_url or settings.WATA_SUCCESS_REDIRECT_URL:
payload["successRedirectUrl"] = success_url or settings.WATA_SUCCESS_REDIRECT_URL
if fail_url or settings.WATA_FAIL_REDIRECT_URL:
payload["failRedirectUrl"] = fail_url or settings.WATA_FAIL_REDIRECT_URL
if expiration_minutes is None:
ttl = settings.WATA_LINK_TTL_MINUTES
expiration_minutes = int(ttl) if ttl is not None else None
if expiration_minutes:
expiration_time = datetime.utcnow() + timedelta(minutes=expiration_minutes)
payload["expirationDateTime"] = self._format_datetime(expiration_time)
if allow_arbitrary_amount:
payload["isArbitraryAmountAllowed"] = True
if arbitrary_amount_prompts:
payload["arbitraryAmountPrompts"] = arbitrary_amount_prompts
logger.info(
"Создаем WATA платежную ссылку: order_id=%s, amount=%s %s",
order_id,
payload["amount"],
currency,
)
response = await self._request("POST", "/links", json=payload)
logger.debug("WATA create link response: %s", response)
return response
async def get_payment_link(self, payment_link_id: str) -> Dict[str, Any]:
logger.debug("Запрашиваем WATA ссылку %s", payment_link_id)
return await self._request("GET", f"/links/{payment_link_id}")
async def search_transactions(
self,
*,
order_id: Optional[str] = None,
payment_link_id: Optional[str] = None,
status: Optional[str] = None,
limit: int = 5,
) -> Dict[str, Any]:
params: Dict[str, Any] = {
"skipCount": 0,
"maxResultCount": max(1, min(limit, 1000)),
}
if order_id:
params["orderId"] = order_id
if status:
params["statuses"] = status
if payment_link_id:
params["paymentLinkId"] = payment_link_id
logger.debug(
"Ищем WATA транзакции: order_id=%s, payment_link_id=%s", order_id, payment_link_id
)
return await self._request("GET", "/transactions", params=params)
async def get_transaction(self, transaction_id: str) -> Dict[str, Any]:
logger.debug("Получаем WATA транзакцию %s", transaction_id)
return await self._request("GET", f"/transactions/{transaction_id}")
+4
View File
@@ -173,6 +173,10 @@ class SquadMigrationStates(StatesGroup):
confirming = State()
class RemnaWaveSyncStates(StatesGroup):
waiting_for_schedule = State()
class AdminSubmenuStates(StatesGroup):
in_users_submenu = State()
in_promo_submenu = State()
+14
View File
@@ -54,6 +54,15 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
"callback": "topup_mulenpay"
})
if settings.is_wata_enabled():
methods.append({
"id": "wata",
"name": "Банковская карта",
"icon": "💳",
"description": "через WATA",
"callback": "topup_wata"
})
if settings.is_pal24_enabled():
methods.append({
"id": "pal24",
@@ -141,6 +150,8 @@ def is_payment_method_available(method_id: str) -> bool:
return settings.TRIBUTE_ENABLED
elif method_id == "mulenpay":
return settings.is_mulenpay_enabled()
elif method_id == "wata":
return settings.is_wata_enabled()
elif method_id == "pal24":
return settings.is_pal24_enabled()
elif method_id == "cryptobot":
@@ -159,6 +170,7 @@ def get_payment_method_status() -> Dict[str, bool]:
"yookassa": settings.is_yookassa_enabled(),
"tribute": settings.TRIBUTE_ENABLED,
"mulenpay": settings.is_mulenpay_enabled(),
"wata": settings.is_wata_enabled(),
"pal24": settings.is_pal24_enabled(),
"cryptobot": settings.is_cryptobot_enabled(),
"support": True
@@ -177,6 +189,8 @@ def get_enabled_payment_methods_count() -> int:
count += 1
if settings.is_mulenpay_enabled():
count += 1
if settings.is_wata_enabled():
count += 1
if settings.is_pal24_enabled():
count += 1
if settings.is_cryptobot_enabled():
+154 -3
View File
@@ -62,7 +62,7 @@ from app.services.remnawave_service import (
RemnaWaveConfigurationError,
RemnaWaveService,
)
from app.services.payment_service import PaymentService
from app.services.payment_service import PaymentService, get_wata_payment_by_link_id
from app.services.promo_offer_service import promo_offer_service
from app.services.promocode_service import PromoCodeService
from app.services.subscription_service import SubscriptionService
@@ -688,6 +688,18 @@ async def get_payment_methods(
)
)
if settings.is_wata_enabled():
methods.append(
MiniAppPaymentMethod(
id="wata",
icon="🌊",
requires_amount=True,
currency="RUB",
min_amount_kopeks=settings.WATA_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.WATA_MAX_AMOUNT_KOPEKS,
)
)
if settings.is_cryptobot_enabled():
rate = await _get_usd_to_rub_rate()
min_amount_kopeks, max_amount_kopeks = _compute_cryptobot_limits(rate)
@@ -718,8 +730,9 @@ async def get_payment_methods(
"yookassa": 3,
"mulenpay": 4,
"pal24": 5,
"cryptobot": 6,
"tribute": 7,
"wata": 6,
"cryptobot": 7,
"tribute": 8,
}
methods.sort(key=lambda item: order_map.get(item.id, 99))
@@ -896,6 +909,42 @@ async def create_payment_link(
},
)
if method == "wata":
if not settings.is_wata_enabled():
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
if amount_kopeks is None or amount_kopeks <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive")
if amount_kopeks < settings.WATA_MIN_AMOUNT_KOPEKS:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum")
if amount_kopeks > settings.WATA_MAX_AMOUNT_KOPEKS:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum")
payment_service = PaymentService()
result = await payment_service.create_wata_payment(
db=db,
user_id=user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
language=user.language,
)
payment_url = result.get("payment_url") if result else None
if not result or not payment_url:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment")
return MiniAppPaymentCreateResponse(
method=method,
payment_url=payment_url,
amount_kopeks=amount_kopeks,
extra={
"local_payment_id": result.get("local_payment_id"),
"payment_link_id": result.get("payment_link_id"),
"payment_id": result.get("payment_link_id"),
"status": result.get("status"),
"order_id": result.get("order_id"),
"requested_at": _current_request_timestamp(),
},
)
if method == "pal24":
if not settings.is_pal24_enabled():
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
@@ -1107,6 +1156,8 @@ async def _resolve_payment_status_entry(
)
if method == "mulenpay":
return await _resolve_mulenpay_payment_status(payment_service, db, user, query)
if method == "wata":
return await _resolve_wata_payment_status(payment_service, db, user, query)
if method == "pal24":
return await _resolve_pal24_payment_status(payment_service, db, user, query)
if method == "cryptobot":
@@ -1255,6 +1306,106 @@ async def _resolve_mulenpay_payment_status(
)
async def _resolve_wata_payment_status(
payment_service: PaymentService,
db: AsyncSession,
user: User,
query: MiniAppPaymentStatusQuery,
) -> MiniAppPaymentStatusResult:
local_id = query.local_payment_id
payment_link_id = query.payment_link_id or query.payment_id or query.invoice_id
fallback_payment = None
if not local_id and payment_link_id:
fallback_payment = await get_wata_payment_by_link_id(db, payment_link_id)
if fallback_payment:
local_id = fallback_payment.id
if not local_id:
return MiniAppPaymentStatusResult(
method="wata",
status="pending",
is_paid=False,
amount_kopeks=query.amount_kopeks,
message="Missing payment identifier",
extra={
"local_payment_id": query.local_payment_id,
"payment_link_id": payment_link_id,
"payment_id": query.payment_id,
"invoice_id": query.invoice_id,
"payload": query.payload,
"started_at": query.started_at,
},
)
status_info = await payment_service.get_wata_payment_status(db, local_id)
payment = (status_info or {}).get("payment") or fallback_payment
if not payment or payment.user_id != user.id:
return MiniAppPaymentStatusResult(
method="wata",
status="pending",
is_paid=False,
amount_kopeks=query.amount_kopeks,
message="Payment not found",
extra={
"local_payment_id": local_id,
"payment_link_id": (payment_link_id or getattr(payment, "payment_link_id", None)),
"payment_id": query.payment_id,
"invoice_id": query.invoice_id,
"payload": query.payload,
"started_at": query.started_at,
},
)
remote_link = (status_info or {}).get("remote_link") if status_info else None
transaction_payload = (status_info or {}).get("transaction") if status_info else None
status_raw = (status_info or {}).get("status") or getattr(payment, "status", None)
is_paid_flag = bool((status_info or {}).get("is_paid") or getattr(payment, "is_paid", False))
status_value = _classify_status(status_raw, is_paid_flag)
completed_at = (
getattr(payment, "paid_at", None)
or getattr(payment, "updated_at", None)
or getattr(payment, "created_at", None)
)
message = None
if status_value == "failed":
message = (
(transaction_payload or {}).get("errorDescription")
or (transaction_payload or {}).get("errorCode")
or (remote_link or {}).get("status")
)
extra: Dict[str, Any] = {
"local_payment_id": payment.id,
"payment_link_id": payment.payment_link_id,
"payment_id": payment.payment_link_id,
"status": status_raw,
"is_paid": getattr(payment, "is_paid", False),
"order_id": getattr(payment, "order_id", None),
"payload": query.payload,
"started_at": query.started_at,
}
if remote_link:
extra["remote_link"] = remote_link
if transaction_payload:
extra["transaction"] = transaction_payload
return MiniAppPaymentStatusResult(
method="wata",
status=status_value,
is_paid=status_value == "paid",
amount_kopeks=payment.amount_kopeks,
currency=payment.currency,
completed_at=completed_at,
transaction_id=payment.transaction_id,
external_id=payment.payment_link_id,
message=message,
extra=extra,
)
async def _resolve_pal24_payment_status(
payment_service: PaymentService,
db: AsyncSession,
+1
View File
@@ -392,6 +392,7 @@ class MiniAppPaymentCreateResponse(BaseModel):
class MiniAppPaymentStatusQuery(BaseModel):
method: str
local_payment_id: Optional[int] = Field(default=None, alias="localPaymentId")
payment_link_id: Optional[str] = Field(default=None, alias="paymentLinkId")
invoice_id: Optional[str] = Field(default=None, alias="invoiceId")
payment_id: Optional[str] = Field(default=None, alias="paymentId")
payload: Optional[str] = None
+75
View File
@@ -0,0 +1,75 @@
# Сквозная корзина (Persistent Cart) для Remnawave Bedolaga Telegram Bot
## Общее описание
Система сквозной корзины позволяет пользователям продолжить оформление подписки после пополнения баланса, не теряя уже выбранные параметры (период, трафик, серверы, устройства и т.д.).
## Архитектура
### 1. Сервис корзины (UserCartService)
Расположение: `app/services/user_cart_service.py`
Использует Redis для хранения данных корзины между сессиями пользователя.
#### Основные методы:
- `save_user_cart(user_id, cart_data, ttl)` - сохраняет корзину пользователя
- `get_user_cart(user_id)` - возвращает данные корзины пользователя
- `delete_user_cart(user_id)` - удаляет корзину пользователя
- `has_user_cart(user_id)` - проверяет наличие корзины у пользователя
### 2. Обновленные обработчики подписки
Расположение: `app/handlers/subscription/purchase.py`
#### Основные функции:
- `save_cart_and_redirect_to_topup` - сохраняет текущую корзину в Redis при недостатке средств и перенаправляет к пополнению
- `return_to_saved_cart` - восстанавливает параметры подписки из Redis и продолжает процесс оформления
- `clear_saved_cart` - очищает сохраненную корзину
### 3. Обновленные клавиатуры
Расположение: `app/keyboards/inline.py`
#### Основные изменения:
- `get_insufficient_balance_keyboard` - добавлена поддержка флага `has_saved_cart` для отображения кнопки возврата к оформлению
- `get_insufficient_balance_keyboard_with_cart` - обновлена для использования флага `has_saved_cart`
- `get_main_menu_keyboard` - добавлен параметр `has_saved_cart` для отображения кнопки возврата к оформлению
### 4. Интеграция с главным меню
Расположение: `app/handlers/menu.py`
Функция `show_main_menu` теперь проверяет наличие сохраненной корзины и отображает соответствующую кнопку.
## Использование
### Сохранение корзины
Когда пользователь не может завершить покупку из-за недостатка средств, его параметры автоматически сохраняются в Redis с TTL 1 час.
### Восстановление корзины
При пополнении баланса пользователь может вернуться к оформлению подписки, нажав на кнопку "Вернуться к оформлению подписки" или через главное меню, если у него есть сохраненная корзина.
### Очистка корзины
Корзина автоматически очищается после успешного оформления подписки или по запросу пользователя.
## Тестирование
Тесты расположены в:
- `tests/test_user_cart_service.py` - модульные тесты сервиса корзины
- `tests/test_subscription_cart_integration.py` - интеграционные тесты
## Безопасность
- Данные корзины хранятся в Redis с ограниченным временем жизни (TTL)
- Используется идентификатор пользователя для изоляции данных
- Корзина автоматически очищается после успешной покупки
## Масштабируемость
- Использование Redis позволяет системе масштабироваться на несколько инстансов бота
- TTL автоматически очищает старые данные
- Нагрузка на Redis минимальна благодаря короткому времени хранения данных
+29
View File
@@ -0,0 +1,29 @@
# Включение/отключение реферальной программы
## Описание
Добавлена возможность включать и отключать реферальную программу (партнерку) через переменную окружения.
## Настройка
В файле `.env` установите значение переменной:
- `REFERRAL_PROGRAM_ENABLED=true` - реферальная программа включена
- `REFERRAL_PROGRAM_ENABLED=false` - реферальная программа отключена
## Поведение
- При отключенной программе кнопки "Партнерка", "Рефералы" и другие связанные элементы скрываются из главного меню бота
- Обычные (не реферальные) кнопки продолжают отображаться независимо от настройки
- Административные функции реферальной программы остаются доступными в админ-панели для управления
- Настройка также учитывается в API и других сервисах, использующих информацию о реферальной программе
## Примеры скрываемых элементов
- Кнопка "🤝 Партнерка" в главном меню
- Кнопка "👥 Мои рефералы"
- Кнопка "🤝 Referral program" (английская версия)
- Другие элементы, содержащие слова "partner", "referr", "партнер", "реферал"
## Технические детали
- Новая переменная `REFERRAL_PROGRAM_ENABLED` добавлена в `app/config.py`
- Метод `is_referral_program_enabled()` проверяет состояние настройки
- Функция `get_main_menu_keyboard` в `app/keyboards/inline.py` скрывает кнопку "Партнерка" при отключенной программе
- Сервис `MainMenuButtonService` в `app/services/main_menu_button_service.py` скрывает дополнительные кнопки, связанные с рефералами
- Обновлен файл `.env.example` с новой настройкой
+18
View File
@@ -74,6 +74,7 @@
"PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Payment methods are temporarily unavailable",
"PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)",
"PAYMENT_CARD_MULENPAY": "💳 Bank card (Mulen Pay)",
"PAYMENT_CARD_WATA": "💳 Bank card (WATA)",
"PAYMENT_CARD_PAL24": "🏦 SBP (PayPalych)",
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
@@ -86,6 +87,10 @@
"MULENPAY_PAYMENT_ERROR": "❌ Failed to create Mulen Pay payment. Please try again later or contact support.",
"MULENPAY_PAY_BUTTON": "💳 Pay with Mulen Pay",
"MULENPAY_PAYMENT_INSTRUCTIONS": "💳 <b>Mulen Pay payment</b>\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 <b>How to pay:</b>\n1. Press Pay with Mulen Pay\n2. Follow the instructions on the payment page\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
"WATA_TOPUP_PROMPT": "💳 <b>Pay via WATA</b>\n\nEnter the amount to top up. Minimum amount — {min_amount}, maximum — {max_amount}.\nPayments are processed through the secure WATA form.",
"WATA_PAYMENT_ERROR": "❌ Failed to create WATA payment. Please try again later or contact support.",
"WATA_PAY_BUTTON": "💳 Pay via WATA",
"WATA_PAYMENT_INSTRUCTIONS": "💳 <b>Pay via WATA</b>\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 <b>How to pay:</b>\n1. Tap Pay via WATA\n2. Follow the gateway prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
"PAL24_TOPUP_PROMPT": "🏦 <b>PayPalych (SBP) payment</b>\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed via the PayPalych Faster Payments System.",
"PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.",
"PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
@@ -96,6 +101,14 @@
"PAL24_INSTRUCTION_FOLLOW": "{step}. Follow the payment page instructions",
"PAL24_INSTRUCTION_CONFIRM": "{step}. Confirm the transfer",
"PAL24_INSTRUCTION_COMPLETE": "{step}. The funds will be credited automatically",
"WATA_AMOUNT_TOO_LOW": "Minimum top-up amount: {amount}",
"WATA_AMOUNT_TOO_HIGH": "Maximum top-up amount: {amount}",
"WATA_STATUS_TITLE": "💳 <b>WATA payment status</b>",
"WATA_STATUS_OPENED": "Waiting for payment",
"WATA_STATUS_CLOSED": "Processing",
"WATA_STATUS_PAID": "Paid",
"WATA_STATUS_DECLINED": "Declined",
"WATA_STATUS_UNKNOWN": "Unknown",
"PENDING_CANCEL_BUTTON": "⌛ Cancel",
"POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀",
"REFERRAL_ANALYTICS_BUTTON": "📊 Analytics",
@@ -304,6 +317,7 @@
"BALANCE_SUPPORT_REQUEST": "🛠️ Request via support",
"BALANCE_TOP_UP": "💳 Top up",
"BALANCE_TOPUP": "💳 Top up balance",
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\\n💡 <b>Balance top-up required</b>\\n\\nYour cart contains items totaling {total_amount}, but your current balance is insufficient.\\n\\n💳 <b>Top up your balance</b> to complete the purchase.\\n\\nChoose a top-up method:",
"CAMPAIGN_EXISTING_USER": "️ This promo link is available only to new users.",
"CAMPAIGN_BONUS_BALANCE": "🎉 You received {amount} for registering via the \"{name}\" campaign!",
"CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 Youve been granted a {days}-day subscription (traffic: {traffic}, devices: {devices}) from the \"{name}\" campaign!",
@@ -559,10 +573,14 @@
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa",
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>SBP (YooKassa)</b>",
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "via YooKassa Fast Payment System",
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Bank card</b>",
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card (Mulen Pay)</b>",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via Mulen Pay",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Bank card (WATA)</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "via WATA",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>SBP (PayPalych)</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</b>",
+26 -8
View File
@@ -164,6 +164,7 @@
"BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку",
"BALANCE_TOP_UP": "💳 Пополнить",
"BALANCE_TOPUP": "💳 Пополнить баланс",
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\\n💡 <b>Требуется пополнение баланса</b>\\n\\nВ вашей корзине находятся товары на общую сумму {total_amount}, но на балансе недостаточно средств.\\n\\n💳 <b>Пополните баланс</b>, чтобы завершить покупку.\\n\\nВыберите способ пополнения:",
"CAMPAIGN_EXISTING_USER": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.",
"CAMPAIGN_BONUS_BALANCE": "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!",
"CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!",
@@ -291,6 +292,7 @@
"PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Способы оплаты временно недоступны",
"PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)",
"PAYMENT_CARD_MULENPAY": "💳 Банковская карта (Mulen Pay)",
"PAYMENT_CARD_WATA": "💳 Банковская карта (WATA)",
"PAYMENT_CARD_PAL24": "🏦 СБП (PayPalych)",
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
@@ -303,6 +305,10 @@
"MULENPAY_PAYMENT_ERROR": "❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
"MULENPAY_PAY_BUTTON": "💳 Оплатить через Mulen Pay",
"MULENPAY_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через Mulen Pay</b>\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через Mulen Pay’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
"WATA_TOPUP_PROMPT": "💳 <b>Оплата через WATA</b>\n\nВведите сумму пополнения. Минимальная сумма — {min_amount}, максимальная — {max_amount}.\nОплата происходит через защищенную форму WATA.",
"WATA_PAYMENT_ERROR": "❌ Ошибка создания платежа WATA. Попробуйте позже или обратитесь в поддержку.",
"WATA_PAY_BUTTON": "💳 Оплатить через WATA",
"WATA_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через WATA</b>\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку 'Оплатить через WATA'\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
"PAL24_TOPUP_PROMPT": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.",
"PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
"PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
@@ -313,6 +319,14 @@
"PAL24_INSTRUCTION_FOLLOW": "{step}. Следуйте подсказкам платёжной системы",
"PAL24_INSTRUCTION_CONFIRM": "{step}. Подтвердите перевод",
"PAL24_INSTRUCTION_COMPLETE": "{step}. Средства зачислятся автоматически",
"WATA_AMOUNT_TOO_LOW": "Минимальная сумма пополнения: {amount}",
"WATA_AMOUNT_TOO_HIGH": "Максимальная сумма пополнения: {amount}",
"WATA_STATUS_TITLE": "💳 <b>Статус платежа WATA</b>",
"WATA_STATUS_OPENED": "Ожидает оплаты",
"WATA_STATUS_CLOSED": "Обрабатывается",
"WATA_STATUS_PAID": "Оплачен",
"WATA_STATUS_DECLINED": "Отклонен",
"WATA_STATUS_UNKNOWN": "Неизвестно",
"PENDING_CANCEL_BUTTON": "⌛ Отмена",
"PERIOD_14_DAYS": "📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}",
"PERIOD_180_DAYS": "📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}",
@@ -336,7 +350,7 @@
"REFERRAL_CODE_SKIP": "⏭️ Пропустить",
"ALREADY_REGISTERED_REFERRAL": "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.",
"REFERRAL_INFO": "\n🤝 <b>Реферальная программа</b>\n\n👥 <b>Приглашено:</b> {referrals_count} друзей\n💰 <b>Заработано:</b> {earned_amount}\n\n🔗 <b>Ваша реферальная ссылка:</b>\n<code>{referral_link}</code>\n\n🎫 <b>Ваш промокод:</b>\n<code>{referral_code}</code>\n\n💰 <b>Условия:</b>\n• За каждого друга: {registration_bonus}\n• Процент с пополнений: {commission_percent}%\n",
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>Приглашение в VPN сервис</b>\n\nПривет! Приглашаю тебя в отличный VPN сервис!\n\n🎁 По моей ссылке ты получишь бонус: {bonus}\n\n🔗 Переходи: {link}\n🎫 Или используй промокод: {code}\n\n💪 Быстро, надежно, недорого!\n",
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>Приглашение в сервис</b>\n\nПривет! Приглашаю тебя в отличный сервис!\n\n🎁 По моей ссылке ты получишь бонус: {bonus}\n\n🔗 Переходи: {link}\n🎫 Или используй промокод: {code}\n\n💪 Быстро, надежно, недорого!\n",
"REFERRAL_LIST_BUTTON": "👥 Список рефералов",
"RESET_ALL_DEVICES_BUTTON": "🔄 Сбросить все устройства",
"RESET_DEVICE_CONFIRM_BUTTON": "✅ Да, сбросить это устройство",
@@ -427,11 +441,11 @@
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic} ГБ\n📱 <b>Устройства:</b> {devices} шт.\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
"TRIAL_SERVER_RANDOM_POOL": "🎲 Случайный из {count} серверов",
"TRIAL_SERVER_DEFAULT_NAME": "🎯 Тестовый сервер",
"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_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без сервиса?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
"UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.",
"UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.",
"USER_NOT_FOUND": "❌ Пользователь не найден",
"WELCOME": "\n🎉 <b>Добро пожаловать в VPN сервис!</b>\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 <b>Преимущества:</b>\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n",
"WELCOME": "\n🎉 <b>Добро пожаловать в сервис!</b>\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 <b>Преимущества:</b>\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n",
"WELCOME_FALLBACK": "Добро пожаловать, {user_name}!",
"YES": "✅ Да",
"SUBSCRIPTION_STATUS_EXPIRED": "Истекла",
@@ -454,9 +468,9 @@
"SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "<blockquote>📱 <b>Подключенные устройства:</b>\n",
"SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "</blockquote>",
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Ссылка для подключения:</b>\n<code>{subscription_url}</code>",
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше VPN приложение",
"SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 <b>Ваша ссылка для импорта в VPN приложение:</b>\n<code>{subscription_url}</code>",
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше приложение",
"SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 <b>Ваша ссылка для импорта в приложение:</b>\n<code>{subscription_url}</code>",
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке на вашем устройстве",
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню",
"CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Кастомная ссылка для мини-приложения не настроена",
@@ -495,7 +509,7 @@
"SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "<b>{title}:</b>",
"SUBSCRIPTION_LINK_USAGE_TITLE": "📱 <b>Как использовать:</b>",
"SUBSCRIPTION_LINK_STEP1": "1. Нажмите на ссылку выше чтобы её скопировать",
"SUBSCRIPTION_LINK_STEP2": "2. Откройте ваше VPN приложение",
"SUBSCRIPTION_LINK_STEP2": "2. Откройте ваше приложение",
"SUBSCRIPTION_LINK_STEP3": "3. Найдите функцию \"Добавить подписку\" или \"Import\"",
"SUBSCRIPTION_LINK_STEP4": "4. Вставьте скопированную ссылку",
"SUBSCRIPTION_LINK_HINT": "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.",
@@ -543,7 +557,7 @@
"REFERRAL_ANALYTICS_TOP_TITLE": "🏆 <b>Топ-{count} рефералов:</b>",
"REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} начислений)",
"REFERRAL_ANALYTICS_FOOTER": "📈 Продолжайте развивать свою реферальную сеть!",
"REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!",
"REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к сервису!",
"REFERRAL_INVITE_BONUS": "💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!",
"REFERRAL_INVITE_FEATURE_FAST": "🚀 Быстрое подключение",
"REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Серверы по всему миру",
@@ -561,10 +575,14 @@
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>СБП (YooKassa)</b>",
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему быстрых платежей YooKassa",
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банковская карта</b>",
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта (Mulen Pay)</b>",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Банковская карта (WATA)</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",
+30
View File
@@ -20,6 +20,7 @@ from app.external.pal24_webhook import start_pal24_webhook_server, Pal24WebhookS
from app.database.universal_migration import run_universal_migration
from app.services.backup_service import backup_service
from app.services.reporting_service import reporting_service
from app.services.remnawave_sync_service import remnawave_sync_service
from app.localization.loader import ensure_locale_templates
from app.services.system_settings_service import bot_configuration_service
from app.services.external_admin_service import ensure_external_admin_token
@@ -185,6 +186,29 @@ async def main():
stage.warning(f"Ошибка запуска сервиса отчетов: {e}")
logger.error(f"❌ Ошибка запуска сервиса отчетов: {e}")
async with timeline.stage(
"Автосинхронизация RemnaWave",
"🔄",
success_message="Сервис автосинхронизации готов",
) as stage:
try:
await remnawave_sync_service.initialize()
status = remnawave_sync_service.get_status()
if status.enabled:
times_text = ", ".join(t.strftime("%H:%M") for t in status.times) or ""
if status.next_run:
next_run_text = status.next_run.strftime("%d.%m.%Y %H:%M")
stage.log(
f"Активирована: расписание {times_text}, ближайший запуск {next_run_text}"
)
else:
stage.log(f"Активирована: расписание {times_text}")
else:
stage.log("Автосинхронизация отключена настройками")
except Exception as e:
stage.warning(f"Ошибка запуска автосинхронизации: {e}")
logger.error(f"❌ Ошибка запуска автосинхронизации RemnaWave: {e}")
payment_service = PaymentService(bot)
async with timeline.stage(
@@ -459,6 +483,12 @@ async def main():
except Exception as e:
logger.error(f"Ошибка остановки сервиса отчетов: {e}")
logger.info("ℹ️ Остановка сервиса автосинхронизации RemnaWave...")
try:
await remnawave_sync_service.stop()
except Exception as e:
logger.error(f"Ошибка остановки автосинхронизации RemnaWave: {e}")
logger.info("ℹ️ Остановка сервиса бекапов...")
try:
await backup_service.stop_auto_backup()
+4
View File
@@ -5615,6 +5615,8 @@
'topup.method.yookassa.description': 'Pay securely with a bank card',
'topup.method.mulenpay.title': 'Bank card (Mulen Pay)',
'topup.method.mulenpay.description': 'Fast payment with bank card',
'topup.method.wata.title': 'Bank card (Wata)',
'topup.method.wata.description': 'Pay with a bank card via Wata',
'topup.method.pal24.title': 'SBP (PayPalych)',
'topup.method.pal24.description': 'Pay via Faster Payments System',
'topup.method.pal24.option.sbp.title': 'Faster Payments (SBP)',
@@ -6019,6 +6021,8 @@
'topup.method.yookassa.description': 'Безопасная оплата банковской картой',
'topup.method.mulenpay.title': 'Банковская карта (Mulen Pay)',
'topup.method.mulenpay.description': 'Мгновенное списание с карты',
'topup.method.wata.title': 'Банковская карта (Wata)',
'topup.method.wata.description': 'Оплата банковской картой через Wata',
'topup.method.pal24.title': 'СБП (PayPalych)',
'topup.method.pal24.description': 'Оплата через Систему быстрых платежей',
'topup.method.pal24.option.sbp.title': 'СБП (рекомендуется)',
@@ -17,6 +17,7 @@ from app.services.payment import ( # noqa: E402
TelegramStarsMixin,
TributePaymentMixin,
YooKassaPaymentMixin,
WataPaymentMixin,
)
from app.services.payment_service import PaymentService # noqa: E402
@@ -31,6 +32,7 @@ def test_payment_service_mro_contains_all_mixins() -> None:
CryptoBotPaymentMixin,
MulenPayPaymentMixin,
Pal24PaymentMixin,
WataPaymentMixin,
}
service_mro = set(PaymentService.__mro__)
assert mixins.issubset(service_mro), "PaymentService должен содержать все mixin-классы"
@@ -46,6 +48,7 @@ def test_payment_service_mro_contains_all_mixins() -> None:
"create_cryptobot_payment",
"create_mulenpay_payment",
"create_pal24_payment",
"create_wata_payment",
],
)
def test_payment_service_exposes_provider_methods(attribute: str) -> None:
+144
View File
@@ -0,0 +1,144 @@
"""Tests for WATA payment mixin."""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
import sys
from typing import Any, Dict, Optional
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 logic required
return None
async def refresh(self, *_: Any) -> None: # pragma: no cover - no logic required
return None
class DummyLocalPayment:
def __init__(self, payment_id: int = 42) -> None:
self.id = payment_id
self.created_at = datetime.utcnow()
class StubWataService:
def __init__(self, response: Optional[Dict[str, Any]]) -> None:
self.response = response
self.calls: list[Dict[str, Any]] = []
async def create_payment_link(self, **kwargs: Any) -> Optional[Dict[str, Any]]:
self.calls.append(kwargs)
return self.response
def _make_service(stub: Optional[StubWataService]) -> PaymentService:
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
service.bot = None
service.wata_service = stub
service.mulenpay_service = None
service.pal24_service = None
service.yookassa_service = None
service.stars_service = None
service.cryptobot_service = None
return service
@pytest.mark.anyio("asyncio")
async def test_create_wata_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
response = {
"id": "123e4567-e89b-12d3-a456-426614174000",
"url": "https://wata.example/link",
"status": "Opened",
"type": "OneTime",
"terminalPublicId": "terminal-id",
"successRedirectUrl": "https://example.com/success",
"failRedirectUrl": "https://example.com/fail",
"expirationDateTime": "2030-01-01T00:00:00Z",
}
stub = StubWataService(response)
service = _make_service(stub)
db = DummySession()
captured_args: Dict[str, Any] = {}
async def fake_create_wata_payment(**kwargs: Any) -> DummyLocalPayment:
captured_args.update(kwargs)
return DummyLocalPayment(payment_id=777)
monkeypatch.setattr(payment_service_module, "create_wata_payment", fake_create_wata_payment, raising=False)
monkeypatch.setattr(settings, "WATA_MIN_AMOUNT_KOPEKS", 5000, raising=False)
monkeypatch.setattr(settings, "WATA_MAX_AMOUNT_KOPEKS", 500_000, raising=False)
result = await service.create_wata_payment(
db=db,
user_id=101,
amount_kopeks=15000,
description="Пополнение",
language="ru",
)
assert result is not None
assert result["local_payment_id"] == 777
assert result["payment_link_id"] == response["id"]
assert result["payment_url"] == response["url"]
assert captured_args["user_id"] == 101
assert captured_args["amount_kopeks"] == 15000
assert captured_args["payment_link_id"] == response["id"]
assert stub.calls and stub.calls[0]["amount_kopeks"] == 15000
@pytest.mark.anyio("asyncio")
async def test_create_wata_payment_respects_amount_limits(monkeypatch: pytest.MonkeyPatch) -> None:
stub = StubWataService({"id": "link"})
service = _make_service(stub)
db = DummySession()
monkeypatch.setattr(settings, "WATA_MIN_AMOUNT_KOPEKS", 10_000, raising=False)
monkeypatch.setattr(settings, "WATA_MAX_AMOUNT_KOPEKS", 20_000, raising=False)
too_low = await service.create_wata_payment(
db=db,
user_id=1,
amount_kopeks=5_000,
description="Пополнение",
)
assert too_low is None
too_high = await service.create_wata_payment(
db=db,
user_id=1,
amount_kopeks=25_000,
description="Пополнение",
)
assert too_high is None
assert not stub.calls
@pytest.mark.anyio("asyncio")
async def test_create_wata_payment_returns_none_without_service() -> None:
service = _make_service(None)
db = DummySession()
result = await service.create_wata_payment(
db=db,
user_id=5,
amount_kopeks=10_000,
description="Пополнение",
)
assert result is None
+182
View File
@@ -3,6 +3,7 @@ import sys
import types
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
import pytest
@@ -89,6 +90,57 @@ async def test_create_payment_link_pal24_uses_selected_option(monkeypatch):
assert captured_calls and captured_calls[0]['payment_method'] == 'CARD'
@pytest.mark.anyio("asyncio")
async def test_create_payment_link_wata_returns_payload(monkeypatch):
monkeypatch.setattr(settings, 'WATA_ENABLED', True, raising=False)
monkeypatch.setattr(settings, 'WATA_ACCESS_TOKEN', 'token', raising=False)
monkeypatch.setattr(settings, 'WATA_TERMINAL_PUBLIC_ID', 'terminal', raising=False)
monkeypatch.setattr(settings, 'WATA_MIN_AMOUNT_KOPEKS', 1000, raising=False)
monkeypatch.setattr(settings, 'WATA_MAX_AMOUNT_KOPEKS', 5000000, raising=False)
captured_call: dict[str, Any] = {}
class DummyPaymentService:
def __init__(self, *args, **kwargs):
pass
async def create_wata_payment(self, db, **kwargs):
captured_call.update({'db': db, **kwargs})
return {
'local_payment_id': 202,
'payment_link_id': 'link_202',
'payment_url': 'https://wata.example/pay',
'status': 'Opened',
'order_id': 'order_202',
}
async def fake_resolve_user(db, init_data):
return types.SimpleNamespace(id=555, language='ru'), {}
monkeypatch.setattr(miniapp, 'PaymentService', lambda *args, **kwargs: DummyPaymentService())
monkeypatch.setattr(miniapp, '_resolve_user_from_init_data', fake_resolve_user)
payload = MiniAppPaymentCreateRequest(
initData='init',
method='wata',
amountKopeks=25000,
)
response = await miniapp.create_payment_link(payload, db=types.SimpleNamespace())
assert response.payment_url == 'https://wata.example/pay'
assert response.amount_kopeks == 25000
assert response.extra['local_payment_id'] == 202
assert response.extra['payment_link_id'] == 'link_202'
assert response.extra['payment_id'] == 'link_202'
assert response.extra['order_id'] == 'order_202'
assert 'requested_at' in response.extra
assert captured_call.get('user_id') == 555
assert captured_call.get('amount_kopeks') == 25000
assert captured_call.get('description')
@pytest.mark.anyio("asyncio")
async def test_resolve_yookassa_status_includes_identifiers(monkeypatch):
payment = types.SimpleNamespace(
@@ -251,6 +303,112 @@ async def test_resolve_pal24_status_includes_identifiers(monkeypatch):
assert result.extra['remote_status'] == 'PAID'
@pytest.mark.anyio("asyncio")
async def test_resolve_wata_payment_status_success():
paid_at = datetime.utcnow()
payment = types.SimpleNamespace(
id=404,
user_id=9,
amount_kopeks=30000,
currency='RUB',
status='Paid',
is_paid=True,
payment_link_id='wata_link_404',
order_id='order_404',
transaction_id=909,
paid_at=paid_at,
updated_at=paid_at,
created_at=paid_at - timedelta(minutes=1),
)
class StubWataService:
async def get_wata_payment_status(self, db, local_payment_id): # noqa: ARG002
assert local_payment_id == 404
return {
'payment': payment,
'status': 'Paid',
'is_paid': True,
'remote_link': {'status': 'Paid'},
'transaction': {'id': 'tx_404', 'status': 'Paid'},
}
query = MiniAppPaymentStatusQuery(
method='wata',
localPaymentId=404,
amountKopeks=30000,
startedAt='2024-06-01T12:00:00Z',
payload='wata_payload',
)
result = await miniapp._resolve_wata_payment_status(
StubWataService(),
db=None,
user=types.SimpleNamespace(id=9),
query=query,
)
assert result.status == 'paid'
assert result.external_id == 'wata_link_404'
assert result.extra['payment_link_id'] == 'wata_link_404'
assert result.extra['transaction']['id'] == 'tx_404'
assert result.extra['payload'] == 'wata_payload'
assert result.extra['started_at'] == '2024-06-01T12:00:00Z'
@pytest.mark.anyio("asyncio")
async def test_resolve_wata_payment_status_uses_payment_link_lookup(monkeypatch):
created_at = datetime.utcnow()
payment = types.SimpleNamespace(
id=505,
user_id=7,
amount_kopeks=15000,
currency='RUB',
status='Opened',
is_paid=False,
payment_link_id='wata_lookup',
order_id='order_lookup',
transaction_id=None,
paid_at=None,
updated_at=None,
created_at=created_at,
)
async def fake_get_wata_payment_by_link_id(db, link_id): # noqa: ARG001
assert link_id == 'wata_lookup'
return payment
monkeypatch.setattr(miniapp, 'get_wata_payment_by_link_id', fake_get_wata_payment_by_link_id)
class StubWataService:
async def get_wata_payment_status(self, db, local_payment_id): # noqa: ARG002
assert local_payment_id == 505
return {
'payment': payment,
'status': payment.status,
'is_paid': False,
'remote_link': None,
'transaction': None,
}
query = MiniAppPaymentStatusQuery(
method='wata',
paymentLinkId='wata_lookup',
amountKopeks=15000,
)
result = await miniapp._resolve_wata_payment_status(
StubWataService(),
db=None,
user=types.SimpleNamespace(id=7),
query=query,
)
assert result.status == 'pending'
assert result.extra['local_payment_id'] == 505
assert result.extra['payment_link_id'] == 'wata_lookup'
assert 'transaction' not in result.extra
@pytest.mark.anyio("asyncio")
async def test_create_payment_link_stars_normalizes_amount(monkeypatch):
monkeypatch.setattr(settings, 'TELEGRAM_STARS_ENABLED', True, raising=False)
@@ -333,6 +491,30 @@ async def test_get_payment_methods_exposes_stars_min_amount(monkeypatch):
assert stars_method is not None
assert stars_method.min_amount_kopeks == 99999
assert stars_method.amount_step_kopeks == 99999
@pytest.mark.anyio("asyncio")
async def test_get_payment_methods_includes_wata(monkeypatch):
monkeypatch.setattr(settings, 'WATA_ENABLED', True, raising=False)
monkeypatch.setattr(settings, 'WATA_ACCESS_TOKEN', 'token', raising=False)
monkeypatch.setattr(settings, 'WATA_TERMINAL_PUBLIC_ID', 'terminal', raising=False)
monkeypatch.setattr(settings, 'WATA_MIN_AMOUNT_KOPEKS', 5000, raising=False)
monkeypatch.setattr(settings, 'WATA_MAX_AMOUNT_KOPEKS', 7500000, raising=False)
async def fake_resolve_user(db, init_data):
return types.SimpleNamespace(id=1, language='ru'), {}
monkeypatch.setattr(miniapp, '_resolve_user_from_init_data', fake_resolve_user)
payload = MiniAppPaymentMethodsRequest(initData='abc')
response = await miniapp.get_payment_methods(payload, db=types.SimpleNamespace())
wata_method = next((method for method in response.methods if method.id == 'wata'), None)
assert wata_method is not None
assert wata_method.min_amount_kopeks == 5000
assert wata_method.max_amount_kopeks == 7500000
assert wata_method.icon == '🌊'
@pytest.mark.anyio("asyncio")
async def test_find_recent_deposit_ignores_transactions_before_attempt():
started_at = datetime(2024, 5, 1, 12, 0, 0)
+210
View File
@@ -0,0 +1,210 @@
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, User as TgUser, Message
from sqlalchemy.ext.asyncio import AsyncSession
from app.handlers.subscription.purchase import save_cart_and_redirect_to_topup, return_to_saved_cart, clear_saved_cart
from app.database.models import User, Subscription
@pytest.fixture
def mock_callback_query():
callback = AsyncMock(spec=CallbackQuery)
callback.message = AsyncMock(spec=Message)
callback.message.edit_text = AsyncMock()
callback.answer = AsyncMock()
callback.data = "subscription_confirm"
return callback
@pytest.fixture
def mock_user():
user = AsyncMock(spec=User)
user.id = 12345
user.telegram_id = 12345
user.language = "ru"
user.balance_kopeks = 10000
user.subscription = None
user.has_had_paid_subscription = False
return user
@pytest.fixture
def mock_db():
db = AsyncMock(spec=AsyncSession)
return db
@pytest.fixture
def mock_state():
state = AsyncMock(spec=FSMContext)
state.get_data = AsyncMock(return_value={
'period_days': 30,
'countries': ['ru'],
'devices': 2,
'traffic_gb': 10,
'total_price': 50000
})
state.set_data = AsyncMock()
state.update_data = AsyncMock()
state.set_state = AsyncMock()
state.clear = AsyncMock()
return state
@pytest.mark.asyncio
async def test_save_cart_and_redirect_to_topup(mock_callback_query, mock_state, mock_user, mock_db):
"""Тест сохранения корзины и перенаправления к пополнению"""
# Мокаем все зависимости
with patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, \
patch('app.handlers.subscription.purchase.get_payment_methods_keyboard_with_cart') as mock_keyboard_func, \
patch('app.localization.texts.get_texts') as mock_get_texts:
# Подготовим моки
mock_cart_service.save_user_cart = AsyncMock(return_value=True)
mock_keyboard = AsyncMock()
mock_keyboard_func.return_value = mock_keyboard
# Подготовим тексты
mock_texts = AsyncMock()
mock_texts.format_price = lambda x: f"{x/100}"
mock_get_texts.return_value = mock_texts
missing_amount = 40000 # 50000 - 10000 = 40000
# Вызываем функцию
await save_cart_and_redirect_to_topup(mock_callback_query, mock_state, mock_user, missing_amount)
# Проверяем, что данные были сохранены в корзину
mock_cart_service.save_user_cart.assert_called_once()
args, kwargs = mock_cart_service.save_user_cart.call_args
saved_user_id, saved_cart_data = args
assert saved_user_id == mock_user.id
assert saved_cart_data['period_days'] == 30
assert saved_cart_data['countries'] == ['ru']
assert saved_cart_data['devices'] == 2
assert saved_cart_data['traffic_gb'] == 10
assert saved_cart_data['total_price'] == 50000
assert saved_cart_data['saved_cart'] is True
assert saved_cart_data['missing_amount'] == missing_amount
assert saved_cart_data['return_to_cart'] is True
assert saved_cart_data['user_id'] == mock_user.id
# Проверяем, что сообщение было отредактировано
mock_callback_query.message.edit_text.assert_called_once()
# В этой функции нет вызова callback.answer()
# mock_callback_query.answer не должен быть вызван
mock_callback_query.answer.assert_not_called()
@pytest.mark.asyncio
async def test_return_to_saved_cart_success(mock_callback_query, mock_state, mock_user, mock_db):
"""Тест возврата к сохраненной корзине с достаточным балансом"""
# Подготовим данные корзины
cart_data = {
'period_days': 30,
'countries': ['ru', 'us'],
'devices': 3,
'traffic_gb': 20,
'total_price': 30000, # Меньше, чем баланс пользователя (50000)
'saved_cart': True,
'user_id': mock_user.id
}
# Мокаем все зависимости
with patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, \
patch('app.handlers.subscription.purchase._get_available_countries') as mock_get_countries, \
patch('app.handlers.subscription.purchase.format_period_description') as mock_format_period, \
patch('app.localization.texts.get_texts') as mock_get_texts, \
patch('app.handlers.subscription.purchase.get_subscription_confirm_keyboard_with_cart') as mock_keyboard_func:
# Подготовим моки
mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data)
mock_get_countries.return_value = [{'uuid': 'ru', 'name': 'Russia'}, {'uuid': 'us', 'name': 'USA'}]
mock_format_period.return_value = "30 дней"
mock_keyboard = AsyncMock()
mock_keyboard_func.return_value = mock_keyboard
# Подготовим тексты
mock_texts = AsyncMock()
mock_texts.format_price = lambda x: f"{x/100}"
mock_get_texts.return_value = mock_texts
# Увеличиваем баланс пользователя, чтобы его хватило
mock_user.balance_kopeks = 50000
# Вызываем функцию
await return_to_saved_cart(mock_callback_query, mock_state, mock_user, mock_db)
# Проверяем, что данные были загружены из корзины и установлены в FSM
mock_state.set_data.assert_called_once_with(cart_data)
# Проверяем, что сообщение было отредактировано
mock_callback_query.message.edit_text.assert_called_once()
# В успешном сценарии вызывается callback.answer()
mock_callback_query.answer.assert_called_once()
@pytest.mark.asyncio
async def test_return_to_saved_cart_insufficient_funds(mock_callback_query, mock_state, mock_user, mock_db):
"""Тест возврата к сохраненной корзине с недостаточным балансом"""
# Подготовим данные корзины
cart_data = {
'period_days': 30,
'countries': ['ru', 'us'],
'devices': 3,
'traffic_gb': 20,
'total_price': 50000, # Больше, чем баланс пользователя (10000)
'saved_cart': True,
'user_id': mock_user.id
}
# Мокаем все зависимости
with patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, \
patch('app.localization.texts.get_texts') as mock_get_texts, \
patch('app.handlers.subscription.purchase.get_insufficient_balance_keyboard_with_cart') as mock_keyboard_func:
# Подготовим моки
mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data)
mock_keyboard = AsyncMock()
mock_keyboard_func.return_value = mock_keyboard
# Подготовим тексты
mock_texts = AsyncMock()
mock_texts.format_price = lambda x: f"{x/100}"
mock_texts.t = lambda key, default: default
mock_get_texts.return_value = mock_texts
# Баланс пользователя меньше стоимости подписки
mock_user.balance_kopeks = 10000
# Вызываем функцию
await return_to_saved_cart(mock_callback_query, mock_state, mock_user, mock_db)
# Проверяем, что FSM не был изменен (данные не установлены)
mock_state.set_data.assert_not_called()
# Проверяем, что сообщение было отредактировано с сообщением о недостатке средств
mock_callback_query.message.edit_text.assert_called_once()
# В этой функции в сценарии недостатка средств вызова callback.answer() не происходит
# (ответ отправляется через return до вызова callback.answer())
mock_callback_query.answer.assert_not_called()
@pytest.mark.asyncio
async def test_clear_saved_cart(mock_callback_query, mock_state, mock_user, mock_db):
"""Тест очистки сохраненной корзины"""
# Мокаем все зависимости
with patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, \
patch('app.handlers.menu.show_main_menu') as mock_show_main_menu:
mock_cart_service.delete_user_cart = AsyncMock(return_value=True)
mock_show_main_menu.return_value = AsyncMock()
# Вызываем функцию
await clear_saved_cart(mock_callback_query, mock_state, mock_user, mock_db)
# Проверяем, что корзина удалена из сервиса
mock_cart_service.delete_user_cart.assert_called_once_with(mock_user.id)
# Проверяем, что FSM очищен
mock_state.clear.assert_called_once()
# Проверяем, что вызван answer
mock_callback_query.answer.assert_called_once()
+145
View File
@@ -0,0 +1,145 @@
import pytest
from unittest.mock import AsyncMock, MagicMock
from app.services.user_cart_service import UserCartService
from app.config import settings
# Мок для Redis клиента
class MockRedis:
def __init__(self):
self.storage = {}
async def setex(self, key, ttl, value):
self.storage[key] = value
return True
async def get(self, key):
return self.storage.get(key)
async def delete(self, key):
if key in self.storage:
del self.storage[key]
return 1
return 0
async def exists(self, key):
return 1 if key in self.storage else 0
@pytest.fixture
def mock_redis():
return MockRedis()
@pytest.fixture
def user_cart_service(mock_redis):
service = UserCartService()
service.redis_client = mock_redis
return service
@pytest.mark.asyncio
async def test_save_user_cart(user_cart_service, mock_redis):
"""Тест сохранения корзины пользователя"""
user_id = 12345
cart_data = {
'period_days': 30,
'countries': ['ru', 'us'],
'devices': 3,
'traffic_gb': 10,
'total_price': 50000
}
result = await user_cart_service.save_user_cart(user_id, cart_data)
assert result is True
assert f"user_cart:{user_id}" in mock_redis.storage
assert cart_data == eval(mock_redis.storage[f"user_cart:{user_id}"])
@pytest.mark.asyncio
async def test_get_user_cart(user_cart_service, mock_redis):
"""Тест получения корзины пользователя"""
user_id = 12345
cart_data = {
'period_days': 30,
'countries': ['ru', 'us'],
'devices': 3,
'traffic_gb': 10,
'total_price': 50000
}
# Сохраняем данные
await user_cart_service.save_user_cart(user_id, cart_data)
# Получаем данные
result = await user_cart_service.get_user_cart(user_id)
assert result == cart_data
@pytest.mark.asyncio
async def test_get_user_cart_not_found(user_cart_service):
"""Тест получения несуществующей корзины пользователя"""
user_id = 99999
result = await user_cart_service.get_user_cart(user_id)
assert result is None
@pytest.mark.asyncio
async def test_delete_user_cart(user_cart_service, mock_redis):
"""Тест удаления корзины пользователя"""
user_id = 12345
cart_data = {
'period_days': 30,
'countries': ['ru', 'us'],
'devices': 3,
'traffic_gb': 10,
'total_price': 50000
}
# Сохраняем данные
await user_cart_service.save_user_cart(user_id, cart_data)
assert f"user_cart:{user_id}" in mock_redis.storage
# Удаляем данные
result = await user_cart_service.delete_user_cart(user_id)
assert result is True
assert f"user_cart:{user_id}" not in mock_redis.storage
@pytest.mark.asyncio
async def test_delete_user_cart_not_found(user_cart_service):
"""Тест удаления несуществующей корзины пользователя"""
user_id = 99999
result = await user_cart_service.delete_user_cart(user_id)
assert result is False
@pytest.mark.asyncio
async def test_has_user_cart(user_cart_service, mock_redis):
"""Тест проверки наличия корзины пользователя"""
user_id = 12345
cart_data = {
'period_days': 30,
'countries': ['ru', 'us'],
'devices': 3,
'traffic_gb': 10,
'total_price': 50000
}
# Проверяем, что корзины нет
result = await user_cart_service.has_user_cart(user_id)
assert result is False
# Сохраняем данные
await user_cart_service.save_user_cart(user_id, cart_data)
# Проверяем, что корзина есть
result = await user_cart_service.has_user_cart(user_id)
assert result is True
@pytest.mark.asyncio
async def test_has_user_cart_not_found(user_cart_service):
"""Тест проверки отсутствия корзины пользователя"""
user_id = 99999
result = await user_cart_service.has_user_cart(user_id)
assert result is False
+131
View File
@@ -0,0 +1,131 @@
import asyncio
from collections import deque
from datetime import datetime, time as time_cls
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from app.config import settings
from app.services.remnawave_sync_service import RemnaWaveAutoSyncService
from app.services.remnawave_service import RemnaWaveConfigurationError
@pytest.mark.parametrize(
"raw, expected",
[
("03:00, 15:30 03:00; 07:05", [time_cls(3, 0), time_cls(7, 5), time_cls(15, 30)]),
("", []),
(None, []),
("25:00, 10:70, test, 09:15", [time_cls(9, 15)]),
],
)
def test_parse_daily_time_list(raw, expected):
assert settings.parse_daily_time_list(raw) == expected
def _patch_datetime(monkeypatch, current):
real_datetime = datetime
monkeypatch.setattr(
"app.services.remnawave_sync_service.datetime",
SimpleNamespace(
utcnow=lambda: current,
combine=lambda date_obj, time_obj: real_datetime.combine(date_obj, time_obj),
),
)
def test_calculate_next_run_same_day(monkeypatch):
service = RemnaWaveAutoSyncService()
current = datetime(2024, 1, 1, 2, 30)
_patch_datetime(monkeypatch, current)
next_run = service._calculate_next_run([time_cls(1, 0), time_cls(3, 0)])
assert next_run == datetime(2024, 1, 1, 3, 0)
def test_calculate_next_run_rollover(monkeypatch):
service = RemnaWaveAutoSyncService()
current = datetime(2024, 1, 1, 23, 45)
_patch_datetime(monkeypatch, current)
next_run = service._calculate_next_run([time_cls(1, 0), time_cls(10, 0)])
assert next_run == datetime(2024, 1, 2, 1, 0)
def test_perform_sync_rebuilds_service_on_each_run(monkeypatch):
class StubService:
def __init__(self, *, configured: bool, user_stats=None, squads=None):
self.is_configured = configured
self.configuration_error = None if configured else "missing config"
self._user_stats = user_stats or {"synced": 1}
self._squads = squads or []
self.sync_calls = 0
self.squad_calls = 0
async def sync_users_from_panel(self, session, scope):
self.sync_calls += 1
return self._user_stats
async def get_all_squads(self):
self.squad_calls += 1
return self._squads
services = deque(
[
StubService(configured=True), # used during service __init__
StubService(configured=False),
StubService(
configured=True,
user_stats={"synced": 2},
squads=[{"id": 1}, {"id": 2}],
),
]
)
def factory():
return services.popleft()
async def fake_sync_with_remnawave(session, squads):
return 1, 2, 3
cache_mock = SimpleNamespace(delete_pattern=AsyncMock())
class DummySession:
async def __aenter__(self):
return SimpleNamespace()
async def __aexit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr(
"app.services.remnawave_sync_service.AsyncSessionLocal",
lambda: DummySession(),
)
monkeypatch.setattr(
"app.services.remnawave_sync_service.sync_with_remnawave",
fake_sync_with_remnawave,
)
monkeypatch.setattr(
"app.services.remnawave_sync_service.cache",
cache_mock,
)
async def runner():
service = RemnaWaveAutoSyncService(service_factory=factory)
with pytest.raises(RemnaWaveConfigurationError):
await service._perform_sync()
user_stats, server_stats = await service._perform_sync()
assert user_stats == {"synced": 2}
assert server_stats == {"created": 1, "updated": 2, "removed": 3, "total": 2}
asyncio.run(runner())
assert not services
cache_mock.delete_pattern.assert_awaited_once_with("available_countries*")