fix: expire Wata payment links sooner

This commit is contained in:
3252a8
2026-05-25 11:34:10 +03:00
parent de1763cfe1
commit 11b7823188
5 changed files with 211 additions and 49 deletions
+129 -37
View File
@@ -63,6 +63,34 @@ 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"} _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: 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") BASE_URL: str = Field(default="https://api.wata.pro/api/h2h")
RETURN_URL: Optional[str] = None RETURN_URL: Optional[str] = None
FAILED_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) WEBHOOK_VERIFY_SIGNATURE: bool = Field(default=True)
PUBLIC_KEY: Optional[str] = None PUBLIC_KEY: Optional[str] = None
TRUSTED_IPS: str = Field(default="62.84.126.140,51.250.106.150") 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 @classmethod
def _clamp_ttl(cls, v): def _clamp_link_ttl_minutes(cls, v):
if isinstance(v, str): return _clamp_wata_link_ttl_minutes(v, default=_WATA_LINK_DEFAULT_TTL_MINUTES)
v = v.strip()
try:
value = int(v)
except (TypeError, ValueError):
return 3
return min(30, max(1, value))
@field_validator("API_TOKEN", "RETURN_URL", "FAILED_URL", "PUBLIC_KEY", mode="before") @field_validator("API_TOKEN", "RETURN_URL", "FAILED_URL", "PUBLIC_KEY", mode="before")
@classmethod @classmethod
@@ -201,8 +223,8 @@ class WataService(HttpClientMixin):
return self.config.FAILED_URL or self.return_url return self.config.FAILED_URL or self.return_url
@property @property
def payment_link_ttl_days(self) -> int: def payment_link_ttl_minutes(self) -> int:
return self.config.PAYMENT_LINK_TTL_DAYS return self.config.LINK_TTL_MINUTES
@property @property
def verify_webhook_signature(self) -> bool: def verify_webhook_signature(self) -> bool:
@@ -236,7 +258,7 @@ class WataService(HttpClientMixin):
session = await self._get_session() session = await self._get_session()
expires_at = ( 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) ).replace(microsecond=0)
body: Dict[str, Any] = { body: Dict[str, Any] = {
"amount": float(format_decimal_amount(amount)), "amount": float(format_decimal_amount(amount)),
@@ -327,20 +349,15 @@ class WataService(HttpClientMixin):
expiration_raw = data.get("expirationDateTime") or data.get("expiration_date_time") expiration_raw = data.get("expirationDateTime") or data.get("expiration_date_time")
if expiration_raw: if expiration_raw:
try: exp_dt = _parse_wata_datetime(expiration_raw)
iso_value = str(expiration_raw).strip() if exp_dt is None:
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( logging.warning(
"Wata try_reuse_pending_link: unparseable expirationDateTime %r", "Wata try_reuse_pending_link: unparseable expirationDateTime %r",
expiration_raw, expiration_raw,
) )
return None
if exp_dt <= datetime.now(timezone.utc):
return None
return first_value(data, "url", "paymentUrl", "payment_url") 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 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: async def refresh_payment_status(self, session: AsyncSession, payment: Any) -> Any:
if str(getattr(payment, "provider", "") or "").lower() != "wata": if str(getattr(payment, "provider", "") or "").lower() != "wata":
return payment return payment
@@ -615,6 +698,16 @@ class WataService(HttpClientMixin):
) )
return refreshed or payment 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 return payment
async def webhook_route(self, request: web.Request) -> web.Response: 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) reuse_amounts = payment_record_amounts(months=parts.months, sale_mode=parts.sale_mode)
months_for_lookup = ( months_for_lookup = (
reuse_amounts.months reuse_amounts.months if sale_mode_base(parts.sale_mode) == "subscription" else None
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,
@@ -784,7 +875,7 @@ async def pay_wata_callback_handler(
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,
since_minutes=wata_service.payment_link_ttl_days * 24 * 60, since_minutes=wata_service.payment_link_ttl_minutes,
) )
if reusable_payment is not None: if reusable_payment is not None:
reusable_url = await wata_service.try_reuse_pending_link(reusable_payment) 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, traffic_gb=ctx.traffic_gb,
) )
months_for_lookup = ( months_for_lookup = (
reuse_amounts.months reuse_amounts.months if sale_mode_base(ctx.sale_mode) == "subscription" else None
if sale_mode_base(ctx.sale_mode) == "subscription"
else None
) )
try: try:
reusable_payment = await payment_dal.find_recent_pending_provider_payment( 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_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,
since_minutes=service.payment_link_ttl_days * 24 * 60, since_minutes=service.payment_link_ttl_minutes,
) )
except Exception: except Exception:
logging.exception("Wata WebApp: lookup of reusable payment failed") 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" "WATA_FAILED_URL", "url", "Failed URL", subsection="Wata", attr="FAILED_URL"
), ),
ProviderManifestField( ProviderManifestField(
"WATA_PAYMENT_LINK_TTL_DAYS", "WATA_LINK_TTL_MINUTES",
"int", "int",
"Payment link lifetime (days)", "Payment link lifetime (minutes)",
description="1..30; Wata defaults to 3 days and allows up to 30 days.", description=(
"15..43200; default 15 minutes. Wata requires more than 10 minutes "
"and allows up to 30 days."
),
subsection="Wata", subsection="Wata",
min=1, min=_WATA_LINK_MIN_TTL_MINUTES,
max=30, max=_WATA_LINK_MAX_TTL_MINUTES,
attr="PAYMENT_LINK_TTL_DAYS", attr="LINK_TTL_MINUTES",
), ),
ProviderManifestField( ProviderManifestField(
"WATA_WEBHOOK_VERIFY_SIGNATURE", "WATA_WEBHOOK_VERIFY_SIGNATURE",
+1 -1
View File
@@ -298,7 +298,7 @@ PAYMENT_HELEKET_TELEGRAM_EMOJI
| `WATA_API_TOKEN` | Bearer token. | | `WATA_API_TOKEN` | Bearer token. |
| `WATA_RETURN_URL` | URL успешного возврата. | | `WATA_RETURN_URL` | URL успешного возврата. |
| `WATA_FAILED_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_WEBHOOK_VERIFY_SIGNATURE` | Проверять `X-Signature`. |
| `WATA_PUBLIC_KEY` | Cached public key; если пусто, загружается из API. | | `WATA_PUBLIC_KEY` | Cached public key; если пусто, загружается из API. |
| `WATA_TRUSTED_IPS` | IP-allowlist webhook-источников. | | `WATA_TRUSTED_IPS` | IP-allowlist webhook-источников. |
+2 -2
View File
@@ -1523,8 +1523,8 @@
"admin_settings_field_wata_base_url_label": "Base URL", "admin_settings_field_wata_base_url_label": "Base URL",
"admin_settings_field_wata_return_url_label": "Return URL", "admin_settings_field_wata_return_url_label": "Return URL",
"admin_settings_field_wata_failed_url_label": "Failed 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_link_ttl_minutes_label": "Payment link lifetime (minutes)",
"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_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_webhook_verify_signature_label": "Verify webhook signature",
"admin_settings_field_wata_public_key_label": "Webhook public key", "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.", "admin_settings_field_wata_public_key_description": "Optional. If empty, the backend fetches it from Wata.",
+2 -2
View File
@@ -1523,8 +1523,8 @@
"admin_settings_field_wata_base_url_label": "Base URL", "admin_settings_field_wata_base_url_label": "Base URL",
"admin_settings_field_wata_return_url_label": "Return URL", "admin_settings_field_wata_return_url_label": "Return URL",
"admin_settings_field_wata_failed_url_label": "Failed URL", "admin_settings_field_wata_failed_url_label": "Failed URL",
"admin_settings_field_wata_payment_link_ttl_days_label": "Срок жизни ссылки (дней)", "admin_settings_field_wata_link_ttl_minutes_label": "Срок жизни ссылки (мин)",
"admin_settings_field_wata_payment_link_ttl_days_description": "1..30; по умолчанию Wata использует 3 дня.", "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_webhook_verify_signature_label": "Проверять подпись вебхука",
"admin_settings_field_wata_public_key_label": "Публичный ключ вебхука", "admin_settings_field_wata_public_key_label": "Публичный ключ вебхука",
"admin_settings_field_wata_public_key_description": "Необязательно. Если пусто, бэкенд получит ключ из Wata.", "admin_settings_field_wata_public_key_description": "Необязательно. Если пусто, бэкенд получит ключ из Wata.",
+77 -7
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import json import json
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace from types import SimpleNamespace
from bot.payment_providers import wata from bot.payment_providers import wata
@@ -48,27 +49,30 @@ def _payment(**overrides):
"subscription_duration_months": 1, "subscription_duration_months": 1,
"sale_mode": "subscription", "sale_mode": "subscription",
"user": None, "user": None,
"created_at": datetime.now(timezone.utc),
} }
values.update(overrides) values.update(overrides)
return SimpleNamespace(**values) return SimpleNamespace(**values)
def _service(session): def _service(session, **config_overrides):
settings = SimpleNamespace( settings = SimpleNamespace(
DEFAULT_CURRENCY_SYMBOL="RUB", DEFAULT_CURRENCY_SYMBOL="RUB",
DEFAULT_LANGUAGE="ru", DEFAULT_LANGUAGE="ru",
traffic_sale_mode=False, traffic_sale_mode=False,
trusted_proxies=[], trusted_proxies=[],
) )
config_values = {
"ENABLED": True,
"API_TOKEN": "token",
"WEBHOOK_VERIFY_SIGNATURE": False,
"TRUSTED_IPS": "",
}
config_values.update(config_overrides)
return WataService( return WataService(
bot=SimpleNamespace(), bot=SimpleNamespace(),
settings=settings, settings=settings,
config=WataConfig( config=WataConfig(**config_values),
ENABLED=True,
API_TOKEN="token",
WEBHOOK_VERIFY_SIGNATURE=False,
TRUSTED_IPS="",
),
i18n=SimpleNamespace(), i18n=SimpleNamespace(),
async_session_factory=session, async_session_factory=session,
subscription_service=SimpleNamespace(), 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 expiration.endswith("Z"), expiration
assert "." not in expiration, expiration assert "." not in expiration, 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