diff --git a/backend/bot/payment_providers/wata.py b/backend/bot/payment_providers/wata.py index 5dffb88..78889b7 100644 --- a/backend/bot/payment_providers/wata.py +++ b/backend/bot/payment_providers/wata.py @@ -55,6 +55,7 @@ from .shared import ( router = Router(name="user_subscription_payments_wata_router") _LOG = "wata" +_WATA_IN_PROGRESS_STATUSES = {"created", "pending"} class WataConfig(ProviderEnvConfig): @@ -287,12 +288,15 @@ class WataService(HttpClientMixin): return web.Response(status=400, text="bad_request") transaction_id = str(payload.get("transactionId") or "").strip() + payment_link_id = str( + payload.get("paymentLinkId") or payload.get("id") or "" + ).strip() status = str(payload.get("transactionStatus") or "").strip().lower() order_id_raw = payload.get("orderId") amount_raw = payload.get("amount") currency = payload.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB" - if not status or not (transaction_id or order_id_raw): + if not status or not (transaction_id or order_id_raw or payment_link_id): logging.error("Wata webhook: missing transaction status or ids: %s", payload) return web.Response(status=400, text="missing_fields") @@ -302,18 +306,46 @@ class WataService(HttpClientMixin): order_id_raw=order_id_raw, provider_payment_id=transaction_id or None, ) + if not payment and payment_link_id: + payment = await lookup_payment_by_order_or_provider_id( + session, + provider_payment_id=payment_link_id, + ) if not payment: logging.error( - "Wata webhook: payment not found (order_id=%s, transaction_id=%s)", + "Wata webhook: payment not found " + "(order_id=%s, transaction_id=%s, payment_link_id=%s)", order_id_raw, transaction_id, + payment_link_id, ) return web.Response(status=404, text="payment_not_found") - if payment.status == "succeeded" and status == "paid": + if payment.status == "succeeded": return web.Response(text="ok") - resolved_transaction_id = transaction_id or str(payment.payment_id) + resolved_transaction_id = transaction_id or payment_link_id or str(payment.payment_id) + + if status in _WATA_IN_PROGRESS_STATUSES: + if transaction_id and payment.provider_payment_id != transaction_id: + try: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + transaction_id, + payment.status, + ) + await session.commit() + except Exception: + await session.rollback() + logging.exception( + "Wata webhook: failed to persist transaction id %s " + "for payment %s.", + transaction_id, + payment.payment_id, + ) + return web.Response(status=500, text="processing_error") + return web.Response(text="ok") if status == "paid": if amount_raw is not None: @@ -409,7 +441,7 @@ class WataService(HttpClientMixin): status, transaction_id, ) - return web.Response(status=202, text="status_ignored") + return web.Response(text="status_ignored") @router.callback_query(F.data.startswith("pay_wata:")) diff --git a/tests/test_wata_webhook.py b/tests/test_wata_webhook.py new file mode 100644 index 0000000..332e26e --- /dev/null +++ b/tests/test_wata_webhook.py @@ -0,0 +1,203 @@ +import asyncio +import json +from types import SimpleNamespace + +from bot.payment_providers import wata +from bot.payment_providers.wata import WataConfig, WataService + + +class _FakeRequest: + def __init__(self, payload): + self.headers = {} + self.remote = "127.0.0.1" + self._raw_body = json.dumps(payload).encode("utf-8") + + async def read(self): + return self._raw_body + + +class _FakeSession: + def __init__(self): + self.commits = 0 + self.rollbacks = 0 + + def __call__(self): + return self + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def commit(self): + self.commits += 1 + + async def rollback(self): + self.rollbacks += 1 + + +def _payment(**overrides): + values = { + "payment_id": 465, + "user_id": 748116183, + "status": "pending_wata", + "amount": 100.0, + "provider_payment_id": "link-id", + "purchased_gb": None, + "subscription_duration_months": 1, + "sale_mode": "subscription", + "user": None, + } + values.update(overrides) + return SimpleNamespace(**values) + + +def _service(session): + settings = SimpleNamespace( + DEFAULT_CURRENCY_SYMBOL="RUB", + DEFAULT_LANGUAGE="ru", + traffic_sale_mode=False, + trusted_proxies=[], + ) + return WataService( + bot=SimpleNamespace(), + settings=settings, + config=WataConfig( + ENABLED=True, + API_TOKEN="token", + WEBHOOK_VERIFY_SIGNATURE=False, + TRUSTED_IPS="", + ), + i18n=SimpleNamespace(), + async_session_factory=session, + subscription_service=SimpleNamespace(), + referral_service=SimpleNamespace(), + default_return_url="test_bot", + ) + + +def test_wata_created_webhook_returns_ok_and_persists_transaction_id(monkeypatch): + session = _FakeSession() + payment = _payment() + updates = [] + + async def lookup_payment(_session, *, order_id_raw=None, provider_payment_id=None): + assert _session is session + assert order_id_raw == "465" + assert provider_payment_id == "tx-1" + return payment + + async def update_provider_payment_and_status( + _session, + payment_id, + provider_payment_id, + status, + ): + updates.append((payment_id, provider_payment_id, status)) + + monkeypatch.setattr(wata, "lookup_payment_by_order_or_provider_id", lookup_payment) + monkeypatch.setattr( + wata.payment_dal, + "update_provider_payment_and_status", + update_provider_payment_and_status, + ) + + response = asyncio.run( + _service(session).webhook_route( + _FakeRequest( + { + "transactionStatus": "Created", + "transactionId": "tx-1", + "orderId": "465", + "amount": 100, + "currency": "RUB", + } + ) + ) + ) + + assert response.status == 200 + assert updates == [(465, "tx-1", "pending_wata")] + assert session.commits == 1 + assert session.rollbacks == 0 + + +def test_wata_created_webhook_can_find_payment_by_payment_link_id(monkeypatch): + session = _FakeSession() + payment = _payment() + lookup_calls = [] + updates = [] + + async def lookup_payment(_session, *, order_id_raw=None, provider_payment_id=None): + lookup_calls.append((order_id_raw, provider_payment_id)) + if provider_payment_id == "link-id": + return payment + return None + + async def update_provider_payment_and_status( + _session, + payment_id, + provider_payment_id, + status, + ): + updates.append((payment_id, provider_payment_id, status)) + + monkeypatch.setattr(wata, "lookup_payment_by_order_or_provider_id", lookup_payment) + monkeypatch.setattr( + wata.payment_dal, + "update_provider_payment_and_status", + update_provider_payment_and_status, + ) + + response = asyncio.run( + _service(session).webhook_route( + _FakeRequest( + { + "transactionStatus": "Created", + "transactionId": "tx-1", + "paymentLinkId": "link-id", + "amount": 100, + "currency": "RUB", + } + ) + ) + ) + + assert response.status == 200 + assert lookup_calls == [(None, "tx-1"), (None, "link-id")] + assert updates == [(465, "tx-1", "pending_wata")] + assert session.commits == 1 + + +def test_wata_known_payment_with_unknown_status_still_acknowledges_webhook(monkeypatch): + session = _FakeSession() + payment = _payment(provider_payment_id="tx-1") + + async def lookup_payment(_session, *, order_id_raw=None, provider_payment_id=None): + return payment + + async def update_provider_payment_and_status(*args, **kwargs): + raise AssertionError("unknown statuses must not mutate payment state") + + monkeypatch.setattr(wata, "lookup_payment_by_order_or_provider_id", lookup_payment) + monkeypatch.setattr( + wata.payment_dal, + "update_provider_payment_and_status", + update_provider_payment_and_status, + ) + + response = asyncio.run( + _service(session).webhook_route( + _FakeRequest( + { + "transactionStatus": "WaitingForBank", + "transactionId": "tx-1", + "orderId": "465", + } + ) + ) + ) + + assert response.status == 200 + assert session.commits == 0