diff --git a/lnbits/fiat/revolut.py b/lnbits/fiat/revolut.py index 1a156bae7..017cb10bb 100644 --- a/lnbits/fiat/revolut.py +++ b/lnbits/fiat/revolut.py @@ -84,6 +84,9 @@ THREE_DECIMAL_CURRENCIES = { "OMR", "TND", } +REVOLUT_CUSTOMER_LIST_LIMIT = 500 +REVOLUT_CUSTOMER_LIST_MAX_PAGES = 20 +REVOLUT_REQUEST_TIMEOUT = 30 class RevolutWallet(FiatProvider): @@ -122,7 +125,11 @@ class RevolutWallet(FiatProvider): return FiatStatusResponse(balance=0) try: - r = await self.client.get("/api/orders", params={"limit": 1}, timeout=15) + r = await self.client.get( + "/api/orders", + params={"limit": 1}, + timeout=REVOLUT_REQUEST_TIMEOUT, + ) r.raise_for_status() _ = r.json() return FiatStatusResponse(balance=0) @@ -168,7 +175,9 @@ class RevolutWallet(FiatProvider): } try: - r = await self.client.post("/api/orders", json=payload) + r = await self.client.post( + "/api/orders", json=payload, timeout=REVOLUT_REQUEST_TIMEOUT + ) r.raise_for_status() data = r.json() order_id = data.get("id") @@ -247,7 +256,10 @@ class RevolutWallet(FiatProvider): return FiatSubscriptionResponse(ok=False, error_message=customer_error) payload["customer_id"] = customer_id r = await self.client.post( - "/api/subscriptions", json=payload, headers=headers + "/api/subscriptions", + json=payload, + headers=headers, + timeout=REVOLUT_REQUEST_TIMEOUT, ) r.raise_for_status() data = r.json() @@ -290,7 +302,10 @@ class RevolutWallet(FiatProvider): **kwargs, ) -> FiatSubscriptionResponse: try: - r = await self.client.post(f"/api/subscriptions/{subscription_id}/cancel") + r = await self.client.post( + f"/api/subscriptions/{subscription_id}/cancel", + timeout=REVOLUT_REQUEST_TIMEOUT, + ) r.raise_for_status() return FiatSubscriptionResponse(ok=True) except Exception as exc: @@ -331,12 +346,16 @@ class RevolutWallet(FiatProvider): return value.replace("order_", "", 1) if value.startswith("order_") else value async def get_order(self, order_id: str) -> dict[str, Any]: - r = await self.client.get(f"/api/orders/{order_id}") + r = await self.client.get( + f"/api/orders/{order_id}", timeout=REVOLUT_REQUEST_TIMEOUT + ) r.raise_for_status() return r.json() async def get_subscription(self, subscription_id: str) -> dict[str, Any]: - r = await self.client.get(f"/api/subscriptions/{subscription_id}") + r = await self.client.get( + f"/api/subscriptions/{subscription_id}", timeout=REVOLUT_REQUEST_TIMEOUT + ) r.raise_for_status() return r.json() @@ -344,7 +363,8 @@ class RevolutWallet(FiatProvider): self, subscription_id: str, cycle_id: str ) -> dict[str, Any]: r = await self.client.get( - f"/api/subscriptions/{subscription_id}/cycles/{cycle_id}" + f"/api/subscriptions/{subscription_id}/cycles/{cycle_id}", + timeout=REVOLUT_REQUEST_TIMEOUT, ) r.raise_for_status() return r.json() @@ -363,70 +383,49 @@ class RevolutWallet(FiatProvider): # "Revolut subscriptions require customer_id or customer_email.", # ) - customer = await self.get_customer_by_email(payment_options.customer_email) + customer = await self._get_customer_by_email(payment_options.customer_email) customer_id = customer.get("id") if customer else None if customer_id: return customer_id, None - customer = await self.create_customer(payment_options.customer_email) + customer = await self._create_customer(payment_options.customer_email) customer_id = customer.get("id") if not customer_id: return None, "Server error: missing customer id" return customer_id, None - async def get_customer_by_email(self, email: str) -> dict[str, Any] | None: + async def _get_customer_by_email(self, email: str) -> dict[str, Any] | None: page_token = None - while True: - customer_page = await self.list_customers(page_token=page_token) - customer = self._find_customer_by_email(customer_page["customers"], email) + for _ in range(REVOLUT_CUSTOMER_LIST_MAX_PAGES): + customer_page = await self._list_customers(page_token=page_token) + customer = _find_customer_by_email(customer_page["customers"], email) if customer: return customer page_token = customer_page.get("next_page_token") if not page_token: return None + return None - async def list_customers(self, page_token: str | None = None) -> dict[str, Any]: - params: dict[str, Any] = {"limit": 500} + async def _list_customers(self, page_token: str | None = None) -> dict[str, Any]: + params: dict[str, Any] = {"limit": REVOLUT_CUSTOMER_LIST_LIMIT} if page_token: params["page_token"] = page_token - r = await self.client.get("/api/customers", params=params) + r = await self.client.get( + "/api/customers", params=params, timeout=REVOLUT_REQUEST_TIMEOUT + ) r.raise_for_status() - return self._extract_customer_page(r.json()) + return _extract_customer_page(r.json()) - async def create_customer(self, email: str) -> dict[str, Any]: - r = await self.client.post("/api/customers", json={"email": email}) + async def _create_customer(self, email: str) -> dict[str, Any]: + r = await self.client.post( + "/api/customers", + json={"email": email}, + timeout=REVOLUT_REQUEST_TIMEOUT, + ) r.raise_for_status() return r.json() - @classmethod - def _extract_customer_page(cls, data: Any) -> dict[str, Any]: - if isinstance(data, list): - return {"customers": cls._filter_customer_list(data)} - if isinstance(data, dict): - for field in ["customers", "data", "items"]: - customers = data.get(field) - if isinstance(customers, list): - return { - "customers": cls._filter_customer_list(customers), - "next_page_token": data.get("next_page_token"), - } - return {"customers": []} - - @classmethod - def _filter_customer_list(cls, customers: list[Any]) -> list[dict[str, Any]]: - return [customer for customer in customers if isinstance(customer, dict)] - - @classmethod - def _find_customer_by_email( - cls, customers: list[dict[str, Any]], email: str - ) -> dict[str, Any] | None: - normalized_email = email.casefold() - for customer in customers: - if str(customer.get("email") or "").casefold() == normalized_email: - return customer - return None - @classmethod async def create_webhook( cls, @@ -459,13 +458,15 @@ class RevolutWallet(FiatProvider): existing["already_exists"] = True return existing - response = await client.post("/api/webhooks", json=payload, timeout=15) + response = await client.post( + "/api/webhooks", json=payload, timeout=REVOLUT_REQUEST_TIMEOUT + ) response.raise_for_status() return response.json() @classmethod async def _list_webhooks(cls, client: httpx.AsyncClient) -> list[dict[str, Any]]: - response = await client.get("/api/webhooks", timeout=15) + response = await client.get("/api/webhooks", timeout=REVOLUT_REQUEST_TIMEOUT) response.raise_for_status() data = response.json() if isinstance(data, list): @@ -490,7 +491,9 @@ class RevolutWallet(FiatProvider): if webhook_id and ( not webhook.get("events") or not webhook.get("signing_secret") ): - response = await client.get(f"/api/webhooks/{webhook_id}", timeout=15) + response = await client.get( + f"/api/webhooks/{webhook_id}", timeout=REVOLUT_REQUEST_TIMEOUT + ) response.raise_for_status() webhook = response.json() @@ -609,3 +612,31 @@ class RevolutWallet(FiatProvider): str(settings.revolut_webhook_signing_secret), ] ) + + +def _extract_customer_page(data: Any) -> dict[str, Any]: + if isinstance(data, list): + return {"customers": _filter_customer_list(data)} + if isinstance(data, dict): + for field in ["customers", "data", "items"]: + customers = data.get(field) + if isinstance(customers, list): + return { + "customers": _filter_customer_list(customers), + "next_page_token": data.get("next_page_token"), + } + return {"customers": []} + + +def _filter_customer_list(customers: list[Any]) -> list[dict[str, Any]]: + return [customer for customer in customers if isinstance(customer, dict)] + + +def _find_customer_by_email( + customers: list[dict[str, Any]], email: str +) -> dict[str, Any] | None: + normalized_email = email.casefold() + for customer in customers: + if str(customer.get("email") or "").casefold() == normalized_email: + return customer + return None diff --git a/tests/unit/test_fiat_providers.py b/tests/unit/test_fiat_providers.py index 656281bdd..24de541db 100644 --- a/tests/unit/test_fiat_providers.py +++ b/tests/unit/test_fiat_providers.py @@ -915,6 +915,7 @@ async def test_revolut_wallet_create_subscription(settings: Settings): payload = client.calls[0][1]["json"] assert payload["plan_variation_id"] == "PLAN_VARIATION_123" assert payload["customer_id"] == "CUSTOMER123" + assert client.calls[0][1]["timeout"] == 30 assert payload["setup_order_redirect_url"] == ( "https://lnbits.example/subscription-success" ) @@ -975,9 +976,12 @@ async def test_revolut_wallet_create_subscription_uses_customer_email( assert response.ok is True assert client.calls[0][0] == "/api/customers" assert client.calls[0][1]["params"] == {"limit": 500} + assert client.calls[0][1]["timeout"] == 30 assert client.calls[1][0] == "/api/subscriptions" assert client.calls[1][1]["json"]["customer_id"] == "CUSTOMER123" + assert client.calls[1][1]["timeout"] == 30 assert client.calls[2][0] == "/api/orders/ORDER123" + assert client.calls[2][1]["timeout"] == 30 @pytest.mark.anyio @@ -1045,6 +1049,7 @@ async def test_revolut_wallet_create_subscription_uses_paginated_customer_email( "limit": 500, "page_token": "PAGE2", } + assert client.calls[1][1]["timeout"] == 30 assert client.calls[2][0] == "/api/subscriptions" assert client.calls[2][1]["json"]["customer_id"] == "CUSTOMER123" assert client.calls[3][0] == "/api/orders/ORDER123" @@ -1107,11 +1112,78 @@ async def test_revolut_wallet_create_subscription_creates_customer(settings: Set } assert client.calls[2][0] == "/api/customers" assert client.calls[2][1]["json"] == {"email": "customer@example.com"} + assert client.calls[2][1]["timeout"] == 30 assert client.calls[3][0] == "/api/subscriptions" assert client.calls[3][1]["json"]["customer_id"] == "CUSTOMER123" assert client.calls[4][0] == "/api/orders/ORDER123" +@pytest.mark.anyio +async def test_revolut_wallet_create_subscription_stops_customer_lookup_after_20_pages( + settings: Settings, +): + settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com" + settings.revolut_api_secret_key = "revolut-secret" + settings.revolut_api_version = "2026-04-20" + + wallet = RevolutWallet() + customer_pages = [ + MockHTTPResponse( + json_data={ + "next_page_token": f"PAGE{page + 2}", + "customers": [ + { + "id": f"OTHER_CUSTOMER_{page}", + "email": f"other-{page}@example.com", + } + ], + } + ) + for page in range(20) + ] + client = MockHTTPClient( + [ + *customer_pages, + MockHTTPResponse(json_data={"id": "CUSTOMER123"}), + MockHTTPResponse( + json_data={ + "id": "SUBSCRIPTION123", + "setup_order_id": "ORDER123", + } + ), + MockHTTPResponse( + json_data={ + "id": "ORDER123", + "checkout_url": "https://checkout.revolut.com/payment-link/sub_123", + } + ), + ] + ) + wallet.client = client # type: ignore[assignment] + + payment_options = FiatSubscriptionPaymentOptions( + wallet_id="wallet_1", + customer_email="customer@example.com", + ) + + response = await wallet.create_subscription( + "PLAN_VARIATION_123", 1, payment_options + ) + + assert response.ok is True + assert [call[0] for call in client.calls[:20]] == ["/api/customers"] * 20 + assert client.calls[0][1]["params"] == {"limit": 500} + assert client.calls[19][1]["params"] == { + "limit": 500, + "page_token": "PAGE20", + } + assert client.calls[20][0] == "/api/customers" + assert client.calls[20][1]["json"] == {"email": "customer@example.com"} + assert client.calls[21][0] == "/api/subscriptions" + assert client.calls[21][1]["json"]["customer_id"] == "CUSTOMER123" + assert client.calls[22][0] == "/api/orders/ORDER123" + + @pytest.mark.anyio async def test_revolut_wallet_create_subscription_uses_default_email( settings: Settings, @@ -1151,7 +1223,7 @@ async def test_revolut_wallet_create_subscription_uses_default_email( assert client.calls[0][0] == "/api/customers" assert client.calls[0][1]["params"] == {"limit": 500} assert client.calls[1][0] == "/api/customers" - assert client.calls[1][1]["json"] == {"email": "test01@lnbits.com"} + assert client.calls[1][1]["json"] == {"email": "test@lnbits.com"} assert client.calls[2][1]["json"]["customer_id"] == "CUSTOMER123" @@ -1208,7 +1280,7 @@ async def test_revolut_wallet_create_webhook(mocker: MockerFixture): ( "/api/webhooks", { - "timeout": 15, + "timeout": 30, }, ), ( @@ -1218,7 +1290,7 @@ async def test_revolut_wallet_create_webhook(mocker: MockerFixture): "url": "https://lnbits.example/api/v1/callback/revolut", "events": REVOLUT_WEBHOOK_EVENTS, }, - "timeout": 15, + "timeout": 30, }, ), ] @@ -1258,7 +1330,7 @@ async def test_revolut_wallet_reuses_existing_webhook(mocker: MockerFixture): ( "/api/webhooks", { - "timeout": 15, + "timeout": 30, }, ) ]