feat: find customer by email
This commit is contained in:
+81
-50
@@ -84,6 +84,9 @@ THREE_DECIMAL_CURRENCIES = {
|
|||||||
"OMR",
|
"OMR",
|
||||||
"TND",
|
"TND",
|
||||||
}
|
}
|
||||||
|
REVOLUT_CUSTOMER_LIST_LIMIT = 500
|
||||||
|
REVOLUT_CUSTOMER_LIST_MAX_PAGES = 20
|
||||||
|
REVOLUT_REQUEST_TIMEOUT = 30
|
||||||
|
|
||||||
|
|
||||||
class RevolutWallet(FiatProvider):
|
class RevolutWallet(FiatProvider):
|
||||||
@@ -122,7 +125,11 @@ class RevolutWallet(FiatProvider):
|
|||||||
return FiatStatusResponse(balance=0)
|
return FiatStatusResponse(balance=0)
|
||||||
|
|
||||||
try:
|
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.raise_for_status()
|
||||||
_ = r.json()
|
_ = r.json()
|
||||||
return FiatStatusResponse(balance=0)
|
return FiatStatusResponse(balance=0)
|
||||||
@@ -168,7 +175,9 @@ class RevolutWallet(FiatProvider):
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
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()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
order_id = data.get("id")
|
order_id = data.get("id")
|
||||||
@@ -247,7 +256,10 @@ class RevolutWallet(FiatProvider):
|
|||||||
return FiatSubscriptionResponse(ok=False, error_message=customer_error)
|
return FiatSubscriptionResponse(ok=False, error_message=customer_error)
|
||||||
payload["customer_id"] = customer_id
|
payload["customer_id"] = customer_id
|
||||||
r = await self.client.post(
|
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()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
@@ -290,7 +302,10 @@ class RevolutWallet(FiatProvider):
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
) -> FiatSubscriptionResponse:
|
) -> FiatSubscriptionResponse:
|
||||||
try:
|
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()
|
r.raise_for_status()
|
||||||
return FiatSubscriptionResponse(ok=True)
|
return FiatSubscriptionResponse(ok=True)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -331,12 +346,16 @@ class RevolutWallet(FiatProvider):
|
|||||||
return value.replace("order_", "", 1) if value.startswith("order_") else value
|
return value.replace("order_", "", 1) if value.startswith("order_") else value
|
||||||
|
|
||||||
async def get_order(self, order_id: str) -> dict[str, Any]:
|
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()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
async def get_subscription(self, subscription_id: str) -> dict[str, Any]:
|
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()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
@@ -344,7 +363,8 @@ class RevolutWallet(FiatProvider):
|
|||||||
self, subscription_id: str, cycle_id: str
|
self, subscription_id: str, cycle_id: str
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
r = await self.client.get(
|
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()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
@@ -363,70 +383,49 @@ class RevolutWallet(FiatProvider):
|
|||||||
# "Revolut subscriptions require customer_id or customer_email.",
|
# "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
|
customer_id = customer.get("id") if customer else None
|
||||||
if customer_id:
|
if customer_id:
|
||||||
return customer_id, None
|
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")
|
customer_id = customer.get("id")
|
||||||
if not customer_id:
|
if not customer_id:
|
||||||
return None, "Server error: missing customer id"
|
return None, "Server error: missing customer id"
|
||||||
return customer_id, None
|
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
|
page_token = None
|
||||||
while True:
|
for _ in range(REVOLUT_CUSTOMER_LIST_MAX_PAGES):
|
||||||
customer_page = await self.list_customers(page_token=page_token)
|
customer_page = await self._list_customers(page_token=page_token)
|
||||||
customer = self._find_customer_by_email(customer_page["customers"], email)
|
customer = _find_customer_by_email(customer_page["customers"], email)
|
||||||
if customer:
|
if customer:
|
||||||
return customer
|
return customer
|
||||||
|
|
||||||
page_token = customer_page.get("next_page_token")
|
page_token = customer_page.get("next_page_token")
|
||||||
if not page_token:
|
if not page_token:
|
||||||
return None
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
async def list_customers(self, page_token: str | None = None) -> dict[str, Any]:
|
async def _list_customers(self, page_token: str | None = None) -> dict[str, Any]:
|
||||||
params: dict[str, Any] = {"limit": 500}
|
params: dict[str, Any] = {"limit": REVOLUT_CUSTOMER_LIST_LIMIT}
|
||||||
if page_token:
|
if page_token:
|
||||||
params["page_token"] = 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()
|
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]:
|
async def _create_customer(self, email: str) -> dict[str, Any]:
|
||||||
r = await self.client.post("/api/customers", json={"email": email})
|
r = await self.client.post(
|
||||||
|
"/api/customers",
|
||||||
|
json={"email": email},
|
||||||
|
timeout=REVOLUT_REQUEST_TIMEOUT,
|
||||||
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
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
|
@classmethod
|
||||||
async def create_webhook(
|
async def create_webhook(
|
||||||
cls,
|
cls,
|
||||||
@@ -459,13 +458,15 @@ class RevolutWallet(FiatProvider):
|
|||||||
existing["already_exists"] = True
|
existing["already_exists"] = True
|
||||||
return existing
|
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()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _list_webhooks(cls, client: httpx.AsyncClient) -> list[dict[str, Any]]:
|
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()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
if isinstance(data, list):
|
if isinstance(data, list):
|
||||||
@@ -490,7 +491,9 @@ class RevolutWallet(FiatProvider):
|
|||||||
if webhook_id and (
|
if webhook_id and (
|
||||||
not webhook.get("events") or not webhook.get("signing_secret")
|
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()
|
response.raise_for_status()
|
||||||
webhook = response.json()
|
webhook = response.json()
|
||||||
|
|
||||||
@@ -609,3 +612,31 @@ class RevolutWallet(FiatProvider):
|
|||||||
str(settings.revolut_webhook_signing_secret),
|
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
|
||||||
|
|||||||
@@ -915,6 +915,7 @@ async def test_revolut_wallet_create_subscription(settings: Settings):
|
|||||||
payload = client.calls[0][1]["json"]
|
payload = client.calls[0][1]["json"]
|
||||||
assert payload["plan_variation_id"] == "PLAN_VARIATION_123"
|
assert payload["plan_variation_id"] == "PLAN_VARIATION_123"
|
||||||
assert payload["customer_id"] == "CUSTOMER123"
|
assert payload["customer_id"] == "CUSTOMER123"
|
||||||
|
assert client.calls[0][1]["timeout"] == 30
|
||||||
assert payload["setup_order_redirect_url"] == (
|
assert payload["setup_order_redirect_url"] == (
|
||||||
"https://lnbits.example/subscription-success"
|
"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 response.ok is True
|
||||||
assert client.calls[0][0] == "/api/customers"
|
assert client.calls[0][0] == "/api/customers"
|
||||||
assert client.calls[0][1]["params"] == {"limit": 500}
|
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][0] == "/api/subscriptions"
|
||||||
assert client.calls[1][1]["json"]["customer_id"] == "CUSTOMER123"
|
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][0] == "/api/orders/ORDER123"
|
||||||
|
assert client.calls[2][1]["timeout"] == 30
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@@ -1045,6 +1049,7 @@ async def test_revolut_wallet_create_subscription_uses_paginated_customer_email(
|
|||||||
"limit": 500,
|
"limit": 500,
|
||||||
"page_token": "PAGE2",
|
"page_token": "PAGE2",
|
||||||
}
|
}
|
||||||
|
assert client.calls[1][1]["timeout"] == 30
|
||||||
assert client.calls[2][0] == "/api/subscriptions"
|
assert client.calls[2][0] == "/api/subscriptions"
|
||||||
assert client.calls[2][1]["json"]["customer_id"] == "CUSTOMER123"
|
assert client.calls[2][1]["json"]["customer_id"] == "CUSTOMER123"
|
||||||
assert client.calls[3][0] == "/api/orders/ORDER123"
|
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][0] == "/api/customers"
|
||||||
assert client.calls[2][1]["json"] == {"email": "customer@example.com"}
|
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][0] == "/api/subscriptions"
|
||||||
assert client.calls[3][1]["json"]["customer_id"] == "CUSTOMER123"
|
assert client.calls[3][1]["json"]["customer_id"] == "CUSTOMER123"
|
||||||
assert client.calls[4][0] == "/api/orders/ORDER123"
|
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
|
@pytest.mark.anyio
|
||||||
async def test_revolut_wallet_create_subscription_uses_default_email(
|
async def test_revolut_wallet_create_subscription_uses_default_email(
|
||||||
settings: Settings,
|
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][0] == "/api/customers"
|
||||||
assert client.calls[0][1]["params"] == {"limit": 500}
|
assert client.calls[0][1]["params"] == {"limit": 500}
|
||||||
assert client.calls[1][0] == "/api/customers"
|
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"
|
assert client.calls[2][1]["json"]["customer_id"] == "CUSTOMER123"
|
||||||
|
|
||||||
|
|
||||||
@@ -1208,7 +1280,7 @@ async def test_revolut_wallet_create_webhook(mocker: MockerFixture):
|
|||||||
(
|
(
|
||||||
"/api/webhooks",
|
"/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",
|
"url": "https://lnbits.example/api/v1/callback/revolut",
|
||||||
"events": REVOLUT_WEBHOOK_EVENTS,
|
"events": REVOLUT_WEBHOOK_EVENTS,
|
||||||
},
|
},
|
||||||
"timeout": 15,
|
"timeout": 30,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -1258,7 +1330,7 @@ async def test_revolut_wallet_reuses_existing_webhook(mocker: MockerFixture):
|
|||||||
(
|
(
|
||||||
"/api/webhooks",
|
"/api/webhooks",
|
||||||
{
|
{
|
||||||
"timeout": 15,
|
"timeout": 30,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user