fix: refresh YooKassa webapp payments
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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="💳",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user