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 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")
+2
View File
@@ -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 = "💳"
+108
View File
@@ -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,
+111
View File
@@ -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,
+129
View File
@@ -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"),
+94
View File
@@ -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,
@@ -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",
@@ -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,
)
@@ -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()
@@ -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:
+10 -45
View File
@@ -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,
+43
View File
@@ -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,
+22 -10
View File
@@ -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)
+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] = [
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,
),
]
+1
View File
@@ -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)
+377 -2
View File
@@ -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,