diff --git a/backend/bot/payment_providers/wata.py b/backend/bot/payment_providers/wata.py index 11fa2c5..7c5194a 100644 --- a/backend/bot/payment_providers/wata.py +++ b/backend/bot/payment_providers/wata.py @@ -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", diff --git a/docs/env-vars.md b/docs/env-vars.md index ce5dc7e..436ccf7 100644 --- a/docs/env-vars.md +++ b/docs/env-vars.md @@ -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-источников. | diff --git a/locales/en.json b/locales/en.json index b510a3a..1df2a56 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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.", diff --git a/locales/ru.json b/locales/ru.json index 10d5347..5be8a79 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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.", diff --git a/tests/test_wata_webhook.py b/tests/test_wata_webhook.py index 4c242c5..98f32f5 100644 --- a/tests/test_wata_webhook.py +++ b/tests/test_wata_webhook.py @@ -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