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:
Egor
2025-10-14 19:12:19 +03:00
committed by GitHub
22 changed files with 1954 additions and 4 deletions

View File

@@ -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
View 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

View File

@@ -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"

View File

@@ -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:

View File

@@ -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:

View File

@@ -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_")

View File

@@ -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(

View File

@@ -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",

View File

@@ -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": "💬 Ответить",

View File

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

View File

@@ -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,

View File

@@ -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",
]

View 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

View File

@@ -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),
)

View File

@@ -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)

View 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

View File

@@ -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():

View 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

View File

@@ -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",
],
)

View File

@@ -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

View 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

View 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)