From 56e0ec4cd2abf654a8842448235c075dd7d9024c Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 15 Oct 2025 01:14:43 +0300 Subject: [PATCH] Fix logging import in WATA CRUD module --- app/config.py | 20 + app/database/crud/wata.py | 168 +++++++++ app/database/models.py | 51 +++ app/database/universal_migration.py | 179 +++++++++ app/handlers/admin/bot_configuration.py | 3 + app/handlers/balance/main.py | 26 ++ app/handlers/balance/wata.py | 234 ++++++++++++ app/keyboards/inline.py | 8 + app/localization/locales/en.json | 15 + app/localization/locales/ru.json | 15 + app/services/payment/__init__.py | 2 + app/services/payment/wata.py | 357 ++++++++++++++++++ app/services/payment_service.py | 37 +- app/services/wata_service.py | 201 ++++++++++ app/utils/payment_utils.py | 14 + locales/en.json | 15 + locales/ru.json | 15 + .../test_payment_service_modularity.py | 3 + tests/services/test_payment_service_wata.py | 144 +++++++ 19 files changed, 1506 insertions(+), 1 deletion(-) create mode 100644 app/database/crud/wata.py create mode 100644 app/handlers/balance/wata.py create mode 100644 app/services/payment/wata.py create mode 100644 app/services/wata_service.py create mode 100644 tests/services/test_payment_service_wata.py diff --git a/app/config.py b/app/config.py index 71065597..0a5c790e 100644 --- a/app/config.py +++ b/app/config.py @@ -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" diff --git a/app/database/crud/wata.py b/app/database/crud/wata.py new file mode 100644 index 00000000..ac047d32 --- /dev/null +++ b/app/database/crud/wata.py @@ -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 diff --git a/app/database/models.py b/app/database/models.py index 75dca572..459eec12 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -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 ( + "".format( + self.id, + self.payment_link_id, + self.amount_rubles, + self.status, + ) + ) + + class PromoGroup(Base): __tablename__ = "promo_groups" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 587095a8..4d1610a2 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -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: diff --git a/app/handlers/admin/bot_configuration.py b/app/handlers/admin/bot_configuration.py index d3fc71e7..a6531fab 100644 --- a/app/handlers/admin/bot_configuration.py +++ b/app/handlers/admin/bot_configuration.py @@ -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")]) diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py index 1638dbb0..7c2cf2a0 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -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, diff --git a/app/handlers/balance/wata.py b/app/handlers/balance/wata.py new file mode 100644 index 00000000..ddd7c5b4 --- /dev/null +++ b/app/handlers/balance/wata.py @@ -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", + ( + "💳 Оплата через WATA\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", + ( + "💳 Оплата через WATA\n\n" + "💰 Сумма: {amount}\n" + "🆔 ID платежа: {payment_id}\n\n" + "📱 Инструкция:\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", "💳 Статус платежа WATA"), + "", + 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() diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 3402415e..43f018c2 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -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( diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 7c88548c..139a07e0 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -621,11 +621,26 @@ "PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)", "PAL24_TOPUP_PROMPT": "🏦 PayPalych (SBP) payment\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": "💳 Bank card (Mulen Pay)", + "PAYMENT_METHOD_WATA_DESCRIPTION": "via WATA", + "PAYMENT_METHOD_WATA_NAME": "💳 Bank card (WATA)", "PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System", "PAYMENT_METHOD_PAL24_NAME": "🏦 SBP (PayPalych)", + "WATA_TOPUP_PROMPT": "💳 Pay via WATA\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": "💳 Pay via WATA\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 How to pay:\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": "💳 WATA payment status", + "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.", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 19f24a76..c4e0c784 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -629,11 +629,26 @@ "PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)", "PAL24_TOPUP_PROMPT": "🏦 Оплата через PayPalych (СБП)\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": "💳 Банковская карта (Mulen Pay)", + "PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA", + "PAYMENT_METHOD_WATA_NAME": "💳 Банковская карта (WATA)", "PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей", "PAYMENT_METHOD_PAL24_NAME": "🏦 СБП (PayPalych)", + "WATA_TOPUP_PROMPT": "💳 Оплата через WATA\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": "💳 Оплата через WATA\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 Инструкция:\n1. Нажмите кнопку 'Оплатить через WATA'\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}", + "WATA_STATUS_TITLE": "💳 Статус платежа WATA", + "WATA_STATUS_OPENED": "Ожидает оплаты", + "WATA_STATUS_CLOSED": "Обрабатывается", + "WATA_STATUS_PAID": "Оплачен", + "WATA_STATUS_DECLINED": "Отклонен", + "WATA_STATUS_UNKNOWN": "Неизвестно", "REPLY_TO_TICKET": "💬 Ответить", "REPORT_CLOSE": "❌ Закрыть", "REPORT_CLOSED": "✅ Отчет закрыт.", diff --git a/app/services/payment/__init__.py b/app/services/payment/__init__.py index 870c33af..71719bfb 100644 --- a/app/services/payment/__init__.py +++ b/app/services/payment/__init__.py @@ -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", ] diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py new file mode 100644 index 00000000..a3d8cca0 --- /dev/null +++ b/app/services/payment/wata.py @@ -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, + ( + "✅ Пополнение успешно!\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 diff --git a/app/services/payment_service.py b/app/services/payment_service.py index c105e986..235224a6 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -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), ) diff --git a/app/services/wata_service.py b/app/services/wata_service.py new file mode 100644 index 00000000..8115b9ab --- /dev/null +++ b/app/services/wata_service.py @@ -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}") diff --git a/app/utils/payment_utils.py b/app/utils/payment_utils.py index 913b18d2..559a03fc 100644 --- a/app/utils/payment_utils.py +++ b/app/utils/payment_utils.py @@ -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(): diff --git a/locales/en.json b/locales/en.json index 3b3c459b..4bea335f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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": "💳 Mulen Pay payment\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 How to pay:\n1. Press ‘Pay with Mulen Pay’\n2. Follow the instructions on the payment page\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}", + "WATA_TOPUP_PROMPT": "💳 Pay via WATA\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": "💳 Pay via WATA\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 How to pay:\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": "🏦 PayPalych (SBP) payment\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": "💳 WATA payment status", + "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": "💳 Bank card (Mulen Pay)", "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via Mulen Pay", + "PAYMENT_METHOD_WATA_NAME": "💳 Bank card (WATA)", + "PAYMENT_METHOD_WATA_DESCRIPTION": "via WATA", "PAYMENT_METHOD_PAL24_NAME": "🏦 SBP (PayPalych)", "PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System", "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Cryptocurrency", diff --git a/locales/ru.json b/locales/ru.json index fa7ec35b..1fa41edb 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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": "💳 Оплата через Mulen Pay\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 Инструкция:\n1. Нажмите кнопку ‘Оплатить через Mulen Pay’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}", + "WATA_TOPUP_PROMPT": "💳 Оплата через WATA\n\nВведите сумму пополнения. Минимальная сумма — {min_amount}, максимальная — {max_amount}.\nОплата происходит через защищенную форму WATA.", + "WATA_PAYMENT_ERROR": "❌ Ошибка создания платежа WATA. Попробуйте позже или обратитесь в поддержку.", + "WATA_PAY_BUTTON": "💳 Оплатить через WATA", + "WATA_PAYMENT_INSTRUCTIONS": "💳 Оплата через WATA\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 Инструкция:\n1. Нажмите кнопку 'Оплатить через WATA'\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}", "PAL24_TOPUP_PROMPT": "🏦 Оплата через PayPalych (СБП)\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.", "PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", "PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)", @@ -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": "💳 Статус платежа WATA", + "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": "💳 Банковская карта (Mulen Pay)", "PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay", + "PAYMENT_METHOD_WATA_NAME": "💳 Банковская карта (WATA)", + "PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA", "PAYMENT_METHOD_PAL24_NAME": "🏦 СБП (PayPalych)", "PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей", "PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 Криптовалюта", diff --git a/tests/services/test_payment_service_modularity.py b/tests/services/test_payment_service_modularity.py index 6c0ead84..0a20450e 100644 --- a/tests/services/test_payment_service_modularity.py +++ b/tests/services/test_payment_service_modularity.py @@ -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: diff --git a/tests/services/test_payment_service_wata.py b/tests/services/test_payment_service_wata.py new file mode 100644 index 00000000..fd30232f --- /dev/null +++ b/tests/services/test_payment_service_wata.py @@ -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