fix: refresh YooKassa webapp payments

This commit is contained in:
3252a8
2026-05-24 18:47:37 +03:00
parent d1b9990bac
commit 235ee25d9f
8 changed files with 308 additions and 8 deletions
+121
View File
@@ -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,
+6 -6
View File
@@ -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="💳",
+1 -1
View File
@@ -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);
@@ -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,
+8
View File
@@ -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" };
}
@@ -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"));
+1 -1
View File
@@ -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):
+125
View File
@@ -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,
)