fix: expire Wata payment links sooner
This commit is contained in:
@@ -63,6 +63,34 @@ router = Router(name="user_subscription_payments_wata_router")
|
||||
_LOG = "wata"
|
||||
_WATA_IN_PROGRESS_STATUSES = {"created", "pending"}
|
||||
_WATA_LINK_OPENED_STATUSES = {"opened", "open"}
|
||||
_WATA_LINK_DEFAULT_TTL_MINUTES = 15
|
||||
_WATA_LINK_MIN_TTL_MINUTES = 15
|
||||
_WATA_LINK_MAX_TTL_MINUTES = 30 * 24 * 60
|
||||
|
||||
|
||||
def _clamp_wata_link_ttl_minutes(value: Any, *, default: int) -> int:
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
try:
|
||||
minutes = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return min(_WATA_LINK_MAX_TTL_MINUTES, max(_WATA_LINK_MIN_TTL_MINUTES, minutes))
|
||||
|
||||
|
||||
def _parse_wata_datetime(raw: Any) -> Optional[datetime]:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
iso_value = str(raw).strip()
|
||||
if iso_value.endswith("Z"):
|
||||
iso_value = iso_value[:-1] + "+00:00"
|
||||
parsed = datetime.fromisoformat(iso_value)
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _wata_success_status(status: int, _body: Any) -> bool:
|
||||
@@ -105,21 +133,15 @@ class WataConfig(ProviderEnvConfig):
|
||||
BASE_URL: str = Field(default="https://api.wata.pro/api/h2h")
|
||||
RETURN_URL: Optional[str] = None
|
||||
FAILED_URL: Optional[str] = None
|
||||
PAYMENT_LINK_TTL_DAYS: int = Field(default=3)
|
||||
LINK_TTL_MINUTES: int = Field(default=_WATA_LINK_DEFAULT_TTL_MINUTES)
|
||||
WEBHOOK_VERIFY_SIGNATURE: bool = Field(default=True)
|
||||
PUBLIC_KEY: Optional[str] = None
|
||||
TRUSTED_IPS: str = Field(default="62.84.126.140,51.250.106.150")
|
||||
|
||||
@field_validator("PAYMENT_LINK_TTL_DAYS", mode="before")
|
||||
@field_validator("LINK_TTL_MINUTES", mode="before")
|
||||
@classmethod
|
||||
def _clamp_ttl(cls, v):
|
||||
if isinstance(v, str):
|
||||
v = v.strip()
|
||||
try:
|
||||
value = int(v)
|
||||
except (TypeError, ValueError):
|
||||
return 3
|
||||
return min(30, max(1, value))
|
||||
def _clamp_link_ttl_minutes(cls, v):
|
||||
return _clamp_wata_link_ttl_minutes(v, default=_WATA_LINK_DEFAULT_TTL_MINUTES)
|
||||
|
||||
@field_validator("API_TOKEN", "RETURN_URL", "FAILED_URL", "PUBLIC_KEY", mode="before")
|
||||
@classmethod
|
||||
@@ -201,8 +223,8 @@ class WataService(HttpClientMixin):
|
||||
return self.config.FAILED_URL or self.return_url
|
||||
|
||||
@property
|
||||
def payment_link_ttl_days(self) -> int:
|
||||
return self.config.PAYMENT_LINK_TTL_DAYS
|
||||
def payment_link_ttl_minutes(self) -> int:
|
||||
return self.config.LINK_TTL_MINUTES
|
||||
|
||||
@property
|
||||
def verify_webhook_signature(self) -> bool:
|
||||
@@ -236,7 +258,7 @@ class WataService(HttpClientMixin):
|
||||
|
||||
session = await self._get_session()
|
||||
expires_at = (
|
||||
datetime.now(timezone.utc) + timedelta(days=self.payment_link_ttl_days)
|
||||
datetime.now(timezone.utc) + timedelta(minutes=self.payment_link_ttl_minutes)
|
||||
).replace(microsecond=0)
|
||||
body: Dict[str, Any] = {
|
||||
"amount": float(format_decimal_amount(amount)),
|
||||
@@ -327,20 +349,15 @@ class WataService(HttpClientMixin):
|
||||
|
||||
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):
|
||||
exp_dt = _parse_wata_datetime(expiration_raw)
|
||||
if exp_dt is None:
|
||||
logging.warning(
|
||||
"Wata try_reuse_pending_link: unparseable expirationDateTime %r",
|
||||
expiration_raw,
|
||||
)
|
||||
return None
|
||||
if exp_dt <= datetime.now(timezone.utc):
|
||||
return None
|
||||
|
||||
return first_value(data, "url", "paymentUrl", "payment_url")
|
||||
|
||||
@@ -579,6 +596,72 @@ class WataService(HttpClientMixin):
|
||||
)
|
||||
return await payment_dal.get_payment_by_db_id(session, payment.payment_id) or payment
|
||||
|
||||
def _local_payment_link_ttl_expired(self, payment: Any) -> bool:
|
||||
created_at = getattr(payment, "created_at", None)
|
||||
if isinstance(created_at, datetime):
|
||||
created_dt = (
|
||||
created_at.replace(tzinfo=timezone.utc)
|
||||
if created_at.tzinfo is None
|
||||
else created_at.astimezone(timezone.utc)
|
||||
)
|
||||
else:
|
||||
created_dt = _parse_wata_datetime(created_at)
|
||||
if created_dt is None:
|
||||
return False
|
||||
expires_at = created_dt + timedelta(minutes=self.payment_link_ttl_minutes)
|
||||
return expires_at <= datetime.now(timezone.utc)
|
||||
|
||||
async def _expired_link_payload_for_payment(self, payment: Any) -> Optional[Mapping[str, Any]]:
|
||||
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):
|
||||
status_code = data.get("status") if isinstance(data, dict) else None
|
||||
if status_code == 404 and self._local_payment_link_ttl_expired(payment):
|
||||
return {"id": provider_payment_id}
|
||||
return None
|
||||
|
||||
expiration_raw = data.get("expirationDateTime") or data.get("expiration_date_time")
|
||||
expiration_dt = _parse_wata_datetime(expiration_raw)
|
||||
if expiration_dt is None:
|
||||
return None
|
||||
if expiration_dt > datetime.now(timezone.utc):
|
||||
return None
|
||||
return data
|
||||
|
||||
async def _mark_expired_link(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
payment: Any,
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
log_prefix: str,
|
||||
) -> Optional[Any]:
|
||||
provider_payment_id = (
|
||||
first_value(payload, "id", "paymentLinkId", "payment_link_id")
|
||||
or getattr(payment, "provider_payment_id", None)
|
||||
or str(payment.payment_id)
|
||||
)
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
str(provider_payment_id),
|
||||
"canceled",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"%s: failed to mark expired payment link %s as canceled.",
|
||||
log_prefix,
|
||||
provider_payment_id,
|
||||
)
|
||||
return None
|
||||
return await payment_dal.get_payment_by_db_id(session, payment.payment_id) or payment
|
||||
|
||||
async def refresh_payment_status(self, session: AsyncSession, payment: Any) -> Any:
|
||||
if str(getattr(payment, "provider", "") or "").lower() != "wata":
|
||||
return payment
|
||||
@@ -615,6 +698,16 @@ class WataService(HttpClientMixin):
|
||||
)
|
||||
return refreshed or payment
|
||||
|
||||
expired_link_payload = await self._expired_link_payload_for_payment(payment)
|
||||
if expired_link_payload:
|
||||
refreshed = await self._mark_expired_link(
|
||||
session,
|
||||
payment,
|
||||
expired_link_payload,
|
||||
log_prefix="Wata status refresh",
|
||||
)
|
||||
return refreshed or payment
|
||||
|
||||
return payment
|
||||
|
||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||
@@ -769,9 +862,7 @@ async def pay_wata_callback_handler(
|
||||
|
||||
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
|
||||
reuse_amounts.months if sale_mode_base(parts.sale_mode) == "subscription" else None
|
||||
)
|
||||
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
|
||||
session,
|
||||
@@ -784,7 +875,7 @@ async def pay_wata_callback_handler(
|
||||
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,
|
||||
since_minutes=wata_service.payment_link_ttl_minutes,
|
||||
)
|
||||
if reusable_payment is not None:
|
||||
reusable_url = await wata_service.try_reuse_pending_link(reusable_payment)
|
||||
@@ -860,9 +951,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
traffic_gb=ctx.traffic_gb,
|
||||
)
|
||||
months_for_lookup = (
|
||||
reuse_amounts.months
|
||||
if sale_mode_base(ctx.sale_mode) == "subscription"
|
||||
else None
|
||||
reuse_amounts.months if sale_mode_base(ctx.sale_mode) == "subscription" else None
|
||||
)
|
||||
try:
|
||||
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
|
||||
@@ -876,7 +965,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
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,
|
||||
since_minutes=service.payment_link_ttl_minutes,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Wata WebApp: lookup of reusable payment failed")
|
||||
@@ -1028,14 +1117,17 @@ _CONFIG_MANIFEST = (
|
||||
"WATA_FAILED_URL", "url", "Failed URL", subsection="Wata", attr="FAILED_URL"
|
||||
),
|
||||
ProviderManifestField(
|
||||
"WATA_PAYMENT_LINK_TTL_DAYS",
|
||||
"WATA_LINK_TTL_MINUTES",
|
||||
"int",
|
||||
"Payment link lifetime (days)",
|
||||
description="1..30; Wata defaults to 3 days and allows up to 30 days.",
|
||||
"Payment link lifetime (minutes)",
|
||||
description=(
|
||||
"15..43200; default 15 minutes. Wata requires more than 10 minutes "
|
||||
"and allows up to 30 days."
|
||||
),
|
||||
subsection="Wata",
|
||||
min=1,
|
||||
max=30,
|
||||
attr="PAYMENT_LINK_TTL_DAYS",
|
||||
min=_WATA_LINK_MIN_TTL_MINUTES,
|
||||
max=_WATA_LINK_MAX_TTL_MINUTES,
|
||||
attr="LINK_TTL_MINUTES",
|
||||
),
|
||||
ProviderManifestField(
|
||||
"WATA_WEBHOOK_VERIFY_SIGNATURE",
|
||||
|
||||
+1
-1
@@ -298,7 +298,7 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
|
||||
| `WATA_API_TOKEN` | Bearer token. |
|
||||
| `WATA_RETURN_URL` | URL успешного возврата. |
|
||||
| `WATA_FAILED_URL` | URL неуспешного возврата. |
|
||||
| `WATA_PAYMENT_LINK_TTL_DAYS` | TTL платежной ссылки в днях. |
|
||||
| `WATA_LINK_TTL_MINUTES` | TTL платежной ссылки в минутах (по умолчанию 15, минимум 15, максимум 43200). |
|
||||
| `WATA_WEBHOOK_VERIFY_SIGNATURE` | Проверять `X-Signature`. |
|
||||
| `WATA_PUBLIC_KEY` | Cached public key; если пусто, загружается из API. |
|
||||
| `WATA_TRUSTED_IPS` | IP-allowlist webhook-источников. |
|
||||
|
||||
+2
-2
@@ -1523,8 +1523,8 @@
|
||||
"admin_settings_field_wata_base_url_label": "Base URL",
|
||||
"admin_settings_field_wata_return_url_label": "Return URL",
|
||||
"admin_settings_field_wata_failed_url_label": "Failed URL",
|
||||
"admin_settings_field_wata_payment_link_ttl_days_label": "Payment link lifetime (days)",
|
||||
"admin_settings_field_wata_payment_link_ttl_days_description": "1..30; Wata defaults to 3 days and allows up to 30 days.",
|
||||
"admin_settings_field_wata_link_ttl_minutes_label": "Payment link lifetime (minutes)",
|
||||
"admin_settings_field_wata_link_ttl_minutes_description": "15..43200; default 15 minutes. Wata requires more than 10 minutes and allows up to 30 days.",
|
||||
"admin_settings_field_wata_webhook_verify_signature_label": "Verify webhook signature",
|
||||
"admin_settings_field_wata_public_key_label": "Webhook public key",
|
||||
"admin_settings_field_wata_public_key_description": "Optional. If empty, the backend fetches it from Wata.",
|
||||
|
||||
+2
-2
@@ -1523,8 +1523,8 @@
|
||||
"admin_settings_field_wata_base_url_label": "Base URL",
|
||||
"admin_settings_field_wata_return_url_label": "Return URL",
|
||||
"admin_settings_field_wata_failed_url_label": "Failed URL",
|
||||
"admin_settings_field_wata_payment_link_ttl_days_label": "Срок жизни ссылки (дней)",
|
||||
"admin_settings_field_wata_payment_link_ttl_days_description": "1..30; по умолчанию Wata использует 3 дня.",
|
||||
"admin_settings_field_wata_link_ttl_minutes_label": "Срок жизни ссылки (мин)",
|
||||
"admin_settings_field_wata_link_ttl_minutes_description": "15..43200; по умолчанию 15 минут. Wata требует больше 10 минут и допускает до 30 дней.",
|
||||
"admin_settings_field_wata_webhook_verify_signature_label": "Проверять подпись вебхука",
|
||||
"admin_settings_field_wata_public_key_label": "Публичный ключ вебхука",
|
||||
"admin_settings_field_wata_public_key_description": "Необязательно. Если пусто, бэкенд получит ключ из Wata.",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from bot.payment_providers import wata
|
||||
@@ -48,27 +49,30 @@ def _payment(**overrides):
|
||||
"subscription_duration_months": 1,
|
||||
"sale_mode": "subscription",
|
||||
"user": None,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
}
|
||||
values.update(overrides)
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
def _service(session):
|
||||
def _service(session, **config_overrides):
|
||||
settings = SimpleNamespace(
|
||||
DEFAULT_CURRENCY_SYMBOL="RUB",
|
||||
DEFAULT_LANGUAGE="ru",
|
||||
traffic_sale_mode=False,
|
||||
trusted_proxies=[],
|
||||
)
|
||||
config_values = {
|
||||
"ENABLED": True,
|
||||
"API_TOKEN": "token",
|
||||
"WEBHOOK_VERIFY_SIGNATURE": False,
|
||||
"TRUSTED_IPS": "",
|
||||
}
|
||||
config_values.update(config_overrides)
|
||||
return WataService(
|
||||
bot=SimpleNamespace(),
|
||||
settings=settings,
|
||||
config=WataConfig(
|
||||
ENABLED=True,
|
||||
API_TOKEN="token",
|
||||
WEBHOOK_VERIFY_SIGNATURE=False,
|
||||
TRUSTED_IPS="",
|
||||
),
|
||||
config=WataConfig(**config_values),
|
||||
i18n=SimpleNamespace(),
|
||||
async_session_factory=session,
|
||||
subscription_service=SimpleNamespace(),
|
||||
@@ -378,3 +382,69 @@ def test_create_payment_link_uses_clean_iso_expiration_without_microseconds(monk
|
||||
assert expiration.endswith("Z"), expiration
|
||||
assert "." not in expiration, expiration
|
||||
assert "+" not in expiration, expiration
|
||||
expiration_dt = wata._parse_wata_datetime(expiration)
|
||||
assert expiration_dt is not None
|
||||
ttl_delta = expiration_dt - datetime.now(timezone.utc)
|
||||
assert timedelta(minutes=14, seconds=30) <= ttl_delta <= timedelta(minutes=15, seconds=30)
|
||||
|
||||
|
||||
def test_wata_link_ttl_uses_minutes_with_default_and_minimum():
|
||||
default_config = WataConfig(ENABLED=True, API_TOKEN="token")
|
||||
assert default_config.LINK_TTL_MINUTES == 15
|
||||
|
||||
too_short_config = WataConfig(ENABLED=True, API_TOKEN="token", LINK_TTL_MINUTES=10)
|
||||
assert too_short_config.LINK_TTL_MINUTES == 15
|
||||
|
||||
custom_config = WataConfig(ENABLED=True, API_TOKEN="token", LINK_TTL_MINUTES=45)
|
||||
assert custom_config.LINK_TTL_MINUTES == 45
|
||||
|
||||
|
||||
def test_refresh_marks_expired_wata_link_as_canceled(monkeypatch):
|
||||
session = _FakeSession()
|
||||
service = _service(session)
|
||||
payment = _payment(
|
||||
provider="wata",
|
||||
provider_payment_id="link-id",
|
||||
created_at=datetime.now(timezone.utc) - timedelta(minutes=16),
|
||||
)
|
||||
updates = []
|
||||
|
||||
async def find_transaction_for_payment(_payment, *, status):
|
||||
return None
|
||||
|
||||
async def get_payment_link(payment_link_id):
|
||||
assert payment_link_id == "link-id"
|
||||
return True, {
|
||||
"id": "link-id",
|
||||
"status": "Opened",
|
||||
"expirationDateTime": "2000-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
async def update_provider_payment_and_status(
|
||||
_session,
|
||||
payment_id,
|
||||
provider_payment_id,
|
||||
status,
|
||||
):
|
||||
updates.append((payment_id, provider_payment_id, status))
|
||||
payment.provider_payment_id = provider_payment_id
|
||||
payment.status = status
|
||||
|
||||
async def get_payment_by_db_id(_session, payment_id):
|
||||
assert payment_id == payment.payment_id
|
||||
return payment
|
||||
|
||||
service._find_transaction_for_payment = find_transaction_for_payment
|
||||
service.get_payment_link = get_payment_link
|
||||
monkeypatch.setattr(
|
||||
wata.payment_dal,
|
||||
"update_provider_payment_and_status",
|
||||
update_provider_payment_and_status,
|
||||
)
|
||||
monkeypatch.setattr(wata.payment_dal, "get_payment_by_db_id", get_payment_by_db_id)
|
||||
|
||||
result = asyncio.run(service.refresh_payment_status(session, payment))
|
||||
|
||||
assert result is payment
|
||||
assert updates == [(465, "link-id", "canceled")]
|
||||
assert session.commits == 1
|
||||
|
||||
Reference in New Issue
Block a user