diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 748dce03..e690f522 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -62,7 +62,7 @@ from app.services.remnawave_service import ( RemnaWaveConfigurationError, RemnaWaveService, ) -from app.services.payment_service import PaymentService +from app.services.payment_service import PaymentService, get_wata_payment_by_link_id from app.services.promo_offer_service import promo_offer_service from app.services.promocode_service import PromoCodeService from app.services.subscription_service import SubscriptionService @@ -688,6 +688,18 @@ async def get_payment_methods( ) ) + if settings.is_wata_enabled(): + methods.append( + MiniAppPaymentMethod( + id="wata", + icon="🌊", + requires_amount=True, + currency="RUB", + min_amount_kopeks=settings.WATA_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.WATA_MAX_AMOUNT_KOPEKS, + ) + ) + if settings.is_cryptobot_enabled(): rate = await _get_usd_to_rub_rate() min_amount_kopeks, max_amount_kopeks = _compute_cryptobot_limits(rate) @@ -718,8 +730,9 @@ async def get_payment_methods( "yookassa": 3, "mulenpay": 4, "pal24": 5, - "cryptobot": 6, - "tribute": 7, + "wata": 6, + "cryptobot": 7, + "tribute": 8, } methods.sort(key=lambda item: order_map.get(item.id, 99)) @@ -896,6 +909,42 @@ async def create_payment_link( }, ) + if method == "wata": + if not settings.is_wata_enabled(): + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + if amount_kopeks is None or amount_kopeks <= 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + if amount_kopeks < settings.WATA_MIN_AMOUNT_KOPEKS: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum") + if amount_kopeks > settings.WATA_MAX_AMOUNT_KOPEKS: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum") + + payment_service = PaymentService() + result = await payment_service.create_wata_payment( + db=db, + user_id=user.id, + amount_kopeks=amount_kopeks, + description=settings.get_balance_payment_description(amount_kopeks), + language=user.language, + ) + payment_url = result.get("payment_url") if result else None + if not result or not payment_url: + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + + return MiniAppPaymentCreateResponse( + method=method, + payment_url=payment_url, + amount_kopeks=amount_kopeks, + extra={ + "local_payment_id": result.get("local_payment_id"), + "payment_link_id": result.get("payment_link_id"), + "payment_id": result.get("payment_link_id"), + "status": result.get("status"), + "order_id": result.get("order_id"), + "requested_at": _current_request_timestamp(), + }, + ) + if method == "pal24": if not settings.is_pal24_enabled(): raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") @@ -1107,6 +1156,8 @@ async def _resolve_payment_status_entry( ) if method == "mulenpay": return await _resolve_mulenpay_payment_status(payment_service, db, user, query) + if method == "wata": + return await _resolve_wata_payment_status(payment_service, db, user, query) if method == "pal24": return await _resolve_pal24_payment_status(payment_service, db, user, query) if method == "cryptobot": @@ -1255,6 +1306,106 @@ async def _resolve_mulenpay_payment_status( ) +async def _resolve_wata_payment_status( + payment_service: PaymentService, + db: AsyncSession, + user: User, + query: MiniAppPaymentStatusQuery, +) -> MiniAppPaymentStatusResult: + local_id = query.local_payment_id + payment_link_id = query.payment_link_id or query.payment_id or query.invoice_id + fallback_payment = None + + if not local_id and payment_link_id: + fallback_payment = await get_wata_payment_by_link_id(db, payment_link_id) + if fallback_payment: + local_id = fallback_payment.id + + if not local_id: + return MiniAppPaymentStatusResult( + method="wata", + status="pending", + is_paid=False, + amount_kopeks=query.amount_kopeks, + message="Missing payment identifier", + extra={ + "local_payment_id": query.local_payment_id, + "payment_link_id": payment_link_id, + "payment_id": query.payment_id, + "invoice_id": query.invoice_id, + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + status_info = await payment_service.get_wata_payment_status(db, local_id) + payment = (status_info or {}).get("payment") or fallback_payment + + if not payment or payment.user_id != user.id: + return MiniAppPaymentStatusResult( + method="wata", + status="pending", + is_paid=False, + amount_kopeks=query.amount_kopeks, + message="Payment not found", + extra={ + "local_payment_id": local_id, + "payment_link_id": (payment_link_id or getattr(payment, "payment_link_id", None)), + "payment_id": query.payment_id, + "invoice_id": query.invoice_id, + "payload": query.payload, + "started_at": query.started_at, + }, + ) + + remote_link = (status_info or {}).get("remote_link") if status_info else None + transaction_payload = (status_info or {}).get("transaction") if status_info else None + status_raw = (status_info or {}).get("status") or getattr(payment, "status", None) + is_paid_flag = bool((status_info or {}).get("is_paid") or getattr(payment, "is_paid", False)) + status_value = _classify_status(status_raw, is_paid_flag) + completed_at = ( + getattr(payment, "paid_at", None) + or getattr(payment, "updated_at", None) + or getattr(payment, "created_at", None) + ) + + message = None + if status_value == "failed": + message = ( + (transaction_payload or {}).get("errorDescription") + or (transaction_payload or {}).get("errorCode") + or (remote_link or {}).get("status") + ) + + extra: Dict[str, Any] = { + "local_payment_id": payment.id, + "payment_link_id": payment.payment_link_id, + "payment_id": payment.payment_link_id, + "status": status_raw, + "is_paid": getattr(payment, "is_paid", False), + "order_id": getattr(payment, "order_id", None), + "payload": query.payload, + "started_at": query.started_at, + } + if remote_link: + extra["remote_link"] = remote_link + if transaction_payload: + extra["transaction"] = transaction_payload + + return MiniAppPaymentStatusResult( + method="wata", + status=status_value, + is_paid=status_value == "paid", + amount_kopeks=payment.amount_kopeks, + currency=payment.currency, + completed_at=completed_at, + transaction_id=payment.transaction_id, + external_id=payment.payment_link_id, + message=message, + extra=extra, + ) + + async def _resolve_pal24_payment_status( payment_service: PaymentService, db: AsyncSession, diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 85c92319..b7eca0a5 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -392,6 +392,7 @@ class MiniAppPaymentCreateResponse(BaseModel): class MiniAppPaymentStatusQuery(BaseModel): method: str local_payment_id: Optional[int] = Field(default=None, alias="localPaymentId") + payment_link_id: Optional[str] = Field(default=None, alias="paymentLinkId") invoice_id: Optional[str] = Field(default=None, alias="invoiceId") payment_id: Optional[str] = Field(default=None, alias="paymentId") payload: Optional[str] = None diff --git a/tests/test_miniapp_payments.py b/tests/test_miniapp_payments.py index ec666cf3..848b8225 100644 --- a/tests/test_miniapp_payments.py +++ b/tests/test_miniapp_payments.py @@ -3,6 +3,7 @@ import sys import types from datetime import datetime, timedelta from pathlib import Path +from typing import Any import pytest @@ -89,6 +90,57 @@ async def test_create_payment_link_pal24_uses_selected_option(monkeypatch): assert captured_calls and captured_calls[0]['payment_method'] == 'CARD' +@pytest.mark.anyio("asyncio") +async def test_create_payment_link_wata_returns_payload(monkeypatch): + monkeypatch.setattr(settings, 'WATA_ENABLED', True, raising=False) + monkeypatch.setattr(settings, 'WATA_ACCESS_TOKEN', 'token', raising=False) + monkeypatch.setattr(settings, 'WATA_TERMINAL_PUBLIC_ID', 'terminal', raising=False) + monkeypatch.setattr(settings, 'WATA_MIN_AMOUNT_KOPEKS', 1000, raising=False) + monkeypatch.setattr(settings, 'WATA_MAX_AMOUNT_KOPEKS', 5000000, raising=False) + + captured_call: dict[str, Any] = {} + + class DummyPaymentService: + def __init__(self, *args, **kwargs): + pass + + async def create_wata_payment(self, db, **kwargs): + captured_call.update({'db': db, **kwargs}) + return { + 'local_payment_id': 202, + 'payment_link_id': 'link_202', + 'payment_url': 'https://wata.example/pay', + 'status': 'Opened', + 'order_id': 'order_202', + } + + async def fake_resolve_user(db, init_data): + return types.SimpleNamespace(id=555, language='ru'), {} + + monkeypatch.setattr(miniapp, 'PaymentService', lambda *args, **kwargs: DummyPaymentService()) + monkeypatch.setattr(miniapp, '_resolve_user_from_init_data', fake_resolve_user) + + payload = MiniAppPaymentCreateRequest( + initData='init', + method='wata', + amountKopeks=25000, + ) + + response = await miniapp.create_payment_link(payload, db=types.SimpleNamespace()) + + assert response.payment_url == 'https://wata.example/pay' + assert response.amount_kopeks == 25000 + assert response.extra['local_payment_id'] == 202 + assert response.extra['payment_link_id'] == 'link_202' + assert response.extra['payment_id'] == 'link_202' + assert response.extra['order_id'] == 'order_202' + assert 'requested_at' in response.extra + + assert captured_call.get('user_id') == 555 + assert captured_call.get('amount_kopeks') == 25000 + assert captured_call.get('description') + + @pytest.mark.anyio("asyncio") async def test_resolve_yookassa_status_includes_identifiers(monkeypatch): payment = types.SimpleNamespace( @@ -251,6 +303,112 @@ async def test_resolve_pal24_status_includes_identifiers(monkeypatch): assert result.extra['remote_status'] == 'PAID' +@pytest.mark.anyio("asyncio") +async def test_resolve_wata_payment_status_success(): + paid_at = datetime.utcnow() + payment = types.SimpleNamespace( + id=404, + user_id=9, + amount_kopeks=30000, + currency='RUB', + status='Paid', + is_paid=True, + payment_link_id='wata_link_404', + order_id='order_404', + transaction_id=909, + paid_at=paid_at, + updated_at=paid_at, + created_at=paid_at - timedelta(minutes=1), + ) + + class StubWataService: + async def get_wata_payment_status(self, db, local_payment_id): # noqa: ARG002 + assert local_payment_id == 404 + return { + 'payment': payment, + 'status': 'Paid', + 'is_paid': True, + 'remote_link': {'status': 'Paid'}, + 'transaction': {'id': 'tx_404', 'status': 'Paid'}, + } + + query = MiniAppPaymentStatusQuery( + method='wata', + localPaymentId=404, + amountKopeks=30000, + startedAt='2024-06-01T12:00:00Z', + payload='wata_payload', + ) + + result = await miniapp._resolve_wata_payment_status( + StubWataService(), + db=None, + user=types.SimpleNamespace(id=9), + query=query, + ) + + assert result.status == 'paid' + assert result.external_id == 'wata_link_404' + assert result.extra['payment_link_id'] == 'wata_link_404' + assert result.extra['transaction']['id'] == 'tx_404' + assert result.extra['payload'] == 'wata_payload' + assert result.extra['started_at'] == '2024-06-01T12:00:00Z' + + +@pytest.mark.anyio("asyncio") +async def test_resolve_wata_payment_status_uses_payment_link_lookup(monkeypatch): + created_at = datetime.utcnow() + payment = types.SimpleNamespace( + id=505, + user_id=7, + amount_kopeks=15000, + currency='RUB', + status='Opened', + is_paid=False, + payment_link_id='wata_lookup', + order_id='order_lookup', + transaction_id=None, + paid_at=None, + updated_at=None, + created_at=created_at, + ) + + async def fake_get_wata_payment_by_link_id(db, link_id): # noqa: ARG001 + assert link_id == 'wata_lookup' + return payment + + monkeypatch.setattr(miniapp, 'get_wata_payment_by_link_id', fake_get_wata_payment_by_link_id) + + class StubWataService: + async def get_wata_payment_status(self, db, local_payment_id): # noqa: ARG002 + assert local_payment_id == 505 + return { + 'payment': payment, + 'status': payment.status, + 'is_paid': False, + 'remote_link': None, + 'transaction': None, + } + + query = MiniAppPaymentStatusQuery( + method='wata', + paymentLinkId='wata_lookup', + amountKopeks=15000, + ) + + result = await miniapp._resolve_wata_payment_status( + StubWataService(), + db=None, + user=types.SimpleNamespace(id=7), + query=query, + ) + + assert result.status == 'pending' + assert result.extra['local_payment_id'] == 505 + assert result.extra['payment_link_id'] == 'wata_lookup' + assert 'transaction' not in result.extra + + @pytest.mark.anyio("asyncio") async def test_create_payment_link_stars_normalizes_amount(monkeypatch): monkeypatch.setattr(settings, 'TELEGRAM_STARS_ENABLED', True, raising=False) @@ -333,6 +491,30 @@ async def test_get_payment_methods_exposes_stars_min_amount(monkeypatch): assert stars_method is not None assert stars_method.min_amount_kopeks == 99999 assert stars_method.amount_step_kopeks == 99999 + + +@pytest.mark.anyio("asyncio") +async def test_get_payment_methods_includes_wata(monkeypatch): + monkeypatch.setattr(settings, 'WATA_ENABLED', True, raising=False) + monkeypatch.setattr(settings, 'WATA_ACCESS_TOKEN', 'token', raising=False) + monkeypatch.setattr(settings, 'WATA_TERMINAL_PUBLIC_ID', 'terminal', raising=False) + monkeypatch.setattr(settings, 'WATA_MIN_AMOUNT_KOPEKS', 5000, raising=False) + monkeypatch.setattr(settings, 'WATA_MAX_AMOUNT_KOPEKS', 7500000, raising=False) + + async def fake_resolve_user(db, init_data): + return types.SimpleNamespace(id=1, language='ru'), {} + + monkeypatch.setattr(miniapp, '_resolve_user_from_init_data', fake_resolve_user) + + payload = MiniAppPaymentMethodsRequest(initData='abc') + response = await miniapp.get_payment_methods(payload, db=types.SimpleNamespace()) + + wata_method = next((method for method in response.methods if method.id == 'wata'), None) + + assert wata_method is not None + assert wata_method.min_amount_kopeks == 5000 + assert wata_method.max_amount_kopeks == 7500000 + assert wata_method.icon == '🌊' @pytest.mark.anyio("asyncio") async def test_find_recent_deposit_ignores_transactions_before_attempt(): started_at = datetime(2024, 5, 1, 12, 0, 0)