Add PayPalych payment integration

This commit is contained in:
Egor
2025-09-24 02:56:51 +03:00
parent d471a84125
commit 4225404673
17 changed files with 1516 additions and 7 deletions

View File

@@ -35,7 +35,7 @@
### ⚡ **Полная автоматизация VPN бизнеса**
- 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + ЮKassa + MulenPay + P2P
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + ЮKassa + MulenPay + PayPalych + P2P
- 🔄 **Автоматизация 99%** - от регистрации до продления подписок
- 📊 **Детальная аналитика** - полная картина вашего бизнеса
- 💬 **Уведомления в топики** об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса 🚧 Включении тех работ ♻️ Появлении новой версии бота
@@ -486,6 +486,25 @@ MULENPAY_VAT_CODE=0
MULENPAY_PAYMENT_SUBJECT=4
MULENPAY_PAYMENT_MODE=4
# PAYPALYCH / PAL24
PAL24_ENABLED=false
PAL24_API_TOKEN=
PAL24_SHOP_ID=
PAL24_SIGNATURE_TOKEN=
PAL24_BASE_URL=https://pal24.pro/api/v1/
PAL24_WEBHOOK_PATH=/pal24-webhook
PAL24_WEBHOOK_PORT=8084
PAL24_PAYMENT_DESCRIPTION="Пополнение баланса"
PAL24_MIN_AMOUNT_KOPEKS=10000
PAL24_MAX_AMOUNT_KOPEKS=100000000
PAL24_REQUEST_TIMEOUT=30
# Настройки PayPalych
1. Включите интеграцию (`PAL24_ENABLED=true`) и укажите `PAL24_API_TOKEN`, `PAL24_SHOP_ID`, а также `PAL24_SIGNATURE_TOKEN` для проверки подписи уведомлений.
2. Настройте в кабинете PayPalych **Result URL** и success/fail redirect на `https://<ваш-домен>/pal24-webhook`.
3. Убедитесь, что порт `PAL24_WEBHOOK_PORT` (по умолчанию `8084`) проброшен через прокси/фаервол.
4. Для теста можно отправить postback вручную (пример команды см. ниже в разделе «Проверка PayPalych postback»).
# ===== ИНТЕРФЕЙС И UX =====
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
@@ -617,6 +636,7 @@ WEBHOOK_PATH=/webhook
- 💳 Tribute
- 💳 YooKassa (включая СБП и онлайн-чек)
- 💳 MulenPay
- 💳 PayPalych (Pal24)
- 💰 CryptoBot (мультивалюта и срок жизни инвойсов)
- 🎁 Реферальные и промо-бонусы
- Детальная история транзакций и чеков
@@ -642,7 +662,7 @@ WEBHOOK_PATH=/webhook
📊 **Мощная аналитика**
- 👥 Детальная статистика пользователей и подписок
- 💰 Анализ платежей по источникам (Stars, YooKassa, Tribute, CryptoBot)
- 💰 Анализ платежей по источникам (Stars, YooKassa, Tribute, MulenPay, PayPalych, CryptoBot)
- 🖥️ Мониторинг серверов Remnawave и статуса сквадов
- 📈 Финансовые отчеты, конверсии и эффективность рекламных кампаний
@@ -935,6 +955,7 @@ docker compose down -v --remove-orphans
- **Telegram Stars**: Работает автоматически
- **Tribute**: Настрой webhook на `https://your-domain.com/tribute-webhook`
- **YooKassa**: Настрой webhook на `https://your-domain.com/yookassa-webhook`
- **PayPalych**: Укажи Result URL `https://your-domain.com/pal24-webhook` в кабинете Pal24
### 🛠️ Настройка Уведомлений в топик группы
@@ -1147,7 +1168,7 @@ docker system prune
|----------|-------------|---------|
| **Бот не отвечает** | `docker logs remnawave_bot` | Проверь `BOT_TOKEN` и интернет |
| **Ошибки БД** | `docker compose ps postgres` | Проверь статус PostgreSQL |
| **Webhook не работает** | Проверь порты 8081/8082 | Настрой прокси-сервер правильно |
| **Webhook не работает** | Проверь порты 8081/8082/8084 | Настрой прокси-сервер правильно |
| **API недоступен** | Проверь логи бота | Проверь `REMNAWAVE_API_URL` и ключ |
| **Мониторинг не работает** | Админ панель → Мониторинг | Проверь `MAINTENANCE_AUTO_ENABLE` |
| **Платежи не проходят** | Проверь webhook'и | Настрой URL в платежных системах |
@@ -1186,7 +1207,16 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# PayPalych webhook endpoint
location /pal24-webhook {
proxy_pass http://127.0.0.1:8084;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Для YooKassa
location /yookassa-webhook {
proxy_pass http://127.0.0.1:8082;
@@ -1217,17 +1247,40 @@ your-domain.com {
handle /mulenpay-webhook* {
reverse_proxy localhost:8081
}
handle /pal24-webhook* {
reverse_proxy localhost:8084
}
handle /yookassa-webhook* {
reverse_proxy localhost:8082
}
handle /health {
reverse_proxy localhost:8081/health
}
}
```
#### 🧪 Проверка PayPalych postback
```bash
# Генерируем подпись: md5("100.00:test-order-1:${PAL24_SIGNATURE_TOKEN}")
SIGNATURE=$(python - <<'PY'
import hashlib, os
token = os.environ.get('PAL24_SIGNATURE_TOKEN', 'test_token')
payload = f"100.00:test-order-1:{token}".encode()
print(hashlib.md5(payload).hexdigest().upper())
PY
)
curl -X POST https://your-domain.com/pal24-webhook \
-H "Content-Type: application/json" \
-d '{"InvId": "test-order-1", "OutSum": "100.00", "Status": "SUCCESS", "SignatureValue": "'$SIGNATURE'"}'
```
Ответ `{"status": "ok"}` подтверждает корректную обработку вебхука.
---
## 💡 Использование

View File

@@ -186,6 +186,18 @@ class Settings(BaseSettings):
MULENPAY_PAYMENT_SUBJECT: int = 4
MULENPAY_PAYMENT_MODE: int = 4
PAL24_ENABLED: bool = False
PAL24_API_TOKEN: Optional[str] = None
PAL24_SHOP_ID: Optional[str] = None
PAL24_SIGNATURE_TOKEN: Optional[str] = None
PAL24_BASE_URL: str = "https://pal24.pro/api/v1/"
PAL24_WEBHOOK_PATH: str = "/pal24-webhook"
PAL24_WEBHOOK_PORT: int = 8084
PAL24_PAYMENT_DESCRIPTION: str = "Пополнение баланса"
PAL24_MIN_AMOUNT_KOPEKS: int = 10000
PAL24_MAX_AMOUNT_KOPEKS: int = 100000000
PAL24_REQUEST_TIMEOUT: int = 30
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
HIDE_SUBSCRIPTION_LINK: bool = False
@@ -463,6 +475,13 @@ class Settings(BaseSettings):
and self.MULENPAY_SHOP_ID is not None
)
def is_pal24_enabled(self) -> bool:
return (
self.PAL24_ENABLED
and self.PAL24_API_TOKEN is not None
and self.PAL24_SHOP_ID is not None
)
def get_cryptobot_base_url(self) -> str:
if self.CRYPTOBOT_TESTNET:
return "https://testnet-pay.crypt.bot"

160
app/database/crud/pal24.py Normal file
View File

@@ -0,0 +1,160 @@
"""CRUD helpers for PayPalych (Pal24) payments."""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import Pal24Payment
logger = logging.getLogger(__name__)
async def create_pal24_payment(
db: AsyncSession,
*,
user_id: int,
bill_id: str,
amount_kopeks: int,
description: Optional[str],
status: str,
type_: str,
currency: str,
link_url: Optional[str],
link_page_url: Optional[str],
order_id: Optional[str] = None,
ttl: Optional[int] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Pal24Payment:
payment = Pal24Payment(
user_id=user_id,
bill_id=bill_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
status=status,
type=type_,
link_url=link_url,
link_page_url=link_page_url,
metadata_json=metadata or {},
ttl=ttl,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(
"Создан Pal24 платеж #%s для пользователя %s: %s копеек (статус %s)",
payment.id,
user_id,
amount_kopeks,
status,
)
return payment
async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Optional[Pal24Payment]:
result = await db.execute(
select(Pal24Payment).where(Pal24Payment.id == payment_id)
)
return result.scalar_one_or_none()
async def get_pal24_payment_by_bill_id(db: AsyncSession, bill_id: str) -> Optional[Pal24Payment]:
result = await db.execute(
select(Pal24Payment).where(Pal24Payment.bill_id == bill_id)
)
return result.scalar_one_or_none()
async def get_pal24_payment_by_order_id(db: AsyncSession, order_id: str) -> Optional[Pal24Payment]:
result = await db.execute(
select(Pal24Payment).where(Pal24Payment.order_id == order_id)
)
return result.scalar_one_or_none()
async def update_pal24_payment_status(
db: AsyncSession,
payment: Pal24Payment,
*,
status: str,
is_active: Optional[bool] = None,
is_paid: Optional[bool] = None,
payment_id: Optional[str] = None,
payment_status: Optional[str] = None,
payment_method: Optional[str] = None,
balance_amount: Optional[str] = None,
balance_currency: Optional[str] = None,
payer_account: Optional[str] = None,
callback_payload: Optional[Dict[str, Any]] = None,
) -> Pal24Payment:
update_values: Dict[str, Any] = {
"status": status,
}
if is_active is not None:
update_values["is_active"] = is_active
if is_paid is not None:
update_values["is_paid"] = is_paid
if payment_id is not None:
update_values["payment_id"] = payment_id
if payment_status is not None:
update_values["payment_status"] = payment_status
if payment_method is not None:
update_values["payment_method"] = payment_method
if balance_amount is not None:
update_values["balance_amount"] = balance_amount
if balance_currency is not None:
update_values["balance_currency"] = balance_currency
if payer_account is not None:
update_values["payer_account"] = payer_account
if callback_payload is not None:
update_values["callback_payload"] = callback_payload
update_values["last_status"] = status
await db.execute(
update(Pal24Payment)
.where(Pal24Payment.id == payment.id)
.values(**update_values)
)
await db.commit()
await db.refresh(payment)
logger.info(
"Обновлен Pal24 платеж %s: статус=%s, is_paid=%s",
payment.bill_id,
payment.status,
payment.is_paid,
)
return payment
async def link_pal24_payment_to_transaction(
db: AsyncSession,
payment: Pal24Payment,
transaction_id: int,
) -> Pal24Payment:
await db.execute(
update(Pal24Payment)
.where(Pal24Payment.id == payment.id)
.values(transaction_id=transaction_id)
)
await db.commit()
await db.refresh(payment)
logger.info(
"Pal24 платеж %s привязан к транзакции %s",
payment.bill_id,
transaction_id,
)
return payment

View File

@@ -56,6 +56,7 @@ class PaymentMethod(Enum):
YOOKASSA = "yookassa"
CRYPTOBOT = "cryptobot"
MULENPAY = "mulenpay"
PAL24 = "pal24"
MANUAL = "manual"
class YooKassaPayment(Base):
@@ -199,6 +200,68 @@ class MulenPayPayment(Base):
)
class Pal24Payment(Base):
__tablename__ = "pal24_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
bill_id = Column(String(255), 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(20), nullable=False, default="normal")
status = Column(String(50), nullable=False, default="NEW")
is_active = Column(Boolean, default=True)
is_paid = Column(Boolean, default=False)
paid_at = Column(DateTime, nullable=True)
last_status = Column(String(50), nullable=True)
last_status_checked_at = Column(DateTime, nullable=True)
link_url = Column(Text, nullable=True)
link_page_url = Column(Text, nullable=True)
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
payment_id = Column(String(255), nullable=True, index=True)
payment_status = Column(String(50), nullable=True)
payment_method = Column(String(50), nullable=True)
balance_amount = Column(String(50), nullable=True)
balance_currency = Column(String(10), nullable=True)
payer_account = Column(String(255), nullable=True)
ttl = Column(Integer, 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="pal24_payments")
transaction = relationship("Transaction", backref="pal24_payment")
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status in {"NEW", "PROCESS"}
def __repr__(self) -> str: # pragma: no cover - debug helper
return (
"<Pal24Payment(id={0}, bill_id={1}, amount={2}₽, status={3})>".format(
self.id,
self.bill_id,
self.amount_rubles,
self.status,
)
)
class PromoGroup(Base):
__tablename__ = "promo_groups"

View File

@@ -376,6 +376,150 @@ async def create_mulenpay_payments_table():
logger.error(f"Ошибка создания таблицы mulenpay_payments: {e}")
return False
async def create_pal24_payments_table():
table_exists = await check_table_exists('pal24_payments')
if table_exists:
logger.info("Таблица pal24_payments уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
create_sql = """
CREATE TABLE pal24_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
bill_id VARCHAR(255) 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(20) NOT NULL DEFAULT 'normal',
status VARCHAR(50) NOT NULL DEFAULT 'NEW',
is_active BOOLEAN NOT NULL DEFAULT 1,
is_paid BOOLEAN NOT NULL DEFAULT 0,
paid_at DATETIME NULL,
last_status VARCHAR(50) NULL,
last_status_checked_at DATETIME NULL,
link_url TEXT NULL,
link_page_url TEXT NULL,
metadata_json JSON NULL,
callback_payload JSON NULL,
payment_id VARCHAR(255) NULL,
payment_status VARCHAR(50) NULL,
payment_method VARCHAR(50) NULL,
balance_amount VARCHAR(50) NULL,
balance_currency VARCHAR(10) NULL,
payer_account VARCHAR(255) NULL,
ttl INTEGER 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 INDEX idx_pal24_bill_id ON pal24_payments(bill_id);
CREATE INDEX idx_pal24_order_id ON pal24_payments(order_id);
CREATE INDEX idx_pal24_payment_id ON pal24_payments(payment_id);
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE pal24_payments (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
bill_id VARCHAR(255) 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(20) NOT NULL DEFAULT 'normal',
status VARCHAR(50) NOT NULL DEFAULT 'NEW',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_paid BOOLEAN NOT NULL DEFAULT FALSE,
paid_at TIMESTAMP NULL,
last_status VARCHAR(50) NULL,
last_status_checked_at TIMESTAMP NULL,
link_url TEXT NULL,
link_page_url TEXT NULL,
metadata_json JSON NULL,
callback_payload JSON NULL,
payment_id VARCHAR(255) NULL,
payment_status VARCHAR(50) NULL,
payment_method VARCHAR(50) NULL,
balance_amount VARCHAR(50) NULL,
balance_currency VARCHAR(10) NULL,
payer_account VARCHAR(255) NULL,
ttl INTEGER 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 INDEX idx_pal24_bill_id ON pal24_payments(bill_id);
CREATE INDEX idx_pal24_order_id ON pal24_payments(order_id);
CREATE INDEX idx_pal24_payment_id ON pal24_payments(payment_id);
"""
elif db_type == 'mysql':
create_sql = """
CREATE TABLE pal24_payments (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
bill_id VARCHAR(255) 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(20) NOT NULL DEFAULT 'normal',
status VARCHAR(50) NOT NULL DEFAULT 'NEW',
is_active BOOLEAN NOT NULL DEFAULT 1,
is_paid BOOLEAN NOT NULL DEFAULT 0,
paid_at DATETIME NULL,
last_status VARCHAR(50) NULL,
last_status_checked_at DATETIME NULL,
link_url TEXT NULL,
link_page_url TEXT NULL,
metadata_json JSON NULL,
callback_payload JSON NULL,
payment_id VARCHAR(255) NULL,
payment_status VARCHAR(50) NULL,
payment_method VARCHAR(50) NULL,
balance_amount VARCHAR(50) NULL,
balance_currency VARCHAR(10) NULL,
payer_account VARCHAR(255) NULL,
ttl INT 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 INDEX idx_pal24_bill_id ON pal24_payments(bill_id);
CREATE INDEX idx_pal24_order_id ON pal24_payments(order_id);
CREATE INDEX idx_pal24_payment_id ON pal24_payments(payment_id);
"""
else:
logger.error(f"Неподдерживаемый тип БД для таблицы pal24_payments: {db_type}")
return False
await conn.execute(text(create_sql))
logger.info("Таблица pal24_payments успешно создана")
return True
except Exception as e:
logger.error(f"Ошибка создания таблицы pal24_payments: {e}")
return False
async def create_user_messages_table():
table_exists = await check_table_exists('user_messages')
if table_exists:
@@ -1212,6 +1356,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей Mulen Pay payments")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PAL24 ===")
pal24_created = await create_pal24_payments_table()
if pal24_created:
logger.info("✅ Таблица Pal24 payments готова")
else:
logger.warning("⚠️ Проблемы с таблицей Pal24 payments")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_MESSAGES ===")
user_messages_created = await create_user_messages_table()
if user_messages_created:

216
app/external/pal24_client.py vendored Normal file
View File

@@ -0,0 +1,216 @@
"""Async client for PayPalych (Pal24) API."""
from __future__ import annotations
import asyncio
import hashlib
import logging
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from typing import Any, Dict, Optional
import aiohttp
from app.config import settings
logger = logging.getLogger(__name__)
class Pal24APIError(Exception):
"""Base error for Pal24 API operations."""
@dataclass(slots=True)
class Pal24Response:
"""Wrapper for Pal24 API responses."""
success: bool
data: Dict[str, Any]
status: int
@classmethod
def from_payload(cls, payload: Dict[str, Any], status: int) -> "Pal24Response":
success = bool(payload.get("success", status < 400))
return cls(success=success, data=payload, status=status)
def raise_for_status(self, endpoint: str) -> None:
if not self.success:
detail = self.data.get("message") or self.data.get("error")
raise Pal24APIError(
f"Pal24 API error at {endpoint}: status={self.status}, detail={detail or self.data}"
)
class Pal24Client:
"""Async client implementing PayPalych API methods."""
def __init__(
self,
*,
api_token: Optional[str] = None,
base_url: Optional[str] = None,
timeout: Optional[int] = None,
) -> None:
self.api_token = api_token or settings.PAL24_API_TOKEN
self.base_url = (base_url or settings.PAL24_BASE_URL or "").rstrip("/") + "/"
self.timeout = timeout or settings.PAL24_REQUEST_TIMEOUT
if not self.api_token:
logger.warning("Pal24Client initialized without API token")
@property
def is_configured(self) -> bool:
return bool(self.api_token and self.base_url)
async def _request(
self,
method: str,
endpoint: str,
*,
json_payload: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
) -> Pal24Response:
if not self.is_configured:
raise Pal24APIError("Pal24 client is not configured")
url = f"{self.base_url}{endpoint.lstrip('/')}"
headers = {
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
timeout = aiohttp.ClientTimeout(total=self.timeout)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.request(
method,
url,
headers=headers,
json=json_payload,
params=params,
) as response:
status = response.status
try:
payload = await response.json(content_type=None)
except aiohttp.ContentTypeError:
text_body = await response.text()
logger.error(
"Pal24 API returned non-JSON response for %s: %s",
endpoint,
text_body,
)
raise Pal24APIError(
f"Pal24 API returned non-JSON response: {text_body}"
) from None
result = Pal24Response.from_payload(payload, status)
if status >= 400 or not result.success:
logger.error(
"Pal24 API error %s %s: %s",
status,
endpoint,
payload,
)
result.raise_for_status(endpoint)
return result
except asyncio.TimeoutError as error:
logger.error("Pal24 API request timeout for %s: %s", endpoint, error)
raise Pal24APIError(f"Pal24 API request timeout for {endpoint}") from error
except aiohttp.ClientError as error:
logger.error("Pal24 API client error for %s: %s", endpoint, error)
raise Pal24APIError(str(error)) from error
# API methods -----------------------------------------------------------------
async def create_bill(
self,
*,
amount: Decimal,
shop_id: str,
order_id: Optional[str] = None,
description: Optional[str] = None,
currency_in: str = "RUB",
type_: str = "normal",
**kwargs: Any,
) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"amount": str(amount),
"shop_id": shop_id,
"currency_in": currency_in,
"type": type_,
}
if order_id:
payload["order_id"] = order_id
if description:
payload["description"] = description
payload.update({k: v for k, v in kwargs.items() if v is not None})
response = await self._request("POST", "bill/create", json_payload=payload)
return response.data
async def get_bill_status(self, bill_id: str) -> Dict[str, Any]:
response = await self._request("GET", "bill/status", params={"id": bill_id})
return response.data
async def toggle_bill_activity(self, bill_id: str, active: bool) -> Dict[str, Any]:
payload = {"id": bill_id, "active": 1 if active else 0}
response = await self._request("POST", "bill/toggle_activity", json_payload=payload)
return response.data
async def search_payments(self, **params: Any) -> Dict[str, Any]:
response = await self._request("GET", "payment/search", params=params)
return response.data
async def get_payment_status(self, payment_id: str) -> Dict[str, Any]:
response = await self._request("GET", "payment/status", params={"id": payment_id})
return response.data
async def get_balance(self) -> Dict[str, Any]:
response = await self._request("GET", "merchant/balance")
return response.data
async def search_bills(self, **params: Any) -> Dict[str, Any]:
response = await self._request("GET", "bill/search", params=params)
return response.data
async def get_bill_payments(self, bill_id: str) -> Dict[str, Any]:
response = await self._request("GET", "bill/payments", params={"id": bill_id})
return response.data
# Helpers ---------------------------------------------------------------------
@staticmethod
def calculate_signature(out_sum: str, inv_id: str, api_token: Optional[str] = None) -> str:
token = api_token or settings.PAL24_SIGNATURE_TOKEN or settings.PAL24_API_TOKEN
if not token:
raise Pal24APIError("Pal24 signature token is not configured")
raw = f"{out_sum}:{inv_id}:{token}".encode("utf-8")
return hashlib.md5(raw).hexdigest().upper()
@staticmethod
def verify_signature(
out_sum: str,
inv_id: str,
signature: str,
api_token: Optional[str] = None,
) -> bool:
try:
expected = Pal24Client.calculate_signature(out_sum, inv_id, api_token)
except Pal24APIError:
logger.error("Pal24 signature verification failed: missing token")
return False
return expected == signature.upper()
@staticmethod
def normalize_amount(amount_kopeks: int) -> Decimal:
try:
return (Decimal(amount_kopeks) / Decimal("100")).quantize(Decimal("0.01"))
except (InvalidOperation, TypeError) as error:
raise Pal24APIError(f"Invalid amount: {amount_kopeks}") from error

150
app/external/pal24_webhook.py vendored Normal file
View File

@@ -0,0 +1,150 @@
"""Flask webhook server for PayPalych postbacks."""
from __future__ import annotations
import asyncio
import json
import logging
import threading
from typing import Any, Dict, Optional
from flask import Flask, jsonify, request
from werkzeug.serving import make_server
from app.config import settings
from app.database.database import get_db
from app.services.pal24_service import Pal24Service, Pal24APIError
from app.services.payment_service import PaymentService
logger = logging.getLogger(__name__)
def _normalize_payload() -> Dict[str, str]:
if request.is_json:
payload = request.get_json(silent=True) or {}
if isinstance(payload, dict):
return {k: str(v) for k, v in payload.items()}
logger.warning("Pal24 webhook JSON payload не является объектом: %s", payload)
return {}
if request.form:
return {k: v for k, v in request.form.items()}
try:
raw_body = request.data.decode("utf-8")
if raw_body:
payload = json.loads(raw_body)
if isinstance(payload, dict):
return {k: str(v) for k, v in payload.items()}
except json.JSONDecodeError:
logger.debug("Pal24 webhook body не удалось распарсить как JSON")
return {}
def create_pal24_flask_app(payment_service: PaymentService) -> Flask:
pal24_service = Pal24Service()
app = Flask(__name__)
@app.route(settings.PAL24_WEBHOOK_PATH, methods=["POST"])
def pal24_webhook() -> tuple:
if not pal24_service.is_configured:
logger.error("Pal24 webhook получен, но сервис не настроен")
return jsonify({"status": "error", "reason": "service_not_configured"}), 503
payload = _normalize_payload()
if not payload:
logger.warning("Пустой Pal24 webhook")
return jsonify({"status": "error", "reason": "empty_payload"}), 400
try:
parsed_payload = pal24_service.parse_postback(payload)
except Pal24APIError as error:
logger.error("Ошибка валидации Pal24 webhook: %s", error)
return jsonify({"status": "error", "reason": str(error)}), 400
async def process() -> bool:
async for db in get_db():
try:
return await payment_service.process_pal24_postback(db, parsed_payload)
finally:
await db.close()
try:
processed = asyncio.run(process())
except Exception as error: # pragma: no cover - defensive
logger.exception("Критическая ошибка обработки Pal24 webhook: %s", error)
return jsonify({"status": "error", "reason": "internal_error"}), 500
if processed:
return jsonify({"status": "ok"}), 200
return jsonify({"status": "error", "reason": "not_processed"}), 400
@app.route(settings.PAL24_WEBHOOK_PATH, methods=["GET"])
def pal24_health() -> tuple:
return jsonify({
"status": "ok",
"service": "pal24_webhook",
"enabled": settings.is_pal24_enabled(),
}), 200
@app.route("/pal24/health", methods=["GET"])
def pal24_additional_health() -> tuple:
return jsonify({
"status": "ok",
"service": "pal24_webhook",
"path": settings.PAL24_WEBHOOK_PATH,
}), 200
return app
class Pal24WebhookServer:
"""Threaded Flask server for Pal24 postbacks."""
def __init__(self, payment_service: PaymentService) -> None:
self.app = create_pal24_flask_app(payment_service)
self._server: Optional[Any] = None
self._thread: Optional[threading.Thread] = None
def start(self) -> None:
if self._server:
logger.warning("Pal24 webhook server уже запущен")
return
self._server = make_server(
host="0.0.0.0",
port=settings.PAL24_WEBHOOK_PORT,
app=self.app,
threaded=True,
)
def _serve() -> None:
logger.info(
"Pal24 webhook сервер запущен на %s:%s%s",
"0.0.0.0",
settings.PAL24_WEBHOOK_PORT,
settings.PAL24_WEBHOOK_PATH,
)
self._server.serve_forever()
self._thread = threading.Thread(target=_serve, daemon=True)
self._thread.start()
def stop(self) -> None:
if self._server:
logger.info("Останавливаем Pal24 webhook сервер")
self._server.shutdown()
self._server = None
if self._thread and self._thread.is_alive():
self._thread.join(timeout=5)
self._thread = None
async def start_pal24_webhook_server(payment_service: PaymentService) -> Pal24WebhookServer:
server = Pal24WebhookServer(payment_service)
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, server.start)
return server

View File

@@ -371,6 +371,45 @@ async def start_mulenpay_payment(
await callback.answer()
@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
message_text = texts.t(
"PAL24_TOPUP_PROMPT",
(
"💳 <b>Оплата через PayPalych</b>\n\n"
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
"Оплата проходит через защищенную платформу PayPalych."
),
)
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED:
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 start_tribute_payment(
callback: types.CallbackQuery,
@@ -570,6 +609,10 @@ async def process_topup_amount(
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "pal24":
from app.database.database import AsyncSessionLocal
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
async with AsyncSessionLocal() as db:
@@ -921,6 +964,119 @@ async def process_mulenpay_payment_amount(
await state.clear()
@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 or not payment_result.get("link_url"):
await message.answer(
texts.t(
"PAL24_PAYMENT_ERROR",
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
return
link_url = payment_result.get("link_url")
bill_id = payment_result.get("bill_id")
local_payment_id = payment_result.get("local_payment_id")
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t("PAL24_PAY_BUTTON", "💳 Оплатить через PayPalych"),
url=link_url,
)
],
[
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")],
]
)
message_template = texts.t(
"PAL24_PAYMENT_INSTRUCTIONS",
(
"💳 <b>Оплата через PayPalych</b>\n\n"
"💰 Сумма: {amount}\n"
"🆔 ID счета: {bill_id}\n\n"
"📱 <b>Инструкция:</b>\n"
"1. Нажмите кнопку ‘Оплатить через PayPalych\n"
"2. Следуйте подсказкам платежной системы\n"
"3. Подтвердите перевод\n"
"4. Средства зачислятся автоматически\n\n"
"❓ Если возникнут проблемы, обратитесь в {support}"
),
)
message_text = message_template.format(
amount=settings.format_price(amount_kopeks),
bill_id=bill_id,
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_yookassa_payment_status(
callback: types.CallbackQuery,
@@ -1033,6 +1189,59 @@ async def check_mulenpay_payment_status(
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
@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, ("", "Неизвестно"))
message_lines = [
"💳 Статус платежа PayPalych:\n\n",
f"🆔 ID счета: {payment.bill_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 {"NEW", "PROCESS"}:
message_lines.append("\n⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.")
if payment.link_url:
message_lines.append(f"\n🔗 Ссылка на оплату: {payment.link_url}")
elif payment.status in {"FAIL", "UNDERPAID", "OVERPAID"}:
message_lines.append(
f"\n❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}"
)
await callback.answer("".join(message_lines), show_alert=True)
except Exception as e:
logger.error(f"Ошибка проверки статуса PayPalych: {e}")
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
@error_handler
async def start_cryptobot_payment(
callback: types.CallbackQuery,
@@ -1362,6 +1571,11 @@ def register_handlers(dp: Dispatcher):
F.data == "topup_mulenpay"
)
dp.callback_query.register(
start_pal24_payment,
F.data == "topup_pal24"
)
dp.callback_query.register(
check_yookassa_payment_status,
F.data.startswith("check_yookassa_")
@@ -1402,6 +1616,11 @@ def register_handlers(dp: Dispatcher):
F.data.startswith("check_mulenpay_")
)
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"

View File

@@ -657,6 +657,14 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
])
if settings.is_pal24_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_CARD_PAL24", "💳 Банковская карта (PayPalych)"),
callback_data="topup_pal24"
)
])
if settings.is_cryptobot_enabled():
keyboard.append([
InlineKeyboardButton(

View File

@@ -353,6 +353,7 @@ class AdminNotificationService:
'yookassa': '💳 YooKassa (карта)',
'tribute': '💎 Tribute (карта)',
'mulenpay': '💳 Mulen Pay (карта)',
'pal24': '💳 PayPalych (карта)',
'manual': '🛠️ Вручную (админ)',
'balance': '💰 С баланса'
}

View File

@@ -0,0 +1,116 @@
"""High level integration with PayPalych API."""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Any, Dict, Optional
from app.config import settings
from app.external.pal24_client import Pal24Client, Pal24APIError
logger = logging.getLogger(__name__)
class Pal24Service:
"""Wrapper around :class:`Pal24Client` providing domain helpers."""
BILL_SUCCESS_STATES = {"SUCCESS", "OVERPAID"}
BILL_FAILED_STATES = {"FAIL", "CANCELLED"}
BILL_PENDING_STATES = {"NEW", "PROCESS", "UNDERPAID"}
def __init__(self, client: Optional[Pal24Client] = None) -> None:
self.client = client or Pal24Client()
@property
def is_configured(self) -> bool:
return self.client.is_configured and settings.is_pal24_enabled()
async def create_bill(
self,
*,
amount_kopeks: int,
user_id: int,
order_id: str,
description: str,
ttl_seconds: Optional[int] = None,
custom_payload: Optional[Dict[str, Any]] = None,
payer_email: Optional[str] = None,
) -> Dict[str, Any]:
if not self.is_configured:
raise Pal24APIError("Pal24 service is not configured")
amount_decimal = Pal24Client.normalize_amount(amount_kopeks)
extra_payload: Dict[str, Any] = {
"custom": custom_payload or {},
"ttl": ttl_seconds,
}
if payer_email:
extra_payload["payer_email"] = payer_email
filtered_payload = {k: v for k, v in extra_payload.items() if v not in (None, {})}
logger.info(
"Создаем Pal24 счет: user_id=%s, order_id=%s, amount=%s, ttl=%s",
user_id,
order_id,
amount_decimal,
ttl_seconds,
)
response = await self.client.create_bill(
amount=amount_decimal,
shop_id=settings.PAL24_SHOP_ID,
order_id=order_id,
description=description,
type_="normal",
**filtered_payload,
)
logger.info("Pal24 счет создан: %s", response)
return response
async def get_bill_status(self, bill_id: str) -> Dict[str, Any]:
logger.debug("Запрашиваем статус Pal24 счета %s", bill_id)
return await self.client.get_bill_status(bill_id)
async def get_payment_status(self, payment_id: str) -> Dict[str, Any]:
logger.debug("Запрашиваем статус Pal24 платежа %s", payment_id)
return await self.client.get_payment_status(payment_id)
@staticmethod
def parse_postback(payload: Dict[str, Any]) -> Dict[str, Any]:
required_fields = ["InvId", "OutSum", "Status", "SignatureValue"]
missing = [field for field in required_fields if field not in payload]
if missing:
raise Pal24APIError(f"Pal24 postback missing fields: {', '.join(missing)}")
inv_id = str(payload["InvId"])
out_sum = str(payload["OutSum"])
signature = str(payload["SignatureValue"])
if not Pal24Client.verify_signature(out_sum, inv_id, signature):
raise Pal24APIError("Pal24 postback signature mismatch")
logger.info(
"Получен Pal24 postback: InvId=%s, Status=%s, TrsId=%s",
inv_id,
payload.get("Status"),
payload.get("TrsId"),
)
return payload
@staticmethod
def convert_to_kopeks(amount: str) -> int:
decimal_amount = Decimal(str(amount))
return int((decimal_amount * Decimal("100")).quantize(Decimal("1")))
@staticmethod
def get_expiration(ttl_seconds: Optional[int]) -> Optional[datetime]:
if not ttl_seconds:
return None
return datetime.utcnow() + timedelta(seconds=ttl_seconds)

View File

@@ -29,6 +29,7 @@ from app.services.subscription_checkout_service import (
should_offer_checkout_resume,
)
from app.services.mulenpay_service import MulenPayService
from app.services.pal24_service import Pal24Service, Pal24APIError
from app.database.crud.mulenpay import (
create_mulenpay_payment,
get_mulenpay_payment_by_local_id,
@@ -37,6 +38,14 @@ from app.database.crud.mulenpay import (
update_mulenpay_payment_status,
link_mulenpay_payment_to_transaction,
)
from app.database.crud.pal24 import (
create_pal24_payment,
get_pal24_payment_by_bill_id,
get_pal24_payment_by_id,
get_pal24_payment_by_order_id,
link_pal24_payment_to_transaction,
update_pal24_payment_status,
)
logger = logging.getLogger(__name__)
@@ -49,6 +58,7 @@ class PaymentService:
self.stars_service = TelegramStarsService(bot) if bot else None
self.cryptobot_service = CryptoBotService() if settings.is_cryptobot_enabled() else None
self.mulenpay_service = MulenPayService() if settings.is_mulenpay_enabled() else None
self.pal24_service = Pal24Service() if settings.is_pal24_enabled() else None
async def build_topup_success_keyboard(self, user) -> InlineKeyboardMarkup:
texts = get_texts(user.language if user else "ru")
@@ -782,6 +792,109 @@ class PaymentService:
logger.error(f"Ошибка создания MulenPay платежа: {e}")
return None
async def create_pal24_payment(
self,
db: AsyncSession,
*,
user_id: int,
amount_kopeks: int,
description: str,
language: str,
ttl_seconds: Optional[int] = None,
payer_email: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
if not self.pal24_service or not self.pal24_service.is_configured:
logger.error("Pal24 сервис не инициализирован")
return None
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
logger.warning(
"Сумма Pal24 меньше минимальной: %s < %s",
amount_kopeks,
settings.PAL24_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
logger.warning(
"Сумма Pal24 больше максимальной: %s > %s",
amount_kopeks,
settings.PAL24_MAX_AMOUNT_KOPEKS,
)
return None
order_id = f"pal24_{user_id}_{uuid.uuid4().hex}"
custom_payload = {
"user_id": user_id,
"amount_kopeks": amount_kopeks,
"language": language,
}
try:
response = await self.pal24_service.create_bill(
amount_kopeks=amount_kopeks,
user_id=user_id,
order_id=order_id,
description=description,
ttl_seconds=ttl_seconds,
custom_payload=custom_payload,
payer_email=payer_email,
)
except Pal24APIError as error:
logger.error("Ошибка Pal24 API при создании счета: %s", error)
return None
if not response.get("success", True):
logger.error("Pal24 вернул ошибку при создании счета: %s", response)
return None
bill_id = response.get("bill_id")
if not bill_id:
logger.error("Pal24 не вернул bill_id: %s", response)
return None
link_url = response.get("link_url")
link_page_url = response.get("link_page_url")
payment = await create_pal24_payment(
db,
user_id=user_id,
bill_id=bill_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
description=description,
status=response.get("status", "NEW"),
type_=response.get("type", "normal"),
currency=response.get("currency", "RUB"),
link_url=link_url,
link_page_url=link_page_url,
ttl=ttl_seconds,
metadata={
"raw_response": response,
"language": language,
},
)
payment_info = {
"bill_id": bill_id,
"order_id": order_id,
"link_url": link_url or link_page_url,
"link_page_url": link_page_url,
"local_payment_id": payment.id,
"amount_kopeks": amount_kopeks,
}
logger.info(
"Создан Pal24 счет %s для пользователя %s на сумму %s",
bill_id,
user_id,
settings.format_price(amount_kopeks),
)
return payment_info
async def process_mulenpay_callback(self, db: AsyncSession, callback_data: dict) -> bool:
try:
uuid_value = callback_data.get("uuid")
@@ -964,6 +1077,155 @@ class PaymentService:
logger.error(f"Ошибка обработки MulenPay callback: {error}", exc_info=True)
return False
async def process_pal24_postback(self, db: AsyncSession, payload: Dict[str, Any]) -> bool:
if not self.pal24_service or not self.pal24_service.is_configured:
logger.error("Pal24 сервис не инициализирован")
return False
try:
order_id_raw = payload.get("InvId")
order_id = str(order_id_raw) if order_id_raw is not None else None
if not order_id:
logger.error("Pal24 postback без InvId")
return False
payment = await get_pal24_payment_by_order_id(db, order_id)
if not payment:
bill_id = payload.get("BillId")
if bill_id:
payment = await get_pal24_payment_by_bill_id(db, str(bill_id))
if not payment:
logger.error("Pal24 платеж не найден для order_id=%s", order_id)
return False
if payment.transaction_id and payment.is_paid:
logger.info("Pal24 платеж %s уже обработан", payment.bill_id)
return True
status = str(payload.get("Status", "UNKNOWN")).upper()
payment_id = payload.get("TrsId")
balance_amount = payload.get("BalanceAmount")
balance_currency = payload.get("BalanceCurrency")
payer_account = payload.get("AccountNumber")
payment_method = payload.get("AccountType")
try:
amount_kopeks = Pal24Service.convert_to_kopeks(str(payload.get("OutSum")))
except Exception:
logger.warning("Не удалось распарсить сумму Pal24, используем сохраненное значение")
amount_kopeks = payment.amount_kopeks
if amount_kopeks != payment.amount_kopeks:
logger.warning(
"Несовпадение суммы Pal24: callback=%s, ожидаемо=%s",
amount_kopeks,
payment.amount_kopeks,
)
is_success = status in Pal24Service.BILL_SUCCESS_STATES
is_failed = status in Pal24Service.BILL_FAILED_STATES
await update_pal24_payment_status(
db,
payment,
status=status,
is_active=not is_failed,
is_paid=is_success,
payment_id=str(payment_id) if payment_id else None,
payment_status=status,
payment_method=str(payment_method) if payment_method else None,
balance_amount=str(balance_amount) if balance_amount is not None else None,
balance_currency=str(balance_currency) if balance_currency is not None else None,
payer_account=str(payer_account) if payer_account is not None else None,
callback_payload=payload,
)
if not is_success:
logger.info(
"Получен Pal24 статус %s для платежа %s (успех=%s)",
status,
payment.bill_id,
is_success,
)
return True
user = await get_user_by_id(db, payment.user_id)
if not user:
logger.error("Пользователь %s не найден для Pal24 платежа", payment.user_id)
return False
transaction = await create_transaction(
db=db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=f"Пополнение через Pal24 ({payment_id})",
payment_method=PaymentMethod.PAL24,
external_id=str(payment_id) if payment_id else payment.bill_id,
is_completed=True,
)
await link_pal24_payment_to_transaction(db, payment, transaction.id)
old_balance = user.balance_kopeks
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(db, user.id, payment.amount_kopeks, self.bot)
except Exception as referral_error:
logger.error("Ошибка обработки реферального пополнения Pal24: %s", referral_error)
if self.bot:
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
db,
user,
transaction,
old_balance,
)
except Exception as notify_error:
logger.error("Ошибка отправки админ уведомления Pal24: %s", notify_error)
if self.bot:
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"
"🦊 Способ: PayPalych\n"
f"🆔 Транзакция: {transaction.id}\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as user_notify_error:
logger.error("Ошибка отправки уведомления пользователю Pal24: %s", user_notify_error)
logger.info(
"✅ Обработан Pal24 платеж %s для пользователя %s",
payment.bill_id,
payment.user_id,
)
return True
except Exception as error:
logger.error("Ошибка обработки Pal24 postback: %s", error, exc_info=True)
return False
@staticmethod
def _map_mulenpay_status(status_code: Optional[int]) -> str:
mapping = {
@@ -1037,6 +1299,50 @@ class PaymentService:
logger.error(f"Ошибка получения статуса MulenPay: {error}", exc_info=True)
return None
async def get_pal24_payment_status(
self,
db: AsyncSession,
local_payment_id: int,
) -> Optional[Dict[str, Any]]:
try:
payment = await get_pal24_payment_by_id(db, local_payment_id)
if not payment:
return None
remote_status = None
remote_data = None
if self.pal24_service and payment.bill_id:
try:
response = await self.pal24_service.get_bill_status(payment.bill_id)
remote_data = response
remote_status = (
response.get("status")
or response.get("bill", {}).get("status")
)
if remote_status and remote_status != payment.status:
await update_pal24_payment_status(
db,
payment,
status=str(remote_status).upper(),
)
payment = await get_pal24_payment_by_id(db, local_payment_id)
except Pal24APIError as error:
logger.error("Ошибка Pal24 API при получении статуса: %s", error)
return {
"payment": payment,
"status": payment.status,
"is_paid": payment.is_paid,
"remote_status": remote_status,
"remote_data": remote_data,
}
except Exception as error:
logger.error("Ошибка получения статуса Pal24: %s", error, exc_info=True)
return None
async def process_cryptobot_webhook(self, db: AsyncSession, webhook_data: dict) -> bool:
try:
from app.database.crud.cryptobot import (

View File

@@ -45,6 +45,15 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
"callback": "topup_mulenpay"
})
if settings.is_pal24_enabled():
methods.append({
"id": "pal24",
"name": "Банковская карта",
"icon": "💳",
"description": "через PayPalych",
"callback": "topup_pal24"
})
if settings.is_cryptobot_enabled():
methods.append({
"id": "cryptobot",
@@ -123,6 +132,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 == "pal24":
return settings.is_pal24_enabled()
elif method_id == "cryptobot":
return settings.is_cryptobot_enabled()
elif method_id == "support":
@@ -139,6 +150,7 @@ def get_payment_method_status() -> Dict[str, bool]:
"yookassa": settings.is_yookassa_enabled(),
"tribute": settings.TRIBUTE_ENABLED,
"mulenpay": settings.is_mulenpay_enabled(),
"pal24": settings.is_pal24_enabled(),
"cryptobot": settings.is_cryptobot_enabled(),
"support": True
}
@@ -156,6 +168,8 @@ def get_enabled_payment_methods_count() -> int:
count += 1
if settings.is_mulenpay_enabled():
count += 1
if settings.is_pal24_enabled():
count += 1
if settings.is_cryptobot_enabled():
count += 1
return count

View File

@@ -57,6 +57,7 @@
"PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Payment methods are temporarily unavailable",
"PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)",
"PAYMENT_CARD_MULENPAY": "💳 Bank card (Mulen Pay)",
"PAYMENT_CARD_PAL24": "💳 Bank card (PayPalych)",
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
"PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)",
@@ -68,6 +69,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}",
"PAL24_TOPUP_PROMPT": "💳 <b>PayPalych payment</b>\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed by the secure PayPalych platform.",
"PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.",
"PAL24_PAY_BUTTON": "💳 Pay with PayPalych",
"PAL24_PAYMENT_INSTRUCTIONS": "💳 <b>PayPalych payment</b>\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 <b>How to pay:</b>\n1. Press Pay with PayPalych\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
"PENDING_CANCEL_BUTTON": "⌛ Cancel",
"POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀",
"REFERRAL_ANALYTICS_BUTTON": "📊 Analytics",
@@ -451,6 +456,8 @@
"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_PAL24_NAME": "💳 <b>Bank card (PayPalych)</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via PayPalych",
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</b>",
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot",
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",

View File

@@ -215,6 +215,7 @@
"PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Способы оплаты временно недоступны",
"PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)",
"PAYMENT_CARD_MULENPAY": "💳 Банковская карта (Mulen Pay)",
"PAYMENT_CARD_PAL24": "💳 Банковская карта (PayPalych)",
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
"PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)",
@@ -226,6 +227,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}",
"PAL24_TOPUP_PROMPT": "💳 <b>Оплата через PayPalych</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через защищенную платформу PayPalych.",
"PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
"PAL24_PAY_BUTTON": "💳 Оплатить через PayPalych",
"PAL24_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через PayPalych</b>\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через PayPalych\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
"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)}",
@@ -451,6 +456,8 @@
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта (Mulen Pay)</b>",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay",
"PAYMENT_METHOD_PAL24_NAME": "💳 <b>Банковская карта (PayPalych)</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через PayPalych",
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",

18
main.py
View File

@@ -16,6 +16,7 @@ from app.services.payment_service import PaymentService
from app.services.version_service import version_service
from app.external.webhook_server import WebhookServer
from app.external.yookassa_webhook import start_yookassa_webhook_server
from app.external.pal24_webhook import start_pal24_webhook_server, Pal24WebhookServer
from app.database.universal_migration import run_universal_migration
from app.services.backup_service import backup_service
from app.localization.loader import ensure_locale_templates
@@ -55,6 +56,7 @@ async def main():
webhook_server = None
yookassa_server_task = None
pal24_server: Pal24WebhookServer | None = None
monitoring_task = None
maintenance_task = None
version_check_task = None
@@ -140,7 +142,13 @@ async def main():
)
else:
logger.info(" YooKassa отключена, webhook сервер не запускается")
if settings.is_pal24_enabled():
logger.info("💳 Запуск PayPalych webhook сервера...")
pal24_server = await start_pal24_webhook_server(payment_service)
else:
logger.info(" PayPalych отключен, webhook сервер не запускается")
logger.info("📊 Запуск службы мониторинга...")
monitoring_task = asyncio.create_task(monitoring_service.start_monitoring())
@@ -172,6 +180,10 @@ async def main():
logger.info(f" CryptoBot: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}")
if settings.is_yookassa_enabled():
logger.info(f" YooKassa: {settings.WEBHOOK_URL}:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}")
if settings.is_pal24_enabled():
logger.info(
f" PayPalych: {settings.WEBHOOK_URL}:{settings.PAL24_WEBHOOK_PORT}{settings.PAL24_WEBHOOK_PATH}"
)
logger.info("📄 Активные фоновые сервисы:")
logger.info(f" Мониторинг: {'Включен' if monitoring_task else 'Отключен'}")
logger.info(f" Техработы: {'Включен' if maintenance_task else 'Отключен'}")
@@ -243,6 +255,10 @@ async def main():
await monitoring_task
except asyncio.CancelledError:
pass
if pal24_server:
logger.info(" Остановка PayPalych webhook сервера...")
await asyncio.get_running_loop().run_in_executor(None, pal24_server.stop)
if maintenance_task and not maintenance_task.done():
logger.info(" Остановка службы техработ...")

View File

@@ -32,3 +32,6 @@ qrcode[pil]==7.4.2
packaging==23.2
aiofiles==23.2.1
# Вебхуки PayPalych (Flask)
Flask==3.1.0