diff --git a/lnbits/fiat/base.py b/lnbits/fiat/base.py index 162ed85bb..b907c7d78 100644 --- a/lnbits/fiat/base.py +++ b/lnbits/fiat/base.py @@ -95,6 +95,14 @@ class FiatSubscriptionPaymentOptions(BaseModel): description="Unique ID that can be used to identify the subscription request." "If not provided, one will be generated.", ) + customer_id: str | None = Field( + default=None, + description="The fiat provider customer ID to use for the subscription.", + ) + customer_email: str | None = Field( + default=None, + description="The customer email to use for the subscription.", + ) tag: str | None = Field( default=None, description="Payments created by the recurring subscription" diff --git a/lnbits/fiat/revolut.py b/lnbits/fiat/revolut.py index 80cec0985..d54475689 100644 --- a/lnbits/fiat/revolut.py +++ b/lnbits/fiat/revolut.py @@ -1,8 +1,8 @@ import asyncio import ipaddress import json -from decimal import Decimal, ROUND_HALF_UP from collections.abc import AsyncGenerator +from decimal import ROUND_HALF_UP, Decimal from typing import Any from urllib.parse import urlparse @@ -212,13 +212,6 @@ class RevolutWallet(FiatProvider): ) extra = payment_options.extra or {} - customer_id = extra.get("customer_id") - if not customer_id: - return FiatSubscriptionResponse( - ok=False, - error_message="Revolut subscriptions require extra.customer_id.", - ) - if not payment_options.subscription_request_id: payment_options.subscription_request_id = urlsafe_short_hash() @@ -231,7 +224,6 @@ class RevolutWallet(FiatProvider): ) payload: dict[str, Any] = { "plan_variation_id": subscription_id, - "customer_id": customer_id, "external_reference": self._serialize_subscription_reference(reference), "setup_order_redirect_url": ( payment_options.success_url @@ -248,6 +240,12 @@ class RevolutWallet(FiatProvider): } try: + customer_id, customer_error = await self._get_subscription_customer_id( + payment_options + ) + if not customer_id: + return FiatSubscriptionResponse(ok=False, error_message=customer_error) + payload["customer_id"] = customer_id r = await self.client.post( "/api/subscriptions", json=payload, headers=headers ) @@ -351,6 +349,31 @@ class RevolutWallet(FiatProvider): r.raise_for_status() return r.json() + async def _get_subscription_customer_id( + self, payment_options: FiatSubscriptionPaymentOptions + ) -> tuple[str | None, str | None]: + if payment_options.customer_id: + return payment_options.customer_id, None + if not payment_options.customer_email: + payment_options.customer_email = "test@lnbits.com" + # TODO: Remove the above line and uncomment the + # below return statement once we require customer_email for subscriptions. + # return ( + # None, + # "Revolut subscriptions require customer_id or 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 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 async def create_webhook( cls, diff --git a/tests/unit/test_fiat_providers.py b/tests/unit/test_fiat_providers.py index 356847bef..bd128bcdc 100644 --- a/tests/unit/test_fiat_providers.py +++ b/tests/unit/test_fiat_providers.py @@ -896,7 +896,8 @@ async def test_revolut_wallet_create_subscription(settings: Settings): wallet_id="wallet_1", memo="Monthly Gold", tag="gold", - extra={"customer_id": "CUSTOMER123", "link": "link-1"}, + customer_id="CUSTOMER123", + extra={"link": "link-1"}, success_url="https://lnbits.example/subscription-success", ) @@ -921,10 +922,77 @@ async def test_revolut_wallet_create_subscription(settings: Settings): assert reference["wallet_id"] == "wallet_1" assert reference["tag"] == "gold" assert reference["memo"] == "Monthly Gold" - assert reference["extra"]["customer_id"] == "CUSTOMER123" + assert reference["extra"]["link"] == "link-1" assert client.calls[1][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" + settings.revolut_api_secret_key = "revolut-secret" + settings.revolut_api_version = "2026-04-20" + + wallet = RevolutWallet() + client = MockHTTPClient( + [ + 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 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" + + +@pytest.mark.anyio +async def test_revolut_wallet_create_subscription_requires_customer(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([]) + wallet.client = client # type: ignore[assignment] + + payment_options = FiatSubscriptionPaymentOptions(wallet_id="wallet_1") + + response = await wallet.create_subscription( + "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 == [] + + @pytest.mark.anyio async def test_revolut_wallet_cancel_subscription(settings: Settings): settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com"