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