Add WATA payment support to miniapp API and tests

This commit is contained in:
Egor
2025-10-15 01:50:34 +03:00
parent 086660ebef
commit 436efcdd3e
3 changed files with 337 additions and 3 deletions

View File

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

View File

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

View File

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