diff --git a/lnbits/core/services/fiat_providers.py b/lnbits/core/services/fiat_providers.py index b0fa5e257..eb52ff529 100644 --- a/lnbits/core/services/fiat_providers.py +++ b/lnbits/core/services/fiat_providers.py @@ -200,6 +200,47 @@ def check_square_signature( raise ValueError("Square signature verification failed.") +def check_revolut_signature( + payload: bytes, + sig_header: str | None, + timestamp_header: str | None, + secret: str | None, + tolerance_seconds=300, +): + if not sig_header: + logger.warning("Revolut signature header is missing.") + raise ValueError("Revolut signature header is missing.") + + if not timestamp_header: + logger.warning("Revolut timestamp header is missing.") + raise ValueError("Revolut timestamp header is missing.") + + if not secret: + logger.warning("Revolut webhook signing secret is not set.") + raise ValueError("Revolut webhook cannot be verified.") + + timestamp = int(timestamp_header) + if abs(time.time() - timestamp) > tolerance_seconds: + logger.warning("Timestamp outside tolerance.") + raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}") + + candidates = [ + payload, + f"{timestamp}.{payload.decode()}".encode(), + timestamp_header.encode() + b"." + payload, + ] + signatures = [] + for candidate in candidates: + digest = hmac.new( + key=secret.encode(), msg=candidate, digestmod=hashlib.sha256 + ).digest() + signatures.extend([digest.hex(), b64encode(digest).decode()]) + + if not any(hmac.compare_digest(expected, sig_header) for expected in signatures): + logger.warning("Revolut signature verification failed.") + raise ValueError("Revolut signature verification failed.") + + async def test_connection(provider: str) -> SimpleStatus: """ Test the connection to Stripe by checking if the API key is valid. diff --git a/lnbits/core/views/callback_api.py b/lnbits/core/views/callback_api.py index 6ee9b350e..be6716399 100644 --- a/lnbits/core/views/callback_api.py +++ b/lnbits/core/views/callback_api.py @@ -13,6 +13,7 @@ from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.payments import CreateInvoice from lnbits.core.services.fiat_providers import ( check_fiat_status, + check_revolut_signature, check_square_signature, check_stripe_signature, verify_paypal_webhook, @@ -21,6 +22,7 @@ from lnbits.core.services.payments import create_fiat_invoice from lnbits.db import Filter, Filters from lnbits.fiat import get_fiat_provider from lnbits.fiat.base import FiatSubscriptionPaymentOptions +from lnbits.fiat.revolut import RevolutWallet from lnbits.fiat.square import SquareWallet from lnbits.settings import settings @@ -74,6 +76,24 @@ async def api_generic_webhook_handler( message=f"Callback received successfully from '{provider_name}'.", ) + if provider_name.lower() == "revolut": + payload = await request.body() + sig_header = request.headers.get("Revolut-Signature") + timestamp_header = request.headers.get("Revolut-Request-Timestamp") + check_revolut_signature( + payload, + sig_header, + timestamp_header, + settings.revolut_webhook_signing_secret, + ) + event = await request.json() + await handle_revolut_event(event) + + return SimpleStatus( + success=True, + message=f"Callback received successfully from '{provider_name}'.", + ) + return SimpleStatus( success=False, message=f"Unknown fiat provider '{provider_name}'.", @@ -322,6 +342,104 @@ async def handle_square_event(event: dict): logger.warning(f"Unhandled Square event type: '{event_type}'.") +async def handle_revolut_event(event: dict): + event_type = event.get("event", "") + order_id = event.get("order_id") + logger.info(f"Handling Revolut event: '{event_type}'. Order ID: '{order_id}'.") + + if event_type in ["ORDER_AUTHORISED", "ORDER_COMPLETED"]: + if not order_id: + logger.warning("Revolut event missing order_id.") + return + + payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}") + if not payment: + logger.warning(f"No payment found for Revolut order: '{order_id}'.") + return + + await check_fiat_status(payment) + return + + if event_type == "SUBSCRIPTION_INITIATED": + await _handle_revolut_subscription_initiated(event) + return + + if event_type in ["SUBSCRIPTION_CANCELLED", "SUBSCRIPTION_FINISHED", "SUBSCRIPTION_OVERDUE"]: + logger.info(f"Revolut subscription lifecycle event received: '{event_type}'.") + return + + logger.warning(f"Unhandled Revolut event type: '{event_type}'.") + + +async def _handle_revolut_subscription_initiated(event: dict): + subscription_id = event.get("subscription_id") + if not subscription_id: + logger.warning("Revolut subscription event missing subscription_id.") + return + + fiat_provider = await get_fiat_provider("revolut") + if not isinstance(fiat_provider, RevolutWallet): + logger.warning("Revolut fiat provider is not configured.") + return + + subscription = await fiat_provider.get_subscription(subscription_id) + reference = fiat_provider.deserialize_subscription_reference( + subscription.get("external_reference") + ) + if not reference: + logger.warning("Revolut subscription event missing LNbits metadata.") + return + + cycle_id = subscription.get("current_cycle_id") + if not cycle_id: + logger.warning("Revolut subscription missing current_cycle_id.") + return + + cycle = await fiat_provider.get_subscription_cycle(subscription_id, cycle_id) + order_id = cycle.get("order_id") + if not order_id: + logger.warning("Revolut subscription cycle missing order_id.") + return + + existing_payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}") + if existing_payment: + if existing_payment.external_id != subscription_id: + existing_payment.external_id = subscription_id + await update_payment(existing_payment) + await check_fiat_status(existing_payment) + return + + order = await fiat_provider.get_order(order_id) + amount_minor = order.get("amount") + currency = (order.get("currency") or "").upper() + if amount_minor is None or not currency: + raise ValueError("Revolut subscription order missing amount or currency.") + + extra = { + **(reference.extra or {}), + "subscription_request_id": reference.subscription_request_id, + "fiat_method": "subscription", + "tag": reference.tag, + "subscription": { + "checking_id": f"order_{order_id}", + "payment_request": order.get("checkout_url") or "", + }, + } + lnbits_payment = await create_fiat_invoice( + wallet_id=reference.wallet_id, + invoice_data=CreateInvoice( + unit=currency, + amount=amount_minor / 100, + memo=reference.memo or "", + extra=extra, + fiat_provider="revolut", + external_id=subscription_id, + ), + ) + + await check_fiat_status(lnbits_payment) + + async def _handle_square_payment_event(event: dict): payment = _square_extract_payment(event) payment_options = _deserialize_square_metadata(_square_payment_note(payment)) diff --git a/lnbits/fiat/__init__.py b/lnbits/fiat/__init__.py index 362cd8939..5d46256fd 100644 --- a/lnbits/fiat/__init__.py +++ b/lnbits/fiat/__init__.py @@ -9,6 +9,7 @@ from lnbits.fiat.base import FiatProvider from lnbits.settings import settings from .paypal import PayPalWallet +from .revolut import RevolutWallet from .square import SquareWallet from .stripe import StripeWallet @@ -19,6 +20,7 @@ class FiatProviderType(Enum): stripe = "StripeWallet" paypal = "PayPalWallet" square = "SquareWallet" + revolut = "RevolutWallet" async def get_fiat_provider(name: str) -> FiatProvider | None: @@ -54,6 +56,7 @@ fiat_providers: dict[str, FiatProvider] = {} __all__ = [ "PayPalWallet", + "RevolutWallet", "SquareWallet", "StripeWallet", ] diff --git a/lnbits/fiat/revolut.py b/lnbits/fiat/revolut.py new file mode 100644 index 000000000..fef4e1fbc --- /dev/null +++ b/lnbits/fiat/revolut.py @@ -0,0 +1,350 @@ +import asyncio +import json +from collections.abc import AsyncGenerator +from typing import Any + +import httpx +from loguru import logger +from pydantic import BaseModel, Field, ValidationError + +from lnbits.helpers import normalize_endpoint, urlsafe_short_hash +from lnbits.settings import settings + +from .base import ( + FiatInvoiceResponse, + FiatPaymentFailedStatus, + FiatPaymentPendingStatus, + FiatPaymentResponse, + FiatPaymentStatus, + FiatPaymentSuccessStatus, + FiatProvider, + FiatStatusResponse, + FiatSubscriptionPaymentOptions, + FiatSubscriptionResponse, +) + + +class RevolutCheckoutOptions(BaseModel): + class Config: + extra = "ignore" + + success_url: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + description: str | None = None + + +class RevolutCreateInvoiceOptions(BaseModel): + class Config: + extra = "ignore" + + checkout: RevolutCheckoutOptions | None = None + + +class RevolutSubscriptionReference(BaseModel): + wallet_id: str + tag: str | None = None + subscription_request_id: str | None = None + extra: dict[str, Any] | None = None + memo: str | None = None + + +class RevolutWallet(FiatProvider): + """https://developer.revolut.com/docs/merchant""" + + def __init__(self): + logger.debug("Initializing RevolutWallet") + self._settings_fields = self._settings_connection_fields() + if not settings.revolut_api_endpoint: + raise ValueError("Cannot initialize RevolutWallet: missing endpoint.") + if not settings.revolut_api_secret_key: + raise ValueError("Cannot initialize RevolutWallet: missing API secret key.") + + self.endpoint = normalize_endpoint(settings.revolut_api_endpoint) + self.headers = { + "Authorization": f"Bearer {settings.revolut_api_secret_key}", + "Revolut-Api-Version": settings.revolut_api_version, + "Content-Type": "application/json", + "User-Agent": settings.user_agent, + } + self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers) + logger.info("RevolutWallet initialized.") + + async def cleanup(self): + try: + await self.client.aclose() + except RuntimeError as e: + logger.warning(f"Error closing Revolut wallet connection: {e}") + + async def status( + self, only_check_settings: bool | None = False + ) -> FiatStatusResponse: + if only_check_settings: + if self._settings_fields != self._settings_connection_fields(): + return FiatStatusResponse("Connection settings have changed.", 0) + return FiatStatusResponse(balance=0) + + try: + r = await self.client.get("/api/orders", params={"limit": 1}, timeout=15) + r.raise_for_status() + _ = r.json() + return FiatStatusResponse(balance=0) + except json.JSONDecodeError: + return FiatStatusResponse("Server error: 'invalid json response'", 0) + except Exception as exc: + logger.warning(exc) + return FiatStatusResponse(f"Unable to connect to {self.endpoint}.", 0) + + async def create_invoice( + self, + amount: float, + payment_hash: str, + currency: str, + memo: str | None = None, + extra: dict[str, Any] | None = None, + **kwargs, + ) -> FiatInvoiceResponse: + opts = self._parse_create_opts(extra or {}) + if opts is None: + return FiatInvoiceResponse(ok=False, error_message="Invalid Revolut options") + + amount_minor = int(amount * 100) + checkout = opts.checkout or RevolutCheckoutOptions() + success_url = ( + checkout.success_url + or settings.revolut_payment_success_url + or "https://lnbits.com" + ) + + payload = { + "amount": amount_minor, + "currency": currency.upper(), + "description": checkout.description or memo or "LNbits Invoice", + "redirect_url": success_url, + "metadata": { + **checkout.metadata, + "payment_hash": payment_hash, + "alan_action": "invoice", + }, + } + + try: + r = await self.client.post("/api/orders", json=payload) + r.raise_for_status() + data = r.json() + order_id = data.get("id") + checkout_url = data.get("checkout_url") + if not order_id or not checkout_url: + return FiatInvoiceResponse( + ok=False, error_message="Server error: missing order id or url" + ) + return FiatInvoiceResponse( + ok=True, + checking_id=f"order_{order_id}", + payment_request=checkout_url, + ) + except json.JSONDecodeError: + return FiatInvoiceResponse( + ok=False, error_message="Server error: invalid json response" + ) + except Exception as exc: + logger.warning(exc) + return FiatInvoiceResponse( + ok=False, error_message=f"Unable to connect to {self.endpoint}." + ) + + async def create_subscription( + self, + subscription_id: str, + quantity: int, + payment_options: FiatSubscriptionPaymentOptions, + **kwargs, + ) -> FiatSubscriptionResponse: + if quantity != 1: + return FiatSubscriptionResponse( + ok=False, + error_message="Revolut subscriptions do not support quantity.", + ) + + wallet_id = payment_options.wallet_id + if not wallet_id: + return FiatSubscriptionResponse( + ok=False, error_message="Wallet ID is required." + ) + + 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() + + reference = RevolutSubscriptionReference( + wallet_id=wallet_id, + tag=payment_options.tag, + subscription_request_id=payment_options.subscription_request_id, + extra=extra, + memo=payment_options.memo, + ) + 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 + or settings.revolut_payment_success_url + or "https://lnbits.com" + ), + } + if extra.get("trial_duration"): + payload["trial_duration"] = extra["trial_duration"] + + headers = {**self.headers, "Idempotency-Key": payment_options.subscription_request_id} + + try: + r = await self.client.post("/api/subscriptions", json=payload, headers=headers) + r.raise_for_status() + data = r.json() + revolut_subscription_id = data.get("id") + setup_order_id = data.get("setup_order_id") + if not revolut_subscription_id or not setup_order_id: + return FiatSubscriptionResponse( + ok=False, + error_message="Server error: missing subscription id or setup order id", + ) + + setup_order = await self.get_order(setup_order_id) + checkout_url = setup_order.get("checkout_url") + if not checkout_url: + return FiatSubscriptionResponse( + ok=False, error_message="Server error: missing setup checkout url" + ) + + return FiatSubscriptionResponse( + ok=True, + checkout_session_url=checkout_url, + subscription_request_id=revolut_subscription_id, + ) + except json.JSONDecodeError: + return FiatSubscriptionResponse( + ok=False, error_message="Server error: invalid json response" + ) + except Exception as exc: + logger.warning(exc) + return FiatSubscriptionResponse( + ok=False, error_message=f"Unable to connect to {self.endpoint}." + ) + + async def cancel_subscription( + self, + subscription_id: str, + correlation_id: str, + **kwargs, + ) -> FiatSubscriptionResponse: + try: + r = await self.client.post(f"/api/subscriptions/{subscription_id}/cancel") + r.raise_for_status() + return FiatSubscriptionResponse(ok=True) + except Exception as exc: + logger.warning(exc) + return FiatSubscriptionResponse( + ok=False, error_message="Unable to cancel subscription." + ) + + async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse: + raise NotImplementedError("Revolut does not support paying invoices directly.") + + async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus: + try: + order_id = self._normalize_revolut_id(checking_id) + return self._status_from_order(await self.get_order(order_id)) + except Exception as exc: + logger.debug(f"Error getting Revolut invoice status: {exc}") + return FiatPaymentPendingStatus() + + async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus: + raise NotImplementedError("Revolut does not support outgoing payments.") + + async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + logger.warning( + "Revolut does not support paid invoices stream. Use webhooks instead." + ) + mock_queue: asyncio.Queue[str] = asyncio.Queue(0) + while settings.lnbits_running: + value = await mock_queue.get() + yield value + + def _normalize_revolut_id(self, checking_id: str) -> str: + value = ( + checking_id.replace("fiat_revolut_", "", 1) + if checking_id.startswith("fiat_revolut_") + else checking_id + ) + 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.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.raise_for_status() + return r.json() + + async def get_subscription_cycle( + self, subscription_id: str, cycle_id: str + ) -> dict[str, Any]: + r = await self.client.get(f"/api/subscriptions/{subscription_id}/cycles/{cycle_id}") + r.raise_for_status() + return r.json() + + def _status_from_order(self, order: dict[str, Any]) -> FiatPaymentStatus: + status = (order.get("state") or "").upper() + if status == "COMPLETED": + return FiatPaymentSuccessStatus() + if status in ["CANCELLED", "FAILED"]: + return FiatPaymentFailedStatus() + return FiatPaymentPendingStatus() + + def _parse_create_opts( + self, raw_opts: dict[str, Any] + ) -> RevolutCreateInvoiceOptions | None: + try: + return RevolutCreateInvoiceOptions.parse_obj(raw_opts) + except ValidationError as e: + logger.warning(f"Invalid Revolut options: {e}") + return None + + def _serialize_subscription_reference( + self, reference: RevolutSubscriptionReference + ) -> str: + payload = reference.dict(exclude_none=True) + serialized = json.dumps(payload, separators=(",", ":")) + if len(serialized) > 1024: + raise ValueError("Revolut subscription external_reference is too long.") + return serialized + + def deserialize_subscription_reference( + self, external_reference: str | None + ) -> RevolutSubscriptionReference | None: + if not external_reference: + return None + try: + return RevolutSubscriptionReference.parse_obj(json.loads(external_reference)) + except (json.JSONDecodeError, ValidationError) as exc: + logger.warning(exc) + return None + + def _settings_connection_fields(self) -> str: + return "-".join( + [ + str(settings.revolut_api_endpoint), + str(settings.revolut_api_secret_key), + str(settings.revolut_api_version), + str(settings.revolut_webhook_signing_secret), + ] + ) diff --git a/lnbits/fiat/square.py b/lnbits/fiat/square.py index a68c7f4c7..4d9995582 100644 --- a/lnbits/fiat/square.py +++ b/lnbits/fiat/square.py @@ -548,55 +548,60 @@ class SquareWallet(FiatProvider): async def _get_square_subscription_id( self, subscription_id: str, wallet_id: str ) -> str: - from lnbits.core.crud.payments import get_payments - from lnbits.core.models import PaymentFilters - from lnbits.db import Filter, Filters + try: + from lnbits.core.crud.payments import get_payments + from lnbits.core.models import PaymentFilters + from lnbits.db import Filter, Filters - payments = await get_payments( - wallet_id=wallet_id, - filters=Filters( - filters=[ - Filter.parse_query("external_id", [subscription_id], PaymentFilters) - ], - model=PaymentFilters, - sortby="created_at", - direction="desc", - limit=1, - ), - ) - payment = next( - ( - payment - for payment in payments - if payment.external_id and payment.fiat_provider == "square" - ), - None, - ) - if payment and payment.external_id: - return payment.external_id + payments = await get_payments( + wallet_id=wallet_id, + filters=Filters( + filters=[ + Filter.parse_query( + "external_id", [subscription_id], PaymentFilters + ) + ], + model=PaymentFilters, + sortby="created_at", + direction="desc", + limit=1, + ), + ) + payment = next( + ( + payment + for payment in payments + if payment.external_id and payment.fiat_provider == "square" + ), + None, + ) + if payment and payment.external_id: + return payment.external_id - payments = await get_payments( - wallet_id=wallet_id, - incoming=True, - filters=Filters( - model=PaymentFilters, - sortby="created_at", - direction="desc", - ), - ) - payment = next( - ( - payment - for payment in payments - if payment.external_id - and payment.fiat_provider == "square" - and (payment.extra or {}).get("subscription_request_id") - == subscription_id - ), - None, - ) - if payment and payment.external_id: - return payment.external_id + payments = await get_payments( + wallet_id=wallet_id, + incoming=True, + filters=Filters( + model=PaymentFilters, + sortby="created_at", + direction="desc", + ), + ) + payment = next( + ( + payment + for payment in payments + if payment.external_id + and payment.fiat_provider == "square" + and (payment.extra or {}).get("subscription_request_id") + == subscription_id + ), + None, + ) + if payment and payment.external_id: + return payment.external_id + except Exception as exc: + logger.warning(exc) return subscription_id diff --git a/lnbits/settings.py b/lnbits/settings.py index 316794ea1..f96d981a1 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -718,6 +718,20 @@ class SquareFiatProvider(LNbitsSettings): square_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits) +class RevolutFiatProvider(LNbitsSettings): + revolut_enabled: bool = Field(default=False) + revolut_api_endpoint: str = Field(default="https://merchant.revolut.com") + revolut_api_secret_key: str | None = Field(default=None) + revolut_api_version: str = Field(default="2026-04-20") + revolut_payment_success_url: str = Field(default="https://lnbits.com") + revolut_payment_webhook_url: str = Field( + default="https://your-lnbits-domain-here.com/api/v1/callback/revolut" + ) + revolut_webhook_signing_secret: str | None = Field(default=None) + + revolut_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits) + + class LightningSettings(LNbitsSettings): lightning_invoice_expiry: int = Field(default=3600, gt=0) @@ -755,7 +769,12 @@ class FundingSourcesSettings( funding_source_max_retries: int = Field(default=4, ge=0) -class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider, SquareFiatProvider): +class FiatProvidersSettings( + StripeFiatProvider, + PayPalFiatProvider, + SquareFiatProvider, + RevolutFiatProvider, +): def is_fiat_provider_enabled(self, provider: str | None) -> bool: """ Checks if a specific fiat provider is enabled. @@ -768,6 +787,8 @@ class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider, SquareFiatPr return self.paypal_enabled if provider == "square": return self.square_enabled + if provider == "revolut": + return self.revolut_enabled return False def get_fiat_providers_for_user(self, user_id: str) -> list[str]: @@ -793,6 +814,12 @@ class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider, SquareFiatPr ): allowed_providers.append("square") + if self.revolut_enabled and ( + not self.revolut_limits.allowed_users + or user_id in self.revolut_limits.allowed_users + ): + allowed_providers.append("revolut") + return allowed_providers def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None: diff --git a/lnbits/static/js/components/admin/lnbits-admin-fiat-providers.js b/lnbits/static/js/components/admin/lnbits-admin-fiat-providers.js index dcd331925..da19e27d6 100644 --- a/lnbits/static/js/components/admin/lnbits-admin-fiat-providers.js +++ b/lnbits/static/js/components/admin/lnbits-admin-fiat-providers.js @@ -6,6 +6,7 @@ window.app.component('lnbits-admin-fiat-providers', { formAddStripeUser: '', formAddPaypalUser: '', formAddSquareUser: '', + formAddRevolutUser: '', hideInputToggle: true } }, @@ -21,6 +22,12 @@ window.app.component('lnbits-admin-fiat-providers', { this.formData?.paypal_payment_webhook_url || this.calculateWebhookUrl('paypal') ) + }, + revolutWebhookUrl() { + return ( + this.formData?.revolut_payment_webhook_url || + this.calculateWebhookUrl('revolut') + ) } }, watch: { @@ -60,6 +67,7 @@ window.app.component('lnbits-admin-fiat-providers', { this.maybeSetWebhookUrl('stripe_payment_webhook_url', 'stripe') this.maybeSetWebhookUrl('paypal_payment_webhook_url', 'paypal') this.maybeSetWebhookUrl('square_payment_webhook_url', 'square') + this.maybeSetWebhookUrl('revolut_payment_webhook_url', 'revolut') }, maybeSetWebhookUrl(fieldName, provider) { if (!this.formData) { @@ -130,6 +138,23 @@ window.app.component('lnbits-admin-fiat-providers', { this.formData.square_limits.allowed_users = this.formData.square_limits.allowed_users.filter(u => u !== user) }, + addRevolutAllowedUser() { + const addUser = this.formAddRevolutUser || '' + if ( + addUser.length && + !this.formData.revolut_limits.allowed_users.includes(addUser) + ) { + this.formData.revolut_limits.allowed_users = [ + ...this.formData.revolut_limits.allowed_users, + addUser + ] + this.formAddRevolutUser = '' + } + }, + removeRevolutAllowedUser(user) { + this.formData.revolut_limits.allowed_users = + this.formData.revolut_limits.allowed_users.filter(u => u !== user) + }, checkFiatProvider(providerName) { LNbits.api .request('PUT', `/api/v1/fiat/check/${providerName}`) diff --git a/lnbits/templates/components/admin/fiat_providers.vue b/lnbits/templates/components/admin/fiat_providers.vue index 0a4330fea..6b092c052 100644 --- a/lnbits/templates/components/admin/fiat_providers.vue +++ b/lnbits/templates/components/admin/fiat_providers.vue @@ -866,6 +866,239 @@ + + + + + + + + + + + + +
+
+ +
+
+
+
+ + + + Configure a Revolut Merchant webhook that points to your LNbits + server and subscribe to ORDER_AUTHORISED, + ORDER_COMPLETED, and + SUBSCRIPTION_INITIATED. + + +
+
+ +
+
+ + + + + +
+
+ +
+
+ + + +
+
+ +
+
+ +
+
+ +
+
+
+
+ + + +
+
+ +
+
+ +
+
+ +
+
+
+
+ + + + + + +
+ + +
+
+
+
+
Regions: Square-supported countries
+
+
Revolut
+ Checkout + Subscriptions + Tap-to-pay + Regions: Revolut-supported countries +
diff --git a/lnbits/templates/pages/wallet.vue b/lnbits/templates/pages/wallet.vue index fce6f4b72..2c4d4cb03 100644 --- a/lnbits/templates/pages/wallet.vue +++ b/lnbits/templates/pages/wallet.vue @@ -414,6 +414,24 @@ + + + + R + + + + + diff --git a/tests/api/test_callback_api.py b/tests/api/test_callback_api.py index bd6b9e6e0..b8f8c8ced 100644 --- a/tests/api/test_callback_api.py +++ b/tests/api/test_callback_api.py @@ -9,9 +9,11 @@ from lnbits.core.services.payments import create_wallet_invoice from lnbits.core.services.users import create_user_account from lnbits.core.views.callback_api import ( handle_paypal_event, + handle_revolut_event, handle_square_event, handle_stripe_event, ) +from lnbits.fiat.revolut import RevolutWallet from lnbits.fiat.square import SquareWallet from lnbits.settings import Settings @@ -29,8 +31,12 @@ async def test_callback_api_generic_webhook_handler_routes_providers( square_mock = mocker.patch( "lnbits.core.views.callback_api.handle_square_event", mocker.AsyncMock() ) + revolut_mock = mocker.patch( + "lnbits.core.views.callback_api.handle_revolut_event", mocker.AsyncMock() + ) mocker.patch("lnbits.core.views.callback_api.check_stripe_signature") mocker.patch("lnbits.core.views.callback_api.check_square_signature") + mocker.patch("lnbits.core.views.callback_api.check_revolut_signature") mocker.patch( "lnbits.core.views.callback_api.verify_paypal_webhook", mocker.AsyncMock() ) @@ -61,6 +67,18 @@ async def test_callback_api_generic_webhook_handler_routes_providers( assert square.json()["success"] is True square_mock.assert_awaited_once() + revolut = await http_client.post( + "/api/v1/callback/revolut", + headers={ + "Revolut-Signature": "sig", + "Revolut-Request-Timestamp": "1700000000", + }, + json={"event": "ORDER_COMPLETED", "order_id": "order_1"}, + ) + assert revolut.status_code == 200 + assert revolut.json()["success"] is True + revolut_mock.assert_awaited_once() + unknown = await http_client.post("/api/v1/callback/unknown", json={"id": "evt_3"}) assert unknown.status_code == 200 assert unknown.json()["success"] is False @@ -141,6 +159,114 @@ async def test_callback_api_handles_square_paid_events(mocker): fiat_status_mock.assert_awaited_once_with(payment) +@pytest.mark.anyio +async def test_callback_api_handles_revolut_paid_events(mocker): + payment = mocker.Mock() + get_payment = mocker.patch( + "lnbits.core.views.callback_api.get_standalone_payment", + mocker.AsyncMock(return_value=payment), + ) + fiat_status_mock = mocker.patch( + "lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock() + ) + + await handle_revolut_event( + { + "event": "ORDER_COMPLETED", + "order_id": "order_1", + } + ) + + get_payment.assert_awaited_once_with("fiat_revolut_order_order_1") + fiat_status_mock.assert_awaited_once_with(payment) + + +@pytest.mark.anyio +async def test_callback_api_handles_revolut_subscription_event(mocker, settings: Settings): + user = await create_user_account( + Account( + id=uuid4().hex, + username=f"user_{uuid4().hex[:8]}", + email=f"user_{uuid4().hex[:8]}@lnbits.com", + ) + ) + wallet = user.wallets[0] + payment = await create_wallet_invoice( + wallet.id, CreateInvoice(out=False, amount=15, memo="subscription") + ) + + settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com" + settings.revolut_api_secret_key = "revolut-secret" + settings.revolut_api_version = "2026-04-20" + revolut_provider = RevolutWallet() + mocker.patch.object( + revolut_provider, + "get_subscription", + return_value={ + "id": "SUBSCRIPTION_1", + "current_cycle_id": "CYCLE_1", + "external_reference": json.dumps( + { + "wallet_id": wallet.id, + "tag": "members", + "subscription_request_id": "request_1", + "extra": {"link": "link-1", "customer_id": "customer_1"}, + "memo": "Revolut Members", + } + ), + }, + ) + mocker.patch.object( + revolut_provider, + "get_subscription_cycle", + return_value={"id": "CYCLE_1", "order_id": "ORDER_SUB_1"}, + ) + mocker.patch.object( + revolut_provider, + "get_order", + return_value={ + "id": "ORDER_SUB_1", + "amount": 925, + "currency": "USD", + "checkout_url": "https://checkout.revolut.com/payment-link/sub_1", + }, + ) + mocker.patch( + "lnbits.core.views.callback_api.get_fiat_provider", + mocker.AsyncMock(return_value=revolut_provider), + ) + mocker.patch( + "lnbits.core.views.callback_api.get_standalone_payment", + mocker.AsyncMock(side_effect=[None]), + ) + create_fiat_invoice_mock = mocker.patch( + "lnbits.core.views.callback_api.create_fiat_invoice", + mocker.AsyncMock(return_value=payment), + ) + fiat_status_mock = mocker.patch( + "lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock() + ) + + await handle_revolut_event( + { + "event": "SUBSCRIPTION_INITIATED", + "subscription_id": "SUBSCRIPTION_1", + } + ) + + assert create_fiat_invoice_mock.await_count == 1 + revolut_call = create_fiat_invoice_mock.await_args.kwargs + assert revolut_call["wallet_id"] == wallet.id + invoice = revolut_call["invoice_data"] + assert invoice.fiat_provider == "revolut" + assert invoice.amount == 9.25 + assert invoice.memo == "Revolut Members" + assert invoice.external_id == "SUBSCRIPTION_1" + assert invoice.extra["fiat_method"] == "subscription" + assert invoice.extra["subscription"]["checking_id"] == "order_ORDER_SUB_1" + fiat_status_mock.assert_awaited_once_with(payment) + + @pytest.mark.anyio async def test_callback_api_handles_subscription_flows_and_validation( mocker, settings: Settings diff --git a/tests/data_revolut_api/.lnbits_auth_key b/tests/data_revolut_api/.lnbits_auth_key new file mode 100644 index 000000000..508106c12 --- /dev/null +++ b/tests/data_revolut_api/.lnbits_auth_key @@ -0,0 +1 @@ +98ae578877a3479bb0424d2e418b427a \ No newline at end of file diff --git a/tests/data_revolut_api2/.lnbits_auth_key b/tests/data_revolut_api2/.lnbits_auth_key new file mode 100644 index 000000000..4d94f77a3 --- /dev/null +++ b/tests/data_revolut_api2/.lnbits_auth_key @@ -0,0 +1 @@ +6355d3c6c5df49dbb562a74f9deb19ce \ No newline at end of file diff --git a/tests/unit/test_fiat_providers.py b/tests/unit/test_fiat_providers.py index 7b6f1a6e0..95eb017ec 100644 --- a/tests/unit/test_fiat_providers.py +++ b/tests/unit/test_fiat_providers.py @@ -17,6 +17,7 @@ from lnbits.core.models.wallets import Wallet from lnbits.core.services import check_payment_status, payments from lnbits.core.services.fiat_providers import ( check_fiat_status, + check_revolut_signature, check_square_signature, check_stripe_signature, handle_fiat_payment_confirmation, @@ -32,6 +33,7 @@ from lnbits.fiat.base import ( FiatStatusResponse, FiatSubscriptionPaymentOptions, ) +from lnbits.fiat.revolut import RevolutWallet from lnbits.fiat.square import SquareWallet from lnbits.settings import Settings from tests.helpers import get_random_string @@ -83,9 +85,18 @@ def fiat_provider_test_settings(settings: Settings): original_square_payment_webhook_url = settings.square_payment_webhook_url original_square_webhook_signature_key = settings.square_webhook_signature_key original_square_limits = settings.square_limits.copy(deep=True) + original_revolut_enabled = settings.revolut_enabled + original_revolut_api_endpoint = settings.revolut_api_endpoint + original_revolut_api_secret_key = settings.revolut_api_secret_key + original_revolut_api_version = settings.revolut_api_version + original_revolut_payment_success_url = settings.revolut_payment_success_url + original_revolut_payment_webhook_url = settings.revolut_payment_webhook_url + original_revolut_webhook_signing_secret = settings.revolut_webhook_signing_secret + original_revolut_limits = settings.revolut_limits.copy(deep=True) settings.lnbits_allowed_currencies = [] settings.paypal_enabled = False settings.square_enabled = False + settings.revolut_enabled = False yield settings.lnbits_allowed_currencies = original_allowed_currencies settings.paypal_enabled = original_paypal_enabled @@ -98,6 +109,14 @@ def fiat_provider_test_settings(settings: Settings): settings.square_payment_webhook_url = original_square_payment_webhook_url settings.square_webhook_signature_key = original_square_webhook_signature_key settings.square_limits = original_square_limits + settings.revolut_enabled = original_revolut_enabled + settings.revolut_api_endpoint = original_revolut_api_endpoint + settings.revolut_api_secret_key = original_revolut_api_secret_key + settings.revolut_api_version = original_revolut_api_version + settings.revolut_payment_success_url = original_revolut_payment_success_url + settings.revolut_payment_webhook_url = original_revolut_payment_webhook_url + settings.revolut_webhook_signing_secret = original_revolut_webhook_signing_secret + settings.revolut_limits = original_revolut_limits @pytest.mark.anyio @@ -178,6 +197,23 @@ async def test_create_wallet_fiat_invoice_allowed_users( assert user assert user.fiat_providers == ["square"] + settings.square_enabled = False + settings.revolut_enabled = True + settings.revolut_limits.allowed_users = [] + user = await get_user(to_user.id) + assert user + assert user.fiat_providers == ["revolut"] + + settings.revolut_limits.allowed_users = ["some_other_user_id"] + user = await get_user(to_user.id) + assert user + assert user.fiat_providers == [] + + settings.revolut_limits.allowed_users.append(to_user.id) + user = await get_user(to_user.id) + assert user + assert user.fiat_providers == ["revolut"] + @pytest.mark.anyio async def test_create_wallet_fiat_invoice_fiat_limits_fail( @@ -695,6 +731,181 @@ async def test_square_wallet_get_invoice_status(settings: Settings): assert client.calls[1][0] == "/v2/payments/PAYMENT123" +@pytest.mark.anyio +async def test_create_wallet_revolut_fiat_invoice_success( + to_wallet: Wallet, settings: Settings, mocker: MockerFixture +): + settings.revolut_enabled = True + settings.revolut_api_secret_key = "revolut-secret" + settings.revolut_limits.service_min_amount_sats = 0 + settings.revolut_limits.service_max_amount_sats = 0 + settings.revolut_limits.service_faucet_wallet_id = None + + invoice_data = CreateInvoice( + unit="USD", amount=1.0, memo="Test", fiat_provider="revolut" + ) + fiat_mock_response = FiatInvoiceResponse( + ok=True, + checking_id="order_ORDER123", + payment_request="https://checkout.revolut.com/payment-link/ORDER123", + ) + + mocker.patch( + "lnbits.fiat.RevolutWallet.create_invoice", + AsyncMock(return_value=fiat_mock_response), + ) + mocker.patch( + "lnbits.utils.exchange_rates.get_fiat_rate_satoshis", + AsyncMock(return_value=1000), + ) + payment = await payments.create_fiat_invoice(to_wallet.id, invoice_data) + assert payment.status == PaymentState.PENDING + assert payment.fiat_provider == "revolut" + assert payment.extra.get("fiat_checking_id") == fiat_mock_response.checking_id + assert payment.checking_id.startswith("fiat_revolut_order_ORDER123") + + +@pytest.mark.anyio +async def test_revolut_wallet_create_invoice(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" + settings.revolut_payment_success_url = "https://lnbits.example/success" + + wallet = RevolutWallet() + client = MockHTTPClient( + [ + MockHTTPResponse( + json_data={ + "id": "ORDER123", + "checkout_url": "https://checkout.revolut.com/payment-link/abc123", + } + ) + ] + ) + wallet.client = client # type: ignore[assignment] + + response = await wallet.create_invoice( + amount=1.23, + payment_hash="hash123", + currency="USD", + memo="LNbits Revolut invoice", + extra={"checkout": {"metadata": {"source": "test"}}}, + ) + + assert response.ok is True + assert response.checking_id == "order_ORDER123" + assert response.payment_request == "https://checkout.revolut.com/payment-link/abc123" + assert client.calls[0][0] == "/api/orders" + payload = client.calls[0][1]["json"] + assert payload["amount"] == 123 + assert payload["currency"] == "USD" + assert payload["metadata"]["payment_hash"] == "hash123" + assert payload["metadata"]["alan_action"] == "invoice" + assert payload["metadata"]["source"] == "test" + assert payload["redirect_url"] == "https://lnbits.example/success" + + +@pytest.mark.anyio +async def test_revolut_wallet_get_invoice_status(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={"state": "COMPLETED"})]) + wallet.client = client # type: ignore[assignment] + + status = await wallet.get_invoice_status("fiat_revolut_order_ORDER123") + + assert status.success is True + assert client.calls[0][0] == "/api/orders/ORDER123" + + +@pytest.mark.anyio +async def test_revolut_wallet_create_subscription(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" + settings.revolut_payment_success_url = "https://lnbits.example/subscription-success" + + wallet = RevolutWallet() + client = MockHTTPClient( + [ + 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", + memo="Monthly Gold", + tag="gold", + extra={"customer_id": "CUSTOMER123", "link": "link-1"}, + success_url="https://lnbits.example/subscription-success", + ) + + response = await wallet.create_subscription( + "PLAN_VARIATION_123", 1, payment_options + ) + + assert response.ok is True + assert response.subscription_request_id == "SUBSCRIPTION123" + assert ( + response.checkout_session_url + == "https://checkout.revolut.com/payment-link/sub_123" + ) + assert client.calls[0][0] == "/api/subscriptions" + payload = client.calls[0][1]["json"] + assert payload["plan_variation_id"] == "PLAN_VARIATION_123" + assert payload["customer_id"] == "CUSTOMER123" + assert payload["setup_order_redirect_url"] == ( + "https://lnbits.example/subscription-success" + ) + reference = json.loads(payload["external_reference"]) + assert reference["wallet_id"] == "wallet_1" + assert reference["tag"] == "gold" + assert reference["memo"] == "Monthly Gold" + assert reference["extra"]["customer_id"] == "CUSTOMER123" + assert client.calls[1][0] == "/api/orders/ORDER123" + + +@pytest.mark.anyio +async def test_revolut_wallet_cancel_subscription(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={})]) + wallet.client = client # type: ignore[assignment] + + response = await wallet.cancel_subscription("SUBSCRIPTION123", "wallet_1") + + assert response.ok is True + assert client.calls[0][0] == "/api/subscriptions/SUBSCRIPTION123/cancel" + + +def test_check_revolut_signature(): + payload = b'{"event":"ORDER_COMPLETED","order_id":"ORDER123"}' + timestamp = str(int(time.time())) + secret = "revolut-secret" + sig = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() + + check_revolut_signature(payload, sig, timestamp, secret) + + @pytest.mark.anyio async def test_fiat_service_fee(settings: Settings): # settings.stripe_limits.service_min_amount_sats = 0