Merge pull request #1323 from Fr1ngg/q1598f-bedolaga/add-new-balance-topping-method-to-bot

Fix missing logging import in WATA CRUD helpers
This commit is contained in:
Egor
2025-10-15 01:21:28 +03:00
committed by GitHub
19 changed files with 1506 additions and 1 deletions

View File

@@ -231,6 +231,19 @@ class Settings(BaseSettings):
PAL24_SBP_BUTTON_VISIBLE: bool = True
PAL24_CARD_BUTTON_VISIBLE: bool = True
WATA_ENABLED: bool = False
WATA_BASE_URL: str = "https://api.wata.pro/api/h2h"
WATA_ACCESS_TOKEN: Optional[str] = None
WATA_TERMINAL_PUBLIC_ID: Optional[str] = None
WATA_PAYMENT_DESCRIPTION: str = "Пополнение баланса"
WATA_PAYMENT_TYPE: str = "OneTime"
WATA_SUCCESS_REDIRECT_URL: Optional[str] = None
WATA_FAIL_REDIRECT_URL: Optional[str] = None
WATA_LINK_TTL_MINUTES: Optional[int] = None
WATA_MIN_AMOUNT_KOPEKS: int = 10000
WATA_MAX_AMOUNT_KOPEKS: int = 100000000
WATA_REQUEST_TIMEOUT: int = 30
MAIN_MENU_MODE: str = "default"
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
@@ -737,6 +750,13 @@ class Settings(BaseSettings):
and self.PAL24_SHOP_ID is not None
)
def is_wata_enabled(self) -> bool:
return (
self.WATA_ENABLED
and self.WATA_ACCESS_TOKEN is not None
and self.WATA_TERMINAL_PUBLIC_ID is not None
)
def get_cryptobot_base_url(self) -> str:
if self.CRYPTOBOT_TESTNET:
return "https://testnet-pay.crypt.bot"

168
app/database/crud/wata.py Normal file
View File

@@ -0,0 +1,168 @@
"""CRUD helpers for WATA payment records."""
import logging
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy import select, update
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,
payment_link_id: str,
amount_kopeks: int,
currency: str,
description: Optional[str],
status: str,
type_: Optional[str],
url: Optional[str],
order_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
expires_at: Optional[datetime] = None,
terminal_public_id: Optional[str] = None,
success_redirect_url: Optional[str] = None,
fail_redirect_url: Optional[str] = None,
) -> WataPayment:
payment = WataPayment(
user_id=user_id,
payment_link_id=payment_link_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
status=status,
type=type_,
url=url,
metadata_json=metadata or {},
expires_at=expires_at,
terminal_public_id=terminal_public_id,
success_redirect_url=success_redirect_url,
fail_redirect_url=fail_redirect_url,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(
"Создан Wata платеж #%s для пользователя %s: %s копеек (статус %s)",
payment.id,
user_id,
amount_kopeks,
status,
)
return payment
async def get_wata_payment_by_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_link_id(
db: AsyncSession,
payment_link_id: str,
) -> Optional[WataPayment]:
result = await db.execute(
select(WataPayment).where(WataPayment.payment_link_id == payment_link_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 update_wata_payment_status(
db: AsyncSession,
payment: WataPayment,
*,
status: Optional[str] = None,
is_paid: Optional[bool] = None,
paid_at: Optional[datetime] = None,
last_status: Optional[str] = None,
url: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
callback_payload: Optional[Dict[str, Any]] = None,
terminal_public_id: Optional[str] = None,
) -> WataPayment:
update_values: Dict[str, Any] = {}
if status is not None:
update_values["status"] = status
if is_paid is not None:
update_values["is_paid"] = is_paid
if paid_at is not None:
update_values["paid_at"] = paid_at
if last_status is not None:
update_values["last_status"] = last_status
if url is not None:
update_values["url"] = url
if metadata is not None:
update_values["metadata_json"] = metadata
if callback_payload is not None:
update_values["callback_payload"] = callback_payload
if terminal_public_id is not None:
update_values["terminal_public_id"] = terminal_public_id
if not update_values:
return payment
await db.execute(
update(WataPayment)
.where(WataPayment.id == payment.id)
.values(**update_values)
)
await db.commit()
await db.refresh(payment)
logger.info(
"Обновлен Wata платеж %s: статус=%s, is_paid=%s",
payment.payment_link_id,
payment.status,
payment.is_paid,
)
return payment
async def link_wata_payment_to_transaction(
db: AsyncSession,
payment: WataPayment,
transaction_id: int,
) -> WataPayment:
await db.execute(
update(WataPayment)
.where(WataPayment.id == payment.id)
.values(transaction_id=transaction_id)
)
await db.commit()
await db.refresh(payment)
logger.info(
"Wata платеж %s привязан к транзакции %s",
payment.payment_link_id,
transaction_id,
)
return payment

View File

@@ -77,6 +77,7 @@ class PaymentMethod(Enum):
CRYPTOBOT = "cryptobot"
MULENPAY = "mulenpay"
PAL24 = "pal24"
WATA = "wata"
MANUAL = "manual"
@@ -293,6 +294,56 @@ 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)
payment_link_id = Column(String(64), unique=True, nullable=False, index=True)
order_id = Column(String(255), nullable=True, index=True)
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default="RUB")
description = Column(Text, nullable=True)
type = Column(String(50), nullable=True)
status = Column(String(50), nullable=False, default="Opened")
is_paid = Column(Boolean, default=False)
paid_at = Column(DateTime, nullable=True)
last_status = Column(String(50), nullable=True)
terminal_public_id = Column(String(64), nullable=True)
url = Column(Text, nullable=True)
success_redirect_url = Column(Text, nullable=True)
fail_redirect_url = Column(Text, nullable=True)
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
expires_at = Column(DateTime, nullable=True)
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
user = relationship("User", backref="wata_payments")
transaction = relationship("Transaction", backref="wata_payment")
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
def __repr__(self) -> str: # pragma: no cover - debug helper
return (
"<WataPayment(id={0}, link_id={1}, amount={2}₽, status={3})>".format(
self.id,
self.payment_link_id,
self.amount_rubles,
self.status,
)
)
class PromoGroup(Base):
__tablename__ = "promo_groups"

View File

@@ -706,6 +706,172 @@ async def create_pal24_payments_table():
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,
payment_link_id VARCHAR(64) NOT NULL UNIQUE,
order_id VARCHAR(255) NULL,
amount_kopeks INTEGER NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
description TEXT NULL,
type VARCHAR(50) NULL,
status VARCHAR(50) NOT NULL DEFAULT 'Opened',
is_paid BOOLEAN NOT NULL DEFAULT 0,
paid_at DATETIME NULL,
last_status VARCHAR(50) NULL,
terminal_public_id VARCHAR(64) NULL,
url TEXT NULL,
success_redirect_url TEXT NULL,
fail_redirect_url TEXT NULL,
metadata_json JSON NULL,
callback_payload JSON NULL,
expires_at DATETIME NULL,
transaction_id INTEGER NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE INDEX idx_wata_link_id ON wata_payments(payment_link_id);
CREATE INDEX idx_wata_order_id ON wata_payments(order_id);
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE wata_payments (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
payment_link_id VARCHAR(64) NOT NULL UNIQUE,
order_id VARCHAR(255) NULL,
amount_kopeks INTEGER NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
description TEXT NULL,
type VARCHAR(50) NULL,
status VARCHAR(50) NOT NULL DEFAULT 'Opened',
is_paid BOOLEAN NOT NULL DEFAULT FALSE,
paid_at TIMESTAMP NULL,
last_status VARCHAR(50) NULL,
terminal_public_id VARCHAR(64) NULL,
url TEXT NULL,
success_redirect_url TEXT NULL,
fail_redirect_url TEXT NULL,
metadata_json JSON NULL,
callback_payload JSON NULL,
expires_at TIMESTAMP NULL,
transaction_id INTEGER NULL REFERENCES transactions(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_wata_link_id ON wata_payments(payment_link_id);
CREATE INDEX idx_wata_order_id ON wata_payments(order_id);
"""
elif db_type == 'mysql':
create_sql = """
CREATE TABLE wata_payments (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
payment_link_id VARCHAR(64) NOT NULL UNIQUE,
order_id VARCHAR(255) NULL,
amount_kopeks INT NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
description TEXT NULL,
type VARCHAR(50) NULL,
status VARCHAR(50) NOT NULL DEFAULT 'Opened',
is_paid BOOLEAN NOT NULL DEFAULT 0,
paid_at DATETIME NULL,
last_status VARCHAR(50) NULL,
terminal_public_id VARCHAR(64) NULL,
url TEXT NULL,
success_redirect_url TEXT NULL,
fail_redirect_url TEXT NULL,
metadata_json JSON NULL,
callback_payload JSON NULL,
expires_at DATETIME NULL,
transaction_id INT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE INDEX idx_wata_link_id ON wata_payments(payment_link_id);
CREATE INDEX idx_wata_order_id ON wata_payments(order_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 ensure_wata_payment_schema() -> bool:
try:
table_exists = await check_table_exists("wata_payments")
if not table_exists:
logger.warning("⚠️ Таблица wata_payments отсутствует — создаём заново")
return await create_wata_payments_table()
link_index_exists = await check_index_exists("wata_payments", "idx_wata_link_id")
order_index_exists = await check_index_exists("wata_payments", "idx_wata_order_id")
async with engine.begin() as conn:
db_type = await get_database_type()
if not link_index_exists:
if db_type in {"sqlite", "postgresql"}:
await conn.execute(
text("CREATE INDEX IF NOT EXISTS idx_wata_link_id ON wata_payments(payment_link_id)")
)
elif db_type == "mysql":
await conn.execute(
text("CREATE INDEX idx_wata_link_id ON wata_payments(payment_link_id)")
)
logger.info("✅ Создан индекс idx_wata_link_id")
else:
logger.info(" Индекс idx_wata_link_id уже существует")
if not order_index_exists:
if db_type in {"sqlite", "postgresql"}:
await conn.execute(
text("CREATE INDEX IF NOT EXISTS idx_wata_order_id ON wata_payments(order_id)")
)
elif db_type == "mysql":
await conn.execute(
text("CREATE INDEX idx_wata_order_id ON wata_payments(order_id)")
)
logger.info("✅ Создан индекс idx_wata_order_id")
else:
logger.info(" Индекс idx_wata_order_id уже существует")
return True
except Exception as e:
logger.error(f"Ошибка обновления схемы wata_payments: {e}")
return False
async def create_discount_offers_table():
table_exists = await check_table_exists('discount_offers')
if table_exists:
@@ -2978,6 +3144,19 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей Pal24 payments")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ WATA ===")
wata_created = await create_wata_payments_table()
if wata_created:
logger.info("✅ Таблица Wata payments готова")
else:
logger.warning("⚠️ Проблемы с таблицей Wata payments")
wata_schema_ok = await ensure_wata_payment_schema()
if wata_schema_ok:
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

@@ -1302,6 +1302,9 @@ def _build_settings_keyboard(
elif category_key == "MULENPAY":
label = texts.t("PAYMENT_CARD_MULENPAY", "💳 Банковская карта (Mulen Pay)")
test_payment_buttons.append([_test_button(f"{label} · тест", "mulenpay")])
elif category_key == "WATA":
label = texts.t("PAYMENT_CARD_WATA", "💳 Банковская карта (WATA)")
test_payment_buttons.append([_test_button(f"{label} · тест", "wata")])
elif category_key == "PAL24":
label = texts.t("PAYMENT_CARD_PAL24", "💳 Банковская карта (PayPalych)")
test_payment_buttons.append([_test_button(f"{label} · тест", "pal24")])

View File

@@ -390,6 +390,12 @@ async def process_topup_amount(
from .mulenpay import process_mulenpay_payment_amount
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
from .wata import process_wata_payment_amount
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
from .pal24 import process_pal24_payment_amount
@@ -490,6 +496,14 @@ 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
from .wata import process_wata_payment_amount
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
from .pal24 import process_pal24_payment_amount
@@ -627,6 +641,12 @@ def register_balance_handlers(dp: Dispatcher):
F.data == "topup_mulenpay"
)
from .wata import start_wata_payment
dp.callback_query.register(
start_wata_payment,
F.data == "topup_wata"
)
from .pal24 import start_pal24_payment
dp.callback_query.register(
start_pal24_payment,
@@ -679,6 +699,12 @@ def register_balance_handlers(dp: Dispatcher):
F.data.startswith("check_mulenpay_")
)
from .wata import check_wata_payment_status
dp.callback_query.register(
check_wata_payment_status,
F.data.startswith("check_wata_")
)
from .pal24 import check_pal24_payment_status
dp.callback_query.register(
check_pal24_payment_status,

View File

@@ -0,0 +1,234 @@
import logging
from typing import Dict
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService, get_user_by_id as fetch_user_by_id
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
@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 временно недоступна", show_alert=True)
return
message_text = texts.t(
"WATA_TOPUP_PROMPT",
(
"💳 <b>Оплата через WATA</b>\n\n"
"Введите сумму пополнения. Минимальная сумма — {min_amount}, максимальная — {max_amount}.\n"
"Оплата происходит через защищенную форму WATA."
),
).format(
min_amount=settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS),
max_amount=settings.format_price(settings.WATA_MAX_AMOUNT_KOPEKS),
)
keyboard = get_back_keyboard(db_user.language)
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
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 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 временно недоступна")
return
if amount_kopeks < settings.WATA_MIN_AMOUNT_KOPEKS:
await message.answer(
texts.t(
"WATA_AMOUNT_TOO_LOW",
"Минимальная сумма пополнения: {amount}",
).format(amount=settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS))
)
return
if amount_kopeks > settings.WATA_MAX_AMOUNT_KOPEKS:
await message.answer(
texts.t(
"WATA_AMOUNT_TOO_HIGH",
"Максимальная сумма пополнения: {amount}",
).format(amount=settings.format_price(settings.WATA_MAX_AMOUNT_KOPEKS))
)
return
payment_service = PaymentService(message.bot)
try:
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,
)
except Exception as error: # pragma: no cover - handled by decorator logs
logger.exception("Ошибка создания WATA платежа: %s", error)
result = None
if not result or not result.get("payment_url"):
await message.answer(
texts.t(
"WATA_PAYMENT_ERROR",
"❌ Ошибка создания платежа WATA. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
return
payment_url = result["payment_url"]
payment_link_id = result["payment_link_id"]
local_payment_id = result["local_payment_id"]
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t("WATA_PAY_BUTTON", "💳 Оплатить через WATA"),
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</b>\n\n"
"💰 Сумма: {amount}\n"
"🆔 ID платежа: {payment_id}\n\n"
"📱 <b>Инструкция:</b>\n"
"1. Нажмите кнопку 'Оплатить через WATA'\n"
"2. Следуйте подсказкам платежной системы\n"
"3. Подтвердите перевод\n"
"4. Средства зачислятся автоматически\n\n"
"❓ Если возникнут проблемы, обратитесь в {support}"
),
)
message_text = message_template.format(
amount=settings.format_price(amount_kopeks),
payment_id=payment_link_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₽, ссылка: %s",
db_user.telegram_id,
amount_kopeks / 100,
payment_link_id,
)
@error_handler
async def check_wata_payment_status(
callback: types.CallbackQuery,
db: AsyncSession,
):
try:
local_payment_id = int(callback.data.split("_")[-1])
except (ValueError, IndexError):
await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True)
return
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("❌ Платеж не найден", show_alert=True)
return
payment = status_info["payment"]
user_language = "ru"
try:
user = await fetch_user_by_id(db, payment.user_id)
if user and getattr(user, "language", None):
user_language = user.language
except Exception as error:
logger.debug("Не удалось получить пользователя для WATA статуса: %s", error)
texts = get_texts(user_language)
status_labels: Dict[str, Dict[str, str]] = {
"Opened": {"emoji": "", "label": texts.t("WATA_STATUS_OPENED", "Ожидает оплаты")},
"Closed": {"emoji": "", "label": texts.t("WATA_STATUS_CLOSED", "Обрабатывается")},
"Paid": {"emoji": "", "label": texts.t("WATA_STATUS_PAID", "Оплачен")},
"Declined": {"emoji": "", "label": texts.t("WATA_STATUS_DECLINED", "Отклонен")},
}
label_info = status_labels.get(payment.status, {"emoji": "", "label": texts.t("WATA_STATUS_UNKNOWN", "Неизвестно")})
message_lines = [
texts.t("WATA_STATUS_TITLE", "💳 <b>Статус платежа WATA</b>"),
"",
f"🆔 ID: {payment.payment_link_id}",
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}",
f"📊 Статус: {label_info['emoji']} {label_info['label']}",
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M') if payment.created_at else ''}",
]
if payment.is_paid:
message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.")
elif payment.status in {"Opened", "Closed"}:
message_lines.append(
"\n⏳ Платеж еще не завершен. Завершите оплату по ссылке и проверьте статус позже."
)
await callback.message.answer("\n".join(message_lines), parse_mode="HTML")
await callback.answer()

View File

@@ -1096,6 +1096,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)"),
callback_data=_build_callback("wata")
)
])
if settings.is_pal24_enabled():
keyboard.append([
InlineKeyboardButton(

View File

@@ -621,11 +621,26 @@
"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)",
"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",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Bank card (WATA)</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>SBP (PayPalych)</b>",
"WATA_TOPUP_PROMPT": "💳 <b>Pay via WATA</b>\n\nEnter the amount to top up. Minimum amount — {min_amount}, maximum — {max_amount}.\nPayments are processed through the secure WATA form.",
"WATA_AMOUNT_TOO_LOW": "Minimum top-up amount: {amount}",
"WATA_AMOUNT_TOO_HIGH": "Maximum top-up amount: {amount}",
"WATA_PAYMENT_ERROR": "❌ Failed to create WATA payment. Please try again later or contact support.",
"WATA_PAY_BUTTON": "💳 Pay via WATA",
"WATA_PAYMENT_INSTRUCTIONS": "💳 <b>Pay via WATA</b>\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 <b>How to pay:</b>\n1. Tap Pay via WATA\n2. Follow the gateway prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
"WATA_STATUS_TITLE": "💳 <b>WATA payment status</b>",
"WATA_STATUS_OPENED": "Waiting for payment",
"WATA_STATUS_CLOSED": "Processing",
"WATA_STATUS_PAID": "Paid",
"WATA_STATUS_DECLINED": "Declined",
"WATA_STATUS_UNKNOWN": "Unknown",
"REPLY_TO_TICKET": "💬 Reply",
"REPORT_CLOSE": "❌ Close",
"REPORT_CLOSED": "✅ Report closed.",

View File

@@ -629,11 +629,26 @@
"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)",
"PAYMENT_CARD_PAL24": "🏦 СБП (PayPalych)",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта (Mulen Pay)</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Банковская карта (WATA)</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
"WATA_TOPUP_PROMPT": "💳 <b>Оплата через WATA</b>\n\nВведите сумму пополнения. Минимальная сумма — {min_amount}, максимальная — {max_amount}.\nОплата происходит через защищенную форму WATA.",
"WATA_AMOUNT_TOO_LOW": "Минимальная сумма пополнения: {amount}",
"WATA_AMOUNT_TOO_HIGH": "Максимальная сумма пополнения: {amount}",
"WATA_PAYMENT_ERROR": "❌ Ошибка создания платежа WATA. Попробуйте позже или обратитесь в поддержку.",
"WATA_PAY_BUTTON": "💳 Оплатить через WATA",
"WATA_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через WATA</b>\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку 'Оплатить через WATA'\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
"WATA_STATUS_TITLE": "💳 <b>Статус платежа WATA</b>",
"WATA_STATUS_OPENED": "Ожидает оплаты",
"WATA_STATUS_CLOSED": "Обрабатывается",
"WATA_STATUS_PAID": "Оплачен",
"WATA_STATUS_DECLINED": "Отклонен",
"WATA_STATUS_UNKNOWN": "Неизвестно",
"REPLY_TO_TICKET": "💬 Ответить",
"REPORT_CLOSE": "❌ Закрыть",
"REPORT_CLOSED": "✅ Отчет закрыт.",

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,357 @@
"""Mixin for integrating WATA payment links into the payment service."""
from __future__ import annotations
import logging
import uuid
from datetime import datetime
from importlib import import_module
from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.wata_service import WataAPIError, WataService
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
class WataPaymentMixin:
"""Encapsulates creation and status handling for WATA payment links."""
async def create_wata_payment(
self,
db: AsyncSession,
user_id: int,
amount_kopeks: int,
description: str,
*,
language: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
if not getattr(self, "wata_service", None):
logger.error("WATA service is not initialised")
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
payment_module = import_module("app.services.payment_service")
order_id = f"wata_{user_id}_{uuid.uuid4().hex[:12]}"
try:
response = await self.wata_service.create_payment_link( # type: ignore[union-attr]
amount_kopeks=amount_kopeks,
currency="RUB",
description=description,
order_id=order_id,
)
except WataAPIError as error:
logger.error("Ошибка создания WATA платежа: %s", error)
return None
except Exception as error: # pragma: no cover - safety net
logger.exception("Непредвиденная ошибка при создании WATA платежа: %s", error)
return None
payment_link_id = response.get("id")
payment_url = response.get("url") or response.get("paymentUrl")
status = response.get("status") or "Opened"
terminal_public_id = response.get("terminalPublicId")
success_url = response.get("successRedirectUrl")
fail_url = response.get("failRedirectUrl")
if not payment_link_id:
logger.error("WATA API не вернула идентификатор платежной ссылки: %s", response)
return None
expiration_raw = response.get("expirationDateTime")
expires_at = WataService._parse_datetime(expiration_raw)
metadata = {
"response": response,
"language": language or settings.DEFAULT_LANGUAGE,
}
local_payment = await payment_module.create_wata_payment(
db=db,
user_id=user_id,
payment_link_id=payment_link_id,
amount_kopeks=amount_kopeks,
currency="RUB",
description=description,
status=status,
type_=response.get("type"),
url=payment_url,
order_id=order_id,
metadata=metadata,
expires_at=expires_at,
terminal_public_id=terminal_public_id,
success_redirect_url=success_url,
fail_redirect_url=fail_url,
)
logger.info(
"Создан WATA платеж %s на %s₽ для пользователя %s",
payment_link_id,
amount_kopeks / 100,
user_id,
)
return {
"local_payment_id": local_payment.id,
"payment_link_id": payment_link_id,
"payment_url": payment_url,
"status": status,
"order_id": order_id,
}
async def get_wata_payment_status(
self,
db: AsyncSession,
local_payment_id: int,
) -> Optional[Dict[str, Any]]:
payment_module = import_module("app.services.payment_service")
payment = await payment_module.get_wata_payment_by_id(db, local_payment_id)
if not payment:
return None
remote_link: Optional[Dict[str, Any]] = None
transaction_payload: Optional[Dict[str, Any]] = None
if getattr(self, "wata_service", None) and payment.payment_link_id:
try:
remote_link = await self.wata_service.get_payment_link(payment.payment_link_id) # type: ignore[union-attr]
except WataAPIError as error:
logger.error("Ошибка получения WATA ссылки %s: %s", payment.payment_link_id, error)
except Exception as error: # pragma: no cover - safety net
logger.exception("Непредвиденная ошибка при запросе WATA ссылки: %s", error)
if remote_link:
remote_status = remote_link.get("status") or payment.status
if remote_status != payment.status:
existing_metadata = dict(getattr(payment, "metadata_json", {}) or {})
existing_metadata["link"] = remote_link
await payment_module.update_wata_payment_status(
db,
payment=payment,
status=remote_status,
last_status=remote_status,
url=remote_link.get("url") or remote_link.get("paymentUrl"),
metadata=existing_metadata,
terminal_public_id=remote_link.get("terminalPublicId"),
)
payment = await payment_module.get_wata_payment_by_id(db, local_payment_id)
if (remote_status or "").lower() == "closed" and not payment.is_paid:
try:
tx_response = await self.wata_service.search_transactions( # type: ignore[union-attr]
order_id=payment.order_id,
payment_link_id=payment.payment_link_id,
status="Paid",
limit=5,
)
items = tx_response.get("items") or []
for item in items:
if (item or {}).get("status") == "Paid":
transaction_payload = item
break
except WataAPIError as error:
logger.error(
"Ошибка поиска WATA транзакций для %s: %s",
payment.payment_link_id,
error,
)
except Exception as error: # pragma: no cover - safety net
logger.exception("Непредвиденная ошибка при поиске WATA транзакции: %s", error)
if transaction_payload and not payment.is_paid:
payment = await self._finalize_wata_payment(db, payment, transaction_payload)
return {
"payment": payment,
"status": payment.status,
"is_paid": payment.is_paid,
"remote_link": remote_link,
"transaction": transaction_payload,
}
async def _finalize_wata_payment(
self,
db: AsyncSession,
payment: Any,
transaction_payload: Dict[str, Any],
) -> Any:
payment_module = import_module("app.services.payment_service")
paid_at = WataService._parse_datetime(transaction_payload.get("paymentTime"))
existing_metadata = dict(getattr(payment, "metadata_json", {}) or {})
existing_metadata["transaction"] = transaction_payload
await payment_module.update_wata_payment_status(
db,
payment=payment,
status="Paid",
is_paid=True,
paid_at=paid_at,
callback_payload=transaction_payload,
metadata=existing_metadata,
)
if payment.transaction_id:
logger.info(
"WATA платеж %s уже привязан к транзакции %s",
payment.payment_link_id,
payment.transaction_id,
)
return payment
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error("Пользователь %s не найден при обработке WATA", payment.user_id)
return payment
transaction_external_id = str(transaction_payload.get("id") or transaction_payload.get("transactionId") or "")
description = f"Пополнение через WATA ({payment.payment_link_id})"
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=description,
payment_method=PaymentMethod.WATA,
external_id=transaction_external_id or payment.payment_link_id,
is_completed=True,
)
await payment_module.link_wata_payment_to_transaction(db, payment, transaction.id)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
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 "🔄 Пополнение"
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
payment.amount_kopeks,
getattr(self, "bot", None),
)
except Exception as error:
logger.error("Ошибка обработки реферального пополнения WATA: %s", error)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
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):
try:
keyboard = await self.build_topup_success_keyboard(user)
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
"🦊 Способ: WATA\n"
f"🆔 Транзакция: {transaction.id}\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as error:
logger.error("Ошибка отправки уведомления пользователю WATA: %s", error)
try:
from app.services.user_cart_service import user_cart_service
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
if has_saved_cart and getattr(self, "bot", None):
from app.localization.texts import get_texts
texts = get_texts(user.language)
cart_message = texts.t(
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
"🛒 У вас есть неоформленный заказ.\n\n"
"Вы можете продолжить оформление с теми же параметрами.",
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout",
)
],
[
types.InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance",
)
],
[
types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu",
)
],
]
)
await self.bot.send_message(
user.telegram_id,
cart_message,
reply_markup=keyboard,
)
except Exception as error:
logger.debug("Не удалось отправить напоминание о корзине после WATA: %s", error)
return payment

View File

@@ -22,8 +22,10 @@ from app.services.payment import (
TelegramStarsMixin,
TributePaymentMixin,
YooKassaPaymentMixin,
WataPaymentMixin,
)
from app.services.yookassa_service import YooKassaService
from app.services.wata_service import WataService
logger = logging.getLogger(__name__)
@@ -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_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 get_wata_payment_by_id(*args, **kwargs):
wata_crud = import_module("app.database.crud.wata")
return await wata_crud.get_wata_payment_by_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 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,15 @@ 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

@@ -0,0 +1,201 @@
"""High level service for interacting with WATA payment API."""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
import aiohttp
from app.config import settings
logger = logging.getLogger(__name__)
class WataAPIError(RuntimeError):
"""Raised when the WATA API returns an error response."""
class WataService:
"""Thin wrapper around the WATA REST API used for balance top-ups."""
def __init__(
self,
*,
base_url: Optional[str] = None,
access_token: Optional[str] = None,
request_timeout: Optional[int] = None,
) -> None:
self.base_url = (base_url or settings.WATA_BASE_URL or "").rstrip("/")
self.access_token = access_token or settings.WATA_ACCESS_TOKEN
self.request_timeout = request_timeout or int(settings.WATA_REQUEST_TIMEOUT)
@property
def is_configured(self) -> bool:
return bool(
settings.is_wata_enabled()
and self.base_url
and self.access_token
)
def _build_url(self, path: str) -> str:
return f"{self.base_url}/{path.lstrip('/')}"
def _build_headers(self) -> Dict[str, str]:
if not self.access_token:
raise WataAPIError("WATA access token is not configured")
return {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
}
async def _request(
self,
method: str,
path: str,
*,
json: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
if not self.is_configured:
raise WataAPIError("WATA service is not configured")
url = self._build_url(path)
timeout = aiohttp.ClientTimeout(total=self.request_timeout)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.request(
method,
url,
json=json,
params=params,
headers=self._build_headers(),
) as response:
response_text = await response.text()
if response.status >= 400:
logger.error(
"WATA API error %s: %s", response.status, response_text
)
raise WataAPIError(
f"WATA API returned status {response.status}: {response_text}"
)
if not response_text:
return {}
try:
data = await response.json()
except aiohttp.ContentTypeError as error:
logger.error("WATA API returned non-JSON response: %s", error)
raise WataAPIError("WATA API returned invalid JSON") from error
return data
except aiohttp.ClientError as error:
logger.error("Error communicating with WATA API: %s", error)
raise WataAPIError("Failed to communicate with WATA API") from error
@staticmethod
def _amount_from_kopeks(amount_kopeks: int) -> float:
return round(amount_kopeks / 100, 2)
@staticmethod
def _format_datetime(value: datetime) -> str:
aware = value.astimezone(timezone.utc)
return aware.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
@staticmethod
def _parse_datetime(raw: Optional[str]) -> Optional[datetime]:
if not raw:
return None
try:
normalized = raw.replace("Z", "+00:00")
return datetime.fromisoformat(normalized)
except (ValueError, TypeError):
logger.debug("Failed to parse WATA datetime: %s", raw)
return None
async def create_payment_link(
self,
*,
amount_kopeks: int,
currency: str,
description: str,
order_id: str,
success_url: Optional[str] = None,
fail_url: Optional[str] = None,
link_type: Optional[str] = None,
expiration_minutes: Optional[int] = None,
allow_arbitrary_amount: bool = False,
arbitrary_amount_prompts: Optional[list[int]] = None,
) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"amount": self._amount_from_kopeks(amount_kopeks),
"currency": currency,
"description": description,
"orderId": order_id,
}
payload["type"] = link_type or settings.WATA_PAYMENT_TYPE or "OneTime"
if success_url or settings.WATA_SUCCESS_REDIRECT_URL:
payload["successRedirectUrl"] = success_url or settings.WATA_SUCCESS_REDIRECT_URL
if fail_url or settings.WATA_FAIL_REDIRECT_URL:
payload["failRedirectUrl"] = fail_url or settings.WATA_FAIL_REDIRECT_URL
if expiration_minutes is None:
ttl = settings.WATA_LINK_TTL_MINUTES
expiration_minutes = int(ttl) if ttl is not None else None
if expiration_minutes:
expiration_time = datetime.utcnow() + timedelta(minutes=expiration_minutes)
payload["expirationDateTime"] = self._format_datetime(expiration_time)
if allow_arbitrary_amount:
payload["isArbitraryAmountAllowed"] = True
if arbitrary_amount_prompts:
payload["arbitraryAmountPrompts"] = arbitrary_amount_prompts
logger.info(
"Создаем WATA платежную ссылку: order_id=%s, amount=%s %s",
order_id,
payload["amount"],
currency,
)
response = await self._request("POST", "/links", json=payload)
logger.debug("WATA create link response: %s", response)
return response
async def get_payment_link(self, payment_link_id: str) -> Dict[str, Any]:
logger.debug("Запрашиваем WATA ссылку %s", payment_link_id)
return await self._request("GET", f"/links/{payment_link_id}")
async def search_transactions(
self,
*,
order_id: Optional[str] = None,
payment_link_id: Optional[str] = None,
status: Optional[str] = None,
limit: int = 5,
) -> Dict[str, Any]:
params: Dict[str, Any] = {
"skipCount": 0,
"maxResultCount": max(1, min(limit, 1000)),
}
if order_id:
params["orderId"] = order_id
if status:
params["statuses"] = status
if payment_link_id:
params["paymentLinkId"] = payment_link_id
logger.debug(
"Ищем WATA транзакции: order_id=%s, payment_link_id=%s", order_id, payment_link_id
)
return await self._request("GET", "/transactions", params=params)
async def get_transaction(self, transaction_id: str) -> Dict[str, Any]:
logger.debug("Получаем WATA транзакцию %s", transaction_id)
return await self._request("GET", f"/transactions/{transaction_id}")

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

@@ -74,6 +74,7 @@
"PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Payment methods are temporarily unavailable",
"PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)",
"PAYMENT_CARD_MULENPAY": "💳 Bank card (Mulen Pay)",
"PAYMENT_CARD_WATA": "💳 Bank card (WATA)",
"PAYMENT_CARD_PAL24": "🏦 SBP (PayPalych)",
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
@@ -86,6 +87,10 @@
"MULENPAY_PAYMENT_ERROR": "❌ Failed to create Mulen Pay payment. Please try again later or contact support.",
"MULENPAY_PAY_BUTTON": "💳 Pay with Mulen Pay",
"MULENPAY_PAYMENT_INSTRUCTIONS": "💳 <b>Mulen Pay payment</b>\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 <b>How to pay:</b>\n1. Press Pay with Mulen Pay\n2. Follow the instructions on the payment page\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
"WATA_TOPUP_PROMPT": "💳 <b>Pay via WATA</b>\n\nEnter the amount to top up. Minimum amount — {min_amount}, maximum — {max_amount}.\nPayments are processed through the secure WATA form.",
"WATA_PAYMENT_ERROR": "❌ Failed to create WATA payment. Please try again later or contact support.",
"WATA_PAY_BUTTON": "💳 Pay via WATA",
"WATA_PAYMENT_INSTRUCTIONS": "💳 <b>Pay via WATA</b>\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 <b>How to pay:</b>\n1. Tap Pay via WATA\n2. Follow the gateway prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
"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.",
"PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.",
"PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
@@ -96,6 +101,14 @@
"PAL24_INSTRUCTION_FOLLOW": "{step}. Follow the payment page instructions",
"PAL24_INSTRUCTION_CONFIRM": "{step}. Confirm the transfer",
"PAL24_INSTRUCTION_COMPLETE": "{step}. The funds will be credited automatically",
"WATA_AMOUNT_TOO_LOW": "Minimum top-up amount: {amount}",
"WATA_AMOUNT_TOO_HIGH": "Maximum top-up amount: {amount}",
"WATA_STATUS_TITLE": "💳 <b>WATA payment status</b>",
"WATA_STATUS_OPENED": "Waiting for payment",
"WATA_STATUS_CLOSED": "Processing",
"WATA_STATUS_PAID": "Paid",
"WATA_STATUS_DECLINED": "Declined",
"WATA_STATUS_UNKNOWN": "Unknown",
"PENDING_CANCEL_BUTTON": "⌛ Cancel",
"POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀",
"REFERRAL_ANALYTICS_BUTTON": "📊 Analytics",
@@ -564,6 +577,8 @@
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card (Mulen Pay)</b>",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via Mulen Pay",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Bank card (WATA)</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "via WATA",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>SBP (PayPalych)</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</b>",

View File

@@ -292,6 +292,7 @@
"PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Способы оплаты временно недоступны",
"PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)",
"PAYMENT_CARD_MULENPAY": "💳 Банковская карта (Mulen Pay)",
"PAYMENT_CARD_WATA": "💳 Банковская карта (WATA)",
"PAYMENT_CARD_PAL24": "🏦 СБП (PayPalych)",
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
@@ -304,6 +305,10 @@
"MULENPAY_PAYMENT_ERROR": "❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
"MULENPAY_PAY_BUTTON": "💳 Оплатить через Mulen Pay",
"MULENPAY_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через Mulen Pay</b>\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через Mulen Pay\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
"WATA_TOPUP_PROMPT": "💳 <b>Оплата через WATA</b>\n\nВведите сумму пополнения. Минимальная сумма — {min_amount}, максимальная — {max_amount}.\nОплата происходит через защищенную форму WATA.",
"WATA_PAYMENT_ERROR": "❌ Ошибка создания платежа WATA. Попробуйте позже или обратитесь в поддержку.",
"WATA_PAY_BUTTON": "💳 Оплатить через WATA",
"WATA_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через WATA</b>\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку 'Оплатить через WATA'\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
"PAL24_TOPUP_PROMPT": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.",
"PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
"PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
@@ -314,6 +319,14 @@
"PAL24_INSTRUCTION_FOLLOW": "{step}. Следуйте подсказкам платёжной системы",
"PAL24_INSTRUCTION_CONFIRM": "{step}. Подтвердите перевод",
"PAL24_INSTRUCTION_COMPLETE": "{step}. Средства зачислятся автоматически",
"WATA_AMOUNT_TOO_LOW": "Минимальная сумма пополнения: {amount}",
"WATA_AMOUNT_TOO_HIGH": "Максимальная сумма пополнения: {amount}",
"WATA_STATUS_TITLE": "💳 <b>Статус платежа WATA</b>",
"WATA_STATUS_OPENED": "Ожидает оплаты",
"WATA_STATUS_CLOSED": "Обрабатывается",
"WATA_STATUS_PAID": "Оплачен",
"WATA_STATUS_DECLINED": "Отклонен",
"WATA_STATUS_UNKNOWN": "Неизвестно",
"PENDING_CANCEL_BUTTON": "⌛ Отмена",
"PERIOD_14_DAYS": "📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}",
"PERIOD_180_DAYS": "📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}",
@@ -566,6 +579,8 @@
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта (Mulen Pay)</b>",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Банковская карта (WATA)</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",

View File

@@ -17,6 +17,7 @@ from app.services.payment import ( # noqa: E402
TelegramStarsMixin,
TributePaymentMixin,
YooKassaPaymentMixin,
WataPaymentMixin,
)
from app.services.payment_service import PaymentService # noqa: E402
@@ -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-классы"
@@ -46,6 +48,7 @@ def test_payment_service_mro_contains_all_mixins() -> None:
"create_cryptobot_payment",
"create_mulenpay_payment",
"create_pal24_payment",
"create_wata_payment",
],
)
def test_payment_service_exposes_provider_methods(attribute: str) -> None:

View File

@@ -0,0 +1,144 @@
"""Tests for WATA payment mixin."""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
import sys
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 - no logic required
return None
async def refresh(self, *_: Any) -> None: # pragma: no cover - no logic required
return None
class DummyLocalPayment:
def __init__(self, payment_id: int = 42) -> None:
self.id = payment_id
self.created_at = datetime.utcnow()
class StubWataService:
def __init__(self, response: Optional[Dict[str, Any]]) -> None:
self.response = response
self.calls: list[Dict[str, Any]] = []
async def create_payment_link(self, **kwargs: Any) -> Optional[Dict[str, Any]]:
self.calls.append(kwargs)
return self.response
def _make_service(stub: Optional[StubWataService]) -> PaymentService:
service = PaymentService.__new__(PaymentService) # type: ignore[call-arg]
service.bot = None
service.wata_service = stub
service.mulenpay_service = None
service.pal24_service = None
service.yookassa_service = None
service.stars_service = None
service.cryptobot_service = None
return service
@pytest.mark.anyio("asyncio")
async def test_create_wata_payment_success(monkeypatch: pytest.MonkeyPatch) -> None:
response = {
"id": "123e4567-e89b-12d3-a456-426614174000",
"url": "https://wata.example/link",
"status": "Opened",
"type": "OneTime",
"terminalPublicId": "terminal-id",
"successRedirectUrl": "https://example.com/success",
"failRedirectUrl": "https://example.com/fail",
"expirationDateTime": "2030-01-01T00:00:00Z",
}
stub = StubWataService(response)
service = _make_service(stub)
db = DummySession()
captured_args: Dict[str, Any] = {}
async def fake_create_wata_payment(**kwargs: Any) -> DummyLocalPayment:
captured_args.update(kwargs)
return DummyLocalPayment(payment_id=777)
monkeypatch.setattr(payment_service_module, "create_wata_payment", fake_create_wata_payment, raising=False)
monkeypatch.setattr(settings, "WATA_MIN_AMOUNT_KOPEKS", 5000, raising=False)
monkeypatch.setattr(settings, "WATA_MAX_AMOUNT_KOPEKS", 500_000, raising=False)
result = await service.create_wata_payment(
db=db,
user_id=101,
amount_kopeks=15000,
description="Пополнение",
language="ru",
)
assert result is not None
assert result["local_payment_id"] == 777
assert result["payment_link_id"] == response["id"]
assert result["payment_url"] == response["url"]
assert captured_args["user_id"] == 101
assert captured_args["amount_kopeks"] == 15000
assert captured_args["payment_link_id"] == response["id"]
assert stub.calls and stub.calls[0]["amount_kopeks"] == 15000
@pytest.mark.anyio("asyncio")
async def test_create_wata_payment_respects_amount_limits(monkeypatch: pytest.MonkeyPatch) -> None:
stub = StubWataService({"id": "link"})
service = _make_service(stub)
db = DummySession()
monkeypatch.setattr(settings, "WATA_MIN_AMOUNT_KOPEKS", 10_000, raising=False)
monkeypatch.setattr(settings, "WATA_MAX_AMOUNT_KOPEKS", 20_000, raising=False)
too_low = await service.create_wata_payment(
db=db,
user_id=1,
amount_kopeks=5_000,
description="Пополнение",
)
assert too_low is None
too_high = await service.create_wata_payment(
db=db,
user_id=1,
amount_kopeks=25_000,
description="Пополнение",
)
assert too_high is None
assert not stub.calls
@pytest.mark.anyio("asyncio")
async def test_create_wata_payment_returns_none_without_service() -> None:
service = _make_service(None)
db = DummySession()
result = await service.create_wata_payment(
db=db,
user_id=5,
amount_kopeks=10_000,
description="Пополнение",
)
assert result is None