mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-03-01 15:52:30 +00:00
Merge pull request #1313 from Fr1ngg/nv8l52-bedolaga/add-new-balance-top-up-method-to-bot
Complete Wata Pay integration
This commit is contained in:
@@ -213,6 +213,20 @@ class Settings(BaseSettings):
|
||||
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000
|
||||
|
||||
WATA_ENABLED: bool = False
|
||||
WATA_ACCESS_TOKEN: Optional[str] = None
|
||||
WATA_BASE_URL: str = "https://api.wata.pro/api/h2h"
|
||||
WATA_WEBHOOK_PATH: str = "/wata-webhook"
|
||||
WATA_DESCRIPTION: str = "Пополнение баланса"
|
||||
WATA_DEFAULT_CURRENCY: str = "RUB"
|
||||
WATA_SUCCESS_REDIRECT_URL: Optional[str] = None
|
||||
WATA_FAIL_REDIRECT_URL: Optional[str] = None
|
||||
WATA_LINK_TYPE: str = "OneTime"
|
||||
WATA_ALLOW_ARBITRARY_AMOUNT: bool = False
|
||||
WATA_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
WATA_MAX_AMOUNT_KOPEKS: int = 100000000
|
||||
WATA_TIMEOUT_SECONDS: int = 60
|
||||
|
||||
PAL24_ENABLED: bool = False
|
||||
PAL24_API_TOKEN: Optional[str] = None
|
||||
PAL24_SHOP_ID: Optional[str] = None
|
||||
@@ -720,6 +734,9 @@ class Settings(BaseSettings):
|
||||
and self.MULENPAY_SHOP_ID is not None
|
||||
)
|
||||
|
||||
def is_wata_enabled(self) -> bool:
|
||||
return bool(self.WATA_ENABLED and self.WATA_ACCESS_TOKEN)
|
||||
|
||||
def is_pal24_enabled(self) -> bool:
|
||||
return (
|
||||
self.PAL24_ENABLED
|
||||
|
||||
139
app/database/crud/wata.py
Normal file
139
app/database/crud/wata.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""CRUD-операции для платежей Wata Pay."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import WataPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_wata_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
order_id: str,
|
||||
description: Optional[str],
|
||||
status: str,
|
||||
currency: str,
|
||||
payment_url: Optional[str],
|
||||
wata_link_id: Optional[str],
|
||||
success_redirect_url: Optional[str],
|
||||
fail_redirect_url: Optional[str],
|
||||
expiration_at: Optional[datetime],
|
||||
metadata: Optional[dict] = None,
|
||||
) -> WataPayment:
|
||||
payment = WataPayment(
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
order_id=order_id,
|
||||
description=description,
|
||||
status=status,
|
||||
currency=currency,
|
||||
payment_url=payment_url,
|
||||
wata_link_id=wata_link_id,
|
||||
success_redirect_url=success_redirect_url,
|
||||
fail_redirect_url=fail_redirect_url,
|
||||
expiration_at=expiration_at,
|
||||
metadata_json=metadata or {},
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан Wata платеж #%s (order=%s) на сумму %s копеек для пользователя %s",
|
||||
payment.id,
|
||||
order_id,
|
||||
amount_kopeks,
|
||||
user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_wata_payment_by_local_id(
|
||||
db: AsyncSession,
|
||||
payment_id: int,
|
||||
) -> Optional[WataPayment]:
|
||||
result = await db.execute(
|
||||
select(WataPayment).where(WataPayment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_wata_payment_by_order_id(
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> Optional[WataPayment]:
|
||||
result = await db.execute(
|
||||
select(WataPayment).where(WataPayment.order_id == order_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_wata_payment_by_link_id(
|
||||
db: AsyncSession,
|
||||
link_id: str,
|
||||
) -> Optional[WataPayment]:
|
||||
result = await db.execute(
|
||||
select(WataPayment).where(WataPayment.wata_link_id == link_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_wata_payment_status(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: WataPayment,
|
||||
status: Optional[str] = None,
|
||||
transaction_status: Optional[str] = None,
|
||||
is_paid: Optional[bool] = None,
|
||||
paid_at: Optional[datetime] = None,
|
||||
callback_payload: Optional[dict] = None,
|
||||
external_transaction_id: Optional[str] = None,
|
||||
payment_url: Optional[str] = None,
|
||||
last_status_payload: Optional[dict] = None,
|
||||
) -> WataPayment:
|
||||
if status is not None:
|
||||
payment.status = status
|
||||
if transaction_status is not None:
|
||||
payment.transaction_status = transaction_status
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if paid_at is not None:
|
||||
payment.paid_at = paid_at
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if external_transaction_id is not None:
|
||||
payment.external_transaction_id = external_transaction_id
|
||||
if payment_url is not None:
|
||||
payment.payment_url = payment_url
|
||||
if last_status_payload is not None:
|
||||
payment.last_status_payload = last_status_payload
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
|
||||
|
||||
async def link_wata_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: WataPayment,
|
||||
transaction_id: int,
|
||||
) -> WataPayment:
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -77,6 +77,7 @@ class PaymentMethod(Enum):
|
||||
CRYPTOBOT = "cryptobot"
|
||||
MULENPAY = "mulenpay"
|
||||
PAL24 = "pal24"
|
||||
WATA = "wata"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
@@ -293,6 +294,62 @@ class Pal24Payment(Base):
|
||||
)
|
||||
|
||||
|
||||
class WataPayment(Base):
|
||||
__tablename__ = "wata_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
wata_link_id = Column(String(64), unique=True, nullable=True, index=True)
|
||||
order_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default="RUB")
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
status = Column(String(32), nullable=False, default="Opened")
|
||||
transaction_status = Column(String(32), nullable=True)
|
||||
external_transaction_id = Column(String(255), nullable=True, index=True)
|
||||
|
||||
payment_url = Column(Text, nullable=True)
|
||||
success_redirect_url = Column(Text, nullable=True)
|
||||
fail_redirect_url = Column(Text, nullable=True)
|
||||
expiration_at = Column(DateTime, nullable=True)
|
||||
|
||||
is_paid = Column(Boolean, default=False)
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
last_status_payload = Column(JSON, nullable=True)
|
||||
|
||||
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", backref="wata_payments")
|
||||
transaction = relationship("Transaction", backref="wata_payment")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return not self.is_paid and (self.status or "").lower() == "opened"
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return (
|
||||
"<WataPayment(id={0}, order_id={1}, amount={2}₽, status={3}, transaction_status={4})>".format(
|
||||
self.id,
|
||||
self.order_id,
|
||||
self.amount_rubles,
|
||||
self.status,
|
||||
self.transaction_status,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = "promo_groups"
|
||||
|
||||
|
||||
@@ -2876,6 +2876,137 @@ async def ensure_default_web_api_token() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
|
||||
async def create_wata_payments_table():
|
||||
table_exists = await check_table_exists('wata_payments')
|
||||
if table_exists:
|
||||
logger.info("Таблица wata_payments уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE wata_payments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
wata_link_id VARCHAR(64) NULL UNIQUE,
|
||||
order_id VARCHAR(255) NOT NULL UNIQUE,
|
||||
amount_kopeks INTEGER NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
|
||||
description TEXT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'Opened',
|
||||
transaction_status VARCHAR(32) NULL,
|
||||
external_transaction_id VARCHAR(255) NULL,
|
||||
payment_url TEXT NULL,
|
||||
success_redirect_url TEXT NULL,
|
||||
fail_redirect_url TEXT NULL,
|
||||
expiration_at DATETIME NULL,
|
||||
is_paid BOOLEAN NOT NULL DEFAULT 0,
|
||||
paid_at DATETIME NULL,
|
||||
metadata_json JSON NULL,
|
||||
callback_payload JSON NULL,
|
||||
last_status_payload JSON 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_wata_external_transaction_id
|
||||
ON wata_payments(external_transaction_id);
|
||||
|
||||
CREATE INDEX idx_wata_user_id
|
||||
ON wata_payments(user_id);
|
||||
"""
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE wata_payments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
wata_link_id VARCHAR(64) NULL UNIQUE,
|
||||
order_id VARCHAR(255) NOT NULL UNIQUE,
|
||||
amount_kopeks INTEGER NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
|
||||
description TEXT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'Opened',
|
||||
transaction_status VARCHAR(32) NULL,
|
||||
external_transaction_id VARCHAR(255) NULL,
|
||||
payment_url TEXT NULL,
|
||||
success_redirect_url TEXT NULL,
|
||||
fail_redirect_url TEXT NULL,
|
||||
expiration_at TIMESTAMP NULL,
|
||||
is_paid BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
paid_at TIMESTAMP NULL,
|
||||
metadata_json JSON NULL,
|
||||
callback_payload JSON NULL,
|
||||
last_status_payload JSON NULL,
|
||||
transaction_id INTEGER NULL REFERENCES transactions(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_wata_external_transaction_id
|
||||
ON wata_payments(external_transaction_id);
|
||||
|
||||
CREATE INDEX idx_wata_user_id
|
||||
ON wata_payments(user_id);
|
||||
"""
|
||||
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE wata_payments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
wata_link_id VARCHAR(64) NULL UNIQUE,
|
||||
order_id VARCHAR(255) NOT NULL UNIQUE,
|
||||
amount_kopeks INT NOT NULL,
|
||||
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
|
||||
description TEXT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'Opened',
|
||||
transaction_status VARCHAR(32) NULL,
|
||||
external_transaction_id VARCHAR(255) NULL,
|
||||
payment_url TEXT NULL,
|
||||
success_redirect_url TEXT NULL,
|
||||
fail_redirect_url TEXT NULL,
|
||||
expiration_at DATETIME NULL,
|
||||
is_paid BOOLEAN NOT NULL DEFAULT 0,
|
||||
paid_at DATETIME NULL,
|
||||
metadata_json JSON NULL,
|
||||
callback_payload JSON NULL,
|
||||
last_status_payload JSON 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_wata_external_transaction_id
|
||||
ON wata_payments(external_transaction_id);
|
||||
|
||||
CREATE INDEX idx_wata_user_id
|
||||
ON wata_payments(user_id);
|
||||
"""
|
||||
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для таблицы wata_payments: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("Таблица wata_payments успешно создана")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы wata_payments: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def run_universal_migration():
|
||||
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
|
||||
|
||||
@@ -2978,6 +3109,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей Pal24 payments")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ WATA PAYMENTS ===")
|
||||
wata_created = await create_wata_payments_table()
|
||||
if wata_created:
|
||||
logger.info("✅ Таблица Wata payments готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей Wata payments")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ DISCOUNT_OFFERS ===")
|
||||
discount_created = await create_discount_offers_table()
|
||||
if discount_created:
|
||||
|
||||
63
app/external/webhook_server.py
vendored
63
app/external/webhook_server.py
vendored
@@ -36,6 +36,9 @@ class WebhookServer:
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
self.app.router.add_post(settings.CRYPTOBOT_WEBHOOK_PATH, self._cryptobot_webhook_handler)
|
||||
|
||||
if settings.is_wata_enabled():
|
||||
self.app.router.add_post(settings.WATA_WEBHOOK_PATH, self._wata_webhook_handler)
|
||||
|
||||
self.app.router.add_get('/health', self._health_check)
|
||||
|
||||
@@ -44,6 +47,8 @@ class WebhookServer:
|
||||
self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_cryptobot_enabled():
|
||||
self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_wata_enabled():
|
||||
self.app.router.add_options(settings.WATA_WEBHOOK_PATH, self._options_handler)
|
||||
|
||||
logger.info(f"Webhook сервер настроен:")
|
||||
logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
@@ -51,6 +56,8 @@ class WebhookServer:
|
||||
logger.info(f" - Mulen Pay webhook: POST {settings.MULENPAY_WEBHOOK_PATH}")
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}")
|
||||
if settings.is_wata_enabled():
|
||||
logger.info(f" - Wata Pay webhook: POST {settings.WATA_WEBHOOK_PATH}")
|
||||
logger.info(f" - Health check: GET /health")
|
||||
|
||||
return self.app
|
||||
@@ -97,6 +104,13 @@ class WebhookServer:
|
||||
settings.TRIBUTE_WEBHOOK_PORT,
|
||||
settings.CRYPTOBOT_WEBHOOK_PATH,
|
||||
)
|
||||
if settings.is_wata_enabled():
|
||||
logger.info(
|
||||
"Wata webhook URL: http://%s:%s%s",
|
||||
settings.TRIBUTE_WEBHOOK_HOST,
|
||||
settings.TRIBUTE_WEBHOOK_PORT,
|
||||
settings.WATA_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска webhook сервера: {e}")
|
||||
@@ -122,7 +136,7 @@ class WebhookServer:
|
||||
headers={
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature, X-MulenPay-Signature, Authorization',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature, X-MulenPay-Signature, X-Signature, Authorization',
|
||||
}
|
||||
)
|
||||
|
||||
@@ -172,6 +186,53 @@ class WebhookServer:
|
||||
logger.error(f"Критическая ошибка Mulen Pay webhook: {error}", exc_info=True)
|
||||
return web.json_response({"status": "error", "reason": "internal_error", "message": str(error)}, status=500)
|
||||
|
||||
async def _wata_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
logger.info(f"Получен Wata webhook: {request.method} {request.path}")
|
||||
raw_body = await request.read()
|
||||
|
||||
if not raw_body:
|
||||
logger.warning("Пустой Wata webhook")
|
||||
return web.json_response({"status": "error", "reason": "empty_body"}, status=400)
|
||||
|
||||
signature = request.headers.get("X-Signature", "")
|
||||
|
||||
payment_service = PaymentService(self.bot)
|
||||
is_valid = await payment_service.verify_wata_webhook_signature(raw_body, signature)
|
||||
if not is_valid:
|
||||
logger.warning("Подпись Wata webhook не прошла проверку")
|
||||
return web.json_response(
|
||||
{"status": "accepted", "reason": "invalid_signature"},
|
||||
status=202,
|
||||
)
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode("utf-8"))
|
||||
except json.JSONDecodeError as error:
|
||||
logger.error(f"Ошибка парсинга Wata webhook: {error}")
|
||||
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
|
||||
|
||||
db_generator = get_db()
|
||||
db = await db_generator.__anext__()
|
||||
|
||||
try:
|
||||
processed = await payment_service.process_wata_webhook(db, payload)
|
||||
if processed:
|
||||
return web.json_response({"status": "ok"}, status=200)
|
||||
return web.json_response({"status": "error", "reason": "processing_failed"}, status=400)
|
||||
except Exception as error:
|
||||
logger.error("Ошибка обработки Wata webhook: %s", error, exc_info=True)
|
||||
return web.json_response({"status": "error", "reason": "internal_error"}, status=500)
|
||||
finally:
|
||||
try:
|
||||
await db_generator.__anext__()
|
||||
except StopAsyncIteration:
|
||||
pass
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Критическая ошибка Wata webhook: %s", error, exc_info=True)
|
||||
return web.json_response({"status": "error", "reason": "internal_error", "message": str(error)}, status=500)
|
||||
|
||||
@staticmethod
|
||||
def _extract_mulenpay_header(request: web.Request, header_names: Iterable[str]) -> Optional[str]:
|
||||
for header_name in header_names:
|
||||
|
||||
@@ -372,6 +372,45 @@ async def start_mulenpay_payment(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_wata_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_wata_enabled():
|
||||
await callback.answer("❌ Оплата через Wata Pay временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
message_text = texts.t(
|
||||
"WATA_TOPUP_PROMPT",
|
||||
(
|
||||
"🌐 <b>Оплата через Wata Pay</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
|
||||
"Оплата проходит через платформу Wata Pay."
|
||||
),
|
||||
)
|
||||
|
||||
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="wata")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_pal24_payment(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -633,6 +672,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 == "wata":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_wata_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
@@ -993,6 +1036,118 @@ async def process_mulenpay_payment_amount(
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_wata_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_wata_enabled():
|
||||
await message.answer("❌ Оплата через Wata Pay временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.WATA_MIN_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
f"Минимальная сумма пополнения: {settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS)}"
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.WATA_MAX_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
f"Максимальная сумма пополнения: {settings.format_price(settings.WATA_MAX_AMOUNT_KOPEKS)}"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
payment_result = await payment_service.create_wata_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("payment_url"):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"WATA_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа Wata Pay. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
payment_url = payment_result["payment_url"]
|
||||
local_payment_id = payment_result["local_payment_id"]
|
||||
order_id = payment_result.get("order_id") or local_payment_id
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("WATA_PAY_BUTTON", "🌐 Оплатить через Wata Pay"),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_wata_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
)
|
||||
|
||||
message_template = texts.t(
|
||||
"WATA_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"🌐 <b>Оплата через Wata Pay</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID платежа: {payment_id}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n"
|
||||
"1. Нажмите кнопку ‘Оплатить через Wata Pay’\n"
|
||||
"2. Следуйте подсказкам платежной системы\n"
|
||||
"3. Подтвердите перевод\n"
|
||||
"4. Средства зачислятся автоматически\n\n"
|
||||
"❓ Если возникнут проблемы, обратитесь в {support}"
|
||||
),
|
||||
)
|
||||
|
||||
message_text = message_template.format(
|
||||
amount=settings.format_price(amount_kopeks),
|
||||
payment_id=order_id,
|
||||
support=settings.get_support_contact_display_html(),
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(
|
||||
"Создан Wata платеж для пользователя %s: %s₽, ID: %s",
|
||||
db_user.telegram_id,
|
||||
amount_kopeks / 100,
|
||||
order_id,
|
||||
)
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка создания Wata платежа: {error}")
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"WATA_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа Wata Pay. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
async def process_pal24_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
@@ -1323,6 +1478,146 @@ async def check_mulenpay_payment_status(
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_wata_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
):
|
||||
base_texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
|
||||
try:
|
||||
local_payment_id = int(callback.data.split('_')[-1])
|
||||
payment_service = PaymentService(callback.bot)
|
||||
status_info = await payment_service.get_wata_payment_status(db, local_payment_id)
|
||||
|
||||
if not status_info:
|
||||
await callback.answer(
|
||||
base_texts.t(
|
||||
"WATA_PAYMENT_NOT_FOUND",
|
||||
"❌ Платеж не найден",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
payment = status_info["payment"]
|
||||
remote_transaction = status_info.get("remote_transaction")
|
||||
|
||||
user = getattr(payment, "user", None)
|
||||
language = getattr(user, "language", None) or settings.DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
status_labels = {
|
||||
"Opened": (
|
||||
"⏳",
|
||||
texts.t("WATA_STATUS_LINK_OPENED", "Ожидает оплаты"),
|
||||
),
|
||||
"Closed": (
|
||||
"✅" if payment.is_paid else "🔒",
|
||||
texts.t("WATA_STATUS_LINK_CLOSED", "Ссылка закрыта"),
|
||||
),
|
||||
}
|
||||
transaction_labels = {
|
||||
"Paid": ("✅", texts.t("WATA_STATUS_TRANSACTION_PAID", "Оплачен")),
|
||||
"Declined": ("❌", texts.t("WATA_STATUS_TRANSACTION_DECLINED", "Отклонен")),
|
||||
"Pending": ("⏳", texts.t("WATA_STATUS_TRANSACTION_PENDING", "В обработке")),
|
||||
None: ("❓", texts.t("WATA_STATUS_TRANSACTION_UNKNOWN", "Неизвестно")),
|
||||
}
|
||||
|
||||
emoji, status_text = status_labels.get(
|
||||
payment.status,
|
||||
("❓", texts.t("WATA_STATUS_LINK_UNKNOWN", "Неизвестно")),
|
||||
)
|
||||
t_emoji, transaction_text = transaction_labels.get(
|
||||
payment.transaction_status,
|
||||
transaction_labels[None],
|
||||
)
|
||||
|
||||
message_lines = [
|
||||
texts.t("WATA_STATUS_HEADER", "🌐 Статус платежа Wata Pay:"),
|
||||
"",
|
||||
texts.t("WATA_STATUS_ORDER_ID", "🆔 ID заказа: {order_id}").format(
|
||||
order_id=payment.order_id,
|
||||
),
|
||||
texts.t("WATA_STATUS_AMOUNT", "💰 Сумма: {amount}").format(
|
||||
amount=settings.format_price(payment.amount_kopeks),
|
||||
),
|
||||
texts.t("WATA_STATUS_LINK_STATUS", "📊 Статус ссылки: {emoji} {status}").format(
|
||||
emoji=emoji,
|
||||
status=status_text,
|
||||
),
|
||||
texts.t(
|
||||
"WATA_STATUS_TRANSACTION_STATUS",
|
||||
"💳 Статус транзакции: {emoji} {status}",
|
||||
).format(
|
||||
emoji=t_emoji,
|
||||
status=transaction_text,
|
||||
),
|
||||
texts.t(
|
||||
"WATA_STATUS_CREATED_AT",
|
||||
"📅 Создан: {created_at}",
|
||||
).format(
|
||||
created_at=(
|
||||
payment.created_at.strftime('%d.%m.%Y %H:%M')
|
||||
if payment.created_at
|
||||
else "-"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
if payment.is_paid:
|
||||
message_lines.append("")
|
||||
message_lines.append(
|
||||
texts.t(
|
||||
"WATA_STATUS_SUCCESS",
|
||||
"✅ Платеж успешно завершен! Средства уже на балансе.",
|
||||
)
|
||||
)
|
||||
else:
|
||||
message_lines.append("")
|
||||
message_lines.append(
|
||||
texts.t(
|
||||
"WATA_STATUS_PENDING",
|
||||
"⏳ Платеж еще не завершен. После оплаты вернитесь и проверьте статус.",
|
||||
)
|
||||
)
|
||||
if payment.payment_url:
|
||||
message_lines.append("")
|
||||
message_lines.append(
|
||||
texts.t(
|
||||
"WATA_STATUS_PAYMENT_URL",
|
||||
"🌐 Ссылка на оплату: {url}",
|
||||
).format(url=payment.payment_url)
|
||||
)
|
||||
|
||||
if remote_transaction and not payment.is_paid:
|
||||
remote_status = remote_transaction.get("status")
|
||||
if remote_status:
|
||||
message_lines.append("")
|
||||
message_lines.append(
|
||||
texts.t(
|
||||
"WATA_STATUS_REMOTE_STATUS",
|
||||
"ℹ️ Последний статус транзакции: {status}",
|
||||
).format(status=remote_status)
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
await callback.message.answer(
|
||||
"\n".join(message_lines),
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка проверки статуса Wata Pay: {error}")
|
||||
await callback.answer(
|
||||
base_texts.t(
|
||||
"WATA_STATUS_ERROR",
|
||||
"❌ Ошибка проверки статуса",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_pal24_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -1674,6 +1969,12 @@ async def handle_quick_amount_selection(
|
||||
await process_mulenpay_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "wata":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_wata_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
@@ -1804,6 +2105,11 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data == "topup_mulenpay"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_wata_payment,
|
||||
F.data == "topup_wata"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_pal24_payment,
|
||||
F.data == "topup_pal24"
|
||||
@@ -1849,6 +2155,11 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data.startswith("check_mulenpay_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_wata_payment_status,
|
||||
F.data.startswith("check_wata_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_pal24_payment_status,
|
||||
F.data.startswith("check_pal24_")
|
||||
|
||||
@@ -1066,6 +1066,14 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_wata_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_CARD_WATA", "🌐 Банковская карта (Wata Pay)"),
|
||||
callback_data=_build_callback("wata")
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_pal24_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
|
||||
@@ -600,6 +600,29 @@
|
||||
"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}",
|
||||
"MULENPAY_PAY_BUTTON": "💳 Pay with Mulen Pay",
|
||||
"MULENPAY_TOPUP_PROMPT": "💳 <b>Mulen Pay payment</b>\n\nEnter an amount between 100 and 100,000 ₽.\nThe payment is processed by the secure Mulen Pay platform.",
|
||||
"WATA_PAYMENT_ERROR": "❌ Failed to create a Wata Pay payment. Please try again later or contact support.",
|
||||
"WATA_PAYMENT_INSTRUCTIONS": "🌐 <b>Wata Pay payment</b>\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 <b>How to pay:</b>\n1. Press ‘Pay with Wata Pay’\n2. Follow the instructions on the payment page\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
|
||||
"WATA_PAY_BUTTON": "🌐 Pay with Wata Pay",
|
||||
"WATA_TOPUP_PROMPT": "🌐 <b>Wata Pay payment</b>\n\nEnter an amount between 100 and 1,000,000 ₽.\nPayments are processed by the Wata Pay platform.",
|
||||
"WATA_PAYMENT_NOT_FOUND": "❌ Payment not found.",
|
||||
"WATA_STATUS_HEADER": "🌐 Wata Pay payment status:",
|
||||
"WATA_STATUS_ORDER_ID": "🆔 Order ID: {order_id}",
|
||||
"WATA_STATUS_AMOUNT": "💰 Amount: {amount}",
|
||||
"WATA_STATUS_LINK_STATUS": "📊 Payment link: {emoji} {status}",
|
||||
"WATA_STATUS_TRANSACTION_STATUS": "💳 Transaction: {emoji} {status}",
|
||||
"WATA_STATUS_CREATED_AT": "📅 Created: {created_at}",
|
||||
"WATA_STATUS_SUCCESS": "✅ Payment completed! The funds are already on your balance.",
|
||||
"WATA_STATUS_PENDING": "⏳ The payment is not finished yet. Complete the payment and check the status again.",
|
||||
"WATA_STATUS_PAYMENT_URL": "🌐 Payment link: {url}",
|
||||
"WATA_STATUS_REMOTE_STATUS": "ℹ️ Latest transaction status: {status}",
|
||||
"WATA_STATUS_LINK_OPENED": "Awaiting payment",
|
||||
"WATA_STATUS_LINK_CLOSED": "Link closed",
|
||||
"WATA_STATUS_LINK_UNKNOWN": "Unknown",
|
||||
"WATA_STATUS_TRANSACTION_PAID": "Paid",
|
||||
"WATA_STATUS_TRANSACTION_DECLINED": "Declined",
|
||||
"WATA_STATUS_TRANSACTION_PENDING": "Processing",
|
||||
"WATA_STATUS_TRANSACTION_UNKNOWN": "Unknown",
|
||||
"WATA_STATUS_ERROR": "❌ Failed to check the status",
|
||||
"MY_TICKETS_BUTTON": "📋 My tickets",
|
||||
"MY_TICKETS_TITLE": "📋 Your tickets:",
|
||||
"NOTIFICATION_CLOSED": "Notification closed.",
|
||||
@@ -621,9 +644,12 @@
|
||||
"PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
|
||||
"PAL24_TOPUP_PROMPT": "🏦 <b>PayPalych (SBP) payment</b>\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed via the PayPalych Faster Payments System.",
|
||||
"PAYMENT_CARD_MULENPAY": "💳 Bank card (Mulen Pay)",
|
||||
"PAYMENT_CARD_WATA": "🌐 Bank card (Wata Pay)",
|
||||
"PAYMENT_CARD_PAL24": "🏦 SBP (PayPalych)",
|
||||
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via Mulen Pay",
|
||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card (Mulen Pay)</b>",
|
||||
"PAYMENT_METHOD_WATA_DESCRIPTION": "via Wata Pay",
|
||||
"PAYMENT_METHOD_WATA_NAME": "🌐 <b>Bank card (Wata Pay)</b>",
|
||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
|
||||
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>SBP (PayPalych)</b>",
|
||||
"REPLY_TO_TICKET": "💬 Reply",
|
||||
|
||||
@@ -608,6 +608,29 @@
|
||||
"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}",
|
||||
"MULENPAY_PAY_BUTTON": "💳 Оплатить через Mulen Pay",
|
||||
"MULENPAY_TOPUP_PROMPT": "💳 <b>Оплата через Mulen Pay</b>\n\nВведите сумму для пополнения от 100 до 100 000 ₽.\nОплата происходит через защищенную платформу Mulen Pay.",
|
||||
"WATA_PAYMENT_ERROR": "❌ Ошибка создания платежа Wata Pay. Попробуйте позже или обратитесь в поддержку.",
|
||||
"WATA_PAYMENT_INSTRUCTIONS": "🌐 <b>Оплата через Wata Pay</b>\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через Wata Pay’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
|
||||
"WATA_PAY_BUTTON": "🌐 Оплатить через Wata Pay",
|
||||
"WATA_TOPUP_PROMPT": "🌐 <b>Оплата через Wata Pay</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через платформу Wata Pay.",
|
||||
"WATA_PAYMENT_NOT_FOUND": "❌ Платеж не найден.",
|
||||
"WATA_STATUS_HEADER": "🌐 Статус платежа Wata Pay:",
|
||||
"WATA_STATUS_ORDER_ID": "🆔 ID заказа: {order_id}",
|
||||
"WATA_STATUS_AMOUNT": "💰 Сумма: {amount}",
|
||||
"WATA_STATUS_LINK_STATUS": "📊 Статус ссылки: {emoji} {status}",
|
||||
"WATA_STATUS_TRANSACTION_STATUS": "💳 Статус транзакции: {emoji} {status}",
|
||||
"WATA_STATUS_CREATED_AT": "📅 Создан: {created_at}",
|
||||
"WATA_STATUS_SUCCESS": "✅ Платеж успешно завершен! Средства уже на балансе.",
|
||||
"WATA_STATUS_PENDING": "⏳ Платеж еще не завершен. После оплаты вернитесь и проверьте статус.",
|
||||
"WATA_STATUS_PAYMENT_URL": "🌐 Ссылка на оплату: {url}",
|
||||
"WATA_STATUS_REMOTE_STATUS": "ℹ️ Последний статус транзакции: {status}",
|
||||
"WATA_STATUS_LINK_OPENED": "Ожидает оплаты",
|
||||
"WATA_STATUS_LINK_CLOSED": "Ссылка закрыта",
|
||||
"WATA_STATUS_LINK_UNKNOWN": "Неизвестно",
|
||||
"WATA_STATUS_TRANSACTION_PAID": "Оплачен",
|
||||
"WATA_STATUS_TRANSACTION_DECLINED": "Отклонен",
|
||||
"WATA_STATUS_TRANSACTION_PENDING": "В обработке",
|
||||
"WATA_STATUS_TRANSACTION_UNKNOWN": "Неизвестно",
|
||||
"WATA_STATUS_ERROR": "❌ Ошибка проверки статуса",
|
||||
"MY_TICKETS_BUTTON": "📋 Мои тикеты",
|
||||
"MY_TICKETS_TITLE": "📋 Ваши тикеты:",
|
||||
"NOTIFICATION_CLOSED": "Уведомление закрыто.",
|
||||
@@ -629,9 +652,12 @@
|
||||
"PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
|
||||
"PAL24_TOPUP_PROMPT": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.",
|
||||
"PAYMENT_CARD_MULENPAY": "💳 Банковская карта (Mulen Pay)",
|
||||
"PAYMENT_CARD_WATA": "🌐 Банковская карта (Wata Pay)",
|
||||
"PAYMENT_CARD_PAL24": "🏦 СБП (PayPalych)",
|
||||
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay",
|
||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта (Mulen Pay)</b>",
|
||||
"PAYMENT_METHOD_WATA_DESCRIPTION": "через Wata Pay",
|
||||
"PAYMENT_METHOD_WATA_NAME": "🌐 <b>Банковская карта (Wata Pay)</b>",
|
||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
|
||||
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
|
||||
"REPLY_TO_TICKET": "💬 Ответить",
|
||||
|
||||
@@ -764,6 +764,7 @@ class AdminNotificationService:
|
||||
'yookassa': '💳 YooKassa (карта)',
|
||||
'tribute': '💎 Tribute (карта)',
|
||||
'mulenpay': '💳 Mulen Pay (карта)',
|
||||
'wata': '🌐 Wata Pay (карта)',
|
||||
'pal24': '🏦 PayPalych (СБП)',
|
||||
'manual': '🛠️ Вручную (админ)',
|
||||
'balance': '💰 С баланса'
|
||||
|
||||
@@ -24,7 +24,7 @@ from app.database.models import (
|
||||
ServerSquad, SubscriptionServer, UserMessage, YooKassaPayment,
|
||||
CryptoBotPayment, WelcomeText, Base, PromoGroup, AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration, SupportAuditLog, Ticket, TicketMessage,
|
||||
MulenPayPayment, Pal24Payment, DiscountOffer, WebApiToken,
|
||||
MulenPayPayment, Pal24Payment, WataPayment, DiscountOffer, WebApiToken,
|
||||
server_squad_promo_groups
|
||||
)
|
||||
|
||||
@@ -82,6 +82,7 @@ class BackupService:
|
||||
CryptoBotPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
WataPayment,
|
||||
PromoCodeUse,
|
||||
ReferralEarning,
|
||||
SentNotification,
|
||||
|
||||
@@ -11,6 +11,7 @@ from .tribute import TributePaymentMixin
|
||||
from .cryptobot import CryptoBotPaymentMixin
|
||||
from .mulenpay import MulenPayPaymentMixin
|
||||
from .pal24 import Pal24PaymentMixin
|
||||
from .wata import WataPaymentMixin
|
||||
|
||||
__all__ = [
|
||||
"PaymentCommonMixin",
|
||||
@@ -20,4 +21,5 @@ __all__ = [
|
||||
"CryptoBotPaymentMixin",
|
||||
"MulenPayPaymentMixin",
|
||||
"Pal24PaymentMixin",
|
||||
"WataPaymentMixin",
|
||||
]
|
||||
|
||||
422
app/services/payment/wata.py
Normal file
422
app/services/payment/wata.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""Mixin, инкапсулирующий работу с платежами Wata Pay."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from importlib import import_module
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from dateutil import parser
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WataPaymentMixin:
|
||||
"""Mixin с созданием платежей, обработкой webhook и синхронизацией статусов Wata."""
|
||||
|
||||
async def create_wata_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
language: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
service = getattr(self, "wata_service", None)
|
||||
if not service or not service.is_configured:
|
||||
logger.error("Wata сервис не инициализирован")
|
||||
return None
|
||||
|
||||
if amount_kopeks < settings.WATA_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма Wata меньше минимальной: %s < %s",
|
||||
amount_kopeks,
|
||||
settings.WATA_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.WATA_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма Wata больше максимальной: %s > %s",
|
||||
amount_kopeks,
|
||||
settings.WATA_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
order_id = f"wata_{user_id}_{datetime.utcnow().strftime('%Y%m%d%H%M%S%f')}"
|
||||
|
||||
payload = {
|
||||
"type": settings.WATA_LINK_TYPE or "OneTime",
|
||||
"amount": self._format_amount(amount_kopeks),
|
||||
"currency": settings.WATA_DEFAULT_CURRENCY or "RUB",
|
||||
"description": description,
|
||||
"orderId": order_id,
|
||||
}
|
||||
|
||||
if settings.WATA_ALLOW_ARBITRARY_AMOUNT:
|
||||
payload["isArbitraryAmountAllowed"] = True
|
||||
|
||||
if settings.WATA_SUCCESS_REDIRECT_URL:
|
||||
payload["successRedirectUrl"] = settings.WATA_SUCCESS_REDIRECT_URL
|
||||
if settings.WATA_FAIL_REDIRECT_URL:
|
||||
payload["failRedirectUrl"] = settings.WATA_FAIL_REDIRECT_URL
|
||||
|
||||
try:
|
||||
response = await service.create_payment_link(**payload)
|
||||
except Exception as error: # pragma: no cover - network failures
|
||||
logger.error("Ошибка Wata API при создании ссылки: %s", error)
|
||||
return None
|
||||
|
||||
if not response:
|
||||
logger.error("Пустой ответ при создании Wata ссылки")
|
||||
return None
|
||||
|
||||
payment_url = response.get("url")
|
||||
link_id = response.get("id")
|
||||
status = response.get("status", "Opened")
|
||||
|
||||
if not payment_url:
|
||||
logger.error("Wata не вернул ссылку на оплату: %s", response)
|
||||
return None
|
||||
|
||||
expiration_at = self._parse_datetime(response.get("expirationDateTime"))
|
||||
|
||||
metadata = {
|
||||
"raw_response": response,
|
||||
"language": language or "ru",
|
||||
}
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
payment = await payment_module.create_wata_payment(
|
||||
db,
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
order_id=order_id,
|
||||
description=description,
|
||||
status=status,
|
||||
currency=response.get("currency", settings.WATA_DEFAULT_CURRENCY or "RUB"),
|
||||
payment_url=payment_url,
|
||||
wata_link_id=link_id,
|
||||
success_redirect_url=response.get("successRedirectUrl"),
|
||||
fail_redirect_url=response.get("failRedirectUrl"),
|
||||
expiration_at=expiration_at,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Создан Wata платеж %s для пользователя %s (%s₽)",
|
||||
order_id,
|
||||
user_id,
|
||||
amount_kopeks / 100,
|
||||
)
|
||||
|
||||
return {
|
||||
"local_payment_id": payment.id,
|
||||
"order_id": order_id,
|
||||
"link_id": link_id,
|
||||
"payment_url": payment_url,
|
||||
"status": status,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
}
|
||||
|
||||
async def verify_wata_webhook_signature(
|
||||
self,
|
||||
raw_body: bytes,
|
||||
signature: str,
|
||||
) -> bool:
|
||||
service = getattr(self, "wata_service", None)
|
||||
if not service or not service.is_configured:
|
||||
logger.error("Wata сервис не инициализирован для проверки подписи")
|
||||
return False
|
||||
|
||||
try:
|
||||
return await service.verify_signature(raw_body, signature)
|
||||
except Exception as error: # pragma: no cover - безопасность
|
||||
logger.error("Ошибка проверки подписи Wata: %s", error)
|
||||
return False
|
||||
|
||||
async def process_wata_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: Dict[str, Any],
|
||||
) -> bool:
|
||||
try:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
order_id = payload.get("orderId") or payload.get("order_id")
|
||||
link_id = payload.get("paymentLinkId") or payload.get("payment_link_id")
|
||||
transaction_status = payload.get("transactionStatus") or payload.get("transaction_status")
|
||||
transaction_id = payload.get("transactionId") or payload.get("transaction_id")
|
||||
amount_value = payload.get("amount")
|
||||
|
||||
payment = None
|
||||
|
||||
if order_id:
|
||||
payment = await payment_module.get_wata_payment_by_order_id(db, order_id)
|
||||
if not payment and link_id:
|
||||
payment = await payment_module.get_wata_payment_by_link_id(db, link_id)
|
||||
|
||||
if not payment:
|
||||
logger.error(
|
||||
"Wata платеж не найден (order_id=%s, link_id=%s)",
|
||||
order_id,
|
||||
link_id,
|
||||
)
|
||||
return False
|
||||
|
||||
status = payload.get("status") or payment.status
|
||||
await payment_module.update_wata_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
status=status,
|
||||
transaction_status=transaction_status,
|
||||
callback_payload=payload,
|
||||
external_transaction_id=transaction_id,
|
||||
)
|
||||
|
||||
payment = await payment_module.get_wata_payment_by_local_id(db, payment.id)
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info("Wata платеж %s уже оплачен", payment.order_id)
|
||||
return True
|
||||
|
||||
if (transaction_status or "").lower() == "paid":
|
||||
amount_kopeks = self._parse_amount_to_kopeks(amount_value)
|
||||
if amount_kopeks is None:
|
||||
amount_kopeks = payment.amount_kopeks
|
||||
|
||||
return await self._finalize_wata_payment(
|
||||
db,
|
||||
payment,
|
||||
amount_kopeks=amount_kopeks,
|
||||
transaction_id=transaction_id or payment.order_id,
|
||||
transaction_payload=payload,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка обработки Wata webhook: %s", error, exc_info=True)
|
||||
return False
|
||||
|
||||
async def get_wata_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
local_payment_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
payment = await payment_module.get_wata_payment_by_local_id(db, local_payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
service = getattr(self, "wata_service", None)
|
||||
remote_link: Optional[Dict[str, Any]] = None
|
||||
remote_transaction: Optional[Dict[str, Any]] = None
|
||||
|
||||
if service and service.is_configured:
|
||||
if payment.wata_link_id:
|
||||
remote_link = await service.get_payment_link(payment.wata_link_id)
|
||||
if remote_link:
|
||||
await payment_module.update_wata_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
status=remote_link.get("status", payment.status),
|
||||
payment_url=remote_link.get("url", payment.payment_url),
|
||||
last_status_payload=remote_link,
|
||||
)
|
||||
payment = await payment_module.get_wata_payment_by_local_id(db, local_payment_id)
|
||||
|
||||
if payment.order_id:
|
||||
try:
|
||||
transactions_response = await service.search_transactions(order_id=payment.order_id)
|
||||
except Exception as error: # pragma: no cover - сеть
|
||||
logger.error("Ошибка запроса транзакций Wata: %s", error)
|
||||
transactions_response = None
|
||||
|
||||
if transactions_response:
|
||||
items = transactions_response.get("items")
|
||||
if isinstance(items, list):
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
remote_transaction = item
|
||||
status = (item.get("status") or "").lower()
|
||||
if status == "paid" and not payment.is_paid:
|
||||
amount_kopeks = self._parse_amount_to_kopeks(item.get("amount"))
|
||||
if amount_kopeks is None:
|
||||
amount_kopeks = payment.amount_kopeks
|
||||
await self._finalize_wata_payment(
|
||||
db,
|
||||
payment,
|
||||
amount_kopeks=amount_kopeks,
|
||||
transaction_id=item.get("id") or payment.order_id,
|
||||
transaction_payload=item,
|
||||
)
|
||||
payment = await payment_module.get_wata_payment_by_local_id(
|
||||
db, local_payment_id
|
||||
)
|
||||
break
|
||||
|
||||
return {
|
||||
"payment": payment,
|
||||
"remote_link": remote_link,
|
||||
"remote_transaction": remote_transaction,
|
||||
}
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка получения статуса Wata: %s", error, exc_info=True)
|
||||
return None
|
||||
|
||||
async def _finalize_wata_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
amount_kopeks: int,
|
||||
transaction_id: str,
|
||||
transaction_payload: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
await payment_module.update_wata_payment_status(
|
||||
db,
|
||||
payment=payment,
|
||||
status="Closed",
|
||||
transaction_status="Paid",
|
||||
is_paid=True,
|
||||
paid_at=datetime.utcnow(),
|
||||
callback_payload=transaction_payload,
|
||||
external_transaction_id=transaction_id,
|
||||
)
|
||||
|
||||
transaction = await payment_module.create_transaction(
|
||||
db=db,
|
||||
user_id=payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение через Wata Pay: {payment.description or payment.order_id}",
|
||||
payment_method=PaymentMethod.WATA,
|
||||
external_id=transaction_id,
|
||||
metadata={"wata_payload": transaction_payload} if transaction_payload else None,
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
await payment_module.link_wata_payment_to_transaction(
|
||||
db=db,
|
||||
payment=payment,
|
||||
transaction_id=transaction.id,
|
||||
)
|
||||
|
||||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||||
if not user:
|
||||
logger.error("Пользователь %s не найден для Wata платежа", payment.user_id)
|
||||
return False
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
if was_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
amount_kopeks,
|
||||
getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Ошибка обработки реферального пополнения Wata: %s", error)
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
subscription = getattr(user, "subscription", None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||||
|
||||
if getattr(self, "bot", None):
|
||||
try:
|
||||
from app.services.admin_notification_service import (
|
||||
AdminNotificationService,
|
||||
)
|
||||
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Ошибка отправки уведомления о пополнении Wata: %s", error)
|
||||
|
||||
if getattr(self, "bot", None) and user.telegram_id:
|
||||
try:
|
||||
texts = get_texts(getattr(user, "language", settings.DEFAULT_LANGUAGE))
|
||||
payment_method_title = texts.t(
|
||||
"PAYMENT_CARD_WATA",
|
||||
"🌐 Банковская карта (Wata Pay)",
|
||||
)
|
||||
await self._send_payment_success_notification(
|
||||
user.telegram_id,
|
||||
amount_kopeks,
|
||||
user=user,
|
||||
db=db,
|
||||
payment_method_title=payment_method_title,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Ошибка отправки уведомления пользователю Wata: %s", error)
|
||||
|
||||
logger.info(
|
||||
"✅ Обработан Wata платеж %s для пользователя %s",
|
||||
payment.order_id,
|
||||
payment.user_id,
|
||||
)
|
||||
return True
|
||||
|
||||
def _format_amount(self, amount_kopeks: int) -> str:
|
||||
return f"{Decimal(amount_kopeks) / Decimal(100):.2f}"
|
||||
|
||||
def _parse_amount_to_kopeks(self, amount: Any) -> Optional[int]:
|
||||
if amount is None:
|
||||
return None
|
||||
try:
|
||||
if isinstance(amount, (int, float, Decimal)):
|
||||
decimal_amount = Decimal(str(amount))
|
||||
elif isinstance(amount, str):
|
||||
decimal_amount = Decimal(amount)
|
||||
else:
|
||||
decimal_amount = Decimal(json.dumps(amount))
|
||||
return int(decimal_amount * 100)
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _parse_datetime(self, value: Any) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return parser.isoparse(value)
|
||||
except (ValueError, TypeError, AttributeError): # pragma: no cover - формат
|
||||
return None
|
||||
@@ -14,11 +14,13 @@ from app.external.cryptobot import CryptoBotService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.services.mulenpay_service import MulenPayService
|
||||
from app.services.pal24_service import Pal24Service
|
||||
from app.services.wata_service import WataService
|
||||
from app.services.payment import (
|
||||
CryptoBotPaymentMixin,
|
||||
MulenPayPaymentMixin,
|
||||
Pal24PaymentMixin,
|
||||
PaymentCommonMixin,
|
||||
WataPaymentMixin,
|
||||
TelegramStarsMixin,
|
||||
TributePaymentMixin,
|
||||
YooKassaPaymentMixin,
|
||||
@@ -126,6 +128,36 @@ async def link_pal24_payment_to_transaction(*args, **kwargs):
|
||||
return await pal_crud.link_pal24_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_wata_payment(*args, **kwargs):
|
||||
wata_crud = import_module("app.database.crud.wata")
|
||||
return await wata_crud.create_wata_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_wata_payment_by_local_id(*args, **kwargs):
|
||||
wata_crud = import_module("app.database.crud.wata")
|
||||
return await wata_crud.get_wata_payment_by_local_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_wata_payment_by_order_id(*args, **kwargs):
|
||||
wata_crud = import_module("app.database.crud.wata")
|
||||
return await wata_crud.get_wata_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_wata_payment_by_link_id(*args, **kwargs):
|
||||
wata_crud = import_module("app.database.crud.wata")
|
||||
return await wata_crud.get_wata_payment_by_link_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_wata_payment_status(*args, **kwargs):
|
||||
wata_crud = import_module("app.database.crud.wata")
|
||||
return await wata_crud.update_wata_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_wata_payment_to_transaction(*args, **kwargs):
|
||||
wata_crud = import_module("app.database.crud.wata")
|
||||
return await wata_crud.link_wata_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_cryptobot_payment(*args, **kwargs):
|
||||
crypto_crud = import_module("app.database.crud.cryptobot")
|
||||
return await crypto_crud.create_cryptobot_payment(*args, **kwargs)
|
||||
@@ -154,6 +186,7 @@ class PaymentService(
|
||||
CryptoBotPaymentMixin,
|
||||
MulenPayPaymentMixin,
|
||||
Pal24PaymentMixin,
|
||||
WataPaymentMixin,
|
||||
):
|
||||
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
||||
|
||||
@@ -174,13 +207,17 @@ class PaymentService(
|
||||
self.pal24_service = (
|
||||
Pal24Service() if settings.is_pal24_enabled() else None
|
||||
)
|
||||
self.wata_service = (
|
||||
WataService() if settings.is_wata_enabled() else None
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, "
|
||||
"MulenPay=%s, Pal24=%s)",
|
||||
"MulenPay=%s, Pal24=%s, Wata=%s)",
|
||||
bool(self.yookassa_service),
|
||||
bool(self.stars_service),
|
||||
bool(self.cryptobot_service),
|
||||
bool(self.mulenpay_service),
|
||||
bool(self.pal24_service),
|
||||
bool(self.wata_service),
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@ from app.database.models import (
|
||||
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
|
||||
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
|
||||
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment,
|
||||
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, WataPayment,
|
||||
AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod,
|
||||
TransactionType
|
||||
)
|
||||
@@ -687,6 +687,27 @@ class UserService:
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления Pal24 платежей: {e}")
|
||||
|
||||
try:
|
||||
wata_result = await db.execute(
|
||||
select(WataPayment).where(WataPayment.user_id == user_id)
|
||||
)
|
||||
wata_payments = wata_result.scalars().all()
|
||||
|
||||
if wata_payments:
|
||||
logger.info(f"🔄 Удаляем {len(wata_payments)} Wata платежей")
|
||||
await db.execute(
|
||||
update(WataPayment)
|
||||
.where(WataPayment.user_id == user_id)
|
||||
.values(transaction_id=None)
|
||||
)
|
||||
await db.flush()
|
||||
await db.execute(
|
||||
delete(WataPayment).where(WataPayment.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления Wata платежей: {e}")
|
||||
|
||||
try:
|
||||
transactions_result = await db.execute(
|
||||
select(Transaction).where(Transaction.user_id == user_id)
|
||||
|
||||
150
app/services/wata_service.py
Normal file
150
app/services/wata_service.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Интеграция с API Wata Pay."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import aiohttp
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WataService:
|
||||
"""Обёртка над REST API Wata Pay."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.base_url = (settings.WATA_BASE_URL or "https://api.wata.pro/api/h2h").rstrip("/")
|
||||
self.access_token = settings.WATA_ACCESS_TOKEN
|
||||
self._public_key_cache: Optional[tuple[str, float]] = None
|
||||
self._public_key_lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return bool(settings.is_wata_enabled() and self.access_token)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
*,
|
||||
json_data: Optional[Dict[str, Any]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.is_configured:
|
||||
logger.error("Wata service is not configured")
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.access_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=settings.WATA_TIMEOUT_SECONDS or 60)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=json_data,
|
||||
params=params,
|
||||
) as response:
|
||||
text = await response.text()
|
||||
if not text:
|
||||
data: Dict[str, Any] = {}
|
||||
else:
|
||||
try:
|
||||
data = await response.json(content_type=None)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"Wata API вернул не-JSON ответ %s: %s", response.status, text
|
||||
)
|
||||
return None
|
||||
|
||||
if response.status >= 400:
|
||||
logger.error(
|
||||
"Wata API error %s %s: %s", response.status, endpoint, data
|
||||
)
|
||||
return None
|
||||
|
||||
return data
|
||||
except aiohttp.ClientError as error:
|
||||
logger.error("Wata API request error: %s", error)
|
||||
return None
|
||||
except Exception as error: # pragma: no cover - непредвиденные ошибки сети
|
||||
logger.error("Unexpected Wata error: %s", error, exc_info=True)
|
||||
return None
|
||||
|
||||
async def create_payment_link(self, **payload: Any) -> Optional[Dict[str, Any]]:
|
||||
return await self._request("POST", "/links", json_data=payload)
|
||||
|
||||
async def get_payment_link(self, link_id: str) -> Optional[Dict[str, Any]]:
|
||||
return await self._request("GET", f"/links/{link_id}")
|
||||
|
||||
async def search_transactions(
|
||||
self,
|
||||
*,
|
||||
order_id: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
params: Dict[str, Any] = {}
|
||||
if order_id:
|
||||
params["orderId"] = order_id
|
||||
return await self._request("GET", "/transactions/", params=params or None)
|
||||
|
||||
async def get_public_key(self, *, force: bool = False) -> Optional[str]:
|
||||
if not force and self._public_key_cache:
|
||||
value, expires_at = self._public_key_cache
|
||||
if time.time() < expires_at:
|
||||
return value
|
||||
|
||||
async with self._public_key_lock:
|
||||
if not force and self._public_key_cache:
|
||||
value, expires_at = self._public_key_cache
|
||||
if time.time() < expires_at:
|
||||
return value
|
||||
|
||||
response = await self._request("GET", "/public-key")
|
||||
if not response:
|
||||
return None
|
||||
|
||||
value = response.get("value")
|
||||
if not isinstance(value, str):
|
||||
logger.error("Некорректный публичный ключ Wata: %s", response)
|
||||
return None
|
||||
|
||||
self._public_key_cache = (value, time.time() + 3600)
|
||||
return value
|
||||
|
||||
async def verify_signature(self, raw_body: bytes, signature: str) -> bool:
|
||||
if not signature:
|
||||
logger.error("Отсутствует подпись Wata webhook")
|
||||
return False
|
||||
|
||||
public_key_pem = await self.get_public_key()
|
||||
if not public_key_pem:
|
||||
logger.error("Не удалось получить публичный ключ Wata")
|
||||
return False
|
||||
|
||||
try:
|
||||
public_key = serialization.load_pem_public_key(public_key_pem.encode())
|
||||
signature_bytes = base64.b64decode(signature)
|
||||
public_key.verify(
|
||||
signature_bytes,
|
||||
raw_body,
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA512(),
|
||||
)
|
||||
return True
|
||||
except Exception as error: # pragma: no cover - безопасность
|
||||
logger.error("Ошибка проверки подписи Wata: %s", error)
|
||||
return False
|
||||
@@ -54,6 +54,15 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
|
||||
"callback": "topup_mulenpay"
|
||||
})
|
||||
|
||||
if settings.is_wata_enabled():
|
||||
methods.append({
|
||||
"id": "wata",
|
||||
"name": "Банковская карта",
|
||||
"icon": "🌐",
|
||||
"description": "через Wata Pay",
|
||||
"callback": "topup_wata"
|
||||
})
|
||||
|
||||
if settings.is_pal24_enabled():
|
||||
methods.append({
|
||||
"id": "pal24",
|
||||
@@ -141,6 +150,8 @@ def is_payment_method_available(method_id: str) -> bool:
|
||||
return settings.TRIBUTE_ENABLED
|
||||
elif method_id == "mulenpay":
|
||||
return settings.is_mulenpay_enabled()
|
||||
elif method_id == "wata":
|
||||
return settings.is_wata_enabled()
|
||||
elif method_id == "pal24":
|
||||
return settings.is_pal24_enabled()
|
||||
elif method_id == "cryptobot":
|
||||
@@ -159,6 +170,7 @@ def get_payment_method_status() -> Dict[str, bool]:
|
||||
"yookassa": settings.is_yookassa_enabled(),
|
||||
"tribute": settings.TRIBUTE_ENABLED,
|
||||
"mulenpay": settings.is_mulenpay_enabled(),
|
||||
"wata": settings.is_wata_enabled(),
|
||||
"pal24": settings.is_pal24_enabled(),
|
||||
"cryptobot": settings.is_cryptobot_enabled(),
|
||||
"support": True
|
||||
@@ -177,6 +189,8 @@ def get_enabled_payment_methods_count() -> int:
|
||||
count += 1
|
||||
if settings.is_mulenpay_enabled():
|
||||
count += 1
|
||||
if settings.is_wata_enabled():
|
||||
count += 1
|
||||
if settings.is_pal24_enabled():
|
||||
count += 1
|
||||
if settings.is_cryptobot_enabled():
|
||||
|
||||
120
tests/handlers/test_balance_wata.py
Normal file
120
tests/handlers/test_balance_wata.py
Normal file
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
from app.handlers.balance import check_wata_payment_status # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
class DummyMessage:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, Dict[str, Any]]] = []
|
||||
|
||||
async def answer(self, text: str, **kwargs: Any) -> None:
|
||||
self.calls.append((text, kwargs))
|
||||
|
||||
|
||||
class DummyCallback:
|
||||
def __init__(self, data: str) -> None:
|
||||
self.data = data
|
||||
self.message = DummyMessage()
|
||||
self.bot = object()
|
||||
self.answers: list[tuple[str, bool]] = []
|
||||
|
||||
async def answer(self, text: str = "", show_alert: bool = False) -> None:
|
||||
self.answers.append((text, show_alert))
|
||||
|
||||
|
||||
class DummyPayment:
|
||||
def __init__(self) -> None:
|
||||
self.order_id = "WATA-TEST"
|
||||
self.amount_kopeks = 2_500
|
||||
self.status = "Opened"
|
||||
self.transaction_status: Optional[str] = None
|
||||
self.created_at = datetime(2024, 1, 5, 12, 30)
|
||||
self.is_paid = False
|
||||
self.payment_url = "https://pay.example"
|
||||
self.user = type("U", (), {"language": "en"})()
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_check_wata_payment_status_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
payment = DummyPayment()
|
||||
remote_transaction = {"status": "Paid"}
|
||||
|
||||
class FakePaymentService:
|
||||
def __init__(self, bot: Any) -> None:
|
||||
self.bot = bot
|
||||
|
||||
async def get_wata_payment_status(
|
||||
self,
|
||||
db: Any,
|
||||
local_payment_id: int,
|
||||
) -> Dict[str, Any] | None:
|
||||
assert local_payment_id == 5
|
||||
payment.transaction_status = "Paid"
|
||||
payment.is_paid = True
|
||||
return {
|
||||
"payment": payment,
|
||||
"remote_transaction": remote_transaction,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.handlers.balance.PaymentService",
|
||||
FakePaymentService,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
callback = DummyCallback("check_wata_5")
|
||||
|
||||
await check_wata_payment_status(callback, db=None)
|
||||
|
||||
assert callback.answers[0] == ("", False)
|
||||
assert callback.message.calls, "expected message to be sent"
|
||||
message_text, kwargs = callback.message.calls[0]
|
||||
assert "Wata Pay payment status" in message_text
|
||||
assert "Order ID: WATA-TEST" in message_text
|
||||
assert "https://pay.example" not in message_text
|
||||
assert kwargs["disable_web_page_preview"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_check_wata_payment_status_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class FakePaymentService:
|
||||
def __init__(self, bot: Any) -> None:
|
||||
self.bot = bot
|
||||
|
||||
async def get_wata_payment_status(
|
||||
self,
|
||||
db: Any,
|
||||
local_payment_id: int,
|
||||
) -> Dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.handlers.balance.PaymentService",
|
||||
FakePaymentService,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
callback = DummyCallback("check_wata_77")
|
||||
|
||||
await check_wata_payment_status(callback, db=None)
|
||||
|
||||
assert callback.answers
|
||||
not_found_text, alert = callback.answers[0]
|
||||
assert alert is True
|
||||
assert "Платеж не найден" in not_found_text
|
||||
@@ -14,6 +14,7 @@ from app.services.payment import ( # noqa: E402
|
||||
MulenPayPaymentMixin,
|
||||
Pal24PaymentMixin,
|
||||
PaymentCommonMixin,
|
||||
WataPaymentMixin,
|
||||
TelegramStarsMixin,
|
||||
TributePaymentMixin,
|
||||
YooKassaPaymentMixin,
|
||||
@@ -31,6 +32,7 @@ def test_payment_service_mro_contains_all_mixins() -> None:
|
||||
CryptoBotPaymentMixin,
|
||||
MulenPayPaymentMixin,
|
||||
Pal24PaymentMixin,
|
||||
WataPaymentMixin,
|
||||
}
|
||||
service_mro = set(PaymentService.__mro__)
|
||||
assert mixins.issubset(service_mro), "PaymentService должен содержать все mixin-классы"
|
||||
@@ -45,6 +47,7 @@ def test_payment_service_mro_contains_all_mixins() -> None:
|
||||
"create_tribute_payment",
|
||||
"create_cryptobot_payment",
|
||||
"create_mulenpay_payment",
|
||||
"create_wata_payment",
|
||||
"create_pal24_payment",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -50,6 +50,7 @@ def _make_service(stub: Optional[StubMulenPayService]) -> PaymentService:
|
||||
service.yookassa_service = None
|
||||
service.stars_service = None
|
||||
service.cryptobot_service = None
|
||||
service.wata_service = None
|
||||
return service
|
||||
|
||||
|
||||
|
||||
288
tests/services/test_payment_service_wata.py
Normal file
288
tests/services/test_payment_service_wata.py
Normal file
@@ -0,0 +1,288 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
import app.services.payment_service as payment_service_module # noqa: E402
|
||||
from app.config import settings # noqa: E402
|
||||
from app.services.payment_service import PaymentService # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
class DummySession:
|
||||
async def commit(self) -> None: # pragma: no cover
|
||||
return None
|
||||
|
||||
|
||||
class DummyPayment:
|
||||
def __init__(self, payment_id: int = 101) -> None:
|
||||
self.id = payment_id
|
||||
|
||||
|
||||
class DummyWataPayment:
|
||||
def __init__(self) -> None:
|
||||
self.id = 11
|
||||
self.user_id = 77
|
||||
self.amount_kopeks = 50000
|
||||
self.order_id = "wata_77_test"
|
||||
self.status = "Opened"
|
||||
self.transaction_status = None
|
||||
self.is_paid = False
|
||||
self.wata_link_id = "link"
|
||||
self.payment_url = "https://pay"
|
||||
self.created_at = datetime(2024, 1, 1, 12, 0, 0)
|
||||
self.user = type("U", (), {"language": "ru", "telegram_id": 123})()
|
||||
|
||||
|
||||
class StubWataService:
|
||||
def __init__(self, response: Optional[Dict[str, Any]]) -> None:
|
||||
self.response = response
|
||||
self.calls: list[Dict[str, Any]] = []
|
||||
self.is_configured = True
|
||||
|
||||
async def create_payment_link(self, **payload: Any) -> Optional[Dict[str, Any]]:
|
||||
self.calls.append(payload)
|
||||
return self.response
|
||||
|
||||
|
||||
def _make_service(stub: Optional[StubWataService]) -> PaymentService:
|
||||
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
|
||||
service.bot = None
|
||||
service.yookassa_service = None
|
||||
service.stars_service = None
|
||||
service.cryptobot_service = None
|
||||
service.mulenpay_service = None
|
||||
service.pal24_service = None
|
||||
service.wata_service = stub
|
||||
return service
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_wata_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
stub = StubWataService({"id": "link", "url": "https://pay"})
|
||||
service = _make_service(stub)
|
||||
db = DummySession()
|
||||
|
||||
captured_kwargs: Dict[str, Any] = {}
|
||||
|
||||
async def fake_create_wata_payment(*args: Any, **kwargs: Any) -> DummyPayment:
|
||||
captured_kwargs.update(kwargs)
|
||||
return DummyPayment(payment_id=555)
|
||||
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"create_wata_payment",
|
||||
fake_create_wata_payment,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(settings, "WATA_MIN_AMOUNT_KOPEKS", 1_000, raising=False)
|
||||
monkeypatch.setattr(settings, "WATA_MAX_AMOUNT_KOPEKS", 1_000_000_00, raising=False)
|
||||
monkeypatch.setattr(settings, "WATA_DEFAULT_CURRENCY", "RUB", raising=False)
|
||||
|
||||
result = await service.create_wata_payment(
|
||||
db=db,
|
||||
user_id=7,
|
||||
amount_kopeks=25000,
|
||||
description="Пополнение",
|
||||
language="ru",
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["local_payment_id"] == 555
|
||||
assert result["payment_url"] == "https://pay"
|
||||
assert stub.calls and stub.calls[0]["amount"] == "250.00"
|
||||
assert captured_kwargs["order_id"].startswith("wata_7_")
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_wata_payment_amount_limits(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
stub = StubWataService({"id": "link", "url": "https://pay"})
|
||||
service = _make_service(stub)
|
||||
db = DummySession()
|
||||
|
||||
monkeypatch.setattr(settings, "WATA_MIN_AMOUNT_KOPEKS", 5000, raising=False)
|
||||
monkeypatch.setattr(settings, "WATA_MAX_AMOUNT_KOPEKS", 10_000, raising=False)
|
||||
|
||||
low_result = await service.create_wata_payment(
|
||||
db=db,
|
||||
user_id=1,
|
||||
amount_kopeks=1000,
|
||||
description="Пополнение",
|
||||
)
|
||||
assert low_result is None
|
||||
|
||||
high_result = await service.create_wata_payment(
|
||||
db=db,
|
||||
user_id=1,
|
||||
amount_kopeks=20_000,
|
||||
description="Пополнение",
|
||||
)
|
||||
assert high_result is None
|
||||
assert not stub.calls
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_wata_payment_without_service() -> None:
|
||||
service = _make_service(None)
|
||||
db = DummySession()
|
||||
|
||||
result = await service.create_wata_payment(
|
||||
db=db,
|
||||
user_id=1,
|
||||
amount_kopeks=10_000,
|
||||
description="Пополнение",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_wata_webhook_paid(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = _make_service(None)
|
||||
db = DummySession()
|
||||
payment = DummyWataPayment()
|
||||
|
||||
async def fake_get_by_order_id(db_session: Any, order_id: str) -> DummyWataPayment | None:
|
||||
assert order_id == payment.order_id
|
||||
return payment
|
||||
|
||||
async def fake_get_by_link_id(db_session: Any, link_id: str) -> DummyWataPayment | None:
|
||||
return None
|
||||
|
||||
async def fake_get_by_local_id(db_session: Any, payment_id: int) -> DummyWataPayment | None:
|
||||
assert payment_id == payment.id
|
||||
return payment
|
||||
|
||||
async def fake_update_status(
|
||||
db_session: Any,
|
||||
*,
|
||||
payment: DummyWataPayment,
|
||||
status: str | None = None,
|
||||
transaction_status: str | None = None,
|
||||
is_paid: bool | None = None,
|
||||
paid_at: datetime | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
external_transaction_id: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
last_status_payload: dict | None = None,
|
||||
) -> DummyWataPayment:
|
||||
if status is not None:
|
||||
payment.status = status
|
||||
if transaction_status is not None:
|
||||
payment.transaction_status = transaction_status
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if payment_url is not None:
|
||||
payment.payment_url = payment_url
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if external_transaction_id is not None:
|
||||
payment.external_transaction_id = external_transaction_id
|
||||
if last_status_payload is not None:
|
||||
payment.last_status_payload = last_status_payload
|
||||
return payment
|
||||
|
||||
finalize_calls: Dict[str, Any] = {}
|
||||
|
||||
async def fake_finalize(
|
||||
self: PaymentService,
|
||||
db_session: Any,
|
||||
payment: DummyWataPayment,
|
||||
*,
|
||||
amount_kopeks: int,
|
||||
transaction_id: str,
|
||||
transaction_payload: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
finalize_calls["amount"] = amount_kopeks
|
||||
finalize_calls["transaction_id"] = transaction_id
|
||||
payment.is_paid = True
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_wata_payment_by_order_id",
|
||||
fake_get_by_order_id,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_wata_payment_by_link_id",
|
||||
fake_get_by_link_id,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_wata_payment_by_local_id",
|
||||
fake_get_by_local_id,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"update_wata_payment_status",
|
||||
fake_update_status,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
PaymentService,
|
||||
"_finalize_wata_payment",
|
||||
fake_finalize,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"orderId": payment.order_id,
|
||||
"transactionStatus": "Paid",
|
||||
"transactionId": "tx-123",
|
||||
"amount": "500.00",
|
||||
}
|
||||
|
||||
processed = await service.process_wata_webhook(db, payload)
|
||||
|
||||
assert processed is True
|
||||
assert finalize_calls["amount"] == 50_000
|
||||
assert finalize_calls["transaction_id"] == "tx-123"
|
||||
assert payment.is_paid is True
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_wata_webhook_missing_payment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = _make_service(None)
|
||||
db = DummySession()
|
||||
|
||||
async def fake_get_by_order_id(db_session: Any, order_id: str) -> None:
|
||||
return None
|
||||
|
||||
async def fake_get_by_link_id(db_session: Any, link_id: str) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_wata_payment_by_order_id",
|
||||
fake_get_by_order_id,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
payment_service_module,
|
||||
"get_wata_payment_by_link_id",
|
||||
fake_get_by_link_id,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
payload = {"orderId": "missing", "transactionStatus": "Paid"}
|
||||
|
||||
processed = await service.process_wata_webhook(db, payload)
|
||||
|
||||
assert processed is False
|
||||
107
tests/services/test_wata_service_adapter.py
Normal file
107
tests/services/test_wata_service_adapter.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
from app.config import settings # noqa: E402
|
||||
from app.services.wata_service import WataService # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
def _enable_service(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(type(settings), "is_wata_enabled", lambda self: True, raising=False)
|
||||
monkeypatch.setattr(settings, "WATA_ACCESS_TOKEN", "token", raising=False)
|
||||
monkeypatch.setattr(settings, "WATA_BASE_URL", "https://wata.test", raising=False)
|
||||
|
||||
|
||||
def test_is_configured(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = WataService()
|
||||
assert not service.is_configured
|
||||
|
||||
_enable_service(monkeypatch)
|
||||
service = WataService()
|
||||
assert service.is_configured
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_payment_link(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_service(monkeypatch)
|
||||
|
||||
captured: Dict[str, Any] = {}
|
||||
|
||||
async def fake_request(method: str, endpoint: str, **kwargs: Any) -> Dict[str, Any]:
|
||||
captured.update({"method": method, "endpoint": endpoint, **kwargs})
|
||||
return {"id": "link", "url": "https://pay"}
|
||||
|
||||
service = WataService()
|
||||
monkeypatch.setattr(service, "_request", fake_request, raising=False)
|
||||
|
||||
response = await service.create_payment_link(amount="100.00", orderId="test")
|
||||
|
||||
assert response == {"id": "link", "url": "https://pay"}
|
||||
assert captured["method"] == "POST"
|
||||
assert captured["endpoint"] == "/links"
|
||||
assert captured["json_data"]["orderId"] == "test"
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_get_public_key_caching(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_service(monkeypatch)
|
||||
service = WataService()
|
||||
|
||||
calls: list[Dict[str, Any]] = []
|
||||
|
||||
async def fake_request(method: str, endpoint: str, **kwargs: Any) -> Dict[str, Any]:
|
||||
calls.append({"method": method, "endpoint": endpoint})
|
||||
return {"value": "-----BEGIN PUBLIC KEY-----\nAAAA\n-----END PUBLIC KEY-----"}
|
||||
|
||||
monkeypatch.setattr(service, "_request", fake_request, raising=False)
|
||||
|
||||
key1 = await service.get_public_key()
|
||||
key2 = await service.get_public_key()
|
||||
|
||||
assert key1 == key2
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_verify_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_enable_service(monkeypatch)
|
||||
service = WataService()
|
||||
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
public_key = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode()
|
||||
|
||||
async def fake_get_public_key(*args: Any, **kwargs: Any) -> Optional[str]:
|
||||
return public_key
|
||||
|
||||
monkeypatch.setattr(service, "get_public_key", fake_get_public_key, raising=False)
|
||||
|
||||
payload = b"{\"event\":\"test\"}"
|
||||
signature = private_key.sign(
|
||||
payload,
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA512(),
|
||||
)
|
||||
|
||||
signature_b64 = base64.b64encode(signature).decode()
|
||||
|
||||
assert await service.verify_signature(payload, signature_b64)
|
||||
|
||||
Reference in New Issue
Block a user