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 hmac
import json
import time
import httpx
from loguru import logger
from lnbits.core.crud import get_wallet
@ -70,6 +72,63 @@ def check_stripe_signature(
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:
"""
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.services.fiat_providers import (
check_stripe_signature,
verify_paypal_webhook,
)
from lnbits.core.services.payments import create_fiat_invoice
from lnbits.fiat.base import FiatSubscriptionPaymentOptions
@ -37,6 +38,17 @@ async def api_generic_webhook_handler(
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(
success=False,
message=f"Unknown fiat provider '{provider_name}'.",
@ -165,3 +177,84 @@ async def _get_stripe_subscription_payment_options(
metadata["extra"] = {}
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,7 +75,15 @@ async def cancel_subscription(
)
async def connection_token(provider: str):
fiat_provider = await get_fiat_provider(provider)
if provider == "stripe":
if not fiat_provider:
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"

View file

@ -8,6 +8,7 @@ from loguru import logger
from lnbits.fiat.base import FiatProvider
from lnbits.settings import settings
from .paypal import PayPalWallet
from .stripe import StripeWallet
fiat_module = importlib.import_module("lnbits.fiat")
@ -15,6 +16,7 @@ fiat_module = importlib.import_module("lnbits.fiat")
class FiatProviderType(Enum):
stripe = "StripeWallet"
paypal = "PayPalWallet"
async def get_fiat_provider(name: str) -> FiatProvider | None:
@ -49,5 +51,6 @@ fiat_providers: dict[str, FiatProvider] = {}
__all__ = [
"PayPalWallet",
"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)
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):
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)
class FiatProvidersSettings(StripeFiatProvider):
class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider):
def is_fiat_provider_enabled(self, provider: str | None) -> bool:
"""
Checks if a specific fiat provider is enabled.
@ -686,7 +700,8 @@ class FiatProvidersSettings(StripeFiatProvider):
return False
if provider == "stripe":
return self.stripe_enabled
# Add checks for other fiat providers here as needed
if provider == "paypal":
return self.paypal_enabled
return False
def get_fiat_providers_for_user(self, user_id: str) -> list[str]:
@ -700,7 +715,12 @@ class FiatProvidersSettings(StripeFiatProvider):
):
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
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',
fiat_tracking: 'Fiat tracking',
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',
update_currency: 'Update currency',
press_to_claim: 'Press to claim bitcoin',
@ -237,6 +239,7 @@ window.localisation.en = {
webhook_url: 'Webhook URL',
webhook_url_hint:
'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_stripe_description:
'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',
endpoint: 'Endpoint',
api: 'API',
api_stripe:
'API (warning: Stripe are twitchy about anything bitcoin, so avoid using word "bitcoin" in your memos!)',
api_stripe: 'API',
api_token: 'API Token',
api_tokens: 'API Tokens',
access_control_list: 'Access Control List',
@ -706,10 +708,15 @@ window.localisation.en = {
filter_labels: 'Filter labels',
filter_date: 'Filter by date',
websocket_example: 'Websocket example',
client_id: 'Client ID',
secret_key: 'Secret Key',
signing_secret: 'Signing Secret',
signing_secret_hint:
'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_hint:
'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_date: 'Suodata päiväyksellä',
websocket_example: 'Websocket example',
client_id: 'Client ID',
secret_key: 'Secret Key',
signing_secret: 'Signing Secret',
signing_secret_hint:
'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_hint:
'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() {
return {
formAddStripeUser: '',
formAddPaypalUser: '',
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: {
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() {
const addUser = this.formAddStripeUser || ''
if (
@ -26,6 +95,23 @@ window.app.component('lnbits-admin-fiat-providers', {
this.formData.stripe_limits.allowed_users =
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) {
LNbits.api
.request('PUT', `/api/v1/fiat/check/${providerName}`)

View file

@ -79,15 +79,32 @@
<span v-text="$t('webhook_stripe_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
class="q-mt-md"
type="text"
disable
v-model="formData.stripe_payment_webhook_url"
:model-value="stripeWebhookUrl"
: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(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
filled
class="q-mt-md"
@ -275,7 +292,292 @@
</q-card>
</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">
<template v-slot:header>
@ -297,6 +599,68 @@
</q-card>
</q-expansion-item>
</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>
</template>

View file

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