From 235ee25d9f4a8ae0ee16b44b75d627025d49184d Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 24 May 2026 18:47:37 +0300 Subject: [PATCH] fix: refresh YooKassa webapp payments --- backend/bot/app/web/webapp/billing.py | 121 +++++++++++++++++ backend/bot/payment_providers/yookassa.py | 12 +- frontend/src/App.svelte | 2 +- frontend/src/lib/webapp/billingActions.js | 5 + frontend/src/lib/webapp/mockApi.js | 8 ++ .../src/lib/webapp/stores/billingStore.js | 41 ++++++ tests/test_payment_provider_registry.py | 2 +- tests/test_webapp_payment_status.py | 125 ++++++++++++++++++ 8 files changed, 308 insertions(+), 8 deletions(-) create mode 100644 tests/test_webapp_payment_status.py diff --git a/backend/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py index 9543397..142807b 100644 --- a/backend/bot/app/web/webapp/billing.py +++ b/backend/bot/app/web/webapp/billing.py @@ -586,6 +586,126 @@ async def device_topup_options_route(request: web.Request) -> web.Response: ) +def _yookassa_payment_payload_for_processing(payload: Dict[str, Any]) -> Dict[str, Any]: + normalized = dict(payload or {}) + if not isinstance(normalized.get("amount"), dict): + amount_value = normalized.get("amount_value") + amount_currency = normalized.get("amount_currency") + if amount_value is not None or amount_currency: + normalized["amount"] = { + "value": str(amount_value if amount_value is not None else 0), + "currency": amount_currency or "RUB", + } + return normalized + + +def _payment_status_can_be_refreshed(payment: Payment) -> bool: + normalized = str(getattr(payment, "status", "") or "").lower() + if normalized == "succeeded": + return False + if normalized in {"failed", "canceled", "cancelled", "failed_creation"}: + return False + return normalized.startswith("pending") or normalized in {"waiting_for_capture", "created"} + + +async def _refresh_yookassa_payment_status( + request: web.Request, + session: AsyncSession, + payment: Payment, +) -> Payment: + if str(getattr(payment, "provider", "") or "").lower() != "yookassa": + return payment + if not _payment_status_can_be_refreshed(payment): + return payment + + yookassa_payment_id = payment.yookassa_payment_id or payment.provider_payment_id + yookassa_service = request.app.get("yookassa_service") + if ( + not yookassa_payment_id + or not yookassa_service + or not getattr(yookassa_service, "configured", False) + or not hasattr(yookassa_service, "get_payment_info") + ): + return payment + + try: + provider_payload = await yookassa_service.get_payment_info(yookassa_payment_id) + except Exception: + logger.exception("Failed to refresh YooKassa payment %s status", payment.payment_id) + return payment + + if not provider_payload: + return payment + + provider_payload = _yookassa_payment_payload_for_processing(provider_payload) + provider_status = str(provider_payload.get("status") or "").lower() + if provider_status == "succeeded" and provider_payload.get("paid") is True: + from bot.payment_providers.yookassa import ( + payment_processing_lock, + process_successful_payment, + ) + + async with payment_processing_lock: + current = await payment_dal.get_payment_by_db_id(session, payment.payment_id) + if not current: + return payment + if current.status == "succeeded": + return current + try: + await process_successful_payment( + session, + request.app["bot"], + provider_payload, + request.app["i18n"], + request.app["settings"], + request.app["panel_service"], + request.app["subscription_service"], + request.app["referral_service"], + request.app.get("lknpd_service"), + ) + await session.commit() + except Exception: + await session.rollback() + logger.exception( + "Failed to process refreshed YooKassa payment %s", + payment.payment_id, + ) + return current + return await payment_dal.get_payment_by_db_id(session, payment.payment_id) or current + + if provider_status in {"canceled", "cancelled"}: + from bot.payment_providers.yookassa import ( + payment_processing_lock, + process_cancelled_payment, + ) + + async with payment_processing_lock: + current = await payment_dal.get_payment_by_db_id(session, payment.payment_id) + if not current: + return payment + if not _payment_status_can_be_refreshed(current): + return current + try: + await process_cancelled_payment( + session, + request.app["bot"], + provider_payload, + request.app["i18n"], + request.app["settings"], + ) + await session.commit() + except Exception: + await session.rollback() + logger.exception( + "Failed to process refreshed cancelled YooKassa payment %s", + payment.payment_id, + ) + return current + return await payment_dal.get_payment_by_db_id(session, payment.payment_id) or current + + return payment + + async def payment_status_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) try: @@ -598,6 +718,7 @@ async def payment_status_route(request: web.Request) -> web.Response: payment = await payment_dal.get_payment_by_db_id(session, payment_id) if not payment or payment.user_id != user_id: return _json_error(404, "not_found", "Payment not found") + payment = await _refresh_yookassa_payment_status(request, session, payment) return web.json_response( { "ok": True, diff --git a/backend/bot/payment_providers/yookassa.py b/backend/bot/payment_providers/yookassa.py index ab7bfad..bdb0814 100644 --- a/backend/bot/payment_providers/yookassa.py +++ b/backend/bot/payment_providers/yookassa.py @@ -258,6 +258,9 @@ class YooKassaService: if save_payment_method: # Ask YooKassa to save method for off-session charges builder.set_save_payment_method(True) + elif not payment_method_id: + # Keep the Smart Payment form unrestricted for one-off payments. + builder.set_save_payment_method(False) if payment_method_id: # Use a previously saved payment method for merchant-initiated payments builder.set_payment_method_id(payment_method_id) @@ -2659,10 +2662,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: description=ctx.description, metadata=metadata, receipt_email=service.config.DEFAULT_RECEIPT_EMAIL, - save_payment_method=bool( - service.config.autopayments_active - and service.config.AUTOPAYMENTS_REQUIRE_CARD_BINDING - ), + save_payment_method=False, ) payment_url = response.get("confirmation_url") if response else None if not payment_url: @@ -2802,8 +2802,8 @@ SPEC = PaymentProviderSpec( id="yookassa", provider_key="yookassa", label="YooKassa", - webapp_label="Банковская карта", - webapp_labels={"ru": "Банковская карта", "en": "Bank card"}, + webapp_label="ЮKassa", + webapp_labels={"ru": "ЮKassa", "en": "YooKassa"}, webapp_icon="CreditCard", telegram_labels={"ru": "ЮKassa", "en": "YooKassa"}, telegram_emoji="💳", diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index fc60c71..ec5f158 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1080,7 +1080,7 @@ function openExternalLink(url) { if (!url) return; if (tg?.openLink) { - tg.openLink(url); + tg.openLink(url, { try_instant_view: false }); return; } window.location.assign(url); diff --git a/frontend/src/lib/webapp/billingActions.js b/frontend/src/lib/webapp/billingActions.js index 1b736f9..a85353b 100644 --- a/frontend/src/lib/webapp/billingActions.js +++ b/frontend/src/lib/webapp/billingActions.js @@ -15,6 +15,10 @@ export function createBillingActions({ api }) { return api("/payments", { method: "POST", body: JSON.stringify(body) }); } + async function fetchPaymentStatus(paymentId) { + return api(`/payments/${encodeURIComponent(paymentId)}`); + } + async function postTariffChange(body) { return api("/tariffs/change", { method: "POST", body: JSON.stringify(body) }); } @@ -79,6 +83,7 @@ export function createBillingActions({ api }) { fetchDeviceTopupOptions, fetchTariffChangeOptions, postPayment, + fetchPaymentStatus, postTariffChange, postTariffChangePayment, planPaymentBody, diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js index 1a125b4..9449718 100644 --- a/frontend/src/lib/webapp/mockApi.js +++ b/frontend/src/lib/webapp/mockApi.js @@ -767,6 +767,14 @@ export async function mockApi(path, options = {}, context = {}) { payment_id: 10001, }; } + if (/^\/payments\/\d+$/.test(path) && String(options.method || "GET").toUpperCase() === "GET") { + return { + ok: true, + payment_id: Number(path.split("/").pop()), + status: "pending_yookassa", + paid: false, + }; + } if (path === "/tariffs/change" && String(options.method || "").toUpperCase() === "POST") { return { ok: true, tariff_key: "business" }; } diff --git a/frontend/src/lib/webapp/stores/billingStore.js b/frontend/src/lib/webapp/stores/billingStore.js index b5e429f..36d812b 100644 --- a/frontend/src/lib/webapp/stores/billingStore.js +++ b/frontend/src/lib/webapp/stores/billingStore.js @@ -24,6 +24,11 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern }); let topupOptionsRequestId = 0; + let paymentPollToken = 0; + + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } function openPaymentModal( tariffMode, @@ -175,6 +180,38 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern openExternalLink(url); } + function startPaymentStatusPolling(paymentId) { + if (!paymentId || !billing.fetchPaymentStatus) return; + const token = ++paymentPollToken; + void (async () => { + for (let attempt = 0; attempt < 45 && token === paymentPollToken; attempt += 1) { + await sleep(attempt === 0 ? 1500 : 2000); + if (token !== paymentPollToken) return; + try { + const status = await billing.fetchPaymentStatus(paymentId); + if (!status?.ok) continue; + if (status.paid || status.status === "succeeded") { + showToast(t("wa_payment_success", {}, "Payment successful")); + await loadData(); + return; + } + const normalized = String(status.status || "").toLowerCase(); + if ( + normalized === "failed" || + normalized === "canceled" || + normalized === "cancelled" || + normalized.startsWith("failed_") + ) { + showToast(t("wa_payment_create_failed")); + return; + } + } catch (_error) { + void _error; + } + } + })(); + } + async function createPayment() { const s = get(state); if (!s.selectedPlan || !s.selectedMethod || s.payBusy) return; @@ -195,6 +232,7 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern if (!response.payment_url) throw response; openExternalLink(response.payment_url); } + startPaymentStatusPolling(response.payment_id); state.update((s) => ({ ...s, paymentModalOpen: false })); } catch (error) { showToast(error?.message || t("wa_payment_create_failed")); @@ -244,6 +282,7 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern if (!response.ok || !response.payment_url) throw response; showToast(t("wa_payment_created")); openExternalLink(response.payment_url); + startPaymentStatusPolling(response.payment_id); state.update((s) => ({ ...s, topupModalOpen: false })); } catch (error) { showToast(error?.message || t("wa_payment_create_failed")); @@ -321,6 +360,7 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern if (!response.ok || !response.payment_url) throw response; showToast(t("wa_payment_created")); openExternalLink(response.payment_url); + startPaymentStatusPolling(response.payment_id); state.update((s) => ({ ...s, changeConfirmOpen: false, changeModalOpen: false })); } catch (error) { showToast(error?.message || t("wa_payment_create_failed")); @@ -364,6 +404,7 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern if (!response.ok || !response.payment_url) throw response; showToast(t("wa_payment_created")); openExternalLink(response.payment_url); + startPaymentStatusPolling(response.payment_id); state.update((s) => ({ ...s, deviceTopupModalOpen: false })); } catch (error) { showToast(error?.message || t("wa_payment_create_failed")); diff --git a/tests/test_payment_provider_registry.py b/tests/test_payment_provider_registry.py index 920f151..50eca55 100644 --- a/tests/test_payment_provider_registry.py +++ b/tests/test_payment_provider_registry.py @@ -189,7 +189,7 @@ def test_provider_presentation_ignores_cross_language_override(): settings = SimpleNamespace(PAYMENT_YOOKASSA_WEBAPP_LABEL_RU="Карта") - assert resolve_provider_presentation(spec, settings, language="en").webapp_label == "Bank card" + assert resolve_provider_presentation(spec, settings, language="en").webapp_label == "YooKassa" def test_payment_method_keyboard_uses_custom_telegram_text_without_changing_callback(monkeypatch): diff --git a/tests/test_webapp_payment_status.py b/tests/test_webapp_payment_status.py new file mode 100644 index 0000000..6a4410a --- /dev/null +++ b/tests/test_webapp_payment_status.py @@ -0,0 +1,125 @@ +from types import SimpleNamespace +from unittest import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock, patch + +from bot.app.web.webapp import billing as billing_module +from bot.payment_providers.base import WebAppPaymentContext +from bot.payment_providers.yookassa import create_webapp_payment + + +class WebAppPaymentStatusTests(IsolatedAsyncioTestCase): + async def test_yookassa_pending_payment_refresh_processes_succeeded_provider_status(self): + payment = SimpleNamespace( + payment_id=42, + user_id=1001, + provider="yookassa", + status="pending_yookassa", + yookassa_payment_id="yk_42", + provider_payment_id=None, + ) + refreshed_payment = SimpleNamespace( + payment_id=42, + user_id=1001, + provider="yookassa", + status="succeeded", + yookassa_payment_id="yk_42", + provider_payment_id=None, + ) + yookassa_service = SimpleNamespace( + configured=True, + get_payment_info=AsyncMock( + return_value={ + "id": "yk_42", + "status": "succeeded", + "paid": True, + "amount_value": 100.0, + "amount_currency": "RUB", + "metadata": {"user_id": "1001", "payment_db_id": "42"}, + } + ), + ) + request = SimpleNamespace( + app={ + "bot": SimpleNamespace(), + "i18n": SimpleNamespace(), + "settings": SimpleNamespace(), + "panel_service": SimpleNamespace(), + "subscription_service": SimpleNamespace(), + "referral_service": SimpleNamespace(), + "lknpd_service": None, + "yookassa_service": yookassa_service, + } + ) + session = AsyncMock() + + with ( + patch.object( + billing_module.payment_dal, + "get_payment_by_db_id", + AsyncMock(side_effect=[payment, refreshed_payment]), + ), + patch( + "bot.payment_providers.yookassa.process_successful_payment", + AsyncMock(), + ) as process_success, + ): + result = await billing_module._refresh_yookassa_payment_status( + request, + session, + payment, + ) + + self.assertIs(result, refreshed_payment) + session.commit.assert_awaited_once() + process_success.assert_awaited_once() + provider_payload = process_success.await_args.args[2] + self.assertEqual(provider_payload["amount"], {"value": "100.0", "currency": "RUB"}) + + async def test_yookassa_webapp_payment_uses_unrestricted_checkout_form(self): + payment_record = SimpleNamespace(payment_id=77) + yookassa_service = SimpleNamespace( + configured=True, + config=SimpleNamespace( + DEFAULT_RECEIPT_EMAIL="receipt@example.test", + autopayments_active=True, + AUTOPAYMENTS_REQUIRE_CARD_BINDING=True, + ), + create_payment=AsyncMock( + return_value={ + "id": "yk_77", + "status": "pending", + "confirmation_url": "https://yookassa.example/pay", + } + ), + ) + session = AsyncMock() + ctx = WebAppPaymentContext( + request=SimpleNamespace(app={"yookassa_service": yookassa_service}), + session=session, + user_id=1001, + method="yookassa", + months=1, + price=100.0, + stars_price=None, + description="Subscription", + sale_mode="subscription", + ) + + with ( + patch( + "bot.payment_providers.yookassa.create_webapp_payment_record", + AsyncMock(return_value=payment_record), + ), + patch( + "bot.payment_providers.yookassa.payment_dal.update_payment_status_by_db_id", + AsyncMock(), + ), + ): + response = await create_webapp_payment(ctx) + + self.assertEqual(response.status, 200) + yookassa_service.create_payment.assert_awaited_once() + self.assertIs( + yookassa_service.create_payment.await_args.kwargs["save_payment_method"], + False, + )