mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-19 19:32:10 +00:00
Add PayPalych payment integration
This commit is contained in:
65
README.md
65
README.md
@@ -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"}` подтверждает корректную обработку вебхука.
|
||||
|
||||
---
|
||||
|
||||
## 💡 Использование
|
||||
|
||||
@@ -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
160
app/database/crud/pal24.py
Normal 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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
216
app/external/pal24_client.py
vendored
Normal 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
150
app/external/pal24_webhook.py
vendored
Normal 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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -353,6 +353,7 @@ class AdminNotificationService:
|
||||
'yookassa': '💳 YooKassa (карта)',
|
||||
'tribute': '💎 Tribute (карта)',
|
||||
'mulenpay': '💳 Mulen Pay (карта)',
|
||||
'pal24': '💳 PayPalych (карта)',
|
||||
'manual': '🛠️ Вручную (админ)',
|
||||
'balance': '💰 С баланса'
|
||||
}
|
||||
|
||||
116
app/services/pal24_service.py
Normal file
116
app/services/pal24_service.py
Normal 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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
@@ -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>",
|
||||
|
||||
@@ -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
18
main.py
@@ -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("ℹ️ Остановка службы техработ...")
|
||||
|
||||
@@ -32,3 +32,6 @@ qrcode[pil]==7.4.2
|
||||
packaging==23.2
|
||||
|
||||
aiofiles==23.2.1
|
||||
|
||||
# Вебхуки PayPalych (Flask)
|
||||
Flask==3.1.0
|
||||
|
||||
Reference in New Issue
Block a user