feat: Adds paypal as a fiat choice (#3637)

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
This commit is contained in:
Arc 2025-12-09 12:35:39 +00:00 committed by GitHub
parent 661b713993
commit baee90da67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1034 additions and 33 deletions

View file

@ -1,7 +1,9 @@
import hashlib import hashlib
import hmac import hmac
import json
import time import time
import httpx
from loguru import logger from loguru import logger
from lnbits.core.crud import get_wallet from lnbits.core.crud import get_wallet
@ -70,6 +72,63 @@ def check_stripe_signature(
raise ValueError("Stripe signature verification failed.") raise ValueError("Stripe signature verification failed.")
async def verify_paypal_webhook(headers, payload: bytes):
"""
Validate PayPal webhook signatures using the PayPal verify API.
"""
webhook_id = settings.paypal_webhook_id
if not webhook_id:
logger.warning("PayPal webhook ID not set; skipping verification.")
return
required_headers = {
"PAYPAL-TRANSMISSION-ID": headers.get("PAYPAL-TRANSMISSION-ID"),
"PAYPAL-TRANSMISSION-TIME": headers.get("PAYPAL-TRANSMISSION-TIME"),
"PAYPAL-TRANSMISSION-SIG": headers.get("PAYPAL-TRANSMISSION-SIG"),
"PAYPAL-CERT-URL": headers.get("PAYPAL-CERT-URL"),
"PAYPAL-AUTH-ALGO": headers.get("PAYPAL-AUTH-ALGO"),
}
if not all(required_headers.values()):
logger.warning("Missing PayPal webhook headers; skipping verification.")
return
try:
async with httpx.AsyncClient(base_url=settings.paypal_api_endpoint) as client:
token_resp = await client.post(
"/v1/oauth2/token",
data={"grant_type": "client_credentials"},
auth=(
settings.paypal_client_id or "",
settings.paypal_client_secret or "",
),
)
token_resp.raise_for_status()
access_token = token_resp.json().get("access_token")
if not access_token:
raise ValueError("PayPal token missing in verification flow.")
verify_resp = await client.post(
"/v1/notifications/verify-webhook-signature",
json={
"auth_algo": required_headers["PAYPAL-AUTH-ALGO"],
"cert_url": required_headers["PAYPAL-CERT-URL"],
"transmission_id": required_headers["PAYPAL-TRANSMISSION-ID"],
"transmission_sig": required_headers["PAYPAL-TRANSMISSION-SIG"],
"transmission_time": required_headers["PAYPAL-TRANSMISSION-TIME"],
"webhook_id": webhook_id,
"webhook_event": json.loads(payload.decode()),
},
headers={"Authorization": f"Bearer {access_token}"},
)
verify_resp.raise_for_status()
verification_status = verify_resp.json().get("verification_status")
if verification_status != "SUCCESS":
raise ValueError("PayPal webhook verification failed.")
except Exception as exc:
logger.warning(exc)
raise ValueError("PayPal webhook cannot be verified.") from exc
async def test_connection(provider: str) -> SimpleStatus: async def test_connection(provider: str) -> SimpleStatus:
""" """
Test the connection to Stripe by checking if the API key is valid. Test the connection to Stripe by checking if the API key is valid.

View file

@ -10,6 +10,7 @@ from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.payments import CreateInvoice from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.fiat_providers import ( from lnbits.core.services.fiat_providers import (
check_stripe_signature, check_stripe_signature,
verify_paypal_webhook,
) )
from lnbits.core.services.payments import create_fiat_invoice from lnbits.core.services.payments import create_fiat_invoice
from lnbits.fiat.base import FiatSubscriptionPaymentOptions from lnbits.fiat.base import FiatSubscriptionPaymentOptions
@ -37,6 +38,17 @@ async def api_generic_webhook_handler(
message=f"Callback received successfully from '{provider_name}'.", message=f"Callback received successfully from '{provider_name}'.",
) )
if provider_name.lower() == "paypal":
payload = await request.body()
await verify_paypal_webhook(request.headers, payload)
event = await request.json()
await handle_paypal_event(event)
return SimpleStatus(
success=True,
message=f"Callback received successfully from '{provider_name}'.",
)
return SimpleStatus( return SimpleStatus(
success=False, success=False,
message=f"Unknown fiat provider '{provider_name}'.", message=f"Unknown fiat provider '{provider_name}'.",
@ -165,3 +177,84 @@ async def _get_stripe_subscription_payment_options(
metadata["extra"] = {} metadata["extra"] = {}
return FiatSubscriptionPaymentOptions(**metadata) return FiatSubscriptionPaymentOptions(**metadata)
async def handle_paypal_event(event: dict):
event_type = event.get("event_type", "")
resource = event.get("resource", {})
if event_type in ("CHECKOUT.ORDER.APPROVED", "PAYMENT.CAPTURE.COMPLETED"):
payment_hash = _paypal_extract_payment_hash(resource)
if not payment_hash:
logger.warning("PayPal event missing payment hash.")
return
payment = await get_standalone_payment(payment_hash)
if not payment:
logger.warning(f"No payment found for hash: '{payment_hash}'.")
return
await payment.check_fiat_status()
return
if event_type in (
"PAYMENT.SALE.COMPLETED",
"BILLING.SUBSCRIPTION.PAYMENT.SUCCEEDED",
):
await _handle_paypal_subscription_payment(resource)
return
logger.info(f"Unhandled PayPal event type: '{event_type}'.")
async def _handle_paypal_subscription_payment(resource: dict):
amount_info = resource.get("amount") or {}
currency = (amount_info.get("currency_code") or "").upper()
total = amount_info.get("value")
if not currency or total is None:
raise ValueError("PayPal subscription event missing amount.")
custom_id = resource.get("custom_id") or resource.get("custom")
if not custom_id:
raise ValueError("PayPal subscription event missing custom metadata.")
try:
metadata = json.loads(custom_id)
except json.JSONDecodeError:
metadata = {}
payment_options = FiatSubscriptionPaymentOptions(**metadata)
if not payment_options.wallet_id:
raise ValueError("PayPal subscription event missing wallet_id.")
memo = payment_options.memo or ""
extra = {
**(payment_options.extra or {}),
"fiat_method": "subscription",
"tag": payment_options.tag,
"subscription": {
"checking_id": resource.get("id") or resource.get("billing_agreement_id"),
"payment_request": "",
},
}
payment = await create_fiat_invoice(
wallet_id=payment_options.wallet_id,
invoice_data=CreateInvoice(
unit=currency,
amount=float(total),
memo=memo,
extra=extra,
fiat_provider="paypal",
),
)
await payment.check_fiat_status()
def _paypal_extract_payment_hash(resource: dict) -> str | None:
purchase_units = resource.get("purchase_units") or []
for pu in purchase_units:
if pu.get("invoice_id"):
return pu.get("invoice_id")
if pu.get("custom_id"):
return pu.get("custom_id")
return None

View file

@ -75,20 +75,28 @@ async def cancel_subscription(
) )
async def connection_token(provider: str): async def connection_token(provider: str):
fiat_provider = await get_fiat_provider(provider) fiat_provider = await get_fiat_provider(provider)
if provider == "stripe": if not fiat_provider:
if not isinstance(fiat_provider, StripeWallet): raise HTTPException(status_code=404, detail="Fiat provider not found")
if provider != "stripe":
raise HTTPException(
status_code=400,
detail=f"Connection tokens are not supported for provider '{provider}'.",
)
if not isinstance(fiat_provider, StripeWallet):
raise HTTPException(
status_code=500, detail="Stripe wallet/provider not configured"
)
try:
tok = await fiat_provider.create_terminal_connection_token()
secret = tok.get("secret")
if not secret:
raise HTTPException( raise HTTPException(
status_code=500, detail="Stripe wallet/provider not configured" status_code=502, detail="Stripe returned no connection token"
) )
try: return {"secret": secret}
tok = await fiat_provider.create_terminal_connection_token() except Exception as e:
secret = tok.get("secret") raise HTTPException(
if not secret: status_code=500, detail="Failed to create connection token"
raise HTTPException( ) from e
status_code=502, detail="Stripe returned no connection token"
)
return {"secret": secret}
except Exception as e:
raise HTTPException(
status_code=500, detail="Failed to create connection token"
) from e

View file

@ -8,6 +8,7 @@ from loguru import logger
from lnbits.fiat.base import FiatProvider from lnbits.fiat.base import FiatProvider
from lnbits.settings import settings from lnbits.settings import settings
from .paypal import PayPalWallet
from .stripe import StripeWallet from .stripe import StripeWallet
fiat_module = importlib.import_module("lnbits.fiat") fiat_module = importlib.import_module("lnbits.fiat")
@ -15,6 +16,7 @@ fiat_module = importlib.import_module("lnbits.fiat")
class FiatProviderType(Enum): class FiatProviderType(Enum):
stripe = "StripeWallet" stripe = "StripeWallet"
paypal = "PayPalWallet"
async def get_fiat_provider(name: str) -> FiatProvider | None: async def get_fiat_provider(name: str) -> FiatProvider | None:
@ -49,5 +51,6 @@ fiat_providers: dict[str, FiatProvider] = {}
__all__ = [ __all__ = [
"PayPalWallet",
"StripeWallet", "StripeWallet",
] ]

338
lnbits/fiat/paypal.py Normal file
View file

@ -0,0 +1,338 @@
import asyncio
import json
import time
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 PayPalCheckoutOptions(BaseModel):
class Config:
extra = "ignore"
success_url: str | None = None
cancel_url: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class PayPalCreateInvoiceOptions(BaseModel):
class Config:
extra = "ignore"
checkout: PayPalCheckoutOptions | None = None
class PayPalWallet(FiatProvider):
"""https://developer.paypal.com/api/rest/"""
def __init__(self):
logger.debug("Initializing PayPalWallet")
self._settings_fields = self._settings_connection_fields()
if not settings.paypal_api_endpoint:
raise ValueError("Cannot initialize PayPalWallet: missing endpoint.")
if not settings.paypal_client_id:
raise ValueError("Cannot initialize PayPalWallet: missing client id.")
if not settings.paypal_client_secret:
raise ValueError("Cannot initialize PayPalWallet: missing client secret.")
self.endpoint = normalize_endpoint(settings.paypal_api_endpoint)
self.headers = {
"User-Agent": f"PayPal Alan:{settings.version}",
}
self._access_token: str | None = None
self._token_expires_at: float = 0
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
logger.info("PayPalWallet initialized.")
async def cleanup(self):
try:
await self.client.aclose()
except RuntimeError as e:
logger.warning(f"Error closing PayPal 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:
await self._ensure_access_token()
return FiatStatusResponse(balance=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 PayPal options")
try:
await self._ensure_access_token()
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message="Unable to authenticate."
)
co = opts.checkout or PayPalCheckoutOptions()
success_url = (
co.success_url
or settings.paypal_payment_success_url
or "https://lnbits.com"
)
cancel_url = co.cancel_url or success_url
order_data = {
"intent": "CAPTURE",
"purchase_units": [
{
"amount": {
"currency_code": currency.upper(),
"value": f"{amount:.2f}",
},
"custom_id": payment_hash[:127], # PayPal limit
"invoice_id": payment_hash[:127],
"description": memo or "LNbits Invoice",
}
],
"application_context": {
"return_url": success_url,
"cancel_url": cancel_url,
"shipping_preference": "NO_SHIPPING",
"user_action": "PAY_NOW",
},
}
try:
r = await self.client.post(
"/v2/checkout/orders", json=order_data, headers=self._auth_headers()
)
r.raise_for_status()
data = r.json()
order_id = data.get("id")
approval_url = self._get_approval_url(data.get("links") or [])
if not order_id or not approval_url:
return FiatInvoiceResponse(
ok=False, error_message="Server error: missing id or approval url"
)
return FiatInvoiceResponse(
ok=True,
checking_id=f"fiat_paypal_{order_id}",
payment_request=approval_url,
)
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:
success_url = (
payment_options.success_url
or settings.paypal_payment_success_url
or "https://lnbits.com"
)
if not payment_options.subscription_request_id:
payment_options.subscription_request_id = urlsafe_short_hash()
payment_options.extra = payment_options.extra or {}
payment_options.extra["subscription_request_id"] = (
payment_options.subscription_request_id
)
try:
await self._ensure_access_token()
payload = {
"plan_id": subscription_id,
"quantity": str(quantity),
"custom_id": self._serialize_metadata(payment_options),
"application_context": {
"return_url": success_url,
"cancel_url": success_url,
},
}
r = await self.client.post(
"/v1/billing/subscriptions",
json=payload,
headers=self._auth_headers(),
)
r.raise_for_status()
data = r.json()
approval_url = self._get_approval_url(data.get("links") or [])
if not approval_url:
return FiatSubscriptionResponse(
ok=False, error_message="Server error: missing approval url"
)
return FiatSubscriptionResponse(
ok=True,
checkout_session_url=approval_url,
subscription_request_id=payment_options.subscription_request_id,
)
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:
await self._ensure_access_token()
r = await self.client.post(
f"/v1/billing/subscriptions/{subscription_id}/cancel",
json={"reason": f"Cancelled by {correlation_id}"},
headers=self._auth_headers(),
)
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("PayPal does not support paying invoices directly.")
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
try:
await self._ensure_access_token()
order_id = self._normalize_paypal_id(checking_id)
r = await self.client.get(
f"/v2/checkout/orders/{order_id}", headers=self._auth_headers()
)
r.raise_for_status()
return self._status_from_order(r.json())
except Exception as exc:
logger.debug(f"Error getting PayPal order status: {exc}")
return FiatPaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
raise NotImplementedError("PayPal does not support outgoing payments.")
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
logger.warning(
"PayPal 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 _status_from_order(self, order: dict[str, Any]) -> FiatPaymentStatus:
status = (order.get("status") or "").upper()
if status in ["COMPLETED", "APPROVED"]:
return FiatPaymentSuccessStatus()
if status in ["VOIDED", "CANCELLED", "CANCELED"]:
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
def _normalize_paypal_id(self, checking_id: str) -> str:
return (
checking_id.replace("fiat_paypal_", "", 1)
if checking_id.startswith("fiat_paypal_")
else checking_id
)
def _serialize_metadata(
self, payment_options: FiatSubscriptionPaymentOptions
) -> str:
md = {
"wallet_id": payment_options.wallet_id,
"memo": payment_options.memo,
"extra": payment_options.extra,
"tag": payment_options.tag,
"subscription_request_id": payment_options.subscription_request_id,
}
raw = json.dumps(md)
return raw[:127] # PayPal custom_id limit
def _parse_create_opts(
self, raw_opts: dict[str, Any]
) -> PayPalCreateInvoiceOptions | None:
try:
return PayPalCreateInvoiceOptions.parse_obj(raw_opts)
except ValidationError as e:
logger.warning(f"Invalid PayPal options: {e}")
return None
async def _ensure_access_token(self):
if self._access_token and time.time() < self._token_expires_at:
return
r = await self.client.post(
"/v1/oauth2/token",
data={"grant_type": "client_credentials"},
auth=(settings.paypal_client_id or "", settings.paypal_client_secret or ""),
headers={"Accept": "application/json"},
)
r.raise_for_status()
data = r.json()
token = data.get("access_token")
expires_in = int(data.get("expires_in") or 300)
if not token:
raise ValueError("Unable to retrieve PayPal access token.")
self._access_token = token
self._token_expires_at = time.time() + expires_in - 30
def _auth_headers(self) -> dict[str, str]:
return {**self.headers, "Authorization": f"Bearer {self._access_token}"}
def _get_approval_url(self, links: list[dict[str, Any]]) -> str | None:
for link in links:
if link.get("rel") == "approve":
return link.get("href")
return None
def _settings_connection_fields(self) -> str:
return "-".join(
[
str(settings.paypal_api_endpoint),
str(settings.paypal_client_id),
str(settings.paypal_client_secret),
str(settings.paypal_webhook_id),
]
)

View file

@ -642,6 +642,20 @@ class StripeFiatProvider(LNbitsSettings):
stripe_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits) stripe_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
class PayPalFiatProvider(LNbitsSettings):
paypal_enabled: bool = Field(default=False)
paypal_api_endpoint: str = Field(default="https://api-m.paypal.com")
paypal_client_id: str | None = Field(default=None)
paypal_client_secret: str | None = Field(default=None)
paypal_payment_success_url: str = Field(default="https://lnbits.com")
paypal_payment_webhook_url: str = Field(
default="https://your-lnbits-domain-here.com/api/v1/callback/paypal"
)
paypal_webhook_id: str | None = Field(default=None)
paypal_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
class LightningSettings(LNbitsSettings): class LightningSettings(LNbitsSettings):
lightning_invoice_expiry: int = Field(default=3600, gt=0) lightning_invoice_expiry: int = Field(default=3600, gt=0)
@ -677,7 +691,7 @@ class FundingSourcesSettings(
funding_source_max_retries: int = Field(default=4, ge=0) funding_source_max_retries: int = Field(default=4, ge=0)
class FiatProvidersSettings(StripeFiatProvider): class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider):
def is_fiat_provider_enabled(self, provider: str | None) -> bool: def is_fiat_provider_enabled(self, provider: str | None) -> bool:
""" """
Checks if a specific fiat provider is enabled. Checks if a specific fiat provider is enabled.
@ -686,7 +700,8 @@ class FiatProvidersSettings(StripeFiatProvider):
return False return False
if provider == "stripe": if provider == "stripe":
return self.stripe_enabled return self.stripe_enabled
# Add checks for other fiat providers here as needed if provider == "paypal":
return self.paypal_enabled
return False return False
def get_fiat_providers_for_user(self, user_id: str) -> list[str]: def get_fiat_providers_for_user(self, user_id: str) -> list[str]:
@ -700,7 +715,12 @@ class FiatProvidersSettings(StripeFiatProvider):
): ):
allowed_providers.append("stripe") allowed_providers.append("stripe")
# Add other fiat providers here as needed if self.paypal_enabled and (
not self.paypal_limits.allowed_users
or user_id in self.paypal_limits.allowed_users
):
allowed_providers.append("paypal")
return allowed_providers return allowed_providers
def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None: def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -74,6 +74,8 @@ window.localisation.en = {
update_name: 'Update name', update_name: 'Update name',
fiat_tracking: 'Fiat tracking', fiat_tracking: 'Fiat tracking',
fiat_providers: 'Fiat providers', fiat_providers: 'Fiat providers',
fiat_warning_bitcoin:
'Fiat providers can get twitchy about anything bitcoin, so avoid using word "bitcoin" in your memos!',
currency: 'Currency', currency: 'Currency',
update_currency: 'Update currency', update_currency: 'Update currency',
press_to_claim: 'Press to claim bitcoin', press_to_claim: 'Press to claim bitcoin',
@ -237,6 +239,7 @@ window.localisation.en = {
webhook_url: 'Webhook URL', webhook_url: 'Webhook URL',
webhook_url_hint: webhook_url_hint:
'Webhook URL to send the payment details to. It will be called when the payment is completed.', 'Webhook URL to send the payment details to. It will be called when the payment is completed.',
copy_webhook_url: 'Copy webhook URL',
webhook_events_list: 'The following events must be supported by the webhook:', webhook_events_list: 'The following events must be supported by the webhook:',
webhook_stripe_description: webhook_stripe_description:
'One the stripe side you must configure a webhook with a URL that points to your LNbits server.', 'One the stripe side you must configure a webhook with a URL that points to your LNbits server.',
@ -428,8 +431,7 @@ window.localisation.en = {
look_and_feel: 'Look and Feel', look_and_feel: 'Look and Feel',
endpoint: 'Endpoint', endpoint: 'Endpoint',
api: 'API', api: 'API',
api_stripe: api_stripe: 'API',
'API (warning: Stripe are twitchy about anything bitcoin, so avoid using word "bitcoin" in your memos!)',
api_token: 'API Token', api_token: 'API Token',
api_tokens: 'API Tokens', api_tokens: 'API Tokens',
access_control_list: 'Access Control List', access_control_list: 'Access Control List',
@ -706,10 +708,15 @@ window.localisation.en = {
filter_labels: 'Filter labels', filter_labels: 'Filter labels',
filter_date: 'Filter by date', filter_date: 'Filter by date',
websocket_example: 'Websocket example', websocket_example: 'Websocket example',
client_id: 'Client ID',
secret_key: 'Secret Key', secret_key: 'Secret Key',
signing_secret: 'Signing Secret', signing_secret: 'Signing Secret',
signing_secret_hint: signing_secret_hint:
'Signing secret for the webhook. Messages will be signed with this secret.', 'Signing secret for the webhook. Messages will be signed with this secret.',
webhook_id: 'Webhook ID',
webhook_id_hint: 'PayPal webhook ID used to verify incoming events.',
webhook_paypal_description:
'On the PayPal side configure a webhook pointing to your LNbits server.',
callback_success_url: 'Callback Success URL', callback_success_url: 'Callback Success URL',
callback_success_url_hint: callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful', 'The user will be redirected to this URL after the payment is successful',

View file

@ -628,10 +628,15 @@ window.localisation.fi = {
filter_payments: 'Suodata maksuja', filter_payments: 'Suodata maksuja',
filter_date: 'Suodata päiväyksellä', filter_date: 'Suodata päiväyksellä',
websocket_example: 'Websocket example', websocket_example: 'Websocket example',
client_id: 'Client ID',
secret_key: 'Secret Key', secret_key: 'Secret Key',
signing_secret: 'Signing Secret', signing_secret: 'Signing Secret',
signing_secret_hint: signing_secret_hint:
'Signing secret for the webhook. Messages will be signed with this secret.', 'Signing secret for the webhook. Messages will be signed with this secret.',
webhook_id: 'Webhook ID',
webhook_id_hint: 'PayPal webhook ID used to verify incoming events.',
webhook_paypal_description:
'On the PayPal side configure a webhook pointing to your LNbits server.',
callback_success_url: 'Callback Success URL', callback_success_url: 'Callback Success URL',
callback_success_url_hint: callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful' 'The user will be redirected to this URL after the payment is successful'

View file

@ -5,10 +5,79 @@ window.app.component('lnbits-admin-fiat-providers', {
data() { data() {
return { return {
formAddStripeUser: '', formAddStripeUser: '',
formAddPaypalUser: '',
hideInputToggle: true hideInputToggle: true
} }
}, },
computed: {
stripeWebhookUrl() {
return (
this.formData?.stripe_payment_webhook_url ||
this.calculateWebhookUrl('stripe')
)
},
paypalWebhookUrl() {
return (
this.formData?.paypal_payment_webhook_url ||
this.calculateWebhookUrl('paypal')
)
}
},
watch: {
formData: {
handler() {
this.syncWebhookUrls()
},
immediate: true
}
},
methods: { methods: {
basePathFromLocation() {
if (typeof window === 'undefined') {
return ''
}
const normalizedPath = window.location.pathname.replace(/\/+$/, '')
const adminIndex = normalizedPath.lastIndexOf('/admin')
const basePath =
adminIndex >= 0
? normalizedPath.slice(0, adminIndex)
: normalizedPath || ''
return basePath || ''
},
calculateWebhookUrl(provider) {
if (typeof window === 'undefined') {
return ''
}
const basePath = this.basePathFromLocation()
const path = `${basePath}/api/v1/callback/${provider}`.replace(
/\/+/g,
'/'
)
const withLeadingSlash = path.startsWith('/') ? path : `/${path}`
return `${window.location.origin}${withLeadingSlash}`
},
syncWebhookUrls() {
this.maybeSetWebhookUrl('stripe_payment_webhook_url', 'stripe')
this.maybeSetWebhookUrl('paypal_payment_webhook_url', 'paypal')
},
maybeSetWebhookUrl(fieldName, provider) {
if (!this.formData) {
return
}
const calculated = this.calculateWebhookUrl(provider)
const current = this.formData[fieldName]
const hasPlaceholder =
!current || current.includes('your-lnbits-domain-here.com')
if (hasPlaceholder && calculated) {
this.formData[fieldName] = calculated
}
},
copyWebhookUrl(url) {
if (!url) {
return
}
this.copyText(url)
},
addStripeAllowedUser() { addStripeAllowedUser() {
const addUser = this.formAddStripeUser || '' const addUser = this.formAddStripeUser || ''
if ( if (
@ -26,6 +95,23 @@ window.app.component('lnbits-admin-fiat-providers', {
this.formData.stripe_limits.allowed_users = this.formData.stripe_limits.allowed_users =
this.formData.stripe_limits.allowed_users.filter(u => u !== user) this.formData.stripe_limits.allowed_users.filter(u => u !== user)
}, },
addPaypalAllowedUser() {
const addUser = this.formAddPaypalUser || ''
if (
addUser.length &&
!this.formData.paypal_limits.allowed_users.includes(addUser)
) {
this.formData.paypal_limits.allowed_users = [
...this.formData.paypal_limits.allowed_users,
addUser
]
this.formAddPaypalUser = ''
}
},
removePaypalAllowedUser(user) {
this.formData.paypal_limits.allowed_users =
this.formData.paypal_limits.allowed_users.filter(u => u !== user)
},
checkFiatProvider(providerName) { checkFiatProvider(providerName) {
LNbits.api LNbits.api
.request('PUT', `/api/v1/fiat/check/${providerName}`) .request('PUT', `/api/v1/fiat/check/${providerName}`)

View file

@ -79,15 +79,32 @@
<span v-text="$t('webhook_stripe_description')"></span> <span v-text="$t('webhook_stripe_description')"></span>
</q-card-section> </q-card-section>
<q-card-section> <q-card-section>
<q-input <div class="row items-center q-gutter-sm q-mt-md">
filled <div class="col">
class="q-mt-md" <q-input
type="text" filled
disable type="text"
v-model="formData.stripe_payment_webhook_url" disable
:label="$t('webhook_url')" :model-value="stripeWebhookUrl"
:hint="$t('webhook_url_hint')" :label="$t('webhook_url')"
></q-input> :hint="$t('webhook_url_hint')"
readonly
></q-input>
</div>
<div class="col-auto">
<q-btn
outline
color="grey"
icon="content_copy"
@click="copyWebhookUrl(stripeWebhookUrl)"
:aria-label="$t('copy_webhook_url')"
>
<q-tooltip>
<span v-text="$t('copy_webhook_url')"></span>
</q-tooltip>
</q-btn>
</div>
</div>
<q-input <q-input
filled filled
class="q-mt-md" class="q-mt-md"
@ -275,7 +292,292 @@
</q-card> </q-card>
</q-expansion-item> </q-expansion-item>
<q-separator /> <q-separator></q-separator>
<q-expansion-item header-class="text-primary text-bold">
<template v-slot:header>
<q-item-section avatar>
<q-avatar color="blue-8" text-color="white">PP</q-avatar>
</q-item-section>
<q-item-section> PayPal </q-item-section>
<q-item-section side>
<div class="row items-center">
<q-toggle
size="md"
:label="$t('enabled')"
v-model="formData.paypal_enabled"
color="green"
unchecked-icon="clear"
/>
</div>
</q-item-section>
</template>
<q-card class="q-pb-xl">
<q-expansion-item :label="$t('api')" default-opened>
<q-card-section class="q-pa-md">
<q-input
filled
type="text"
v-model="formData.paypal_api_endpoint"
:label="$t('endpoint')"
></q-input>
<q-input
filled
class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.paypal_client_id"
:label="$t('client_id')"
></q-input>
<q-input
filled
class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.paypal_client_secret"
:label="$t('secret_key')"
></q-input>
<q-input
filled
class="q-mt-md"
type="text"
v-model="formData.paypal_payment_success_url"
:label="$t('callback_success_url')"
:hint="$t('callback_success_url_hint')"
></q-input>
</q-card-section>
<q-card-section class="q-pa-md">
<div class="row">
<div class="col">
<q-btn
outline
color="grey"
class="float-right"
:label="$t('check_connection')"
@click="checkFiatProvider('paypal')"
></q-btn>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('webhook')" default-opened>
<q-card-section>
<span v-text="$t('webhook_paypal_description')"></span>
</q-card-section>
<q-card-section>
<div class="row items-center q-gutter-sm q-mt-md">
<div class="col">
<q-input
filled
type="text"
disable
:model-value="paypalWebhookUrl"
:label="$t('webhook_url')"
:hint="$t('webhook_url_hint')"
readonly
></q-input>
</div>
<div class="col-auto">
<q-btn
outline
color="grey"
icon="content_copy"
@click="copyWebhookUrl(paypalWebhookUrl)"
:aria-label="$t('copy_webhook_url')"
>
<q-tooltip>
<span v-text="$t('copy_webhook_url')"></span>
</q-tooltip>
</q-btn>
</div>
</div>
<q-input
filled
class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.paypal_webhook_id"
:label="$t('webhook_id')"
:hint="$t('webhook_id_hint')"
></q-input>
</q-card-section>
<q-card-section>
<span v-text="$t('webhook_events_list')"></span>
<ul>
<li><code>CHECKOUT.ORDER.APPROVED</code></li>
<li><code>PAYMENT.CAPTURE.COMPLETED</code></li>
<li><code>PAYMENT.SALE.COMPLETED</code></li>
<li><code>BILLING.SUBSCRIPTION.PAYMENT.SUCCEEDED</code></li>
</ul>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('service_fee')">
<q-card-section>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.paypal_limits.service_fee_percent"
@update:model-value="formData.touch = null"
:label="$t('service_fee_label')"
:hint="$t('service_fee_hint')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.paypal_limits.service_max_fee_sats"
@update:model-value="formData.touch = null"
:label="$t('service_fee_max')"
:hint="$t('service_fee_max_hint')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="text"
v-model="formData.paypal_limits.service_fee_wallet_id"
@update:model-value="formData.touch = null"
:label="$t('fee_wallet_label')"
:hint="$t('fee_wallet_hint')"
></q-input>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('amount_limits')">
<q-card-section>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.paypal_limits.service_min_amount_sats"
@update:model-value="formData.touch = null"
:label="$t('min_incoming_payment_amount')"
:hint="$t('min_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.paypal_limits.service_max_amount_sats"
@update:model-value="formData.touch = null"
:label="$t('max_incoming_payment_amount')"
:hint="$t('max_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
v-model="formData.paypal_limits.service_faucet_wallet_id"
@update:model-value="formData.touch = null"
:label="$t('faucest_wallet_id')"
:hint="$t('faucest_wallet_id_hint')"
></q-input>
</div>
</div>
<q-item>
<q-item-section>
<q-item-label v-text="$t('faucest_wallet')"></q-item-label>
<q-item-label caption>
<ul>
<li>
<span
v-text="
$t('faucest_wallet_desc_1', {
provider: 'paypal'
})
"
></span>
</li>
<li>
<span
v-text="
$t('faucest_wallet_desc_2', {
provider: 'paypal'
})
"
></span>
</li>
<li>
<span v-text="$t('faucest_wallet_desc_3')"></span>
</li>
<li>
<span
v-text="
$t('faucest_wallet_desc_4', {
provider: 'paypal'
})
"
></span>
</li>
<li>
<span v-text="$t('faucest_wallet_desc_5')"></span>
</li>
</ul>
<br />
</q-item-label>
</q-item-section>
</q-item>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('allowed_users')">
<q-card-section>
<q-input
filled
v-model="formAddPaypalUser"
@keydown.enter="addPaypalAllowedUser"
type="text"
:label="$t('allowed_users_label')"
:hint="
$t('allowed_users_hint_feature', {
feature: 'PayPal'
})
"
>
<q-btn
@click="addPaypalAllowedUser"
dense
flat
icon="add"
></q-btn>
</q-input>
<div>
<q-chip
v-for="user in formData.paypal_limits.allowed_users"
@update:model-value="formData.touch = null"
:key="user"
removable
@remove="removePaypalAllowedUser(user)"
color="primary"
text-color="white"
:label="user"
class="ellipsis"
>
</q-chip>
</div>
</q-card-section>
</q-expansion-item>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item header-class="text-primary text-bold"> <q-expansion-item header-class="text-primary text-bold">
<template v-slot:header> <template v-slot:header>
@ -297,6 +599,68 @@
</q-card> </q-card>
</q-expansion-item> </q-expansion-item>
</q-list> </q-list>
<div
class="q-my-md q-pa-sm text-body2 text-grey-4 bg-grey-9 rounded-borders"
>
<q-icon
name="warning"
color="orange-4"
size="18px"
class="q-mr-xs"
></q-icon>
<span v-text="$t('fiat_warning_bitcoin')"></span>
</div>
<q-card flat bordered class="q-mt-sm q-pa-md text-body2">
<div class="q-gutter-y-sm">
<div class="row items-center q-gutter-sm">
<div class="text-bold" style="min-width: 140px">Stripe</div>
<q-chip dense color="positive" text-color="white" icon="check"
>Checkout</q-chip
>
<q-chip dense color="positive" text-color="white" icon="check"
>Subscriptions</q-chip
>
<q-chip dense color="positive" text-color="white" icon="check">
Tap-to-pay (with LNbits Android TPoS Wrapper APP)
</q-chip>
<q-chip dense color="grey-9" text-color="white" icon="public"
>Regions: Global</q-chip
>
</div>
<div class="row items-center q-gutter-sm">
<div class="text-bold" style="min-width: 140px">PayPal</div>
<q-chip dense color="positive" text-color="white" icon="check"
>Checkout</q-chip
>
<q-chip dense color="positive" text-color="white" icon="check"
>Subscriptions</q-chip
>
<q-chip dense color="negative" text-color="white" icon="close"
>Tap-to-pay</q-chip
>
<q-chip dense color="grey-9" text-color="white" icon="public"
>Regions: Global</q-chip
>
</div>
<div class="row items-center q-gutter-sm">
<div class="text-bold" style="min-width: 140px">
Square (coming soon)
</div>
<q-chip dense color="positive" text-color="white" icon="check"
>Checkout</q-chip
>
<q-chip dense color="positive" text-color="white" icon="check"
>Subscriptions</q-chip
>
<q-chip dense color="negative" text-color="white" icon="close"
>Tap-to-pay</q-chip
>
<q-chip dense color="grey-9" text-color="white" icon="public"
>Regions: Global</q-chip
>
</div>
</div>
</q-card>
</div> </div>
</div> </div>
</template> </template>

View file

@ -347,6 +347,7 @@
</q-item> </q-item>
<q-separator></q-separator> <q-separator></q-separator>
<q-item <q-item
v-if="g.user.fiat_providers?.includes('stripe')"
:active="receive.fiatProvider === 'stripe'" :active="receive.fiatProvider === 'stripe'"
@click="receive.fiatProvider = 'stripe'" @click="receive.fiatProvider = 'stripe'"
active-class="bg-teal-1 text-grey-8 text-weight-bold" active-class="bg-teal-1 text-grey-8 text-weight-bold"
@ -362,6 +363,24 @@
<span v-text="$t('pay_with', {provider: 'Stripe'})"></span> <span v-text="$t('pay_with', {provider: 'Stripe'})"></span>
</q-item-section> </q-item-section>
</q-item> </q-item>
<q-separator
v-if="g.user.fiat_providers?.includes('paypal')"
></q-separator>
<q-item
v-if="g.user.fiat_providers?.includes('paypal')"
:active="receive.fiatProvider === 'paypal'"
@click="receive.fiatProvider = 'paypal'"
active-class="bg-teal-1 text-grey-8 text-weight-bold"
clickable
v-ripple
>
<q-item-section avatar>
<q-avatar color="blue-8" text-color="white">PP</q-avatar>
</q-item-section>
<q-item-section>
<span v-text="$t('pay_with', {provider: 'PayPal'})"></span>
</q-item-section>
</q-item>
</q-list> </q-list>
</div> </div>
@ -399,7 +418,6 @@
> >
<lnbits-qrcode <lnbits-qrcode
v-if="receive.fiatPaymentReq" v-if="receive.fiatPaymentReq"
:show-buttons="false"
:href="receive.fiatPaymentReq" :href="receive.fiatPaymentReq"
:value="receive.fiatPaymentReq" :value="receive.fiatPaymentReq"
> >