From ea6965905e4b922d65e2751db8f71a534f44464d Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 20 May 2026 18:15:26 +0300 Subject: [PATCH] fix: find customer by email --- lnbits/fiat/revolut.py | 53 +++++++++ tests/unit/test_fiat_providers.py | 186 ++++++++++++++++++++++++++++-- 2 files changed, 227 insertions(+), 12 deletions(-) diff --git a/lnbits/fiat/revolut.py b/lnbits/fiat/revolut.py index d54475689..1a156bae7 100644 --- a/lnbits/fiat/revolut.py +++ b/lnbits/fiat/revolut.py @@ -363,17 +363,70 @@ class RevolutWallet(FiatProvider): # "Revolut subscriptions require customer_id or 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_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: + 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) + if customer: + return customer + + page_token = customer_page.get("next_page_token") + if not page_token: + return None + + async def list_customers(self, page_token: str | None = None) -> dict[str, Any]: + params: dict[str, Any] = {"limit": 500} + if page_token: + params["page_token"] = page_token + r = await self.client.get("/api/customers", params=params) + r.raise_for_status() + return self._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}) 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, diff --git a/tests/unit/test_fiat_providers.py b/tests/unit/test_fiat_providers.py index bd128bcdc..656281bdd 100644 --- a/tests/unit/test_fiat_providers.py +++ b/tests/unit/test_fiat_providers.py @@ -926,6 +926,130 @@ async def test_revolut_wallet_create_subscription(settings: Settings): assert client.calls[1][0] == "/api/orders/ORDER123" +@pytest.mark.anyio +async def test_revolut_wallet_create_subscription_uses_customer_email( + 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() + client = MockHTTPClient( + [ + MockHTTPResponse( + json_data={ + "customers": [ + { + "id": "CUSTOMER123", + "email": "customer@example.com", + } + ] + } + ), + 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 client.calls[0][0] == "/api/customers" + assert client.calls[0][1]["params"] == {"limit": 500} + assert client.calls[1][0] == "/api/subscriptions" + assert client.calls[1][1]["json"]["customer_id"] == "CUSTOMER123" + assert client.calls[2][0] == "/api/orders/ORDER123" + + +@pytest.mark.anyio +async def test_revolut_wallet_create_subscription_uses_paginated_customer_email( + 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() + client = MockHTTPClient( + [ + MockHTTPResponse( + json_data={ + "next_page_token": "PAGE2", + "customers": [ + { + "id": "OTHER_CUSTOMER", + "email": "other@example.com", + } + ], + } + ), + MockHTTPResponse( + json_data={ + "customers": [ + { + "id": "CUSTOMER123", + "email": "customer@example.com", + } + ], + } + ), + 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 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]["params"] == { + "limit": 500, + "page_token": "PAGE2", + } + assert client.calls[2][0] == "/api/subscriptions" + assert client.calls[2][1]["json"]["customer_id"] == "CUSTOMER123" + assert client.calls[3][0] == "/api/orders/ORDER123" + + @pytest.mark.anyio async def test_revolut_wallet_create_subscription_creates_customer(settings: Settings): settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com" @@ -935,6 +1059,18 @@ async def test_revolut_wallet_create_subscription_creates_customer(settings: Set wallet = RevolutWallet() client = MockHTTPClient( [ + MockHTTPResponse( + json_data={ + "next_page_token": "PAGE2", + "customers": [ + { + "id": "OTHER_CUSTOMER", + "email": "other@example.com", + } + ], + } + ), + MockHTTPResponse(json_data={"customers": []}), MockHTTPResponse(json_data={"id": "CUSTOMER123"}), MockHTTPResponse( json_data={ @@ -963,20 +1099,46 @@ async def test_revolut_wallet_create_subscription_creates_customer(settings: Set assert response.ok is True assert client.calls[0][0] == "/api/customers" - assert client.calls[0][1]["json"] == {"email": "customer@example.com"} - assert client.calls[1][0] == "/api/subscriptions" - assert client.calls[1][1]["json"]["customer_id"] == "CUSTOMER123" - assert client.calls[2][0] == "/api/orders/ORDER123" + assert client.calls[0][1]["params"] == {"limit": 500} + assert client.calls[1][0] == "/api/customers" + assert client.calls[1][1]["params"] == { + "limit": 500, + "page_token": "PAGE2", + } + assert client.calls[2][0] == "/api/customers" + assert client.calls[2][1]["json"] == {"email": "customer@example.com"} + 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_requires_customer(settings: Settings): +async def test_revolut_wallet_create_subscription_uses_default_email( + 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() - client = MockHTTPClient([]) + client = MockHTTPClient( + [ + MockHTTPResponse(json_data={"customers": []}), + 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") @@ -985,12 +1147,12 @@ async def test_revolut_wallet_create_subscription_requires_customer(settings: Se "PLAN_VARIATION_123", 1, payment_options ) - assert response.ok is False - assert ( - response.error_message - == "Revolut subscriptions require customer_id or customer_email." - ) - assert client.calls == [] + assert response.ok is True + 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[2][1]["json"]["customer_id"] == "CUSTOMER123" @pytest.mark.anyio