fix: reuse pending Wata payment links on retry
This commit is contained in:
@@ -48,15 +48,20 @@ 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_unavailable,
|
payment_unavailable,
|
||||||
post_json_request,
|
post_json_request,
|
||||||
render_link_or_fail,
|
render_link_or_fail,
|
||||||
|
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")
|
||||||
_LOG = "wata"
|
_LOG = "wata"
|
||||||
_WATA_IN_PROGRESS_STATUSES = {"created", "pending"}
|
_WATA_IN_PROGRESS_STATUSES = {"created", "pending"}
|
||||||
|
_WATA_LINK_OPENED_STATUSES = {"opened", "open"}
|
||||||
|
|
||||||
|
|
||||||
def _wata_success_status(status: int, _body: Any) -> bool:
|
def _wata_success_status(status: int, _body: Any) -> bool:
|
||||||
@@ -229,7 +234,9 @@ class WataService(HttpClientMixin):
|
|||||||
return False, {"message": "service_not_configured"}
|
return False, {"message": "service_not_configured"}
|
||||||
|
|
||||||
session = await self._get_session()
|
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] = {
|
body: Dict[str, Any] = {
|
||||||
"amount": float(format_decimal_amount(amount)),
|
"amount": float(format_decimal_amount(amount)),
|
||||||
"currency": (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper(),
|
"currency": (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper(),
|
||||||
@@ -237,7 +244,7 @@ class WataService(HttpClientMixin):
|
|||||||
"orderId": str(payment_db_id),
|
"orderId": str(payment_db_id),
|
||||||
"successRedirectUrl": self.return_url,
|
"successRedirectUrl": self.return_url,
|
||||||
"failRedirectUrl": self.failed_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(
|
return await post_json_request(
|
||||||
session,
|
session,
|
||||||
@@ -295,6 +302,47 @@ class WataService(HttpClientMixin):
|
|||||||
log_prefix="Wata get_payment_link",
|
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]]:
|
async def get_transaction(self, transaction_id: str) -> Tuple[bool, Dict[str, Any]]:
|
||||||
return await self._get_json(
|
return await self._get_json(
|
||||||
f"{self.base_url}/transactions/{transaction_id}",
|
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"
|
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||||
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)
|
||||||
|
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(
|
record_payload = build_payment_record_payload(
|
||||||
user_id=callback.from_user.id,
|
user_id=callback.from_user.id,
|
||||||
amount=parts.price,
|
amount=parts.price,
|
||||||
@@ -761,6 +844,47 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
|||||||
return payment_unavailable()
|
return payment_unavailable()
|
||||||
|
|
||||||
currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
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:
|
try:
|
||||||
payment = await create_webapp_payment_record(
|
payment = await create_webapp_payment_record(
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
@@ -86,6 +86,66 @@ async def get_payment_by_db_id(session: AsyncSession, payment_db_id: int) -> Opt
|
|||||||
return result.scalar_one_or_none()
|
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(
|
async def update_payment_status_by_db_id(
|
||||||
session: AsyncSession, payment_db_id: int, new_status: str, yk_payment_id: Optional[str] = None
|
session: AsyncSession, payment_db_id: int, new_status: str, yk_payment_id: Optional[str] = None
|
||||||
) -> Optional[Payment]:
|
) -> Optional[Payment]:
|
||||||
|
|||||||
@@ -268,3 +268,113 @@ def test_wata_refresh_finds_paid_transaction_by_order_id_and_finalizes(monkeypat
|
|||||||
assert updates == [(465, "tx-paid", "succeeded")]
|
assert updates == [(465, "tx-paid", "succeeded")]
|
||||||
assert finalized == [(465, "wata", "wata")]
|
assert finalized == [(465, "wata", "wata")]
|
||||||
assert session.commits == 1
|
assert session.commits == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_try_reuse_pending_link_returns_url_for_opened_link():
|
||||||
|
service = _service(_FakeSession())
|
||||||
|
payment = _payment(provider_payment_id="link-id")
|
||||||
|
|
||||||
|
future_iso = "2099-01-01T00:00:00Z"
|
||||||
|
|
||||||
|
async def fake_get_payment_link(payment_link_id):
|
||||||
|
assert payment_link_id == "link-id"
|
||||||
|
return True, {
|
||||||
|
"id": "link-id",
|
||||||
|
"status": "Opened",
|
||||||
|
"url": "https://wata.pro/p/link-id",
|
||||||
|
"expirationDateTime": future_iso,
|
||||||
|
}
|
||||||
|
|
||||||
|
service.get_payment_link = fake_get_payment_link
|
||||||
|
|
||||||
|
url = asyncio.run(service.try_reuse_pending_link(payment))
|
||||||
|
assert url == "https://wata.pro/p/link-id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_try_reuse_pending_link_returns_none_for_closed_link():
|
||||||
|
service = _service(_FakeSession())
|
||||||
|
payment = _payment(provider_payment_id="link-id")
|
||||||
|
|
||||||
|
async def fake_get_payment_link(payment_link_id):
|
||||||
|
return True, {
|
||||||
|
"id": "link-id",
|
||||||
|
"status": "Closed",
|
||||||
|
"url": "https://wata.pro/p/link-id",
|
||||||
|
"expirationDateTime": "2099-01-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
service.get_payment_link = fake_get_payment_link
|
||||||
|
|
||||||
|
url = asyncio.run(service.try_reuse_pending_link(payment))
|
||||||
|
assert url is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_try_reuse_pending_link_returns_none_for_expired_link():
|
||||||
|
service = _service(_FakeSession())
|
||||||
|
payment = _payment(provider_payment_id="link-id")
|
||||||
|
|
||||||
|
async def fake_get_payment_link(payment_link_id):
|
||||||
|
return True, {
|
||||||
|
"id": "link-id",
|
||||||
|
"status": "Opened",
|
||||||
|
"url": "https://wata.pro/p/link-id",
|
||||||
|
"expirationDateTime": "2000-01-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
service.get_payment_link = fake_get_payment_link
|
||||||
|
|
||||||
|
url = asyncio.run(service.try_reuse_pending_link(payment))
|
||||||
|
assert url is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_try_reuse_pending_link_returns_none_when_no_provider_payment_id():
|
||||||
|
service = _service(_FakeSession())
|
||||||
|
payment = _payment(provider_payment_id=None)
|
||||||
|
|
||||||
|
async def fake_get_payment_link(payment_link_id):
|
||||||
|
raise AssertionError("get_payment_link must not be called without provider id")
|
||||||
|
|
||||||
|
service.get_payment_link = fake_get_payment_link
|
||||||
|
|
||||||
|
url = asyncio.run(service.try_reuse_pending_link(payment))
|
||||||
|
assert url is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_try_reuse_pending_link_returns_none_when_get_fails():
|
||||||
|
service = _service(_FakeSession())
|
||||||
|
payment = _payment(provider_payment_id="link-id")
|
||||||
|
|
||||||
|
async def fake_get_payment_link(payment_link_id):
|
||||||
|
return False, {"status": 429, "message": "rate_limited"}
|
||||||
|
|
||||||
|
service.get_payment_link = fake_get_payment_link
|
||||||
|
|
||||||
|
url = asyncio.run(service.try_reuse_pending_link(payment))
|
||||||
|
assert url is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_payment_link_uses_clean_iso_expiration_without_microseconds(monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_post_json_request(session, url, *, body, headers, log_prefix, is_success):
|
||||||
|
captured["body"] = body
|
||||||
|
return True, {"id": "link-1", "url": "https://wata.pro/p/link-1"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(wata, "post_json_request", fake_post_json_request)
|
||||||
|
|
||||||
|
service = _service(_FakeSession())
|
||||||
|
|
||||||
|
success, _ = asyncio.run(
|
||||||
|
service.create_payment_link(
|
||||||
|
payment_db_id=465,
|
||||||
|
amount=199.5,
|
||||||
|
currency="RUB",
|
||||||
|
description="Оплата подписки на 1 мес.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert success is True
|
||||||
|
|
||||||
|
expiration = captured["body"]["expirationDateTime"]
|
||||||
|
assert expiration.endswith("Z"), expiration
|
||||||
|
assert "." not in expiration, expiration
|
||||||
|
assert "+" not in expiration, expiration
|
||||||
|
|||||||
Reference in New Issue
Block a user