Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7aab2c3da | |||
| f59e04b231 | |||
| a2ac04a57a | |||
| 4526df2342 | |||
| d0c65f201f | |||
| 4fd736ecbf | |||
| 351700f886 | |||
| 453d1dbf92 | |||
| 0f358a9691 | |||
| ab890ea958 | |||
| e0b0498f81 | |||
| af8dc119a5 | |||
| 7ad98ceebb | |||
| 004aad4f06 | |||
| ec5524d74f | |||
| c1211ee025 | |||
| bf6528128f | |||
| 34c22b8955 | |||
| d48488338e | |||
| a37fd89d8b | |||
| 4553b1e038 | |||
| 8c39f5aecf | |||
| ce5ceb2f89 | |||
| 8ca7750d25 | |||
| 1045cf42f9 | |||
| 427a8594d5 | |||
| e007e8142a | |||
| 55e47e8e00 | |||
| f84c54da03 | |||
| 9e5279454c | |||
| e935d13ccd | |||
| 2c17ce5c3a | |||
| 947ad48acd | |||
| aefae5818e | |||
| 47191342f6 | |||
| 0c841cbf86 | |||
| 8ef4c74b4a | |||
| feb5e92fc2 | |||
| 27338809c1 | |||
| 53231ca074 | |||
| 1c177660c0 | |||
| 041f1d09d7 | |||
| 8e13f644b0 | |||
| fc0b7591f9 | |||
| 308c645096 | |||
| fd41d22322 | |||
| 694d85ec10 | |||
| 790ffecade | |||
| 5c5cc42af7 | |||
| 0784f56f7c | |||
| 775da62e00 | |||
| 436efcdd3e | |||
| 086660ebef | |||
| c546d10d93 | |||
| 169bdae0d1 | |||
| 56e0ec4cd2 | |||
| f62e2794b2 | |||
| f19db8f184 | |||
| 41d4c9b507 | |||
| 73e4abb29f | |||
| f7cd8c9fc3 | |||
| 6497f52a11 | |||
| a4de6a8330 | |||
| d27e625ee6 | |||
| e7a34152fc | |||
| e332fc41fc | |||
| 6bf0ac7fbf | |||
| 0effaa5240 | |||
| 89b0544f86 | |||
| b1626d32cd | |||
| 8cdb4289c9 | |||
| d4e3adddfc | |||
| 1baeb50322 | |||
| 7ed635600e | |||
| cb81fae3d8 | |||
| 93cb99d50c | |||
| 5a4f59dfb2 | |||
| 59f39e18a0 | |||
| d3dd423042 | |||
| b90b270a9e | |||
| 7846b54a6f | |||
| 77083e8349 | |||
| f121a5733b | |||
| ad6bb95df7 | |||
| 58588e37d0 | |||
| 9717876fe7 | |||
| badf44a9e7 | |||
| a320de1cbb |
@@ -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 =====
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from . import balance
|
||||
|
||||
__all__ = ["balance"]
|
||||
@@ -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
@@ -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
@@ -0,0 +1,3 @@
|
||||
from .main import register_balance_handlers
|
||||
|
||||
__all__ = ["register_balance_handlers"]
|
||||
@@ -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)
|
||||
@@ -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|")
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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("⚠️ Ошибка создания платежа")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "✅ Отчет закрыт.",
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 минимальна благодаря короткому времени хранения данных
|
||||
@@ -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` с новой настройкой
|
||||
@@ -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": "🎉 You’ve 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
@@ -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>",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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*")
|
||||
Reference in New Issue
Block a user