From 4225404673de8db4ea307ed894b4e4735d54e619 Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 02:56:51 +0300 Subject: [PATCH] Add PayPalych payment integration --- README.md | 65 ++++- app/config.py | 19 ++ app/database/crud/pal24.py | 160 +++++++++++ app/database/models.py | 63 +++++ app/database/universal_migration.py | 151 ++++++++++ app/external/pal24_client.py | 216 +++++++++++++++ app/external/pal24_webhook.py | 150 ++++++++++ app/handlers/balance.py | 219 +++++++++++++++ app/keyboards/inline.py | 8 + app/services/admin_notification_service.py | 1 + app/services/pal24_service.py | 116 ++++++++ app/services/payment_service.py | 306 +++++++++++++++++++++ app/utils/payment_utils.py | 14 + locales/en.json | 7 + locales/ru.json | 7 + main.py | 18 +- requirements.txt | 3 + 17 files changed, 1516 insertions(+), 7 deletions(-) create mode 100644 app/database/crud/pal24.py create mode 100644 app/external/pal24_client.py create mode 100644 app/external/pal24_webhook.py create mode 100644 app/services/pal24_service.py diff --git a/README.md b/README.md index 1f15091c..b73295b6 100644 --- a/README.md +++ b/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"}` подтверждает корректную обработку вебхука. + --- ## 💡 Использование diff --git a/app/config.py b/app/config.py index 1824490e..6c038b5a 100644 --- a/app/config.py +++ b/app/config.py @@ -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" diff --git a/app/database/crud/pal24.py b/app/database/crud/pal24.py new file mode 100644 index 00000000..42a03015 --- /dev/null +++ b/app/database/crud/pal24.py @@ -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 + diff --git a/app/database/models.py b/app/database/models.py index 3db2df8a..1c67713f 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -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 ( + "".format( + self.id, + self.bill_id, + self.amount_rubles, + self.status, + ) + ) + + class PromoGroup(Base): __tablename__ = "promo_groups" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index c122b86c..6c3c7853 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -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: diff --git a/app/external/pal24_client.py b/app/external/pal24_client.py new file mode 100644 index 00000000..4c77a250 --- /dev/null +++ b/app/external/pal24_client.py @@ -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 + diff --git a/app/external/pal24_webhook.py b/app/external/pal24_webhook.py new file mode 100644 index 00000000..6f815bd6 --- /dev/null +++ b/app/external/pal24_webhook.py @@ -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 + diff --git a/app/handlers/balance.py b/app/handlers/balance.py index dc875340..6ed27777 100644 --- a/app/handlers/balance.py +++ b/app/handlers/balance.py @@ -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", + ( + "💳 Оплата через PayPalych\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", + ( + "💳 Оплата через PayPalych\n\n" + "💰 Сумма: {amount}\n" + "🆔 ID счета: {bill_id}\n\n" + "📱 Инструкция:\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" diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 7802b31e..a7362e81 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -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( diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index 59831d1e..1882e35b 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -353,6 +353,7 @@ class AdminNotificationService: 'yookassa': '💳 YooKassa (карта)', 'tribute': '💎 Tribute (карта)', 'mulenpay': '💳 Mulen Pay (карта)', + 'pal24': '💳 PayPalych (карта)', 'manual': '🛠️ Вручную (админ)', 'balance': '💰 С баланса' } diff --git a/app/services/pal24_service.py b/app/services/pal24_service.py new file mode 100644 index 00000000..a4f72047 --- /dev/null +++ b/app/services/pal24_service.py @@ -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) + diff --git a/app/services/payment_service.py b/app/services/payment_service.py index a08992a8..a761ef5c 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -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, + ( + "✅ Пополнение успешно!\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 ( diff --git a/app/utils/payment_utils.py b/app/utils/payment_utils.py index 0bc6191f..83b704b1 100644 --- a/app/utils/payment_utils.py +++ b/app/utils/payment_utils.py @@ -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 \ No newline at end of file diff --git a/locales/en.json b/locales/en.json index b3d62a39..a38f1744 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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": "💳 Mulen Pay payment\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 How to pay:\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": "💳 PayPalych payment\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": "💳 PayPalych payment\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 How to pay:\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": "💳 Bank card (Mulen Pay)", "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via Mulen Pay", + "PAYMENT_METHOD_PAL24_NAME": "💳 Bank card (PayPalych)", + "PAYMENT_METHOD_PAL24_DESCRIPTION": "via PayPalych", "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Cryptocurrency", "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", diff --git a/locales/ru.json b/locales/ru.json index 6c84da2c..eeb76649 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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": "💳 Оплата через Mulen Pay\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через Mulen Pay’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}", + "PAL24_TOPUP_PROMPT": "💳 Оплата через PayPalych\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через защищенную платформу PayPalych.", + "PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", + "PAL24_PAY_BUTTON": "💳 Оплатить через PayPalych", + "PAL24_PAYMENT_INSTRUCTIONS": "💳 Оплата через PayPalych\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 Инструкция:\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": "💳 Банковская карта (Mulen Pay)", "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay", + "PAYMENT_METHOD_PAL24_NAME": "💳 Банковская карта (PayPalych)", + "PAYMENT_METHOD_PAL24_DESCRIPTION": "через PayPalych", "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Криптовалюта", "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", diff --git a/main.py b/main.py index 0ba40b99..4be13cde 100644 --- a/main.py +++ b/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("ℹ️ Остановка службы техработ...") diff --git a/requirements.txt b/requirements.txt index 95294b91..53f98559 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,3 +32,6 @@ qrcode[pil]==7.4.2 packaging==23.2 aiofiles==23.2.1 + +# Вебхуки PayPalych (Flask) +Flask==3.1.0