feat(payments): reuse pending provider payments
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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 = "💳"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user