feat: Adds paypal as a fiat choice (#3637)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
This commit is contained in:
parent
661b713993
commit
baee90da67
13 changed files with 1034 additions and 33 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
338
lnbits/fiat/paypal.py
Normal 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),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
2
lnbits/static/bundle-components.min.js
vendored
2
lnbits/static/bundle-components.min.js
vendored
File diff suppressed because one or more lines are too long
2
lnbits/static/bundle.min.js
vendored
2
lnbits/static/bundle.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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}`)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue