feat(payments): reuse pending provider payments

This commit is contained in:
BADtochka
2026-06-09 14:19:50 +03:00
parent 1362edde42
commit 234fc69505
16 changed files with 992 additions and 79 deletions
+38 -22
View File
@@ -1068,28 +1068,44 @@ async def _create_subscription_payment(
"payment_amount_below_minimum", "payment_amount_below_minimum",
"Payment amount is below the provider minimum", "Payment amount is below the provider minimum",
) )
return await provider_spec.create_webapp_payment( payment_context = WebAppPaymentContext(
WebAppPaymentContext( request=request,
request=request, session=session,
session=session, user_id=user_id,
user_id=user_id, method=method,
method=method, months=months,
months=months, price=price,
price=price, stars_price=stars_price,
stars_price=stars_price, currency=payment_currency,
currency=payment_currency, description=description,
description=description, sale_mode=sale_mode,
sale_mode=sale_mode, traffic_gb=traffic_gb,
traffic_gb=traffic_gb, hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
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_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_valid_until=hwid_quote.get("valid_until") if hwid_quote else None, hwid_pricing_period_months=hwid_quote.get("pricing_period_months")
hwid_pricing_period_months=hwid_quote.get("pricing_period_months") if hwid_quote
if hwid_quote else None,
else None, hwid_proration_ratio=hwid_quote.get("proration_ratio") 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,
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") return _json_error(400, "payment_unavailable", "Payment method unavailable")
+2
View File
@@ -127,6 +127,7 @@ ServiceFactory = Callable[[ServiceFactoryContext], Any]
WebhookPathGetter = Callable[[Any], str] WebhookPathGetter = Callable[[Any], str]
WebhookRoute = Callable[[Any], Awaitable[Any]] WebhookRoute = Callable[[Any], Awaitable[Any]]
WebAppPaymentFactory = Callable[[WebAppPaymentContext], Awaitable[Any]] WebAppPaymentFactory = Callable[[WebAppPaymentContext], Awaitable[Any]]
ReusableWebAppPaymentResolver = Callable[[WebAppPaymentContext, Any], Awaitable[Optional[str]]]
CurrencySupportResolver = Callable[[Any], Optional[Sequence[str]]] CurrencySupportResolver = Callable[[Any], Optional[Sequence[str]]]
PaymentAmountResolver = Callable[[Any, Any, Any], bool] PaymentAmountResolver = Callable[[Any, Any, Any], bool]
PaymentMinimumResolver = Callable[[Any, Any], Optional[Mapping[str, Any]]] PaymentMinimumResolver = Callable[[Any, Any], Optional[Mapping[str, Any]]]
@@ -180,6 +181,7 @@ class PaymentProviderSpec:
webhook_route: Optional[WebhookRoute] = None webhook_route: Optional[WebhookRoute] = None
webhook_requires_base_url: bool = False webhook_requires_base_url: bool = False
create_webapp_payment: Optional[WebAppPaymentFactory] = None create_webapp_payment: Optional[WebAppPaymentFactory] = None
reuse_webapp_payment: Optional[ReusableWebAppPaymentResolver] = None
requires_configured_service: bool = True requires_configured_service: bool = True
price_source: str = "rub" price_source: str = "rub"
emoji: str = "💳" emoji: str = "💳"
+108
View File
@@ -53,11 +53,14 @@ from .shared import (
notify_service_unavailable, notify_service_unavailable,
parse_payment_callback, parse_payment_callback,
payment_failed, payment_failed,
payment_record_amounts,
payment_unavailable, payment_unavailable,
payment_units_for_activation, payment_units_for_activation,
post_json_request, post_json_request,
quote_hwid_callback_parts, quote_hwid_callback_parts,
render_link_or_fail, render_link_or_fail,
render_payment_link,
safe_callback_answer,
) )
_LOG = "freekassa" _LOG = "freekassa"
@@ -253,6 +256,69 @@ class FreeKassaService(HttpClientMixin):
is_success=lambda status, data: status == 200 and (data or {}).get("type") == "success", 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 def _generate_nonce(self) -> int:
async with self._nonce_lock: async with self._nonce_lock:
candidate = int(time.time() * 1000) candidate = int(time.time() * 1000)
@@ -513,6 +579,39 @@ async def pay_fk_callback_handler(
hwid_quote=hwid_quote, 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: try:
payment_record = await payment_dal.create_payment_record(session, record_payload) payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit() 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( _PRESENTATION_MANIFEST = tuple(
ProviderManifestField( ProviderManifestField(
key=key, key=key,
@@ -772,6 +879,7 @@ SPEC = PaymentProviderSpec(
webhook_path=lambda source: "/webhook/freekassa", webhook_path=lambda source: "/webhook/freekassa",
webhook_route=freekassa_webhook_route, webhook_route=freekassa_webhook_route,
create_webapp_payment=create_webapp_payment, create_webapp_payment=create_webapp_payment,
reuse_webapp_payment=reuse_webapp_payment,
config_class=FreeKassaConfig, config_class=FreeKassaConfig,
presentation_class=FreeKassaPresentation, presentation_class=FreeKassaPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
+111
View File
@@ -3,6 +3,7 @@ import hashlib
import hmac import hmac
import json import json
import logging import logging
import time
from collections import OrderedDict from collections import OrderedDict
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
@@ -54,10 +55,12 @@ from .shared import (
notify_user_payment_failed, notify_user_payment_failed,
parse_payment_callback, parse_payment_callback,
payment_failed, payment_failed,
payment_record_amounts,
payment_unavailable, payment_unavailable,
payment_units_for_activation, payment_units_for_activation,
quote_hwid_callback_parts, quote_hwid_callback_parts,
render_link_or_fail, render_link_or_fail,
render_payment_link,
) )
router = Router(name="user_subscription_payments_heleket_router") router = Router(name="user_subscription_payments_heleket_router")
@@ -372,6 +375,74 @@ class HeleketService(HttpClientMixin):
logging.exception("Heleket create_payment_link: request failed.") logging.exception("Heleket create_payment_link: request failed.")
return False, {"message": str(exc)} 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: def _verify_signature(self, payload: Dict[str, Any]) -> bool:
received = payload.get("sign") received = payload.get("sign")
if not isinstance(received, str) or not received: if not isinstance(received, str) or not received:
@@ -612,6 +683,38 @@ async def pay_heleket_callback_handler(
hwid_quote=hwid_quote, 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: try:
payment_record = await payment_dal.create_payment_record(session, record_payload) payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit() 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: async def heleket_webhook_route(request: web.Request) -> web.Response:
service: HeleketService = request.app["heleket_service"] service: HeleketService = request.app["heleket_service"]
return await service.webhook_route(request) return await service.webhook_route(request)
@@ -887,6 +997,7 @@ SPEC = PaymentProviderSpec(
webhook_path=lambda source: "/webhook/heleket", webhook_path=lambda source: "/webhook/heleket",
webhook_route=heleket_webhook_route, webhook_route=heleket_webhook_route,
create_webapp_payment=create_webapp_payment, create_webapp_payment=create_webapp_payment,
reuse_webapp_payment=reuse_webapp_payment,
emoji="🪙", emoji="🪙",
config_class=HeleketConfig, config_class=HeleketConfig,
presentation_class=HeleketPresentation, presentation_class=HeleketPresentation,
+129
View File
@@ -55,6 +55,7 @@ from .shared import (
post_json_request, post_json_request,
quote_hwid_callback_parts, quote_hwid_callback_parts,
render_link_or_fail, render_link_or_fail,
render_payment_link,
safe_callback_answer, safe_callback_answer,
) )
@@ -282,6 +283,82 @@ class PlategaService(HttpClientMixin):
log_prefix="Platega create_transaction", 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: async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured: if not self.configured:
return web.Response(status=503, text="platega_disabled") return web.Response(status=503, text="platega_disabled")
@@ -519,6 +596,43 @@ async def pay_platega_callback_handler(
hwid_quote=hwid_quote, 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: try:
payment_record = await payment_dal.create_payment_record(session, record_payload) payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit() 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") 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: def _platega_presentation_manifest(subsection: str, default_icon: str, prefix: str) -> tuple:
return tuple( return tuple(
ProviderManifestField( ProviderManifestField(
@@ -819,6 +946,7 @@ SBP_SPEC = PaymentProviderSpec(
webhook_path=lambda source: "/webhook/platega", webhook_path=lambda source: "/webhook/platega",
webhook_route=platega_webhook_route, webhook_route=platega_webhook_route,
create_webapp_payment=create_sbp_webapp_payment, create_webapp_payment=create_sbp_webapp_payment,
reuse_webapp_payment=reuse_webapp_payment,
config_class=PlategaConfig, config_class=PlategaConfig,
presentation_class=PlategaSbpPresentation, presentation_class=PlategaSbpPresentation,
manifest_fields=_CONFIG_MANIFEST manifest_fields=_CONFIG_MANIFEST
@@ -851,6 +979,7 @@ CRYPTO_SPEC = PaymentProviderSpec(
service_key="platega_service", service_key="platega_service",
callback_prefix="pay_platega_crypto", callback_prefix="pay_platega_crypto",
create_webapp_payment=create_crypto_webapp_payment, create_webapp_payment=create_crypto_webapp_payment,
reuse_webapp_payment=reuse_webapp_payment,
config_class=PlategaConfig, config_class=PlategaConfig,
presentation_class=PlategaCryptoPresentation, presentation_class=PlategaCryptoPresentation,
manifest_fields=_platega_presentation_manifest("Platega", "Bitcoin", "PLATEGA_CRYPTO"), manifest_fields=_platega_presentation_manifest("Platega", "Bitcoin", "PLATEGA_CRYPTO"),
+94
View File
@@ -38,6 +38,7 @@ from .shared import (
PaymentSuccessRequest, PaymentSuccessRequest,
build_payment_record_payload, build_payment_record_payload,
create_webapp_payment_record, create_webapp_payment_record,
decimal_amounts_equal,
describe_payment, describe_payment,
finalize_successful_payment, finalize_successful_payment,
finalize_webapp_link_payment, finalize_webapp_link_payment,
@@ -51,11 +52,13 @@ from .shared import (
notify_user_payment_failed, notify_user_payment_failed,
parse_payment_callback, parse_payment_callback,
payment_failed, payment_failed,
payment_record_amounts,
payment_unavailable, payment_unavailable,
payment_units_for_activation, payment_units_for_activation,
post_json_request, post_json_request,
quote_hwid_callback_parts, quote_hwid_callback_parts,
render_link_or_fail, render_link_or_fail,
render_payment_link,
) )
_LOG = "severpay" _LOG = "severpay"
@@ -248,6 +251,57 @@ class SeverPayService(HttpClientMixin):
return True, response_data.get("data") or response_data return True, response_data.get("data") or response_data
return False, 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: async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured: if not self.configured:
return web.json_response({"status": False, "msg": "severpay_disabled"}, status=503) 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, 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: try:
payment_record = await payment_dal.create_payment_record(session, record_payload) payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit() 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( _PRESENTATION_MANIFEST = tuple(
ProviderManifestField( ProviderManifestField(
key=key, key=key,
@@ -677,6 +770,7 @@ SPEC = PaymentProviderSpec(
webhook_path=lambda source: "/webhook/severpay", webhook_path=lambda source: "/webhook/severpay",
webhook_route=severpay_webhook_route, webhook_route=severpay_webhook_route,
create_webapp_payment=create_webapp_payment, create_webapp_payment=create_webapp_payment,
reuse_webapp_payment=reuse_webapp_payment,
config_class=SeverPayConfig, config_class=SeverPayConfig,
presentation_class=SeverPayPresentation, presentation_class=SeverPayPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
@@ -42,6 +42,7 @@ from .common import (
payment_record_amounts, payment_record_amounts,
payment_unavailable, payment_unavailable,
payment_units_for_activation, payment_units_for_activation,
reusable_webapp_payment_response,
sale_mode_base, sale_mode_base,
sale_mode_is_hwid_devices, sale_mode_is_hwid_devices,
sale_mode_is_traffic, sale_mode_is_traffic,
@@ -117,6 +118,7 @@ __all__ = [
"payment_link_message_text", "payment_link_message_text",
"payment_link_response", "payment_link_response",
"payment_record_amounts", "payment_record_amounts",
"reusable_webapp_payment_response",
"payment_units_for_activation", "payment_units_for_activation",
"payment_unavailable", "payment_unavailable",
"post_json_request", "post_json_request",
@@ -285,6 +285,7 @@ async def safe_store_provider_payment_id(
payment: Payment, payment: Payment,
*, *,
provider_payment_id: str, provider_payment_id: str,
provider_payment_url: Optional[str] = None,
new_status: Optional[str] = None, new_status: Optional[str] = None,
log_prefix: str, log_prefix: str,
) -> bool: ) -> bool:
@@ -300,6 +301,7 @@ async def safe_store_provider_payment_id(
payment.payment_id, payment.payment_id,
str(provider_payment_id), str(provider_payment_id),
new_status or payment.status, new_status or payment.status,
provider_payment_url=provider_payment_url,
) )
await session.commit() await session.commit()
return True return True
@@ -359,6 +361,7 @@ async def render_link_or_fail(
session, session,
payment, payment,
provider_payment_id=provider_payment_id, provider_payment_id=provider_payment_id,
provider_payment_url=payment_url,
new_status=new_status, new_status=new_status,
log_prefix=log_prefix, log_prefix=log_prefix,
) )
@@ -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: 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 payment_dal.update_payment_status_by_db_id(session, payment_id, "failed_creation")
await session.commit() await session.commit()
@@ -46,6 +46,7 @@ async def finalize_webapp_link_payment(
payment.payment_id, payment.payment_id,
str(provider_payment_id), str(provider_payment_id),
new_status or payment.status, new_status or payment.status,
provider_payment_url=payment_url,
) )
await session.commit() await session.commit()
except Exception: except Exception:
+10 -45
View File
@@ -54,7 +54,6 @@ from .shared import (
notify_user_payment_failed, notify_user_payment_failed,
parse_payment_callback, parse_payment_callback,
payment_failed, payment_failed,
payment_link_response,
payment_record_amounts, payment_record_amounts,
payment_unavailable, payment_unavailable,
payment_units_for_activation, payment_units_for_activation,
@@ -63,7 +62,6 @@ from .shared import (
render_link_or_fail, render_link_or_fail,
render_payment_link, render_payment_link,
safe_callback_answer, safe_callback_answer,
sale_mode_base,
) )
router = Router(name="user_subscription_payments_wata_router") router = Router(name="user_subscription_payments_wata_router")
@@ -889,17 +887,15 @@ async def pay_wata_callback_handler(
payment_description = describe_payment(translator, parts) payment_description = describe_payment(translator, parts)
reuse_amounts = payment_record_amounts(months=parts.months, sale_mode=parts.sale_mode) 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( reusable_payment = await payment_dal.find_recent_pending_provider_payment(
session, session,
user_id=callback.from_user.id, user_id=callback.from_user.id,
provider="wata", provider="wata",
pending_status="pending_wata", pending_status="pending_wata",
amount=parts.price, amount=parts.price,
currency=currency_code,
sale_mode=parts.sale_mode, sale_mode=parts.sale_mode,
months=months_for_lookup, months=reuse_amounts.months,
purchased_gb=reuse_amounts.purchased_gb, purchased_gb=reuse_amounts.purchased_gb,
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices, purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
tariff_key=reuse_amounts.tariff_key, 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" 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: try:
payment = await create_webapp_payment_record( payment = await create_webapp_payment_record(
ctx, 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: async def wata_webhook_route(request: web.Request) -> web.Response:
service: WataService = request.app["wata_service"] service: WataService = request.app["wata_service"]
return await service.webhook_route(request) return await service.webhook_route(request)
@@ -1204,6 +1168,7 @@ SPEC = PaymentProviderSpec(
webhook_path=lambda source: "/webhook/wata", webhook_path=lambda source: "/webhook/wata",
webhook_route=wata_webhook_route, webhook_route=wata_webhook_route,
create_webapp_payment=create_webapp_payment, create_webapp_payment=create_webapp_payment,
reuse_webapp_payment=reuse_webapp_payment,
config_class=WataConfig, config_class=WataConfig,
presentation_class=WataPresentation, presentation_class=WataPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
+43
View File
@@ -63,6 +63,7 @@ from .shared import (
append_hwid_renewal_note, append_hwid_renewal_note,
build_success_message, build_success_message,
create_webapp_payment_record, create_webapp_payment_record,
decimal_amounts_equal,
format_human_units, format_human_units,
format_number_for_payload, format_number_for_payload,
is_traffic_sale_base, is_traffic_sale_base,
@@ -382,6 +383,10 @@ class YooKassaService:
"title": pm_title, "title": pm_title,
"card_last4": last4_val, "card_last4": last4_val,
} }
confirmation = getattr(payment_info_yk, "confirmation", None)
confirmation_url = (
getattr(confirmation, "confirmation_url", None) if confirmation else None
)
return { return {
"id": payment_info_yk.id, "id": payment_info_yk.id,
"status": payment_info_yk.status, "status": payment_info_yk.status,
@@ -399,6 +404,7 @@ class YooKassaService:
and hasattr(payment_info_yk.captured_at, "isoformat") and hasattr(payment_info_yk.captured_at, "isoformat")
else None, else None,
"payment_method": pm_payload, "payment_method": pm_payload,
"confirmation_url": confirmation_url,
"test_mode": getattr(payment_info_yk, "test", None), "test_mode": getattr(payment_info_yk, "test", None),
} }
else: else:
@@ -2939,6 +2945,42 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
return payment_failed() 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( _PRESENTATION_MANIFEST = tuple(
ProviderManifestField( ProviderManifestField(
key=key, key=key,
@@ -3073,6 +3115,7 @@ SPEC = PaymentProviderSpec(
webhook_route=yookassa_webhook_route, webhook_route=yookassa_webhook_route,
webhook_requires_base_url=True, webhook_requires_base_url=True,
create_webapp_payment=create_webapp_payment, create_webapp_payment=create_webapp_payment,
reuse_webapp_payment=reuse_webapp_payment,
config_class=YooKassaConfig, config_class=YooKassaConfig,
presentation_class=YooKassaPresentation, presentation_class=YooKassaPresentation,
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST, manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
+22 -10
View File
@@ -1,7 +1,7 @@
import logging import logging
from typing import Any, Dict, List, Optional 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.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm import joinedload, selectinload
@@ -116,31 +116,37 @@ async def find_recent_pending_provider_payment(
provider: str, provider: str,
pending_status: str, pending_status: str,
amount: float, amount: float,
currency: Optional[str],
sale_mode: Optional[str], sale_mode: Optional[str],
months: Optional[int], months: Optional[int],
purchased_gb: Optional[float], purchased_gb: Optional[float],
purchased_hwid_devices: Optional[int], purchased_hwid_devices: Optional[int],
tariff_key: Optional[str] = None, tariff_key: Optional[str] = None,
since_minutes: int = 60, since_minutes: Optional[int] = None,
) -> Optional[Payment]: ) -> Optional[Payment]:
"""Return the most recent pending payment matching the given tariff parameters. """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 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`` on repeated user clicks. A generic or provider-specific payment id must be
are returned without it, there's no link to reuse. populated so the caller can verify the remote payment link.
""" """
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc) - timedelta(minutes=max(1, since_minutes))
conditions = [ conditions = [
Payment.user_id == user_id, Payment.user_id == user_id,
Payment.provider == provider, Payment.provider == provider,
Payment.status == pending_status, Payment.status.in_((pending_status, "pending")),
Payment.provider_payment_id.isnot(None), or_(
Payment.created_at >= cutoff, Payment.provider_payment_id.isnot(None),
Payment.yookassa_payment_id.isnot(None),
),
func.abs(Payment.amount - float(amount)) < 0.01, 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: if sale_mode is not None:
conditions.append(Payment.sale_mode == sale_mode) conditions.append(Payment.sale_mode == sale_mode)
if tariff_key is not None: if tariff_key is not None:
@@ -238,12 +244,18 @@ async def count_user_succeeded_payments(
async def update_provider_payment_and_status( 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]: ) -> Optional[Payment]:
payment = await get_payment_by_db_id(session, payment_db_id) payment = await get_payment_by_db_id(session, payment_db_id)
if payment: if payment:
payment.status = new_status payment.status = new_status
payment.provider_payment_id = provider_payment_id payment.provider_payment_id = provider_payment_id
if provider_payment_url:
payment.provider_payment_url = provider_payment_url
payment.updated_at = func.now() payment.updated_at = func.now()
await session.flush() await session.flush()
await session.refresh(payment) await session.refresh(payment)
+12
View File
@@ -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] = [ MIGRATIONS: List[Migration] = [
Migration( Migration(
id="0001_add_channel_subscription_fields", id="0001_add_channel_subscription_fields",
@@ -1342,6 +1349,11 @@ MIGRATIONS: List[Migration] = [
description="Suppress multi-day expiry reminders for trial and bonus subscriptions", description="Suppress multi-day expiry reminders for trial and bonus subscriptions",
upgrade=_migration_0035_add_subscription_promo_expiry_flag, 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,
),
] ]
+1
View File
@@ -203,6 +203,7 @@ class Payment(Base):
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True) user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
yookassa_payment_id = Column(String, unique=True, index=True, nullable=True) yookassa_payment_id = Column(String, unique=True, index=True, nullable=True)
provider_payment_id = Column(String, unique=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) provider = Column(String, nullable=False, default="yookassa", index=True)
idempotence_key = Column(String, unique=True, nullable=True) idempotence_key = Column(String, unique=True, nullable=True)
amount = Column(Float, nullable=False) amount = Column(Float, nullable=False)
+377 -2
View File
@@ -1,11 +1,18 @@
import json
import time
from types import SimpleNamespace from types import SimpleNamespace
from unittest import IsolatedAsyncioTestCase from unittest import IsolatedAsyncioTestCase
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import bot.app.web.subscription_webapp # noqa: F401 import bot.app.web.subscription_webapp # noqa: F401
from bot.app.web.webapp import billing as billing_module from bot.app.web.webapp import billing as billing_module
from bot.payment_providers.base import WebAppPaymentContext from bot.payment_providers.base import PaymentProviderSpec, WebAppPaymentContext
from bot.payment_providers.yookassa import create_webapp_payment 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: class _SessionFactory:
@@ -20,6 +27,374 @@ class _SessionFactory:
class WebAppPaymentStatusTests(IsolatedAsyncioTestCase): 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): async def test_yookassa_pending_payment_refresh_processes_succeeded_provider_status(self):
payment = SimpleNamespace( payment = SimpleNamespace(
payment_id=42, payment_id=42,