fix: reuse pending Wata payment links on retry

This commit is contained in:
3252a8
2026-05-25 10:49:57 +03:00
parent 0d68da9624
commit d30b069876
3 changed files with 296 additions and 2 deletions
+126 -2
View File
@@ -48,15 +48,20 @@ from .shared import (
notify_user_payment_failed,
parse_payment_callback,
payment_failed,
payment_link_response,
payment_record_amounts,
payment_unavailable,
post_json_request,
render_link_or_fail,
render_payment_link,
safe_callback_answer,
sale_mode_base,
)
router = Router(name="user_subscription_payments_wata_router")
_LOG = "wata"
_WATA_IN_PROGRESS_STATUSES = {"created", "pending"}
_WATA_LINK_OPENED_STATUSES = {"opened", "open"}
def _wata_success_status(status: int, _body: Any) -> bool:
@@ -229,7 +234,9 @@ class WataService(HttpClientMixin):
return False, {"message": "service_not_configured"}
session = await self._get_session()
expires_at = datetime.now(timezone.utc) + timedelta(days=self.payment_link_ttl_days)
expires_at = (datetime.now(timezone.utc) + timedelta(days=self.payment_link_ttl_days)).replace(
microsecond=0
)
body: Dict[str, Any] = {
"amount": float(format_decimal_amount(amount)),
"currency": (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper(),
@@ -237,7 +244,7 @@ class WataService(HttpClientMixin):
"orderId": str(payment_db_id),
"successRedirectUrl": self.return_url,
"failRedirectUrl": self.failed_url,
"expirationDateTime": expires_at.isoformat().replace("+00:00", "Z"),
"expirationDateTime": expires_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
}
return await post_json_request(
session,
@@ -295,6 +302,47 @@ class WataService(HttpClientMixin):
log_prefix="Wata get_payment_link",
)
async def try_reuse_pending_link(self, payment: Any) -> Optional[str]:
"""Return the existing payment link URL if it's still usable; else None.
Used to avoid creating duplicate Wata links each time a user re-clicks
the pay button. Repeated abandoned links inflate Wata's anti-fraud
signals and can cause downstream bank-side rejections during the
bank-selection step.
"""
if not self.configured:
return None
provider_payment_id = str(getattr(payment, "provider_payment_id", "") or "").strip()
if not provider_payment_id:
return None
success, data = await self.get_payment_link(provider_payment_id)
if not success or not isinstance(data, dict):
return None
status = _normalized_wata_status(data) or str(data.get("status") or "").strip().lower()
if status and status not in _WATA_LINK_OPENED_STATUSES:
return None
expiration_raw = data.get("expirationDateTime") or data.get("expiration_date_time")
if expiration_raw:
try:
iso_value = str(expiration_raw).strip()
if iso_value.endswith("Z"):
iso_value = iso_value[:-1] + "+00:00"
exp_dt = datetime.fromisoformat(iso_value)
if exp_dt.tzinfo is None:
exp_dt = exp_dt.replace(tzinfo=timezone.utc)
if exp_dt <= datetime.now(timezone.utc):
return None
except (TypeError, ValueError):
logging.warning(
"Wata try_reuse_pending_link: unparseable expirationDateTime %r",
expiration_raw,
)
return first_value(data, "url", "paymentUrl", "payment_url")
async def get_transaction(self, transaction_id: str) -> Tuple[bool, Dict[str, Any]]:
return await self._get_json(
f"{self.base_url}/transactions/{transaction_id}",
@@ -709,6 +757,41 @@ async def pay_wata_callback_handler(
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
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,
sale_mode=parts.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=wata_service.payment_link_ttl_days * 24 * 60,
)
if reusable_payment is not None:
reusable_url = await wata_service.try_reuse_pending_link(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
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
amount=parts.price,
@@ -761,6 +844,47 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
return payment_unavailable()
currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
reuse_amounts = payment_record_amounts(
months=ctx.months,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
)
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_days * 24 * 60,
)
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,
+60
View File
@@ -86,6 +86,66 @@ async def get_payment_by_db_id(session: AsyncSession, payment_db_id: int) -> Opt
return result.scalar_one_or_none()
async def find_recent_pending_provider_payment(
session: AsyncSession,
*,
user_id: int,
provider: str,
pending_status: str,
amount: float,
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,
) -> 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.
"""
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,
func.abs(Payment.amount - float(amount)) < 0.01,
]
if sale_mode is not None:
conditions.append(Payment.sale_mode == sale_mode)
if tariff_key is not None:
conditions.append(Payment.tariff_key == tariff_key)
if months is not None:
conditions.append(Payment.subscription_duration_months == months)
else:
conditions.append(Payment.subscription_duration_months.is_(None))
if purchased_gb is not None:
conditions.append(func.abs(Payment.purchased_gb - float(purchased_gb)) < 0.0001)
else:
conditions.append(Payment.purchased_gb.is_(None))
if purchased_hwid_devices is not None:
conditions.append(Payment.purchased_hwid_devices == purchased_hwid_devices)
else:
conditions.append(Payment.purchased_hwid_devices.is_(None))
stmt = (
select(Payment)
.where(and_(*conditions))
.options(joinedload(Payment.user), joinedload(Payment.promo_code_used))
.order_by(Payment.created_at.desc())
.limit(1)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def update_payment_status_by_db_id(
session: AsyncSession, payment_db_id: int, new_status: str, yk_payment_id: Optional[str] = None
) -> Optional[Payment]: