mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-04-29 01:00:03 +00:00
16
.env.example
16
.env.example
@@ -331,6 +331,22 @@ PAL24_SBP_BUTTON_VISIBLE=true
|
||||
# Отображать кнопку оплаты картой в PayPalych (true - отображать, false - скрывать)
|
||||
PAL24_CARD_BUTTON_VISIBLE=true
|
||||
|
||||
# PLATEGA
|
||||
PLATEGA_ENABLED=false
|
||||
PLATEGA_MERCHANT_ID=
|
||||
PLATEGA_SECRET=
|
||||
PLATEGA_BASE_URL=https://app.platega.io
|
||||
PLATEGA_RETURN_URL=
|
||||
PLATEGA_FAILED_URL=
|
||||
PLATEGA_CURRENCY=RUB
|
||||
# Список ID активных методов из кабинета Platega (через запятую)
|
||||
PLATEGA_ACTIVE_METHODS=2,10,11,12,13
|
||||
PLATEGA_MIN_AMOUNT_KOPEKS=10000
|
||||
PLATEGA_MAX_AMOUNT_KOPEKS=100000000
|
||||
PLATEGA_WEBHOOK_PATH=/platega-webhook
|
||||
PLATEGA_WEBHOOK_HOST=0.0.0.0
|
||||
PLATEGA_WEBHOOK_PORT=8086
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
|
||||
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
|
||||
|
||||
32
README.md
32
README.md
@@ -36,7 +36,7 @@
|
||||
|
||||
### ⚡ **Полная автоматизация VPN бизнеса**
|
||||
- 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня
|
||||
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + Heleket + YooKassa (СБП + карты) + MulenPay + PayPalych (СБП + карты) + WATA
|
||||
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + Heleket + YooKassa (СБП + карты) + MulenPay + PayPalych (СБП + карты) + Platega (карты + СБП) + WATA
|
||||
- 🔄 **Автоматизация 99%** - от регистрации до продления подписок
|
||||
- - 📱 **MiniApp лк** - личный кабинет с возможностью покупки/продления подписки
|
||||
- 📊 **Детальная аналитика** - полная картина вашего бизнеса
|
||||
@@ -479,6 +479,32 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=secret_key_name
|
||||
```
|
||||
|
||||
### 💳 Platega.io
|
||||
|
||||
Платёжный провайдер [Platega.io](https://platega.io) добавляет ещё один способ приёма оплат картой и по СБП. Включите его, если у вас есть кабинет мерчанта и доступ к API.
|
||||
|
||||
1. В кабинете Platega получите `Merchant ID` и `Secret` (раздел **Интеграция → API**).
|
||||
2. В настройках провайдера укажите URL возврата и ошибки. Их можно задать в `.env` (`PLATEGA_RETURN_URL`, `PLATEGA_FAILED_URL`).
|
||||
3. Активируйте только нужные платёжные методы и пропишите их ID через запятую в `PLATEGA_ACTIVE_METHODS`.
|
||||
4. Добавьте вебхук `https://your-domain.com/platega-webhook` в личном кабинете Platega.
|
||||
|
||||
Пример набора переменных окружения:
|
||||
|
||||
```env
|
||||
PLATEGA_ENABLED=true
|
||||
PLATEGA_MERCHANT_ID=your_merchant_id
|
||||
PLATEGA_SECRET=your_secret_key
|
||||
PLATEGA_RETURN_URL=https://your-domain.com/payments/success
|
||||
PLATEGA_FAILED_URL=https://your-domain.com/payments/failed
|
||||
PLATEGA_ACTIVE_METHODS=2,10,11
|
||||
PLATEGA_MIN_AMOUNT_KOPEKS=10000
|
||||
PLATEGA_MAX_AMOUNT_KOPEKS=5000000
|
||||
PLATEGA_CURRENCY=RUB
|
||||
PLATEGA_WEBHOOK_PATH=/platega-webhook
|
||||
```
|
||||
|
||||
Остальные параметры (`PLATEGA_BASE_URL`, `PLATEGA_WEBHOOK_HOST`, `PLATEGA_WEBHOOK_PORT`) оставьте по умолчанию, если работаете через встроенный FastAPI сервер.
|
||||
|
||||
### 📊 Режимы продажи трафика
|
||||
|
||||
#### **Выбираемые пакеты** (по умолчанию)
|
||||
@@ -609,6 +635,7 @@ REDIS_URL=redis://redis:6379/0
|
||||
- 🪙 Heleket (криптовалюта с наценкой)
|
||||
- 💳 MulenPay (СБП)
|
||||
- 🏦 PayPalych/Pal24 (СБП + карты)
|
||||
- 💳 Platega (СБП + банковские карты)
|
||||
- 💳 **WATA**
|
||||
- 📥 Автогенерация счетов и webhook-уведомления
|
||||
- 💼 История операций
|
||||
@@ -807,6 +834,7 @@ REDIS_URL=redis://redis:6379/0
|
||||
- **Heleket**: Настрой webhook на `https://your-domain.com/heleket-webhook`
|
||||
- **MulenPay**: Настрой webhook на `https://your-domain.com/mulenpay-webhook`
|
||||
- **PayPalych**: Укажи Result URL `https://your-domain.com/pal24-webhook` в кабинете Pal24
|
||||
- **Platega**: Настрой webhook на `https://your-domain.com/platega-webhook`
|
||||
- **WATA**: Настрой webhook на `https://your-domain.com/wata-webhook`
|
||||
|
||||
4. **🔄 Настройка автосинхронизации** (опционально)
|
||||
@@ -1131,7 +1159,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
|
||||
| Метрика | Значение |
|
||||
|---------|----------|
|
||||
| 💳 **Платёжных систем** | 8 (Stars, YooKassa, Tribute, CryptoBot, Heleket, MulenPay, Pal24, WATA) |
|
||||
| 💳 **Платёжных систем** | 9 (Stars, YooKassa, Tribute, CryptoBot, Heleket, MulenPay, Pal24, Platega, WATA) |
|
||||
| 🌍 **Языков интерфейса** | 2 (RU, EN) с возможностью расширения |
|
||||
| 📊 **Периодов подписки** | 6 (от 14 дней до года) |
|
||||
| 🎁 **Типов промо-акций** | 5 (коды, группы, предложения, скидки, кампании) |
|
||||
|
||||
@@ -270,6 +270,20 @@ class Settings(BaseSettings):
|
||||
PAL24_SBP_BUTTON_VISIBLE: bool = True
|
||||
PAL24_CARD_BUTTON_VISIBLE: bool = True
|
||||
|
||||
PLATEGA_ENABLED: bool = False
|
||||
PLATEGA_MERCHANT_ID: Optional[str] = None
|
||||
PLATEGA_SECRET: Optional[str] = None
|
||||
PLATEGA_BASE_URL: str = "https://app.platega.io"
|
||||
PLATEGA_RETURN_URL: Optional[str] = None
|
||||
PLATEGA_FAILED_URL: Optional[str] = None
|
||||
PLATEGA_CURRENCY: str = "RUB"
|
||||
PLATEGA_ACTIVE_METHODS: str = "2,10,11,12,13"
|
||||
PLATEGA_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
PLATEGA_MAX_AMOUNT_KOPEKS: int = 100000000
|
||||
PLATEGA_WEBHOOK_PATH: str = "/platega-webhook"
|
||||
PLATEGA_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
PLATEGA_WEBHOOK_PORT: int = 8086
|
||||
|
||||
WATA_ENABLED: bool = False
|
||||
WATA_BASE_URL: str = "https://api.wata.pro/api/h2h"
|
||||
WATA_ACCESS_TOKEN: Optional[str] = None
|
||||
@@ -917,6 +931,74 @@ class Settings(BaseSettings):
|
||||
and self.PAL24_SHOP_ID is not None
|
||||
)
|
||||
|
||||
def is_platega_enabled(self) -> bool:
|
||||
return (
|
||||
self.PLATEGA_ENABLED
|
||||
and self.PLATEGA_MERCHANT_ID is not None
|
||||
and self.PLATEGA_SECRET is not None
|
||||
)
|
||||
|
||||
def get_platega_return_url(self) -> Optional[str]:
|
||||
if self.PLATEGA_RETURN_URL:
|
||||
return self.PLATEGA_RETURN_URL
|
||||
if self.WEBHOOK_URL:
|
||||
return f"{self.WEBHOOK_URL}/payment-success"
|
||||
return None
|
||||
|
||||
def get_platega_failed_url(self) -> Optional[str]:
|
||||
if self.PLATEGA_FAILED_URL:
|
||||
return self.PLATEGA_FAILED_URL
|
||||
if self.WEBHOOK_URL:
|
||||
return f"{self.WEBHOOK_URL}/payment-failed"
|
||||
return None
|
||||
|
||||
def get_platega_active_methods(self) -> List[int]:
|
||||
raw_value = str(self.PLATEGA_ACTIVE_METHODS or "")
|
||||
normalized = raw_value.replace(";", ",")
|
||||
methods: list[int] = []
|
||||
seen: set[int] = set()
|
||||
for part in normalized.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
method_code = int(part)
|
||||
except ValueError:
|
||||
logger.warning("Некорректный код метода Platega: %s", part)
|
||||
continue
|
||||
if method_code in {2, 10, 11, 12, 13} and method_code not in seen:
|
||||
methods.append(method_code)
|
||||
seen.add(method_code)
|
||||
|
||||
if not methods:
|
||||
return [2]
|
||||
|
||||
return methods
|
||||
|
||||
@staticmethod
|
||||
def get_platega_method_definitions() -> Dict[int, Dict[str, str]]:
|
||||
return {
|
||||
2: {"name": "СБП (QR)", "title": "🏦 СБП (QR)"},
|
||||
10: {"name": "Банковские карты (RUB)", "title": "💳 Карты (RUB)"},
|
||||
11: {"name": "Банковские карты", "title": "💳 Банковские карты"},
|
||||
12: {"name": "Международные карты", "title": "🌍 Международные карты"},
|
||||
13: {"name": "Криптовалюта", "title": "🪙 Криптовалюта"},
|
||||
}
|
||||
|
||||
def get_platega_method_display_name(self, method_code: int) -> str:
|
||||
definitions = self.get_platega_method_definitions()
|
||||
info = definitions.get(method_code)
|
||||
if info and info.get("name"):
|
||||
return info["name"]
|
||||
return f"Метод {method_code}"
|
||||
|
||||
def get_platega_method_display_title(self, method_code: int) -> str:
|
||||
definitions = self.get_platega_method_definitions()
|
||||
info = definitions.get(method_code)
|
||||
if not info:
|
||||
return f"Platega {method_code}"
|
||||
return info.get("title") or info.get("name") or f"Platega {method_code}"
|
||||
|
||||
def is_wata_enabled(self) -> bool:
|
||||
return (
|
||||
self.WATA_ENABLED
|
||||
|
||||
156
app/database/crud/platega.py
Normal file
156
app/database/crud/platega.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""CRUD-операции для платежей Platega."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import PlategaPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_platega_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
currency: str,
|
||||
description: Optional[str],
|
||||
status: str,
|
||||
payment_method_code: int,
|
||||
correlation_id: str,
|
||||
platega_transaction_id: Optional[str],
|
||||
redirect_url: Optional[str],
|
||||
return_url: Optional[str],
|
||||
failed_url: Optional[str],
|
||||
payload: Optional[str],
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
) -> PlategaPayment:
|
||||
payment = PlategaPayment(
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
status=status,
|
||||
payment_method_code=payment_method_code,
|
||||
correlation_id=correlation_id,
|
||||
platega_transaction_id=platega_transaction_id,
|
||||
redirect_url=redirect_url,
|
||||
return_url=return_url,
|
||||
failed_url=failed_url,
|
||||
payload=payload,
|
||||
metadata_json=metadata or {},
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан Platega платеж #%s (tx=%s) на сумму %s копеек для пользователя %s",
|
||||
payment.id,
|
||||
platega_transaction_id,
|
||||
amount_kopeks,
|
||||
user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_platega_payment_by_id(
|
||||
db: AsyncSession, payment_id: int
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment).where(PlategaPayment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_platega_payment_by_id_for_update(
|
||||
db: AsyncSession, payment_id: int
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment)
|
||||
.where(PlategaPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_platega_payment_by_transaction_id(
|
||||
db: AsyncSession, transaction_id: str
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment).where(
|
||||
PlategaPayment.platega_transaction_id == transaction_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_platega_payment_by_correlation_id(
|
||||
db: AsyncSession, correlation_id: str
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment).where(
|
||||
PlategaPayment.correlation_id == correlation_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_platega_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: PlategaPayment,
|
||||
status: Optional[str] = None,
|
||||
is_paid: Optional[bool] = None,
|
||||
paid_at: Optional[datetime] = None,
|
||||
platega_transaction_id: Optional[str] = None,
|
||||
redirect_url: Optional[str] = None,
|
||||
callback_payload: Optional[dict[str, Any]] = None,
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
) -> PlategaPayment:
|
||||
if status is not None:
|
||||
payment.status = status
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if paid_at is not None:
|
||||
payment.paid_at = paid_at
|
||||
if platega_transaction_id and not payment.platega_transaction_id:
|
||||
payment.platega_transaction_id = platega_transaction_id
|
||||
if redirect_url is not None:
|
||||
payment.redirect_url = redirect_url
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if metadata is not None:
|
||||
payment.metadata_json = metadata
|
||||
if expires_at is not None:
|
||||
payment.expires_at = expires_at
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
|
||||
|
||||
async def link_platega_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: PlategaPayment,
|
||||
transaction_id: int,
|
||||
) -> PlategaPayment:
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -82,6 +82,7 @@ class PaymentMethod(Enum):
|
||||
MULENPAY = "mulenpay"
|
||||
PAL24 = "pal24"
|
||||
WATA = "wata"
|
||||
PLATEGA = "platega"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
@@ -414,6 +415,56 @@ class WataPayment(Base):
|
||||
)
|
||||
|
||||
|
||||
class PlategaPayment(Base):
|
||||
__tablename__ = "platega_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
platega_transaction_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
correlation_id = Column(String(64), unique=True, nullable=False, index=True)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default="RUB")
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
payment_method_code = Column(Integer, nullable=False)
|
||||
status = Column(String(50), nullable=False, default="PENDING")
|
||||
is_paid = Column(Boolean, default=False)
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
|
||||
redirect_url = Column(Text, nullable=True)
|
||||
return_url = Column(Text, nullable=True)
|
||||
failed_url = Column(Text, nullable=True)
|
||||
payload = Column(String(255), nullable=True)
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
|
||||
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", backref="platega_payments")
|
||||
transaction = relationship("Transaction", backref="platega_payment")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return (
|
||||
"<PlategaPayment(id={0}, transaction_id={1}, amount={2}₽, status={3}, method={4})>".format(
|
||||
self.id,
|
||||
self.platega_transaction_id,
|
||||
self.amount_rubles,
|
||||
self.status,
|
||||
self.payment_method_code,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = "promo_groups"
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
},
|
||||
"payments": {
|
||||
"title": "💳 Платежные системы",
|
||||
"description": "YooKassa, CryptoBot, Heleket, MulenPay, PAL24, Wata, Tribute и Telegram Stars.",
|
||||
"description": "YooKassa, CryptoBot, Heleket, MulenPay, PAL24, Wata, Platega, Tribute и Telegram Stars.",
|
||||
"icon": "💳",
|
||||
"categories": (
|
||||
"PAYMENT",
|
||||
@@ -72,6 +72,7 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"MULENPAY",
|
||||
"PAL24",
|
||||
"WATA",
|
||||
"PLATEGA",
|
||||
"TRIBUTE",
|
||||
"TELEGRAM",
|
||||
),
|
||||
@@ -253,6 +254,7 @@ def _get_group_status(group_key: str) -> Tuple[str, str]:
|
||||
payment_statuses = {
|
||||
"YooKassa": settings.is_yookassa_enabled(),
|
||||
"CryptoBot": settings.is_cryptobot_enabled(),
|
||||
"Platega": settings.is_platega_enabled(),
|
||||
"MulenPay": settings.is_mulenpay_enabled(),
|
||||
"PAL24": settings.is_pal24_enabled(),
|
||||
"Tribute": settings.TRIBUTE_ENABLED,
|
||||
|
||||
@@ -37,6 +37,8 @@ def _method_display(method: PaymentMethod) -> str:
|
||||
return "Heleket"
|
||||
if method == PaymentMethod.YOOKASSA:
|
||||
return "YooKassa"
|
||||
if method == PaymentMethod.PLATEGA:
|
||||
return "Platega"
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
return "CryptoBot"
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
@@ -90,6 +92,18 @@ def _status_info(
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.PLATEGA:
|
||||
mapping = {
|
||||
"pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"inprogress": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"confirmed": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"failed": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"cancelled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"expired": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.HELEKET:
|
||||
if status in {"pending", "created", "waiting", "check", "processing"}:
|
||||
return "⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
|
||||
@@ -136,6 +150,8 @@ def _is_checkable(record: PendingPayment) -> bool:
|
||||
return status in {"created", "processing", "hold"}
|
||||
if record.method == PaymentMethod.WATA:
|
||||
return status in {"opened", "pending", "processing", "inprogress", "in_progress"}
|
||||
if record.method == PaymentMethod.PLATEGA:
|
||||
return status in {"pending", "inprogress", "in_progress"}
|
||||
if record.method == PaymentMethod.HELEKET:
|
||||
return status not in {"paid", "paid_over", "cancel", "canceled", "fail", "failed", "expired"}
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
|
||||
@@ -284,7 +284,16 @@ async def show_payment_methods(
|
||||
devices_discounted_per_month * months_in_period
|
||||
)
|
||||
|
||||
current_tariff_desc = f"📱 Подписка: {len(current_connected_squads)} серверов, {current_traffic} ГБ, {current_device_limit} устр."
|
||||
traffic_value = current_traffic or 0
|
||||
if traffic_value <= 0:
|
||||
traffic_display = texts.t("TRAFFIC_UNLIMITED_SHORT", "Безлимит")
|
||||
else:
|
||||
traffic_display = texts.format_traffic(traffic_value)
|
||||
|
||||
current_tariff_desc = (
|
||||
f"📱 Подписка: {len(current_connected_squads)} серверов, "
|
||||
f"{traffic_display}, {current_device_limit} устр."
|
||||
)
|
||||
estimated_price_info = f"💰 Стоимость продления (примерно): {texts.format_price(total_price)} за {duration_days} дней"
|
||||
|
||||
tariff_info = f"\n\n📋 <b>Ваш текущий тариф:</b>\n{current_tariff_desc}\n{estimated_price_info}"
|
||||
@@ -519,6 +528,14 @@ async def process_topup_amount(
|
||||
from .mulenpay import process_mulenpay_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "platega":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .platega import process_platega_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(
|
||||
message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "wata":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .wata import process_wata_payment_amount
|
||||
@@ -630,6 +647,14 @@ async def handle_quick_amount_selection(
|
||||
await process_mulenpay_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "platega":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .platega import process_platega_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "wata":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .wata import process_wata_payment_amount
|
||||
@@ -717,6 +742,13 @@ async def handle_topup_amount_callback(
|
||||
await process_mulenpay_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "platega":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .platega import process_platega_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .pal24 import process_pal24_payment_amount
|
||||
@@ -821,6 +853,16 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
F.data.startswith("pal24_method_"),
|
||||
)
|
||||
|
||||
from .platega import start_platega_payment, handle_platega_method_selection
|
||||
dp.callback_query.register(
|
||||
start_platega_payment,
|
||||
F.data == "topup_platega"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
handle_platega_method_selection,
|
||||
F.data.startswith("platega_method_"),
|
||||
)
|
||||
|
||||
from .yookassa import check_yookassa_payment_status
|
||||
dp.callback_query.register(
|
||||
check_yookassa_payment_status,
|
||||
@@ -889,6 +931,12 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
F.data.startswith("check_pal24_")
|
||||
)
|
||||
|
||||
from .platega import check_platega_payment_status
|
||||
dp.callback_query.register(
|
||||
check_platega_payment_status,
|
||||
F.data.startswith("check_platega_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_payment_methods_unavailable,
|
||||
F.data == "payment_methods_unavailable"
|
||||
|
||||
315
app/handlers/balance/platega.py
Normal file
315
app/handlers/balance/platega.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""Handlers for Platega balance interactions."""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_active_methods() -> List[int]:
|
||||
methods = settings.get_platega_active_methods()
|
||||
return [code for code in methods if code in {2, 10, 11, 12, 13}]
|
||||
|
||||
|
||||
async def _prompt_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
method_code: int,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
method_name = settings.get_platega_method_display_title(method_code)
|
||||
|
||||
prompt_template = texts.t(
|
||||
"PLATEGA_TOPUP_PROMPT",
|
||||
(
|
||||
"💳 <b>Оплата через Platega ({method_name})</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
|
||||
"Оплата происходит через Platega."
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await message.edit_text(
|
||||
prompt_template.format(method_name=method_name),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="platega", platega_method=method_code)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_platega_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_platega_enabled():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PLATEGA_TEMPORARILY_UNAVAILABLE",
|
||||
"❌ Оплата через Platega временно недоступна",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
active_methods = _get_active_methods()
|
||||
if not active_methods:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PLATEGA_METHODS_NOT_CONFIGURED",
|
||||
"⚠️ На стороне Platega нет доступных методов оплаты",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(payment_method="platega")
|
||||
|
||||
if len(active_methods) == 1:
|
||||
await _prompt_amount(callback.message, db_user, state, active_methods[0])
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
method_buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
for method_code in active_methods:
|
||||
label = settings.get_platega_method_display_title(method_code)
|
||||
method_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=label,
|
||||
callback_data=f"platega_method_{method_code}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
method_buttons.append(
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"PLATEGA_SELECT_PAYMENT_METHOD",
|
||||
"Выберите способ оплаты Platega:",
|
||||
),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
|
||||
)
|
||||
await state.set_state(BalanceStates.waiting_for_platega_method)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_platega_method_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
try:
|
||||
method_code = int(callback.data.rsplit("_", 1)[-1])
|
||||
except ValueError:
|
||||
await callback.answer("❌ Некорректный способ оплаты", show_alert=True)
|
||||
return
|
||||
|
||||
if method_code not in _get_active_methods():
|
||||
await callback.answer("⚠️ Этот способ сейчас недоступен", show_alert=True)
|
||||
return
|
||||
|
||||
await _prompt_amount(callback.message, db_user, state, method_code)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_platega_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_platega_enabled():
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_TEMPORARILY_UNAVAILABLE",
|
||||
"❌ Оплата через Platega временно недоступна",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
method_code = int(data.get("platega_method", 0))
|
||||
if method_code not in _get_active_methods():
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_METHOD_SELECTION_REQUIRED",
|
||||
"⚠️ Выберите способ оплаты Platega перед вводом суммы",
|
||||
)
|
||||
)
|
||||
await state.set_state(BalanceStates.waiting_for_platega_method)
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_AMOUNT_TOO_LOW",
|
||||
"Минимальная сумма для оплаты через Platega: {amount}",
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS))
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_AMOUNT_TOO_HIGH",
|
||||
"Максимальная сумма для оплаты через Platega: {amount}",
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS))
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
payment_result = await payment_service.create_platega_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=db_user.language,
|
||||
payment_method_code=method_code,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.exception("Ошибка создания платежа Platega: %s", error)
|
||||
payment_result = None
|
||||
|
||||
if not payment_result or not payment_result.get("redirect_url"):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа Platega. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
redirect_url = payment_result.get("redirect_url")
|
||||
local_payment_id = payment_result.get("local_payment_id")
|
||||
transaction_id = payment_result.get("transaction_id")
|
||||
method_title = settings.get_platega_method_display_title(method_code)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"PLATEGA_PAY_BUTTON",
|
||||
"💳 Оплатить через {method}",
|
||||
).format(method=method_title),
|
||||
url=redirect_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_platega_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
)
|
||||
|
||||
instructions_template = texts.t(
|
||||
"PLATEGA_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"💳 <b>Оплата через Platega ({method})</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID транзакции: {transaction}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n"
|
||||
"1. Нажмите кнопку «Оплатить»\n"
|
||||
"2. Следуйте подсказкам платёжной системы\n"
|
||||
"3. Подтвердите перевод\n"
|
||||
"4. Средства зачислятся автоматически\n\n"
|
||||
"❓ Если возникнут проблемы, обратитесь в {support}"
|
||||
),
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
instructions_template.format(
|
||||
method=method_title,
|
||||
amount=settings.format_price(amount_kopeks),
|
||||
transaction=transaction_id or local_payment_id,
|
||||
support=settings.get_support_contact_display_html(),
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_platega_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split("_")[-1])
|
||||
except ValueError:
|
||||
await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
|
||||
try:
|
||||
status_info = await payment_service.get_platega_payment_status(db, local_payment_id)
|
||||
except Exception as error:
|
||||
logger.exception("Ошибка проверки статуса Platega: %s", error)
|
||||
await callback.answer("⚠️ Ошибка проверки статуса", show_alert=True)
|
||||
return
|
||||
|
||||
if not status_info:
|
||||
await callback.answer("⚠️ Платёж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
payment = status_info.get("payment")
|
||||
status = status_info.get("status")
|
||||
is_paid = status_info.get("is_paid")
|
||||
|
||||
language = "ru"
|
||||
user = getattr(payment, "user", None)
|
||||
if user and getattr(user, "language", None):
|
||||
language = user.language
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
if is_paid:
|
||||
await callback.answer(texts.t("PLATEGA_PAYMENT_ALREADY_CONFIRMED", "✅ Платёж уже зачислен"), show_alert=True)
|
||||
else:
|
||||
await callback.answer(
|
||||
texts.t("PLATEGA_PAYMENT_STATUS", "Текущий статус платежа: {status}").format(status=status),
|
||||
show_alert=True,
|
||||
)
|
||||
@@ -435,6 +435,12 @@ async def show_trial_offer(
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения триального сервера: {e}")
|
||||
|
||||
trial_device_limit = settings.TRIAL_DEVICE_LIMIT
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is not None:
|
||||
trial_device_limit = forced_limit
|
||||
|
||||
devices_line = ""
|
||||
if settings.is_devices_selection_enabled():
|
||||
devices_line_template = texts.t(
|
||||
@@ -442,12 +448,13 @@ async def show_trial_offer(
|
||||
"\n📱 <b>Устройства:</b> {devices} шт.",
|
||||
)
|
||||
devices_line = devices_line_template.format(
|
||||
devices=settings.TRIAL_DEVICE_LIMIT,
|
||||
devices=trial_device_limit,
|
||||
)
|
||||
|
||||
trial_text = texts.TRIAL_AVAILABLE.format(
|
||||
days=settings.TRIAL_DURATION_DAYS,
|
||||
traffic=texts.format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB),
|
||||
devices=trial_device_limit if trial_device_limit is not None else "",
|
||||
devices_line=devices_line,
|
||||
server_name=trial_server_name
|
||||
)
|
||||
|
||||
@@ -1159,6 +1159,14 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_platega_enabled() and settings.get_platega_active_methods():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_PLATEGA", "💳 Platega"),
|
||||
callback_data=_build_callback("platega"),
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
|
||||
@@ -1027,6 +1027,7 @@
|
||||
"PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)",
|
||||
"PAYMENT_CARD_WATA": "💳 Bank card (WATA)",
|
||||
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
|
||||
"PAYMENT_PLATEGA": "💳 Platega",
|
||||
"PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
|
||||
"PAYMENT_HELEKET": "🪙 Cryptocurrency (Heleket)",
|
||||
@@ -1078,6 +1079,18 @@
|
||||
"PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)",
|
||||
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
|
||||
"PAYMENT_VIA_SUPPORT": "🛠️ Via support",
|
||||
"PLATEGA_TOPUP_PROMPT": "💳 <b>Payment via Platega ({method_name})</b>\n\nEnter the amount from 100 to 1,000,000 ₽.\nPayment is processed by Platega.",
|
||||
"PLATEGA_SELECT_PAYMENT_METHOD": "Choose a Platega payment method:",
|
||||
"PLATEGA_TEMPORARILY_UNAVAILABLE": "❌ Platega payments are temporarily unavailable",
|
||||
"PLATEGA_METHODS_NOT_CONFIGURED": "⚠️ No active Platega methods configured",
|
||||
"PLATEGA_METHOD_SELECTION_REQUIRED": "⚠️ Select a Platega payment method before entering the amount",
|
||||
"PLATEGA_AMOUNT_TOO_LOW": "Minimum amount for Platega: {amount}",
|
||||
"PLATEGA_AMOUNT_TOO_HIGH": "Maximum amount for Platega: {amount}",
|
||||
"PLATEGA_PAYMENT_ERROR": "❌ Failed to create Platega payment. Please try again later or contact support.",
|
||||
"PLATEGA_PAYMENT_INSTRUCTIONS": "💳 <b>Payment via Platega ({method})</b>\n\n💰 Amount: {amount}\n🆔 Transaction ID: {transaction}\n\n📱 <b>Instructions:</b>\n1. Tap the ‘Pay’ button\n2. Follow the payment provider instructions\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ If you have issues, contact {support}",
|
||||
"PLATEGA_PAY_BUTTON": "💳 Pay via {method}",
|
||||
"PLATEGA_PAYMENT_ALREADY_CONFIRMED": "✅ Payment already credited",
|
||||
"PLATEGA_PAYMENT_STATUS": "Current payment status: {status}",
|
||||
"PAY_NOW_BUTTON": "💳 Pay",
|
||||
"PAY_WITH_COINS_BUTTON": "🪙 Pay",
|
||||
"PENDING_CANCEL_BUTTON": "⌛ Cancel",
|
||||
@@ -1396,6 +1409,7 @@
|
||||
"TRAFFIC_NO_CHANGE": "ℹ️ Traffic limit was not changed",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured",
|
||||
"TRAFFIC_UNLIMITED": "📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"TRAFFIC_UNLIMITED_SHORT": "Unlimited",
|
||||
"TRIAL_ACTIVATED": "🎉 Trial subscription activated!",
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Activate",
|
||||
"TRIAL_ALREADY_USED": "❌ The trial subscription has already been used",
|
||||
|
||||
@@ -1047,6 +1047,7 @@
|
||||
"PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)",
|
||||
"PAYMENT_CARD_WATA": "💳 Банковская карта (WATA)",
|
||||
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
|
||||
"PAYMENT_PLATEGA": "💳 Platega",
|
||||
"PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
|
||||
"PAYMENT_HELEKET": "🪙 Криптовалюта (Heleket)",
|
||||
@@ -1098,6 +1099,18 @@
|
||||
"PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)",
|
||||
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
|
||||
"PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку",
|
||||
"PLATEGA_TOPUP_PROMPT": "💳 <b>Оплата через Platega ({method_name})</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата происходит через Platega.",
|
||||
"PLATEGA_SELECT_PAYMENT_METHOD": "Выберите способ оплаты Platega:",
|
||||
"PLATEGA_TEMPORARILY_UNAVAILABLE": "❌ Оплата через Platega временно недоступна",
|
||||
"PLATEGA_METHODS_NOT_CONFIGURED": "⚠️ На стороне Platega нет доступных методов оплаты",
|
||||
"PLATEGA_METHOD_SELECTION_REQUIRED": "⚠️ Выберите способ оплаты Platega перед вводом суммы",
|
||||
"PLATEGA_AMOUNT_TOO_LOW": "Минимальная сумма для оплаты через Platega: {amount}",
|
||||
"PLATEGA_AMOUNT_TOO_HIGH": "Максимальная сумма для оплаты через Platega: {amount}",
|
||||
"PLATEGA_PAYMENT_ERROR": "❌ Ошибка создания платежа Platega. Попробуйте позже или обратитесь в поддержку.",
|
||||
"PLATEGA_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через Platega ({method})</b>\n\n💰 Сумма: {amount}\n🆔 ID транзакции: {transaction}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку «Оплатить»\n2. Следуйте подсказкам платёжной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
|
||||
"PLATEGA_PAY_BUTTON": "💳 Оплатить через {method}",
|
||||
"PLATEGA_PAYMENT_ALREADY_CONFIRMED": "✅ Платёж уже зачислен",
|
||||
"PLATEGA_PAYMENT_STATUS": "Текущий статус платежа: {status}",
|
||||
"PAY_NOW_BUTTON": "💳 Оплатить",
|
||||
"PAY_WITH_COINS_BUTTON": "🪙 Оплатить",
|
||||
"PENDING_CANCEL_BUTTON": "⌛ Отмена",
|
||||
@@ -1416,6 +1429,7 @@
|
||||
"TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены",
|
||||
"TRAFFIC_UNLIMITED": "📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"TRAFFIC_UNLIMITED_SHORT": "Безлимит",
|
||||
"TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!",
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Активировать",
|
||||
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
|
||||
|
||||
@@ -12,6 +12,7 @@ from .cryptobot import CryptoBotPaymentMixin
|
||||
from .heleket import HeleketPaymentMixin
|
||||
from .mulenpay import MulenPayPaymentMixin
|
||||
from .pal24 import Pal24PaymentMixin
|
||||
from .platega import PlategaPaymentMixin
|
||||
from .wata import WataPaymentMixin
|
||||
|
||||
__all__ = [
|
||||
@@ -23,5 +24,6 @@ __all__ = [
|
||||
"HeleketPaymentMixin",
|
||||
"MulenPayPaymentMixin",
|
||||
"Pal24PaymentMixin",
|
||||
"PlategaPaymentMixin",
|
||||
"WataPaymentMixin",
|
||||
]
|
||||
|
||||
532
app/services/payment/platega.py
Normal file
532
app/services/payment/platega.py
Normal file
@@ -0,0 +1,532 @@
|
||||
"""Mixin для интеграции платежей Platega."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.platega_service import PlategaService
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlategaPaymentMixin:
|
||||
"""Логика создания и обработки платежей Platega."""
|
||||
|
||||
_SUCCESS_STATUSES = {"CONFIRMED"}
|
||||
_FAILED_STATUSES = {"FAILED", "CANCELED", "EXPIRED"}
|
||||
_PENDING_STATUSES = {"PENDING", "INPROGRESS"}
|
||||
|
||||
async def create_platega_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
language: str,
|
||||
payment_method_code: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
service: Optional[PlategaService] = getattr(self, "platega_service", None)
|
||||
if not service or not service.is_configured:
|
||||
logger.error("Platega сервис не инициализирован")
|
||||
return None
|
||||
|
||||
if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма Platega меньше минимальной: %s < %s",
|
||||
amount_kopeks,
|
||||
settings.PLATEGA_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма Platega больше максимальной: %s > %s",
|
||||
amount_kopeks,
|
||||
settings.PLATEGA_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
correlation_id = uuid.uuid4().hex
|
||||
payload_token = f"platega:{correlation_id}"
|
||||
|
||||
amount_value = amount_kopeks / 100
|
||||
|
||||
try:
|
||||
response = await service.create_payment(
|
||||
payment_method=payment_method_code,
|
||||
amount=amount_value,
|
||||
currency=settings.PLATEGA_CURRENCY,
|
||||
description=description,
|
||||
return_url=settings.get_platega_return_url(),
|
||||
failed_url=settings.get_platega_failed_url(),
|
||||
payload=payload_token,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - network errors
|
||||
logger.exception("Ошибка Platega при создании платежа: %s", error)
|
||||
return None
|
||||
|
||||
if not response:
|
||||
logger.error("Platega вернул пустой ответ при создании платежа")
|
||||
return None
|
||||
|
||||
transaction_id = response.get("transactionId") or response.get("id")
|
||||
redirect_url = response.get("redirect")
|
||||
status = str(response.get("status") or "PENDING").upper()
|
||||
expires_at = PlategaService.parse_expires_at(response.get("expiresIn"))
|
||||
|
||||
metadata = {
|
||||
"raw_response": response,
|
||||
"language": language,
|
||||
"selected_method": payment_method_code,
|
||||
}
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
payment = await payment_module.create_platega_payment(
|
||||
db,
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=settings.PLATEGA_CURRENCY,
|
||||
description=description,
|
||||
status=status,
|
||||
payment_method_code=payment_method_code,
|
||||
correlation_id=correlation_id,
|
||||
platega_transaction_id=transaction_id,
|
||||
redirect_url=redirect_url,
|
||||
return_url=settings.get_platega_return_url(),
|
||||
failed_url=settings.get_platega_failed_url(),
|
||||
payload=payload_token,
|
||||
metadata=metadata,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Создан Platega платеж %s для пользователя %s (метод %s, сумма %s₽)",
|
||||
transaction_id or payment.id,
|
||||
user_id,
|
||||
payment_method_code,
|
||||
amount_value,
|
||||
)
|
||||
|
||||
return {
|
||||
"local_payment_id": payment.id,
|
||||
"transaction_id": transaction_id,
|
||||
"redirect_url": redirect_url,
|
||||
"status": status,
|
||||
"expires_at": expires_at,
|
||||
"correlation_id": correlation_id,
|
||||
}
|
||||
|
||||
async def process_platega_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: Dict[str, Any],
|
||||
) -> bool:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
transaction_id = str(payload.get("id") or "").strip()
|
||||
payload_token = payload.get("payload")
|
||||
|
||||
payment = None
|
||||
if transaction_id:
|
||||
payment = await payment_module.get_platega_payment_by_transaction_id(
|
||||
db, transaction_id
|
||||
)
|
||||
if not payment and payload_token:
|
||||
payment = await payment_module.get_platega_payment_by_correlation_id(
|
||||
db, str(payload_token).replace("platega:", "")
|
||||
)
|
||||
|
||||
if not payment:
|
||||
logger.warning("Platega webhook: платеж не найден (id=%s)", transaction_id)
|
||||
return False
|
||||
|
||||
status_raw = str(payload.get("status") or "").upper()
|
||||
if not status_raw:
|
||||
logger.warning("Platega webhook без статуса для платежа %s", payment.id)
|
||||
return False
|
||||
|
||||
update_kwargs = {
|
||||
"status": status_raw,
|
||||
"callback_payload": payload,
|
||||
}
|
||||
|
||||
if transaction_id:
|
||||
update_kwargs["platega_transaction_id"] = transaction_id
|
||||
|
||||
if status_raw in self._SUCCESS_STATUSES:
|
||||
if payment.is_paid:
|
||||
logger.info(
|
||||
"Platega платеж %s уже помечен как оплачен", payment.correlation_id
|
||||
)
|
||||
await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
**update_kwargs,
|
||||
is_paid=True,
|
||||
)
|
||||
return True
|
||||
|
||||
payment = await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
**update_kwargs,
|
||||
)
|
||||
await self._finalize_platega_payment(db, payment, payload)
|
||||
return True
|
||||
|
||||
if status_raw in self._FAILED_STATUSES:
|
||||
await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
**update_kwargs,
|
||||
is_paid=False,
|
||||
)
|
||||
logger.info(
|
||||
"Platega платеж %s перешёл в статус %s", payment.correlation_id, status_raw
|
||||
)
|
||||
return True
|
||||
|
||||
await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
**update_kwargs,
|
||||
)
|
||||
return True
|
||||
|
||||
async def get_platega_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
local_payment_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
payment = await payment_module.get_platega_payment_by_id(db, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
service: Optional[PlategaService] = getattr(self, "platega_service", None)
|
||||
remote_status: Optional[str] = None
|
||||
remote_payload: Optional[Dict[str, Any]] = None
|
||||
|
||||
if service and payment.platega_transaction_id:
|
||||
try:
|
||||
remote_payload = await service.get_transaction(
|
||||
payment.platega_transaction_id
|
||||
)
|
||||
except Exception as error: # pragma: no cover - network errors
|
||||
logger.error(
|
||||
"Ошибка Platega при получении транзакции %s: %s",
|
||||
payment.platega_transaction_id,
|
||||
error,
|
||||
)
|
||||
|
||||
if remote_payload:
|
||||
remote_status = str(remote_payload.get("status") or "").upper()
|
||||
if remote_status and remote_status != payment.status:
|
||||
await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
status=remote_status,
|
||||
metadata={
|
||||
**(getattr(payment, "metadata_json", {}) or {}),
|
||||
"remote_status": remote_payload,
|
||||
},
|
||||
)
|
||||
payment = await payment_module.get_platega_payment_by_id(db, local_payment_id)
|
||||
|
||||
if (
|
||||
remote_status in self._SUCCESS_STATUSES
|
||||
and not payment.is_paid
|
||||
):
|
||||
payment = await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
status=remote_status,
|
||||
callback_payload=remote_payload,
|
||||
)
|
||||
await self._finalize_platega_payment(db, payment, remote_payload)
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
"status": payment.status,
|
||||
"is_paid": payment.is_paid,
|
||||
"remote": remote_payload,
|
||||
}
|
||||
|
||||
async def _finalize_platega_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
payload: Optional[Dict[str, Any]],
|
||||
) -> Any:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||
if payload is not None:
|
||||
metadata["webhook"] = payload
|
||||
|
||||
paid_at = None
|
||||
if isinstance(payload, dict):
|
||||
paid_at_raw = payload.get("paidAt") or payload.get("confirmedAt")
|
||||
if paid_at_raw:
|
||||
try:
|
||||
paid_at = datetime.fromisoformat(str(paid_at_raw))
|
||||
except ValueError:
|
||||
paid_at = None
|
||||
|
||||
payment = await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
status="CONFIRMED",
|
||||
is_paid=True,
|
||||
paid_at=paid_at,
|
||||
metadata=metadata,
|
||||
callback_payload=payload,
|
||||
)
|
||||
|
||||
locked_payment = await payment_module.get_platega_payment_by_id_for_update(
|
||||
db, payment.id
|
||||
)
|
||||
if locked_payment:
|
||||
payment = locked_payment
|
||||
|
||||
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||
balance_already_credited = bool(metadata.get("balance_credited"))
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
"Platega платеж %s уже связан с транзакцией %s",
|
||||
payment.correlation_id,
|
||||
payment.transaction_id,
|
||||
)
|
||||
return payment
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error("Пользователь %s не найден для Platega", payment.user_id)
|
||||
return payment
|
||||
|
||||
transaction_external_id = (
|
||||
str(payload.get("id"))
|
||||
if isinstance(payload, dict) and payload.get("id")
|
||||
else payment.platega_transaction_id
|
||||
)
|
||||
|
||||
existing_transaction = None
|
||||
if transaction_external_id:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id(
|
||||
db,
|
||||
transaction_external_id,
|
||||
PaymentMethod.PLATEGA,
|
||||
)
|
||||
|
||||
method_display = settings.get_platega_method_display_name(payment.payment_method_code)
|
||||
description = (
|
||||
f"Пополнение через Platega ({method_display})"
|
||||
if method_display
|
||||
else "Пополнение через Platega"
|
||||
)
|
||||
|
||||
transaction = existing_transaction
|
||||
created_transaction = False
|
||||
|
||||
if not transaction:
|
||||
transaction = await payment_module.create_transaction(
|
||||
db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.PLATEGA,
|
||||
external_id=transaction_external_id or payment.correlation_id,
|
||||
is_completed=True,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await payment_module.link_platega_payment_to_transaction(
|
||||
db, payment=payment, transaction_id=transaction.id
|
||||
)
|
||||
|
||||
should_credit_balance = created_transaction or not balance_already_credited
|
||||
|
||||
if not should_credit_balance:
|
||||
logger.info(
|
||||
"Platega платеж %s уже зачислил баланс ранее",
|
||||
payment.correlation_id,
|
||||
)
|
||||
return payment
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Ошибка обработки реферального пополнения Platega: %s", error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Ошибка отправки админ уведомления Platega: %s", error)
|
||||
|
||||
method_title = settings.get_platega_method_display_title(payment.payment_method_code)
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
f"🦊 Способ: {method_title}\n"
|
||||
f"🆔 Транзакция: {transaction.id}\n\n"
|
||||
"Баланс пополнен автоматически!"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Ошибка отправки уведомления пользователю Platega: %s", error)
|
||||
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from aiogram import types
|
||||
|
||||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||||
auto_purchase_success = False
|
||||
if has_saved_cart:
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as auto_error:
|
||||
logger.error(
|
||||
"Ошибка автоматической покупки подписки для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
|
||||
"🛒 У вас есть неоформленный заказ.\n\n"
|
||||
"Вы можете продолжить оформление с теми же параметрами.",
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="return_to_saved_cart",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
callback_data="menu_balance",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=(
|
||||
f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
|
||||
f"{cart_message}"
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
|
||||
payment.user_id,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
metadata["balance_change"] = {
|
||||
"old_balance": old_balance,
|
||||
"new_balance": user.balance_kopeks,
|
||||
"credited_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
metadata["balance_credited"] = True
|
||||
|
||||
await payment_module.update_platega_payment(
|
||||
db,
|
||||
payment=payment,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Platega платеж %s для пользователя %s",
|
||||
payment.correlation_id,
|
||||
payment.user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -15,11 +15,13 @@ from app.external.heleket import HeleketService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.services.mulenpay_service import MulenPayService
|
||||
from app.services.pal24_service import Pal24Service
|
||||
from app.services.platega_service import PlategaService
|
||||
from app.services.payment import (
|
||||
CryptoBotPaymentMixin,
|
||||
HeleketPaymentMixin,
|
||||
MulenPayPaymentMixin,
|
||||
Pal24PaymentMixin,
|
||||
PlategaPaymentMixin,
|
||||
PaymentCommonMixin,
|
||||
TelegramStarsMixin,
|
||||
TributePaymentMixin,
|
||||
@@ -65,6 +67,11 @@ async def create_transaction(*args, **kwargs):
|
||||
return await transaction_crud.create_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_transaction_by_external_id(*args, **kwargs):
|
||||
transaction_crud = import_module("app.database.crud.transaction")
|
||||
return await transaction_crud.get_transaction_by_external_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def add_user_balance(*args, **kwargs):
|
||||
user_crud = import_module("app.database.crud.user")
|
||||
return await user_crud.add_user_balance(*args, **kwargs)
|
||||
@@ -170,6 +177,41 @@ async def link_wata_payment_to_transaction(*args, **kwargs):
|
||||
return await wata_crud.link_wata_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_platega_payment(*args, **kwargs):
|
||||
platega_crud = import_module("app.database.crud.platega")
|
||||
return await platega_crud.create_platega_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_platega_payment_by_id(*args, **kwargs):
|
||||
platega_crud = import_module("app.database.crud.platega")
|
||||
return await platega_crud.get_platega_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_platega_payment_by_id_for_update(*args, **kwargs):
|
||||
platega_crud = import_module("app.database.crud.platega")
|
||||
return await platega_crud.get_platega_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_platega_payment_by_transaction_id(*args, **kwargs):
|
||||
platega_crud = import_module("app.database.crud.platega")
|
||||
return await platega_crud.get_platega_payment_by_transaction_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_platega_payment_by_correlation_id(*args, **kwargs):
|
||||
platega_crud = import_module("app.database.crud.platega")
|
||||
return await platega_crud.get_platega_payment_by_correlation_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_platega_payment(*args, **kwargs):
|
||||
platega_crud = import_module("app.database.crud.platega")
|
||||
return await platega_crud.update_platega_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_platega_payment_to_transaction(*args, **kwargs):
|
||||
platega_crud = import_module("app.database.crud.platega")
|
||||
return await platega_crud.link_platega_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_cryptobot_payment(*args, **kwargs):
|
||||
crypto_crud = import_module("app.database.crud.cryptobot")
|
||||
return await crypto_crud.create_cryptobot_payment(*args, **kwargs)
|
||||
@@ -224,6 +266,7 @@ class PaymentService(
|
||||
HeleketPaymentMixin,
|
||||
MulenPayPaymentMixin,
|
||||
Pal24PaymentMixin,
|
||||
PlategaPaymentMixin,
|
||||
WataPaymentMixin,
|
||||
):
|
||||
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
||||
@@ -248,11 +291,14 @@ class PaymentService(
|
||||
self.pal24_service = (
|
||||
Pal24Service() if settings.is_pal24_enabled() else None
|
||||
)
|
||||
self.platega_service = (
|
||||
PlategaService() if settings.is_platega_enabled() else None
|
||||
)
|
||||
self.wata_service = WataService() if settings.is_wata_enabled() else None
|
||||
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.debug(
|
||||
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, Heleket=%s, %s=%s, Pal24=%s, Wata=%s)",
|
||||
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, Heleket=%s, %s=%s, Pal24=%s, Platega=%s, Wata=%s)",
|
||||
bool(self.yookassa_service),
|
||||
bool(self.stars_service),
|
||||
bool(self.cryptobot_service),
|
||||
@@ -260,5 +306,6 @@ class PaymentService(
|
||||
mulenpay_name,
|
||||
bool(self.mulenpay_service),
|
||||
bool(self.pal24_service),
|
||||
bool(self.platega_service),
|
||||
bool(self.wata_service),
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.database.models import (
|
||||
HeleketPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PlategaPayment,
|
||||
PaymentMethod,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
@@ -62,6 +63,7 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
PaymentMethod.WATA,
|
||||
PaymentMethod.HELEKET,
|
||||
PaymentMethod.CRYPTOBOT,
|
||||
PaymentMethod.PLATEGA,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -73,6 +75,7 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
PaymentMethod.PAL24,
|
||||
PaymentMethod.WATA,
|
||||
PaymentMethod.CRYPTOBOT,
|
||||
PaymentMethod.PLATEGA,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -86,6 +89,8 @@ def method_display_name(method: PaymentMethod) -> str:
|
||||
return "YooKassa"
|
||||
if method == PaymentMethod.WATA:
|
||||
return "WATA"
|
||||
if method == PaymentMethod.PLATEGA:
|
||||
return "Platega"
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
return "CryptoBot"
|
||||
if method == PaymentMethod.HELEKET:
|
||||
@@ -104,6 +109,8 @@ def _method_is_enabled(method: PaymentMethod) -> bool:
|
||||
return settings.is_pal24_enabled()
|
||||
if method == PaymentMethod.WATA:
|
||||
return settings.is_wata_enabled()
|
||||
if method == PaymentMethod.PLATEGA:
|
||||
return settings.is_platega_enabled()
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
return settings.is_cryptobot_enabled()
|
||||
if method == PaymentMethod.HELEKET:
|
||||
@@ -315,6 +322,13 @@ def _is_wata_pending(payment: WataPayment) -> bool:
|
||||
}
|
||||
|
||||
|
||||
def _is_platega_pending(payment: PlategaPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
status = (payment.status or "").lower()
|
||||
return status in {"pending", "inprogress", "in_progress"}
|
||||
|
||||
|
||||
def _is_heleket_pending(payment: HeleketPayment) -> bool:
|
||||
if payment.is_paid:
|
||||
return False
|
||||
@@ -459,6 +473,33 @@ async def _fetch_wata_payments(db: AsyncSession, cutoff: datetime) -> List[Pendi
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_platega_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(PlategaPayment)
|
||||
.options(selectinload(PlategaPayment.user))
|
||||
.where(PlategaPayment.created_at >= cutoff)
|
||||
.order_by(desc(PlategaPayment.created_at))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records: List[PendingPayment] = []
|
||||
for payment in result.scalars().all():
|
||||
if not _is_platega_pending(payment):
|
||||
continue
|
||||
identifier = payment.platega_transaction_id or payment.correlation_id or str(payment.id)
|
||||
record = _build_record(
|
||||
PaymentMethod.PLATEGA,
|
||||
payment,
|
||||
identifier=identifier,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, "expires_at", None),
|
||||
)
|
||||
if record:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
async def _fetch_heleket_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]:
|
||||
stmt = (
|
||||
select(HeleketPayment)
|
||||
@@ -582,6 +623,7 @@ async def list_recent_pending_payments(
|
||||
await _fetch_pal24_payments(db, cutoff),
|
||||
await _fetch_mulenpay_payments(db, cutoff),
|
||||
await _fetch_wata_payments(db, cutoff),
|
||||
await _fetch_platega_payments(db, cutoff),
|
||||
await _fetch_heleket_payments(db, cutoff),
|
||||
await _fetch_cryptobot_payments(db, cutoff),
|
||||
await _fetch_stars_transactions(db, cutoff),
|
||||
@@ -648,6 +690,22 @@ async def get_payment_record(
|
||||
expires_at=getattr(payment, "expires_at", None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.PLATEGA:
|
||||
payment = await db.get(PlategaPayment, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
await db.refresh(payment, attribute_names=["user"])
|
||||
identifier = payment.platega_transaction_id or payment.correlation_id or str(payment.id)
|
||||
return _build_record(
|
||||
method,
|
||||
payment,
|
||||
identifier=identifier,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or "",
|
||||
is_paid=bool(payment.is_paid),
|
||||
expires_at=getattr(payment, "expires_at", None),
|
||||
)
|
||||
|
||||
if method == PaymentMethod.HELEKET:
|
||||
payment = await db.get(HeleketPayment, local_payment_id)
|
||||
if not payment:
|
||||
@@ -732,6 +790,9 @@ async def run_manual_check(
|
||||
elif method == PaymentMethod.WATA:
|
||||
result = await payment_service.get_wata_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
elif method == PaymentMethod.PLATEGA:
|
||||
result = await payment_service.get_platega_payment_status(db, local_payment_id)
|
||||
payment = result.get("payment") if result else None
|
||||
elif method == PaymentMethod.HELEKET:
|
||||
payment = await payment_service.sync_heleket_payment_status(
|
||||
db, local_payment_id=local_payment_id
|
||||
|
||||
190
app/services/platega_service.py
Normal file
190
app/services/platega_service.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""HTTP-интеграция с Platega API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlategaService:
|
||||
"""Обертка над Platega API с базовой повторной отправкой запросов."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.base_url = (settings.PLATEGA_BASE_URL or "https://app.platega.io").rstrip("/")
|
||||
self.merchant_id = settings.PLATEGA_MERCHANT_ID
|
||||
self.secret = settings.PLATEGA_SECRET
|
||||
self._timeout = aiohttp.ClientTimeout(total=30, connect=10, sock_read=25)
|
||||
self._max_retries = 3
|
||||
self._retry_delay = 0.5
|
||||
self._retryable_statuses = {500, 502, 503, 504}
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return settings.is_platega_enabled()
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
*,
|
||||
payment_method: int,
|
||||
amount: float,
|
||||
currency: str,
|
||||
description: Optional[str] = None,
|
||||
return_url: Optional[str] = None,
|
||||
failed_url: Optional[str] = None,
|
||||
payload: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
body: Dict[str, Any] = {
|
||||
"paymentMethod": payment_method,
|
||||
"paymentDetails": {
|
||||
"amount": round(amount, 2),
|
||||
"currency": currency,
|
||||
},
|
||||
}
|
||||
|
||||
if description:
|
||||
body["description"] = description
|
||||
if return_url:
|
||||
body["return"] = return_url
|
||||
if failed_url:
|
||||
body["failedUrl"] = failed_url
|
||||
if payload:
|
||||
body["payload"] = payload
|
||||
|
||||
return await self._request("POST", "/transaction/process", json_data=body)
|
||||
|
||||
async def get_transaction(self, transaction_id: str) -> Optional[Dict[str, Any]]:
|
||||
endpoint = f"/transaction/{transaction_id}"
|
||||
return await self._request("GET", endpoint)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
*,
|
||||
json_data: Optional[Dict[str, Any]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.is_configured:
|
||||
logger.error("Platega service is not configured")
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
headers = {
|
||||
"X-MerchantId": self.merchant_id or "",
|
||||
"X-Secret": self.secret or "",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
last_error: Optional[BaseException] = None
|
||||
|
||||
for attempt in range(1, self._max_retries + 1):
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=self._timeout) as session:
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
json=json_data,
|
||||
params=params,
|
||||
headers=headers,
|
||||
) as response:
|
||||
data, raw_text = await self._deserialize_response(response)
|
||||
|
||||
if response.status >= 400:
|
||||
logger.error(
|
||||
"Platega API error %s %s: %s",
|
||||
response.status,
|
||||
endpoint,
|
||||
raw_text,
|
||||
)
|
||||
if (
|
||||
response.status in self._retryable_statuses
|
||||
and attempt < self._max_retries
|
||||
):
|
||||
await asyncio.sleep(self._retry_delay * attempt)
|
||||
continue
|
||||
return None
|
||||
|
||||
return data
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Platega request cancelled: %s %s", method, endpoint)
|
||||
raise
|
||||
except asyncio.TimeoutError as error:
|
||||
last_error = error
|
||||
logger.warning(
|
||||
"Platega request timeout (%s %s) attempt %s/%s",
|
||||
method,
|
||||
endpoint,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
)
|
||||
except aiohttp.ClientError as error:
|
||||
last_error = error
|
||||
logger.warning(
|
||||
"Platega client error (%s %s) attempt %s/%s: %s",
|
||||
method,
|
||||
endpoint,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - safety
|
||||
logger.exception("Unexpected Platega error: %s", error)
|
||||
return None
|
||||
|
||||
if attempt < self._max_retries:
|
||||
await asyncio.sleep(self._retry_delay * attempt)
|
||||
|
||||
if last_error is not None:
|
||||
logger.error(
|
||||
"Platega request failed after %s attempts (%s %s): %s",
|
||||
self._max_retries,
|
||||
method,
|
||||
endpoint,
|
||||
last_error,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def _deserialize_response(
|
||||
response: aiohttp.ClientResponse,
|
||||
) -> tuple[Optional[Dict[str, Any]], str]:
|
||||
raw_text = await response.text()
|
||||
if not raw_text:
|
||||
return None, ""
|
||||
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
if "json" in content_type.lower() or not content_type:
|
||||
try:
|
||||
return json.loads(raw_text), raw_text
|
||||
except json.JSONDecodeError as error:
|
||||
logger.error(
|
||||
"Failed to decode Platega JSON response %s: %s",
|
||||
response.url,
|
||||
error,
|
||||
)
|
||||
return None, raw_text
|
||||
|
||||
return None, raw_text
|
||||
|
||||
@staticmethod
|
||||
def parse_expires_at(expires_in: Optional[str]) -> Optional[datetime]:
|
||||
if not expires_in:
|
||||
return None
|
||||
|
||||
try:
|
||||
hours, minutes, seconds = [int(part) for part in expires_in.split(":", 2)]
|
||||
delta = timedelta(hours=hours, minutes=minutes, seconds=seconds)
|
||||
return datetime.utcnow() + delta
|
||||
except Exception:
|
||||
logger.warning("Failed to parse Platega expiresIn value: %s", expires_in)
|
||||
return None
|
||||
@@ -84,6 +84,7 @@ class BotConfigurationService:
|
||||
"CRYPTOBOT": "🪙 CryptoBot",
|
||||
"HELEKET": "🪙 Heleket",
|
||||
"YOOKASSA": "🟣 YooKassa",
|
||||
"PLATEGA": "💳 Platega",
|
||||
"TRIBUTE": "🎁 Tribute",
|
||||
"MULENPAY": "💰 {mulenpay_name}",
|
||||
"PAL24": "🏦 PAL24 / PayPalych",
|
||||
@@ -137,6 +138,7 @@ class BotConfigurationService:
|
||||
"YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
|
||||
"CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.",
|
||||
"HELEKET": "Heleket: криптоплатежи, ключи мерчанта и вебхуки.",
|
||||
"PLATEGA": "Platega: merchant ID, секрет, ссылки возврата и методы оплаты.",
|
||||
"MULENPAY": "Платежи {mulenpay_name} и параметры магазина.",
|
||||
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
|
||||
"TRIBUTE": "Tribute и донат-сервисы.",
|
||||
@@ -303,6 +305,7 @@ class BotConfigurationService:
|
||||
"YOOKASSA_": "YOOKASSA",
|
||||
"CRYPTOBOT_": "CRYPTOBOT",
|
||||
"HELEKET_": "HELEKET",
|
||||
"PLATEGA_": "PLATEGA",
|
||||
"MULENPAY_": "MULENPAY",
|
||||
"PAL24_": "PAL24",
|
||||
"PAYMENT_": "PAYMENT",
|
||||
|
||||
@@ -25,6 +25,7 @@ class SubscriptionStates(StatesGroup):
|
||||
class BalanceStates(StatesGroup):
|
||||
waiting_for_amount = State()
|
||||
waiting_for_pal24_method = State()
|
||||
waiting_for_platega_method = State()
|
||||
waiting_for_stars_payment = State()
|
||||
waiting_for_support_request = State()
|
||||
|
||||
|
||||
@@ -577,6 +577,54 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
|
||||
routes_registered = True
|
||||
|
||||
if settings.is_platega_enabled():
|
||||
|
||||
@router.get(settings.PLATEGA_WEBHOOK_PATH)
|
||||
async def platega_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "platega_webhook",
|
||||
"enabled": settings.is_platega_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.PLATEGA_WEBHOOK_PATH)
|
||||
async def platega_webhook(request: Request) -> JSONResponse:
|
||||
merchant_id = request.headers.get("X-MerchantId", "")
|
||||
secret = request.headers.get("X-Secret", "")
|
||||
if (
|
||||
merchant_id != (settings.PLATEGA_MERCHANT_ID or "")
|
||||
or secret != (settings.PLATEGA_SECRET or "")
|
||||
):
|
||||
return JSONResponse(
|
||||
{"status": "error", "reason": "unauthorized"},
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
try:
|
||||
payload = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse(
|
||||
{"status": "error", "reason": "invalid_json"},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
"process_platega_webhook",
|
||||
)
|
||||
if success:
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
return JSONResponse(
|
||||
{"status": "error", "reason": "not_processed"},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
if routes_registered:
|
||||
@router.get("/health/payment-webhooks")
|
||||
async def payment_webhooks_health() -> JSONResponse:
|
||||
@@ -590,6 +638,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
"wata_enabled": settings.is_wata_enabled(),
|
||||
"heleket_enabled": settings.is_heleket_enabled(),
|
||||
"pal24_enabled": settings.is_pal24_enabled(),
|
||||
"platega_enabled": settings.is_platega_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "2b3c1d4e5f6a"
|
||||
down_revision: Union[str, None] = "9f0f2d5a1c7b"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"platega_payments",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("platega_transaction_id", sa.String(length=255), nullable=True, unique=True),
|
||||
sa.Column("correlation_id", sa.String(length=64), nullable=False, unique=True),
|
||||
sa.Column("amount_kopeks", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"currency",
|
||||
sa.String(length=10),
|
||||
nullable=False,
|
||||
server_default="RUB",
|
||||
),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("payment_method_code", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.String(length=50),
|
||||
nullable=False,
|
||||
server_default="PENDING",
|
||||
),
|
||||
sa.Column(
|
||||
"is_paid",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
sa.Column("paid_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("redirect_url", sa.Text(), nullable=True),
|
||||
sa.Column("return_url", sa.Text(), nullable=True),
|
||||
sa.Column("failed_url", sa.Text(), nullable=True),
|
||||
sa.Column("payload", sa.String(length=255), nullable=True),
|
||||
sa.Column("metadata_json", sa.JSON(), nullable=True),
|
||||
sa.Column("callback_payload", sa.JSON(), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("transaction_id", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["transaction_id"], ["transactions.id"], ondelete="SET NULL"),
|
||||
)
|
||||
|
||||
op.create_index("ix_platega_payments_id", "platega_payments", ["id"])
|
||||
op.create_index("ix_platega_payments_user_id", "platega_payments", ["user_id"])
|
||||
op.create_index(
|
||||
"ix_platega_payments_platega_transaction_id",
|
||||
"platega_payments",
|
||||
["platega_transaction_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_platega_payments_correlation_id",
|
||||
"platega_payments",
|
||||
["correlation_id"],
|
||||
unique=True,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_platega_payments_transaction_id",
|
||||
"platega_payments",
|
||||
["transaction_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_platega_payments_transaction_id", table_name="platega_payments")
|
||||
op.drop_index("ix_platega_payments_correlation_id", table_name="platega_payments")
|
||||
op.drop_index(
|
||||
"ix_platega_payments_platega_transaction_id",
|
||||
table_name="platega_payments",
|
||||
)
|
||||
op.drop_index("ix_platega_payments_user_id", table_name="platega_payments")
|
||||
op.drop_index("ix_platega_payments_id", table_name="platega_payments")
|
||||
op.drop_table("platega_payments")
|
||||
232
tests/services/test_payment_service_platega.py
Normal file
232
tests/services/test_payment_service_platega.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""Тесты для сценариев Platega в PaymentService."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
import app.services.payment_service as payment_service_module # noqa: E402
|
||||
from app.config import settings # noqa: E402
|
||||
from app.services.payment_service import PaymentService # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
class DummySession:
|
||||
async def commit(self) -> None: # pragma: no cover - no custom logic required
|
||||
return None
|
||||
|
||||
async def refresh(self, *_: Any) -> None: # pragma: no cover - no custom logic required
|
||||
return None
|
||||
|
||||
|
||||
class DummyLocalPayment:
|
||||
def __init__(self, payment_id: int = 101) -> None:
|
||||
self.id = payment_id
|
||||
self.created_at = datetime.utcnow()
|
||||
|
||||
|
||||
class StubPlategaService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
configured: bool = True,
|
||||
response: Optional[Dict[str, Any]] = None,
|
||||
transaction_payload: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
self.is_configured = configured
|
||||
self.response = response or {
|
||||
"transactionId": "trx-001",
|
||||
"redirect": "https://platega.example/pay",
|
||||
"status": "PENDING",
|
||||
"expiresIn": 900,
|
||||
}
|
||||
self.transaction_payload = transaction_payload
|
||||
self.calls: list[Dict[str, Any]] = []
|
||||
self.raise_error: Optional[Exception] = None
|
||||
|
||||
async def create_payment(self, **kwargs: Any) -> Optional[Dict[str, Any]]:
|
||||
self.calls.append(kwargs)
|
||||
if self.raise_error:
|
||||
raise self.raise_error
|
||||
return self.response
|
||||
|
||||
async def get_transaction(self, transaction_id: str) -> Optional[Dict[str, Any]]:
|
||||
self.calls.append({"transaction_lookup": transaction_id})
|
||||
return self.transaction_payload
|
||||
|
||||
|
||||
def _make_service(stub: Optional[StubPlategaService]) -> PaymentService:
|
||||
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
|
||||
service.bot = None
|
||||
service.platega_service = stub
|
||||
service.yookassa_service = None
|
||||
service.cryptobot_service = None
|
||||
service.heleket_service = None
|
||||
service.mulenpay_service = None
|
||||
service.pal24_service = None
|
||||
service.stars_service = None
|
||||
service.wata_service = None
|
||||
return service
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_platega_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
stub = StubPlategaService()
|
||||
service = _make_service(stub)
|
||||
db = DummySession()
|
||||
|
||||
captured_args: Dict[str, Any] = {}
|
||||
|
||||
async def fake_create_platega_payment(*args: Any, **kwargs: Any) -> DummyLocalPayment:
|
||||
if args:
|
||||
captured_args["db_arg"] = args[0]
|
||||
captured_args.update(kwargs)
|
||||
return DummyLocalPayment(payment_id=777)
|
||||
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"create_platega_payment",
|
||||
fake_create_platega_payment,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(settings, "PLATEGA_MIN_AMOUNT_KOPEKS", 10_000, raising=False)
|
||||
monkeypatch.setattr(settings, "PLATEGA_MAX_AMOUNT_KOPEKS", 500_000, raising=False)
|
||||
monkeypatch.setattr(settings, "PLATEGA_CURRENCY", "RUB", raising=False)
|
||||
monkeypatch.setattr(settings, "PLATEGA_RETURN_URL", "https://return", raising=False)
|
||||
monkeypatch.setattr(settings, "PLATEGA_FAILED_URL", "https://failed", raising=False)
|
||||
|
||||
result = await service.create_platega_payment(
|
||||
db=db,
|
||||
user_id=42,
|
||||
amount_kopeks=50_000,
|
||||
description="Пополнение счёта",
|
||||
language="ru",
|
||||
payment_method_code=10,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["local_payment_id"] == 777
|
||||
assert result["transaction_id"] == "trx-001"
|
||||
assert result["redirect_url"] == "https://platega.example/pay"
|
||||
assert result["status"] == "PENDING"
|
||||
assert "correlation_id" in result and len(result["correlation_id"]) == 32
|
||||
assert captured_args["user_id"] == 42
|
||||
assert captured_args["amount_kopeks"] == 50_000
|
||||
assert captured_args["payment_method_code"] == 10
|
||||
assert captured_args["metadata"]["selected_method"] == 10
|
||||
assert stub.calls and stub.calls[0]["payment_method"] == 10
|
||||
assert stub.calls[0]["amount"] == pytest.approx(500.0)
|
||||
assert stub.calls[0]["currency"] == "RUB"
|
||||
assert captured_args["metadata"]["language"] == "ru"
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_platega_payment_respects_limits_and_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
stub = StubPlategaService()
|
||||
service = _make_service(stub)
|
||||
db = DummySession()
|
||||
|
||||
monkeypatch.setattr(settings, "PLATEGA_MIN_AMOUNT_KOPEKS", 20_000, raising=False)
|
||||
monkeypatch.setattr(settings, "PLATEGA_MAX_AMOUNT_KOPEKS", 40_000, raising=False)
|
||||
|
||||
too_low = await service.create_platega_payment(
|
||||
db=db,
|
||||
user_id=1,
|
||||
amount_kopeks=10_000,
|
||||
description="Пополнение",
|
||||
language="ru",
|
||||
payment_method_code=2,
|
||||
)
|
||||
assert too_low is None
|
||||
|
||||
too_high = await service.create_platega_payment(
|
||||
db=db,
|
||||
user_id=1,
|
||||
amount_kopeks=100_000,
|
||||
description="Пополнение",
|
||||
language="ru",
|
||||
payment_method_code=2,
|
||||
)
|
||||
assert too_high is None
|
||||
|
||||
not_configured_service = _make_service(StubPlategaService(configured=False))
|
||||
result = await not_configured_service.create_platega_payment(
|
||||
db=db,
|
||||
user_id=1,
|
||||
amount_kopeks=30_000,
|
||||
description="Пополнение",
|
||||
language="ru",
|
||||
payment_method_code=2,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_platega_payment_handles_service_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
stub = StubPlategaService()
|
||||
stub.raise_error = RuntimeError("network down")
|
||||
service = _make_service(stub)
|
||||
db = DummySession()
|
||||
|
||||
async def fake_create_platega_payment(*_: Any, **__: Any) -> DummyLocalPayment:
|
||||
pytest.fail("local payment must not be created when Platega call fails")
|
||||
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"create_platega_payment",
|
||||
fake_create_platega_payment,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(settings, "PLATEGA_MIN_AMOUNT_KOPEKS", 1_000, raising=False)
|
||||
monkeypatch.setattr(settings, "PLATEGA_MAX_AMOUNT_KOPEKS", 1_000_000, raising=False)
|
||||
|
||||
result = await service.create_platega_payment(
|
||||
db=db,
|
||||
user_id=5,
|
||||
amount_kopeks=25_000,
|
||||
description="Пополнение",
|
||||
language="ru",
|
||||
payment_method_code=13,
|
||||
)
|
||||
assert result is None
|
||||
assert stub.calls and "payment_method" in stub.calls[0]
|
||||
|
||||
|
||||
def test_get_platega_active_methods_parses_and_filters(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
settings,
|
||||
"PLATEGA_ACTIVE_METHODS",
|
||||
" 2,10, 11 ;12,13,13,invalid ",
|
||||
raising=False,
|
||||
)
|
||||
|
||||
methods = settings.get_platega_active_methods()
|
||||
|
||||
assert methods == [2, 10, 11, 12, 13]
|
||||
|
||||
|
||||
def test_get_platega_active_methods_returns_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "PLATEGA_ACTIVE_METHODS", "", raising=False)
|
||||
|
||||
methods = settings.get_platega_active_methods()
|
||||
|
||||
assert methods == [2]
|
||||
|
||||
|
||||
def test_platega_method_display_helpers() -> None:
|
||||
assert settings.get_platega_method_display_name(10) == "Банковские карты (RUB)"
|
||||
assert settings.get_platega_method_display_title(10) == "💳 Карты (RUB)"
|
||||
assert settings.get_platega_method_display_name(999) == "Метод 999"
|
||||
assert settings.get_platega_method_display_title(999) == "Platega 999"
|
||||
Reference in New Issue
Block a user