diff --git a/backend/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py index b119884..3106e3e 100644 --- a/backend/bot/app/web/webapp/billing.py +++ b/backend/bot/app/web/webapp/billing.py @@ -1068,28 +1068,44 @@ async def _create_subscription_payment( "payment_amount_below_minimum", "Payment amount is below the provider minimum", ) - return await provider_spec.create_webapp_payment( - WebAppPaymentContext( - request=request, - session=session, - user_id=user_id, - method=method, - months=months, - price=price, - stars_price=stars_price, - currency=payment_currency, - description=description, - sale_mode=sale_mode, - traffic_gb=traffic_gb, - hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None, - hwid_valid_from=hwid_quote.get("valid_from") if hwid_quote else None, - hwid_valid_until=hwid_quote.get("valid_until") if hwid_quote else None, - hwid_pricing_period_months=hwid_quote.get("pricing_period_months") - if hwid_quote - else None, - hwid_proration_ratio=hwid_quote.get("proration_ratio") if hwid_quote else None, - hwid_full_price=hwid_quote.get("full_price") if hwid_quote else None, - ) + payment_context = WebAppPaymentContext( + request=request, + session=session, + user_id=user_id, + method=method, + months=months, + price=price, + stars_price=stars_price, + currency=payment_currency, + description=description, + sale_mode=sale_mode, + traffic_gb=traffic_gb, + hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None, + hwid_valid_from=hwid_quote.get("valid_from") if hwid_quote else None, + hwid_valid_until=hwid_quote.get("valid_until") if hwid_quote else None, + hwid_pricing_period_months=hwid_quote.get("pricing_period_months") + if hwid_quote + else None, + hwid_proration_ratio=hwid_quote.get("proration_ratio") if hwid_quote else None, + hwid_full_price=hwid_quote.get("full_price") if hwid_quote else None, ) + if provider_spec.reuse_webapp_payment: + from bot.payment_providers.shared import reusable_webapp_payment_response + + try: + reusable_response = await reusable_webapp_payment_response( + payment_context, + provider_spec, + ) + except Exception: + logger.exception( + "Failed to verify reusable payment: user_id=%s provider=%s", + user_id, + provider_spec.provider_key, + ) + reusable_response = None + if reusable_response is not None: + return reusable_response + return await provider_spec.create_webapp_payment(payment_context) return _json_error(400, "payment_unavailable", "Payment method unavailable") diff --git a/backend/bot/payment_providers/base.py b/backend/bot/payment_providers/base.py index dc2e670..7562ed1 100644 --- a/backend/bot/payment_providers/base.py +++ b/backend/bot/payment_providers/base.py @@ -127,6 +127,7 @@ ServiceFactory = Callable[[ServiceFactoryContext], Any] WebhookPathGetter = Callable[[Any], str] WebhookRoute = Callable[[Any], Awaitable[Any]] WebAppPaymentFactory = Callable[[WebAppPaymentContext], Awaitable[Any]] +ReusableWebAppPaymentResolver = Callable[[WebAppPaymentContext, Any], Awaitable[Optional[str]]] CurrencySupportResolver = Callable[[Any], Optional[Sequence[str]]] PaymentAmountResolver = Callable[[Any, Any, Any], bool] PaymentMinimumResolver = Callable[[Any, Any], Optional[Mapping[str, Any]]] @@ -180,6 +181,7 @@ class PaymentProviderSpec: webhook_route: Optional[WebhookRoute] = None webhook_requires_base_url: bool = False create_webapp_payment: Optional[WebAppPaymentFactory] = None + reuse_webapp_payment: Optional[ReusableWebAppPaymentResolver] = None requires_configured_service: bool = True price_source: str = "rub" emoji: str = "💳" diff --git a/backend/bot/payment_providers/freekassa.py b/backend/bot/payment_providers/freekassa.py index 6ee8d17..b3ac8c9 100644 --- a/backend/bot/payment_providers/freekassa.py +++ b/backend/bot/payment_providers/freekassa.py @@ -53,11 +53,14 @@ from .shared import ( notify_service_unavailable, parse_payment_callback, payment_failed, + payment_record_amounts, payment_unavailable, payment_units_for_activation, post_json_request, quote_hwid_callback_parts, render_link_or_fail, + render_payment_link, + safe_callback_answer, ) _LOG = "freekassa" @@ -253,6 +256,69 @@ class FreeKassaService(HttpClientMixin): is_success=lambda status, data: status == 200 and (data or {}).get("type") == "success", ) + async def get_orders( + self, + *, + payment_id: int, + order_status: Optional[int] = None, + ) -> Tuple[bool, Dict[str, Any]]: + if not self.configured: + return False, {"message": "service_not_configured"} + + payload: Dict[str, Any] = { + "shopId": int(self.shop_id), + "nonce": await self._generate_nonce(), + "paymentId": str(payment_id), + } + if order_status is not None: + payload["orderStatus"] = int(order_status) + payload["signature"] = self._sign_payload(payload) + + session = await self._get_session() + return await post_json_request( + session, + f"{self.api_base_url}/orders", + body=payload, + log_prefix="FreeKassa get_orders", + is_success=lambda status, data: status == 200 and (data or {}).get("type") == "success", + ) + + async def try_reuse_pending_order(self, payment: Any) -> Optional[str]: + order_hash = str(getattr(payment, "provider_payment_id", None) or "").strip() + if not order_hash: + return None + + success, response_data = await self.get_orders( + payment_id=payment.payment_id, + order_status=0, + ) + if not success: + return None + + expected_currency = normalize_payment_currency_code(getattr(payment, "currency", None)) + for order in response_data.get("orders") or []: + if not isinstance(order, dict): + continue + try: + is_new = int(order.get("status", -1)) == 0 + amount_matches = decimal_amounts_equal( + order.get("amount"), + getattr(payment, "amount", None), + ) + except (TypeError, ValueError): + continue + if not is_new or not amount_matches: + continue + if str(order.get("merchant_order_id") or "") != str(payment.payment_id): + continue + if normalize_payment_currency_code(order.get("currency")) != expected_currency: + continue + fk_order_id = str(order.get("fk_order_id") or "").strip() + if fk_order_id: + payment_url = (self.config.PAYMENT_URL or "https://pay.freekassa.net/").rstrip("/") + return f"{payment_url}/form/{fk_order_id}/{order_hash}" + return None + async def _generate_nonce(self) -> int: async with self._nonce_lock: candidate = int(time.time() * 1000) @@ -513,6 +579,39 @@ async def pay_fk_callback_handler( hwid_quote=hwid_quote, ) + reuse_amounts = payment_record_amounts( + months=parts.months, + sale_mode=parts.sale_mode, + hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None, + ) + reusable_payment = await payment_dal.find_recent_pending_provider_payment( + session, + user_id=callback.from_user.id, + provider="freekassa", + pending_status="pending_freekassa", + amount=parts.price, + currency=currency_code, + sale_mode=parts.sale_mode, + months=reuse_amounts.months, + purchased_gb=reuse_amounts.purchased_gb, + purchased_hwid_devices=reuse_amounts.purchased_hwid_devices, + tariff_key=reuse_amounts.tariff_key, + ) + if reusable_payment is not None: + reusable_url = await freekassa_service.try_reuse_pending_order(reusable_payment) + if reusable_url: + await safe_callback_answer(callback) + await render_payment_link( + callback, + translator=translator, + current_lang=current_lang, + i18n=i18n, + parts=parts, + payment_url=reusable_url, + log_prefix=_LOG, + ) + return + try: payment_record = await payment_dal.create_payment_record(session, record_payload) await session.commit() @@ -625,6 +724,14 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: ) +async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]: + service: FreeKassaService = ctx.request.app.get("freekassa_service") + if not service or not service.configured: + return None + + return await service.try_reuse_pending_order(payment) + + _PRESENTATION_MANIFEST = tuple( ProviderManifestField( key=key, @@ -772,6 +879,7 @@ SPEC = PaymentProviderSpec( webhook_path=lambda source: "/webhook/freekassa", webhook_route=freekassa_webhook_route, create_webapp_payment=create_webapp_payment, + reuse_webapp_payment=reuse_webapp_payment, config_class=FreeKassaConfig, presentation_class=FreeKassaPresentation, manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, diff --git a/backend/bot/payment_providers/heleket.py b/backend/bot/payment_providers/heleket.py index f11bed5..28b4bdb 100644 --- a/backend/bot/payment_providers/heleket.py +++ b/backend/bot/payment_providers/heleket.py @@ -3,6 +3,7 @@ import hashlib import hmac import json import logging +import time from collections import OrderedDict from typing import Any, Dict, List, Optional, Tuple @@ -54,10 +55,12 @@ from .shared import ( notify_user_payment_failed, parse_payment_callback, payment_failed, + payment_record_amounts, payment_unavailable, payment_units_for_activation, quote_hwid_callback_parts, render_link_or_fail, + render_payment_link, ) router = Router(name="user_subscription_payments_heleket_router") @@ -372,6 +375,74 @@ class HeleketService(HttpClientMixin): logging.exception("Heleket create_payment_link: request failed.") return False, {"message": str(exc)} + async def get_payment_info(self, payment_uuid: str) -> Tuple[bool, Dict[str, Any]]: + if not self.configured: + return False, {"message": "service_not_configured"} + + payment_uuid = str(payment_uuid or "").strip() + if not payment_uuid: + return False, {"message": "missing_payment_uuid"} + + body = {"uuid": payment_uuid} + headers = { + "merchant": self.merchant_id, + "sign": _compute_signature(body, self.api_key), + "Content-Type": "application/json", + } + session = await self._get_session() + try: + async with session.post( + f"{self.base_url}/v1/payment/info", + data=_serialize_for_signature(body).encode("utf-8"), + headers=headers, + ) as response: + response_data = await response.json(content_type=None) + state = response_data.get("state") if isinstance(response_data, dict) else None + if response.status != 200 or state != 0: + logging.warning( + "Heleket get_payment_info failed: uuid=%s status=%s body=%s", + payment_uuid, + response.status, + response_data, + ) + return False, {"status": response.status, "message": response_data} + result = response_data.get("result") or {} + return isinstance(result, dict), result + except Exception as exc: + logging.exception("Heleket get_payment_info request failed: uuid=%s", payment_uuid) + return False, {"message": str(exc)} + + async def try_reuse_pending_payment(self, payment: Any) -> Optional[str]: + payment_uuid = str(getattr(payment, "provider_payment_id", None) or "").strip() + if not payment_uuid: + return None + + success, data = await self.get_payment_info(payment_uuid) + status = str(data.get("payment_status") or data.get("status") or "").lower() + if not success or status != "check" or bool(data.get("is_final")): + return None + if str(data.get("uuid") or "") != payment_uuid: + return None + if str(data.get("order_id") or "") != str(payment.payment_id): + return None + try: + if not decimal_amounts_equal(data.get("amount"), getattr(payment, "amount", None)): + return None + expired_at = int(data.get("expired_at") or 0) + except (TypeError, ValueError): + return None + if expired_at and expired_at <= int(time.time()): + return None + provider_currency = normalize_payment_currency_code(data.get("currency")) + payment_currency = normalize_payment_currency_code(getattr(payment, "currency", None)) + if provider_currency != payment_currency: + return None + return ( + str(data.get("url") or "").strip() + or str(getattr(payment, "provider_payment_url", None) or "").strip() + or None + ) + def _verify_signature(self, payload: Dict[str, Any]) -> bool: received = payload.get("sign") if not isinstance(received, str) or not received: @@ -612,6 +683,38 @@ async def pay_heleket_callback_handler( hwid_quote=hwid_quote, ) + reuse_amounts = payment_record_amounts( + months=parts.months, + sale_mode=parts.sale_mode, + hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None, + ) + reusable_payment = await payment_dal.find_recent_pending_provider_payment( + session, + user_id=callback.from_user.id, + provider="heleket", + pending_status="pending_heleket", + amount=parts.price, + currency=currency_code, + sale_mode=parts.sale_mode, + months=reuse_amounts.months, + purchased_gb=reuse_amounts.purchased_gb, + purchased_hwid_devices=reuse_amounts.purchased_hwid_devices, + tariff_key=reuse_amounts.tariff_key, + ) + if reusable_payment is not None: + reusable_url = await heleket_service.try_reuse_pending_payment(reusable_payment) + if reusable_url: + await render_payment_link( + callback, + translator=translator, + current_lang=current_lang, + i18n=i18n, + parts=parts, + payment_url=reusable_url, + log_prefix=_LOG, + ) + return + try: payment_record = await payment_dal.create_payment_record(session, record_payload) await session.commit() @@ -684,6 +787,13 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: ) +async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]: + service: HeleketService = ctx.request.app.get("heleket_service") + if not service or not service.configured: + return None + return await service.try_reuse_pending_payment(payment) + + async def heleket_webhook_route(request: web.Request) -> web.Response: service: HeleketService = request.app["heleket_service"] return await service.webhook_route(request) @@ -887,6 +997,7 @@ SPEC = PaymentProviderSpec( webhook_path=lambda source: "/webhook/heleket", webhook_route=heleket_webhook_route, create_webapp_payment=create_webapp_payment, + reuse_webapp_payment=reuse_webapp_payment, emoji="🪙", config_class=HeleketConfig, presentation_class=HeleketPresentation, diff --git a/backend/bot/payment_providers/platega.py b/backend/bot/payment_providers/platega.py index a05ffe2..0d7c28e 100644 --- a/backend/bot/payment_providers/platega.py +++ b/backend/bot/payment_providers/platega.py @@ -55,6 +55,7 @@ from .shared import ( post_json_request, quote_hwid_callback_parts, render_link_or_fail, + render_payment_link, safe_callback_answer, ) @@ -282,6 +283,82 @@ class PlategaService(HttpClientMixin): log_prefix="Platega create_transaction", ) + async def get_transaction(self, transaction_id: str) -> Tuple[bool, Dict[str, Any]]: + if not self.configured: + return False, {"message": "service_not_configured"} + + transaction_id = str(transaction_id or "").strip() + if not transaction_id: + return False, {"message": "missing_transaction_id"} + + session = await self._get_session() + try: + async with session.get( + f"{self.base_url}/transaction/{transaction_id}", + headers=self._auth_headers, + ) as response: + data = await response.json(content_type=None) + if response.status != 200 or not isinstance(data, dict): + logging.warning( + "Platega get_transaction failed: id=%s status=%s body=%s", + transaction_id, + response.status, + data, + ) + return False, {"status": response.status, "message": data} + return True, data + except Exception as exc: + logging.exception("Platega get_transaction request failed: id=%s", transaction_id) + return False, {"message": str(exc)} + + async def try_reuse_pending_transaction( + self, + payment: Any, + *, + user_id: int, + sale_mode: str, + variant: str, + ) -> Optional[str]: + transaction_id = str(getattr(payment, "provider_payment_id", None) or "").strip() + payment_url = str(getattr(payment, "provider_payment_url", None) or "").strip() + if not transaction_id or not payment_url: + return None + + success, data = await self.get_transaction(transaction_id) + if not success or str(data.get("status") or "").upper() != "PENDING": + return None + if str(data.get("id") or "") != transaction_id: + return None + + details = data.get("paymentDetails") or {} + if not isinstance(details, dict): + return None + try: + if not decimal_amounts_equal(details.get("amount"), getattr(payment, "amount", None)): + return None + except (TypeError, ValueError): + return None + provider_currency = normalize_payment_currency_code(details.get("currency")) + payment_currency = normalize_payment_currency_code(getattr(payment, "currency", None)) + if provider_currency != payment_currency: + return None + + try: + payload = json.loads(str(data.get("payload") or "")) + except (TypeError, ValueError, json.JSONDecodeError): + return None + expected = { + "payment_db_id": str(payment.payment_id), + "user_id": str(user_id), + "sale_mode": str(sale_mode), + "platega_variant": str(variant), + } + if not isinstance(payload, dict) or any( + str(payload.get(key) or "") != value for key, value in expected.items() + ): + return None + return payment_url + async def webhook_route(self, request: web.Request) -> web.Response: if not self.configured: return web.Response(status=503, text="platega_disabled") @@ -519,6 +596,43 @@ async def pay_platega_callback_handler( hwid_quote=hwid_quote, ) + reuse_amounts = payment_record_amounts( + months=parts.months, + sale_mode=parts.sale_mode, + hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None, + ) + reusable_payment = await payment_dal.find_recent_pending_provider_payment( + session, + user_id=callback.from_user.id, + provider="platega", + pending_status="pending_platega", + amount=parts.price, + currency=currency_code, + sale_mode=parts.sale_mode, + months=reuse_amounts.months, + purchased_gb=reuse_amounts.purchased_gb, + purchased_hwid_devices=reuse_amounts.purchased_hwid_devices, + tariff_key=reuse_amounts.tariff_key, + ) + if reusable_payment is not None: + reusable_url = await platega_service.try_reuse_pending_transaction( + reusable_payment, + user_id=callback.from_user.id, + sale_mode=parts.sale_mode, + variant=platega_variant, + ) + if reusable_url: + await render_payment_link( + callback, + translator=translator, + current_lang=current_lang, + i18n=i18n, + parts=parts, + payment_url=reusable_url, + log_prefix=_LOG, + ) + return + try: payment_record = await payment_dal.create_payment_record(session, record_payload) await session.commit() @@ -665,6 +779,19 @@ async def create_crypto_webapp_payment(ctx: WebAppPaymentContext) -> web.Respons return await _create_webapp_payment(ctx, "platega_crypto") +async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]: + service: PlategaService = ctx.request.app.get("platega_service") + if not service or not service.configured: + return None + variant = "crypto" if ctx.method == "platega_crypto" else "sbp" + return await service.try_reuse_pending_transaction( + payment, + user_id=ctx.user_id, + sale_mode=ctx.sale_mode, + variant=variant, + ) + + def _platega_presentation_manifest(subsection: str, default_icon: str, prefix: str) -> tuple: return tuple( ProviderManifestField( @@ -819,6 +946,7 @@ SBP_SPEC = PaymentProviderSpec( webhook_path=lambda source: "/webhook/platega", webhook_route=platega_webhook_route, create_webapp_payment=create_sbp_webapp_payment, + reuse_webapp_payment=reuse_webapp_payment, config_class=PlategaConfig, presentation_class=PlategaSbpPresentation, manifest_fields=_CONFIG_MANIFEST @@ -851,6 +979,7 @@ CRYPTO_SPEC = PaymentProviderSpec( service_key="platega_service", callback_prefix="pay_platega_crypto", create_webapp_payment=create_crypto_webapp_payment, + reuse_webapp_payment=reuse_webapp_payment, config_class=PlategaConfig, presentation_class=PlategaCryptoPresentation, manifest_fields=_platega_presentation_manifest("Platega", "Bitcoin", "PLATEGA_CRYPTO"), diff --git a/backend/bot/payment_providers/severpay.py b/backend/bot/payment_providers/severpay.py index 92fd910..3046556 100644 --- a/backend/bot/payment_providers/severpay.py +++ b/backend/bot/payment_providers/severpay.py @@ -38,6 +38,7 @@ from .shared import ( PaymentSuccessRequest, build_payment_record_payload, create_webapp_payment_record, + decimal_amounts_equal, describe_payment, finalize_successful_payment, finalize_webapp_link_payment, @@ -51,11 +52,13 @@ from .shared import ( notify_user_payment_failed, parse_payment_callback, payment_failed, + payment_record_amounts, payment_unavailable, payment_units_for_activation, post_json_request, quote_hwid_callback_parts, render_link_or_fail, + render_payment_link, ) _LOG = "severpay" @@ -248,6 +251,57 @@ class SeverPayService(HttpClientMixin): return True, response_data.get("data") or response_data return False, response_data + async def get_payment(self, provider_payment_id: str) -> Tuple[bool, Dict[str, Any]]: + if not self.configured: + return False, {"message": "service_not_configured"} + + provider_payment_id = str(provider_payment_id or "").strip() + if not provider_payment_id: + return False, {"message": "missing_payment_id"} + + identifier: Dict[str, Any] + if provider_payment_id.isdigit(): + identifier = {"id": int(provider_payment_id)} + else: + identifier = {"uid": provider_payment_id} + + session = await self._get_session() + success, response_data = await post_json_request( + session, + f"{self.base_url}/payin/get", + body=self._build_signed_body(identifier), + log_prefix="SeverPay get_payment", + is_success=lambda status, data: status == 200 and bool((data or {}).get("status")), + ) + if success: + return True, response_data.get("data") or response_data + return False, response_data + + async def try_reuse_pending_payment(self, payment: Any) -> Optional[str]: + provider_payment_id = str(getattr(payment, "provider_payment_id", None) or "").strip() + payment_url = str(getattr(payment, "provider_payment_url", None) or "").strip() + if not provider_payment_id or not payment_url: + return None + + success, data = await self.get_payment(provider_payment_id) + if not success or str(data.get("status") or "").lower() not in {"new", "process"}: + return None + returned_ids = {str(data.get("id") or ""), str(data.get("uid") or "")} + if provider_payment_id not in returned_ids: + return None + if str(data.get("order_id") or "") != str(payment.payment_id): + return None + try: + if not decimal_amounts_equal(data.get("amount"), getattr(payment, "amount", None)): + return None + except (TypeError, ValueError): + return None + provider_currency = normalize_payment_currency_code(data.get("currency")) + payment_currency = normalize_payment_currency_code(getattr(payment, "currency", None)) + if provider_currency != payment_currency: + return None + return payment_url + async def webhook_route(self, request: web.Request) -> web.Response: if not self.configured: return web.json_response({"status": False, "msg": "severpay_disabled"}, status=503) @@ -465,6 +519,38 @@ async def pay_severpay_callback_handler( hwid_quote=hwid_quote, ) + reuse_amounts = payment_record_amounts( + months=parts.months, + sale_mode=parts.sale_mode, + hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None, + ) + reusable_payment = await payment_dal.find_recent_pending_provider_payment( + session, + user_id=callback.from_user.id, + provider="severpay", + pending_status="pending_severpay", + amount=parts.price, + currency=currency_code, + sale_mode=parts.sale_mode, + months=reuse_amounts.months, + purchased_gb=reuse_amounts.purchased_gb, + purchased_hwid_devices=reuse_amounts.purchased_hwid_devices, + tariff_key=reuse_amounts.tariff_key, + ) + if reusable_payment is not None: + reusable_url = await severpay_service.try_reuse_pending_payment(reusable_payment) + if reusable_url: + await render_payment_link( + callback, + translator=translator, + current_lang=current_lang, + i18n=i18n, + parts=parts, + payment_url=reusable_url, + log_prefix=_LOG, + ) + return + try: payment_record = await payment_dal.create_payment_record(session, record_payload) await session.commit() @@ -552,6 +638,13 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: ) +async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]: + service: SeverPayService = ctx.request.app.get("severpay_service") + if not service or not service.configured: + return None + return await service.try_reuse_pending_payment(payment) + + _PRESENTATION_MANIFEST = tuple( ProviderManifestField( key=key, @@ -677,6 +770,7 @@ SPEC = PaymentProviderSpec( webhook_path=lambda source: "/webhook/severpay", webhook_route=severpay_webhook_route, create_webapp_payment=create_webapp_payment, + reuse_webapp_payment=reuse_webapp_payment, config_class=SeverPayConfig, presentation_class=SeverPayPresentation, manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, diff --git a/backend/bot/payment_providers/shared/__init__.py b/backend/bot/payment_providers/shared/__init__.py index e8b4e82..67027c2 100644 --- a/backend/bot/payment_providers/shared/__init__.py +++ b/backend/bot/payment_providers/shared/__init__.py @@ -42,6 +42,7 @@ from .common import ( payment_record_amounts, payment_unavailable, payment_units_for_activation, + reusable_webapp_payment_response, sale_mode_base, sale_mode_is_hwid_devices, sale_mode_is_traffic, @@ -117,6 +118,7 @@ __all__ = [ "payment_link_message_text", "payment_link_response", "payment_record_amounts", + "reusable_webapp_payment_response", "payment_units_for_activation", "payment_unavailable", "post_json_request", diff --git a/backend/bot/payment_providers/shared/callbacks.py b/backend/bot/payment_providers/shared/callbacks.py index d646df2..d8523d0 100644 --- a/backend/bot/payment_providers/shared/callbacks.py +++ b/backend/bot/payment_providers/shared/callbacks.py @@ -285,6 +285,7 @@ async def safe_store_provider_payment_id( payment: Payment, *, provider_payment_id: str, + provider_payment_url: Optional[str] = None, new_status: Optional[str] = None, log_prefix: str, ) -> bool: @@ -300,6 +301,7 @@ async def safe_store_provider_payment_id( payment.payment_id, str(provider_payment_id), new_status or payment.status, + provider_payment_url=provider_payment_url, ) await session.commit() return True @@ -359,6 +361,7 @@ async def render_link_or_fail( session, payment, provider_payment_id=provider_payment_id, + provider_payment_url=payment_url, new_status=new_status, log_prefix=log_prefix, ) diff --git a/backend/bot/payment_providers/shared/common.py b/backend/bot/payment_providers/shared/common.py index 19680f8..fd266c6 100644 --- a/backend/bot/payment_providers/shared/common.py +++ b/backend/bot/payment_providers/shared/common.py @@ -315,6 +315,45 @@ async def create_webapp_payment_record( ) +async def reusable_webapp_payment_response( + ctx: WebAppPaymentContext, + provider_spec: Any, + *, + since_minutes: Optional[int] = None, +) -> Optional[web.Response]: + resolver = getattr(provider_spec, "reuse_webapp_payment", None) + if resolver is None: + return None + + amounts = payment_record_amounts( + months=ctx.months, + sale_mode=ctx.sale_mode, + traffic_gb=ctx.traffic_gb, + hwid_device_count=ctx.hwid_device_count, + ) + payment = await payment_dal.find_recent_pending_provider_payment( + ctx.session, + user_id=ctx.user_id, + provider=provider_spec.provider_key, + pending_status=provider_spec.pending_status, + amount=ctx.price, + currency=ctx.currency, + sale_mode=ctx.sale_mode, + months=amounts.months, + purchased_gb=amounts.purchased_gb, + purchased_hwid_devices=amounts.purchased_hwid_devices, + tariff_key=amounts.tariff_key, + since_minutes=since_minutes, + ) + if payment is None: + return None + + payment_url = await resolver(ctx, payment) + if not payment_url: + return None + return payment_link_response(payment_url=payment_url, payment_id=payment.payment_id) + + async def mark_payment_failed_creation(session: AsyncSession, payment_id: int) -> None: await payment_dal.update_payment_status_by_db_id(session, payment_id, "failed_creation") await session.commit() diff --git a/backend/bot/payment_providers/shared/webapp.py b/backend/bot/payment_providers/shared/webapp.py index d8f1c23..7577957 100644 --- a/backend/bot/payment_providers/shared/webapp.py +++ b/backend/bot/payment_providers/shared/webapp.py @@ -46,6 +46,7 @@ async def finalize_webapp_link_payment( payment.payment_id, str(provider_payment_id), new_status or payment.status, + provider_payment_url=payment_url, ) await session.commit() except Exception: diff --git a/backend/bot/payment_providers/wata.py b/backend/bot/payment_providers/wata.py index 4c4e2ec..6d8fe8c 100644 --- a/backend/bot/payment_providers/wata.py +++ b/backend/bot/payment_providers/wata.py @@ -54,7 +54,6 @@ from .shared import ( notify_user_payment_failed, parse_payment_callback, payment_failed, - payment_link_response, payment_record_amounts, payment_unavailable, payment_units_for_activation, @@ -63,7 +62,6 @@ from .shared import ( render_link_or_fail, render_payment_link, safe_callback_answer, - sale_mode_base, ) router = Router(name="user_subscription_payments_wata_router") @@ -889,17 +887,15 @@ async def pay_wata_callback_handler( payment_description = describe_payment(translator, parts) reuse_amounts = payment_record_amounts(months=parts.months, sale_mode=parts.sale_mode) - months_for_lookup = ( - reuse_amounts.months if sale_mode_base(parts.sale_mode) == "subscription" else None - ) reusable_payment = await payment_dal.find_recent_pending_provider_payment( session, user_id=callback.from_user.id, provider="wata", pending_status="pending_wata", amount=parts.price, + currency=currency_code, sale_mode=parts.sale_mode, - months=months_for_lookup, + months=reuse_amounts.months, purchased_gb=reuse_amounts.purchased_gb, purchased_hwid_devices=reuse_amounts.purchased_hwid_devices, tariff_key=reuse_amounts.tariff_key, @@ -974,45 +970,6 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: currency = ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB" - reuse_amounts = payment_record_amounts( - months=ctx.months, - sale_mode=ctx.sale_mode, - traffic_gb=ctx.traffic_gb, - hwid_device_count=ctx.hwid_device_count, - ) - months_for_lookup = ( - reuse_amounts.months if sale_mode_base(ctx.sale_mode) == "subscription" else None - ) - try: - reusable_payment = await payment_dal.find_recent_pending_provider_payment( - ctx.session, - user_id=ctx.user_id, - provider="wata", - pending_status="pending_wata", - amount=ctx.price, - sale_mode=ctx.sale_mode, - months=months_for_lookup, - purchased_gb=reuse_amounts.purchased_gb, - purchased_hwid_devices=reuse_amounts.purchased_hwid_devices, - tariff_key=reuse_amounts.tariff_key, - since_minutes=service.payment_link_ttl_minutes, - ) - except Exception: - logging.exception("Wata WebApp: lookup of reusable payment failed") - reusable_payment = None - - if reusable_payment is not None: - try: - reusable_url = await service.try_reuse_pending_link(reusable_payment) - except Exception: - logging.exception("Wata WebApp: failed to verify reusable link") - reusable_url = None - if reusable_url: - return payment_link_response( - payment_url=reusable_url, - payment_id=reusable_payment.payment_id, - ) - try: payment = await create_webapp_payment_record( ctx, @@ -1044,6 +1001,13 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: ) +async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]: + service: WataService = ctx.request.app.get("wata_service") + if not service or not service.configured: + return None + return await service.try_reuse_pending_link(payment) + + async def wata_webhook_route(request: web.Request) -> web.Response: service: WataService = request.app["wata_service"] return await service.webhook_route(request) @@ -1204,6 +1168,7 @@ SPEC = PaymentProviderSpec( webhook_path=lambda source: "/webhook/wata", webhook_route=wata_webhook_route, create_webapp_payment=create_webapp_payment, + reuse_webapp_payment=reuse_webapp_payment, config_class=WataConfig, presentation_class=WataPresentation, manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, diff --git a/backend/bot/payment_providers/yookassa.py b/backend/bot/payment_providers/yookassa.py index 65f79b9..30b33b5 100644 --- a/backend/bot/payment_providers/yookassa.py +++ b/backend/bot/payment_providers/yookassa.py @@ -63,6 +63,7 @@ from .shared import ( append_hwid_renewal_note, build_success_message, create_webapp_payment_record, + decimal_amounts_equal, format_human_units, format_number_for_payload, is_traffic_sale_base, @@ -382,6 +383,10 @@ class YooKassaService: "title": pm_title, "card_last4": last4_val, } + confirmation = getattr(payment_info_yk, "confirmation", None) + confirmation_url = ( + getattr(confirmation, "confirmation_url", None) if confirmation else None + ) return { "id": payment_info_yk.id, "status": payment_info_yk.status, @@ -399,6 +404,7 @@ class YooKassaService: and hasattr(payment_info_yk.captured_at, "isoformat") else None, "payment_method": pm_payload, + "confirmation_url": confirmation_url, "test_mode": getattr(payment_info_yk, "test", None), } else: @@ -2939,6 +2945,42 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response: return payment_failed() +async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]: + service: YooKassaService = ctx.request.app.get("yookassa_service") + if not service or not service.configured: + return None + + provider_payment_id = str( + getattr(payment, "yookassa_payment_id", None) + or getattr(payment, "provider_payment_id", None) + or "" + ).strip() + if not provider_payment_id: + return None + + info = await service.get_payment_info(provider_payment_id) + if not info or str(info.get("status") or "").strip().lower() != "pending": + return None + if bool(info.get("paid")): + return None + if not decimal_amounts_equal(info.get("amount_value"), getattr(payment, "amount", None)): + return None + provider_currency = normalize_payment_currency_code(info.get("amount_currency")) + payment_currency = normalize_payment_currency_code(getattr(payment, "currency", None)) + if provider_currency != payment_currency: + return None + + metadata = info.get("metadata") or {} + expected_metadata = { + "user_id": str(ctx.user_id), + "payment_db_id": str(payment.payment_id), + "sale_mode": str(ctx.sale_mode), + } + if any(str(metadata.get(key) or "") != value for key, value in expected_metadata.items()): + return None + return str(info.get("confirmation_url") or "").strip() or None + + _PRESENTATION_MANIFEST = tuple( ProviderManifestField( key=key, @@ -3073,6 +3115,7 @@ SPEC = PaymentProviderSpec( webhook_route=yookassa_webhook_route, webhook_requires_base_url=True, create_webapp_payment=create_webapp_payment, + reuse_webapp_payment=reuse_webapp_payment, config_class=YooKassaConfig, presentation_class=YooKassaPresentation, manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, diff --git a/backend/db/dal/payment_dal.py b/backend/db/dal/payment_dal.py index f47338a..6c435f8 100644 --- a/backend/db/dal/payment_dal.py +++ b/backend/db/dal/payment_dal.py @@ -1,7 +1,7 @@ import logging from typing import Any, Dict, List, Optional -from sqlalchemy import Date, and_, case, cast, func +from sqlalchemy import Date, and_, case, cast, func, or_ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import joinedload, selectinload @@ -116,31 +116,37 @@ async def find_recent_pending_provider_payment( provider: str, pending_status: str, amount: float, + currency: Optional[str], sale_mode: Optional[str], months: Optional[int], purchased_gb: Optional[float], purchased_hwid_devices: Optional[int], tariff_key: Optional[str] = None, - since_minutes: int = 60, + since_minutes: Optional[int] = None, ) -> Optional[Payment]: """Return the most recent pending payment matching the given tariff parameters. Used to reuse an existing provider payment link instead of creating a new one - on repeated user clicks. Only payments with a populated ``provider_payment_id`` - are returned — without it, there's no link to reuse. + on repeated user clicks. A generic or provider-specific payment id must be + populated so the caller can verify the remote payment link. """ from datetime import datetime, timedelta, timezone - cutoff = datetime.now(timezone.utc) - timedelta(minutes=max(1, since_minutes)) - conditions = [ Payment.user_id == user_id, Payment.provider == provider, - Payment.status == pending_status, - Payment.provider_payment_id.isnot(None), - Payment.created_at >= cutoff, + Payment.status.in_((pending_status, "pending")), + or_( + Payment.provider_payment_id.isnot(None), + Payment.yookassa_payment_id.isnot(None), + ), func.abs(Payment.amount - float(amount)) < 0.01, ] + if since_minutes is not None: + cutoff = datetime.now(timezone.utc) - timedelta(minutes=max(1, since_minutes)) + conditions.append(Payment.created_at >= cutoff) + if currency is not None: + conditions.append(func.upper(Payment.currency) == str(currency).strip().upper()) if sale_mode is not None: conditions.append(Payment.sale_mode == sale_mode) if tariff_key is not None: @@ -238,12 +244,18 @@ async def count_user_succeeded_payments( async def update_provider_payment_and_status( - session: AsyncSession, payment_db_id: int, provider_payment_id: str, new_status: str + session: AsyncSession, + payment_db_id: int, + provider_payment_id: str, + new_status: str, + provider_payment_url: Optional[str] = None, ) -> Optional[Payment]: payment = await get_payment_by_db_id(session, payment_db_id) if payment: payment.status = new_status payment.provider_payment_id = provider_payment_id + if provider_payment_url: + payment.provider_payment_url = provider_payment_url payment.updated_at = func.now() await session.flush() await session.refresh(payment) diff --git a/backend/db/migrator.py b/backend/db/migrator.py index ad97cce..e55fad8 100644 --- a/backend/db/migrator.py +++ b/backend/db/migrator.py @@ -1155,6 +1155,13 @@ def _migration_0035_add_subscription_promo_expiry_flag(connection: Connection) - ) +def _migration_0036_add_provider_payment_url(connection: Connection) -> None: + inspector = inspect(connection) + columns: Set[str] = {col["name"] for col in inspector.get_columns("payments")} + if "provider_payment_url" not in columns: + connection.execute(text("ALTER TABLE payments ADD COLUMN provider_payment_url VARCHAR")) + + MIGRATIONS: List[Migration] = [ Migration( id="0001_add_channel_subscription_fields", @@ -1342,6 +1349,11 @@ MIGRATIONS: List[Migration] = [ description="Suppress multi-day expiry reminders for trial and bonus subscriptions", upgrade=_migration_0035_add_subscription_promo_expiry_flag, ), + Migration( + id="0036_add_provider_payment_url", + description="Persist provider payment links for reusable pending payments", + upgrade=_migration_0036_add_provider_payment_url, + ), ] diff --git a/backend/db/models.py b/backend/db/models.py index 25d70e2..348d2df 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -203,6 +203,7 @@ class Payment(Base): user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True) yookassa_payment_id = Column(String, unique=True, index=True, nullable=True) provider_payment_id = Column(String, unique=True, nullable=True) + provider_payment_url = Column(String, nullable=True) provider = Column(String, nullable=False, default="yookassa", index=True) idempotence_key = Column(String, unique=True, nullable=True) amount = Column(Float, nullable=False) diff --git a/tests/test_webapp_payment_status.py b/tests/test_webapp_payment_status.py index d2bcebd..b044993 100644 --- a/tests/test_webapp_payment_status.py +++ b/tests/test_webapp_payment_status.py @@ -1,11 +1,18 @@ +import json +import time from types import SimpleNamespace from unittest import IsolatedAsyncioTestCase from unittest.mock import AsyncMock, patch import bot.app.web.subscription_webapp # noqa: F401 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 +from bot.payment_providers.base import PaymentProviderSpec, WebAppPaymentContext +from bot.payment_providers.freekassa import FreeKassaService +from bot.payment_providers.heleket import HeleketService +from bot.payment_providers.platega import PlategaService +from bot.payment_providers.severpay import SeverPayService +from bot.payment_providers.shared import reusable_webapp_payment_response +from bot.payment_providers.yookassa import create_webapp_payment, reuse_webapp_payment class _SessionFactory: @@ -20,6 +27,374 @@ class _SessionFactory: class WebAppPaymentStatusTests(IsolatedAsyncioTestCase): + async def test_heleket_reuses_unexpired_check_payment(self): + payment = SimpleNamespace( + payment_id=77, + amount=299.0, + currency="RUB", + provider_payment_id="invoice-77", + provider_payment_url=None, + ) + service = object.__new__(HeleketService) + service.get_payment_info = AsyncMock( + return_value=( + True, + { + "uuid": "invoice-77", + "order_id": "77", + "amount": "299.00", + "currency": "RUB", + "payment_status": "check", + "is_final": False, + "expired_at": int(time.time()) + 900, + "url": "https://heleket.example/pay/77", + }, + ) + ) + + url = await service.try_reuse_pending_payment(payment) + + self.assertEqual(url, "https://heleket.example/pay/77") + service.get_payment_info.assert_awaited_once_with("invoice-77") + + async def test_heleket_does_not_reuse_processing_payment(self): + payment = SimpleNamespace( + payment_id=77, + amount=299.0, + currency="RUB", + provider_payment_id="invoice-77", + provider_payment_url="https://heleket.example/pay/77", + ) + service = object.__new__(HeleketService) + service.get_payment_info = AsyncMock( + return_value=( + True, + { + "uuid": "invoice-77", + "order_id": "77", + "amount": "299.00", + "currency": "RUB", + "payment_status": "process", + "is_final": False, + "expired_at": int(time.time()) + 900, + }, + ) + ) + + self.assertIsNone(await service.try_reuse_pending_payment(payment)) + + async def test_severpay_reuses_new_payment(self): + payment = SimpleNamespace( + payment_id=77, + amount=299.0, + currency="RUB", + provider_payment_id="12345", + provider_payment_url="https://severpay.example/pay/77", + ) + service = object.__new__(SeverPayService) + service.get_payment = AsyncMock( + return_value=( + True, + { + "id": 12345, + "uid": "payment-uid-77", + "order_id": "77", + "amount": 299.0, + "currency": "RUB", + "status": "new", + }, + ) + ) + + url = await service.try_reuse_pending_payment(payment) + + self.assertEqual(url, "https://severpay.example/pay/77") + service.get_payment.assert_awaited_once_with("12345") + + async def test_severpay_does_not_reuse_failed_payment(self): + payment = SimpleNamespace( + payment_id=77, + amount=299.0, + currency="RUB", + provider_payment_id="12345", + provider_payment_url="https://severpay.example/pay/77", + ) + service = object.__new__(SeverPayService) + service.get_payment = AsyncMock( + return_value=( + True, + { + "id": 12345, + "order_id": "77", + "amount": 299.0, + "currency": "RUB", + "status": "fail", + }, + ) + ) + + self.assertIsNone(await service.try_reuse_pending_payment(payment)) + + async def test_platega_reuses_matching_pending_transaction(self): + payment = SimpleNamespace( + payment_id=77, + amount=299.0, + currency="RUB", + provider_payment_id="transaction-77", + provider_payment_url="https://platega.example/pay/77", + ) + service = object.__new__(PlategaService) + service.get_transaction = AsyncMock( + return_value=( + True, + { + "id": "transaction-77", + "status": "PENDING", + "paymentDetails": {"amount": 299.0, "currency": "RUB"}, + "payload": json.dumps( + { + "payment_db_id": 77, + "user_id": 1001, + "sale_mode": "subscription@standard", + "platega_variant": "sbp", + } + ), + }, + ) + ) + + url = await service.try_reuse_pending_transaction( + payment, + user_id=1001, + sale_mode="subscription@standard", + variant="sbp", + ) + + self.assertEqual(url, "https://platega.example/pay/77") + service.get_transaction.assert_awaited_once_with("transaction-77") + + async def test_platega_does_not_reuse_other_variant(self): + payment = SimpleNamespace( + payment_id=77, + amount=299.0, + currency="RUB", + provider_payment_id="transaction-77", + provider_payment_url="https://platega.example/pay/77", + ) + service = object.__new__(PlategaService) + service.get_transaction = AsyncMock( + return_value=( + True, + { + "id": "transaction-77", + "status": "PENDING", + "paymentDetails": {"amount": 299.0, "currency": "RUB"}, + "payload": json.dumps( + { + "payment_db_id": 77, + "user_id": 1001, + "sale_mode": "subscription@standard", + "platega_variant": "crypto", + } + ), + }, + ) + ) + + self.assertIsNone( + await service.try_reuse_pending_transaction( + payment, + user_id=1001, + sale_mode="subscription@standard", + variant="sbp", + ) + ) + + async def test_freekassa_reuses_matching_new_order(self): + payment = SimpleNamespace( + payment_id=77, + amount=299.0, + currency="RUB", + provider_payment_id="order-hash-77", + ) + service = object.__new__(FreeKassaService) + service.config = SimpleNamespace(PAYMENT_URL="https://freekassa.example/") + service.get_orders = AsyncMock( + return_value=( + True, + { + "type": "success", + "orders": [ + { + "merchant_order_id": "77", + "fk_order_id": 12345, + "amount": 299.0, + "currency": "RUB", + "status": 0, + } + ], + }, + ) + ) + + url = await service.try_reuse_pending_order(payment) + + self.assertEqual(url, "https://freekassa.example/form/12345/order-hash-77") + service.get_orders.assert_awaited_once_with(payment_id=77, order_status=0) + + async def test_freekassa_does_not_reuse_order_with_other_amount(self): + payment = SimpleNamespace( + payment_id=77, + amount=299.0, + currency="RUB", + provider_payment_id="order-hash-77", + ) + service = object.__new__(FreeKassaService) + service.config = SimpleNamespace(PAYMENT_URL="https://freekassa.example/") + service.get_orders = AsyncMock( + return_value=( + True, + { + "orders": [ + { + "merchant_order_id": "77", + "fk_order_id": 12345, + "amount": 199.0, + "currency": "RUB", + "status": 0, + } + ] + }, + ) + ) + + self.assertIsNone(await service.try_reuse_pending_order(payment)) + + async def test_reusable_payment_response_returns_existing_payment(self): + payment = SimpleNamespace(payment_id=77) + resolver = AsyncMock(return_value="https://provider.example/pay/77") + spec = PaymentProviderSpec( + id="provider", + provider_key="provider", + label="Provider", + pending_status="pending_provider", + enabled=lambda _config: True, + reuse_webapp_payment=resolver, + ) + ctx = WebAppPaymentContext( + request=SimpleNamespace(app={}), + session=AsyncMock(), + user_id=1001, + method="provider", + months=3, + price=299.0, + stars_price=None, + description="Subscription", + sale_mode="subscription@standard", + currency="RUB", + ) + + with patch.object( + billing_module.payment_dal, + "find_recent_pending_provider_payment", + AsyncMock(return_value=payment), + ) as find_pending: + response = await reusable_webapp_payment_response(ctx, spec) + + self.assertIsNotNone(response) + self.assertEqual(response.status, 200) + self.assertIn(b'"payment_id": 77', response.body) + resolver.assert_awaited_once_with(ctx, payment) + self.assertEqual(find_pending.await_args.kwargs["amount"], 299.0) + self.assertEqual(find_pending.await_args.kwargs["currency"], "RUB") + self.assertEqual(find_pending.await_args.kwargs["sale_mode"], "subscription@standard") + self.assertEqual(find_pending.await_args.kwargs["months"], 3) + self.assertEqual(find_pending.await_args.kwargs["tariff_key"], "standard") + + async def test_yookassa_reuses_only_matching_pending_invoice(self): + payment = SimpleNamespace( + payment_id=77, + amount=299.0, + currency="RUB", + yookassa_payment_id="yk_77", + provider_payment_id=None, + ) + service = SimpleNamespace( + configured=True, + get_payment_info=AsyncMock( + return_value={ + "id": "yk_77", + "status": "pending", + "paid": False, + "amount_value": 299.0, + "amount_currency": "RUB", + "metadata": { + "user_id": "1001", + "payment_db_id": "77", + "sale_mode": "subscription@standard", + }, + "confirmation_url": "https://yookassa.example/pay/77", + } + ), + ) + ctx = WebAppPaymentContext( + request=SimpleNamespace(app={"yookassa_service": service}), + session=AsyncMock(), + user_id=1001, + method="yookassa", + months=3, + price=299.0, + stars_price=None, + description="Subscription", + sale_mode="subscription@standard", + currency="RUB", + ) + + url = await reuse_webapp_payment(ctx, payment) + + self.assertEqual(url, "https://yookassa.example/pay/77") + service.get_payment_info.assert_awaited_once_with("yk_77") + + async def test_yookassa_does_not_reuse_invoice_with_other_sale_mode(self): + payment = SimpleNamespace( + payment_id=77, + amount=299.0, + currency="RUB", + yookassa_payment_id="yk_77", + provider_payment_id=None, + ) + service = SimpleNamespace( + configured=True, + get_payment_info=AsyncMock( + return_value={ + "status": "pending", + "paid": False, + "amount_value": 299.0, + "amount_currency": "RUB", + "metadata": { + "user_id": "1001", + "payment_db_id": "77", + "sale_mode": "traffic@standard", + }, + "confirmation_url": "https://yookassa.example/pay/77", + } + ), + ) + ctx = WebAppPaymentContext( + request=SimpleNamespace(app={"yookassa_service": service}), + session=AsyncMock(), + user_id=1001, + method="yookassa", + months=3, + price=299.0, + stars_price=None, + description="Subscription", + sale_mode="subscription@standard", + currency="RUB", + ) + + self.assertIsNone(await reuse_webapp_payment(ctx, payment)) + async def test_yookassa_pending_payment_refresh_processes_succeeded_provider_status(self): payment = SimpleNamespace( payment_id=42,