From 2bdb89b4b9a898d065cd6fbc4727cfde08f98eae Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 20 Feb 2026 10:56:08 +0200 Subject: [PATCH] fix: paypal subscriptions (#3797) --- lnbits/core/views/callback_api.py | 49 +++++++---- lnbits/core/views/fiat_api.py | 12 +++ lnbits/fiat/paypal.py | 81 ++++++++++++++----- .../components/admin/fiat_providers.vue | 1 - tests/api/test_users.py | 2 +- 5 files changed, 111 insertions(+), 34 deletions(-) diff --git a/lnbits/core/views/callback_api.py b/lnbits/core/views/callback_api.py index cca131f8e..75c7d02b4 100644 --- a/lnbits/core/views/callback_api.py +++ b/lnbits/core/views/callback_api.py @@ -24,7 +24,7 @@ callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"]) async def api_generic_webhook_handler( provider_name: str, request: Request ) -> SimpleStatus: - + logger.info(f"Received callback from provider: '{provider_name}'.") if provider_name.lower() == "stripe": payload = await request.body() sig_header = request.headers.get("Stripe-Signature") @@ -181,8 +181,10 @@ async def _get_stripe_subscription_payment_options( async def handle_paypal_event(event: dict): + event_id = event.get("id", "") event_type = event.get("event_type", "") resource = event.get("resource", {}) + logger.info(f"Handling PayPal event: '{event_id}'. Type: '{event_type}'.") if event_type in ("CHECKOUT.ORDER.APPROVED", "PAYMENT.CAPTURE.COMPLETED"): payment_hash = _paypal_extract_payment_hash(resource) @@ -196,20 +198,17 @@ async def handle_paypal_event(event: dict): await check_fiat_status(payment) return - if event_type in ( - "PAYMENT.SALE.COMPLETED", - "BILLING.SUBSCRIPTION.PAYMENT.SUCCEEDED", - ): + if event_type in ("PAYMENT.SALE.COMPLETED"): await _handle_paypal_subscription_payment(resource) return - logger.info(f"Unhandled PayPal event type: '{event_type}'.") + logger.warning(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") + currency = (amount_info.get("currency") or "").upper() + total = amount_info.get("total") if not currency or total is None: raise ValueError("PayPal subscription event missing amount.") @@ -217,18 +216,14 @@ async def _handle_paypal_subscription_payment(resource: dict): 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) + payment_options = _deserialize_paypal_metadata(custom_id) if not payment_options.wallet_id: raise ValueError("PayPal subscription event missing wallet_id.") memo = payment_options.memo or "" extra = { **(payment_options.extra or {}), + "subscription_request_id": resource.get("billing_agreement_id"), "fiat_method": "subscription", "tag": payment_options.tag, "subscription": { @@ -259,3 +254,29 @@ def _paypal_extract_payment_hash(resource: dict) -> str | None: if pu.get("custom_id"): return pu.get("custom_id") return None + + +def _deserialize_paypal_metadata(custom_id: str) -> FiatSubscriptionPaymentOptions: + try: + meta = json.loads(custom_id) + wallet_id = meta[0] if len(meta) > 0 else None + tag = meta[1] if len(meta) > 1 else None + subscription_request_id = meta[2] if len(meta) > 2 else None + extra_link = meta[3] if len(meta) > 3 else None + memo = meta[4] if len(meta) > 4 else None + + extra = { + "link": extra_link, + "subscription_request_id": subscription_request_id, + } + + return FiatSubscriptionPaymentOptions( + wallet_id=wallet_id, + tag=tag, + subscription_request_id=subscription_request_id, + extra=extra, + memo=memo, + ) + except (json.JSONDecodeError, IndexError) as e: + logger.warning(f"Failed to deserialize PayPal metadata: {e}") + return FiatSubscriptionPaymentOptions() diff --git a/lnbits/core/views/fiat_api.py b/lnbits/core/views/fiat_api.py index a6872d804..9a59efe96 100644 --- a/lnbits/core/views/fiat_api.py +++ b/lnbits/core/views/fiat_api.py @@ -1,6 +1,7 @@ from http import HTTPStatus from fastapi import APIRouter, Depends, HTTPException +from loguru import logger from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.wallets import WalletTypeInfo @@ -30,6 +31,11 @@ async def create_subscription( data: CreateFiatSubscription, key_type: WalletTypeInfo = Depends(require_admin_key), ) -> FiatSubscriptionResponse: + logger.debug( + f"Creating subscription with provider '{provider}. '" + f"Subscription ID '{data.subscription_id}'." + ) + fiat_provider = await get_fiat_provider(provider) if not fiat_provider: raise HTTPException(404, "Fiat provider not found") @@ -47,6 +53,12 @@ async def create_subscription( subscription_response = await fiat_provider.create_subscription( data.subscription_id, data.quantity, data.payment_options ) + if subscription_response.error_message: + logger.warning( + f"Failed to create subscription with provider '{provider}': " + f"{subscription_response.error_message}" + ) + return subscription_response diff --git a/lnbits/fiat/paypal.py b/lnbits/fiat/paypal.py index 07f0fb127..33d054cef 100644 --- a/lnbits/fiat/paypal.py +++ b/lnbits/fiat/paypal.py @@ -2,7 +2,7 @@ import asyncio import json import time from collections.abc import AsyncGenerator -from typing import Any +from typing import Any, Literal import httpx from loguru import logger @@ -24,6 +24,8 @@ from .base import ( FiatSubscriptionResponse, ) +FiatMethod = Literal["checkout", "subscription"] + class PayPalCheckoutOptions(BaseModel): class Config: @@ -34,11 +36,21 @@ class PayPalCheckoutOptions(BaseModel): metadata: dict[str, Any] = Field(default_factory=dict) +class PayPalSubscriptionOptions(BaseModel): + class Config: + extra = "ignore" + + checking_id: str | None = None + payment_request: str | None = None + + class PayPalCreateInvoiceOptions(BaseModel): class Config: extra = "ignore" + fiat_method: FiatMethod = "checkout" checkout: PayPalCheckoutOptions | None = None + subscription: PayPalSubscriptionOptions | None = None class PayPalWallet(FiatProvider): @@ -96,6 +108,14 @@ class PayPalWallet(FiatProvider): opts = self._parse_create_opts(extra or {}) if opts is None: return FiatInvoiceResponse(ok=False, error_message="Invalid PayPal options") + if opts.fiat_method == "subscription": + term = opts.subscription or PayPalSubscriptionOptions() + checking_id = term.checking_id or urlsafe_short_hash() + return FiatInvoiceResponse( + ok=True, + checking_id=f"subscription_{checking_id}", + payment_request=term.payment_request or "", + ) try: await self._ensure_access_token() @@ -171,6 +191,7 @@ class PayPalWallet(FiatProvider): or "https://lnbits.com" ) + logger.debug(f"Creating PayPal subscription with ID '{subscription_id}'") if not payment_options.subscription_request_id: payment_options.subscription_request_id = urlsafe_short_hash() payment_options.extra = payment_options.extra or {} @@ -182,7 +203,6 @@ class PayPalWallet(FiatProvider): 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, @@ -205,7 +225,7 @@ class PayPalWallet(FiatProvider): return FiatSubscriptionResponse( ok=True, checkout_session_url=approval_url, - subscription_request_id=payment_options.subscription_request_id, + subscription_request_id=data.get("id"), ) except Exception as exc: logger.warning(exc) @@ -219,6 +239,10 @@ class PayPalWallet(FiatProvider): correlation_id: str, **kwargs, ) -> FiatSubscriptionResponse: + logger.debug( + f"Cancelling PayPal subscription '{subscription_id}'. " + f"Correlation ID '{correlation_id}'." + ) try: await self._ensure_access_token() r = await self.client.post( @@ -240,12 +264,20 @@ class PayPalWallet(FiatProvider): 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()) + paypal_id = self._normalize_paypal_id(checking_id) + if paypal_id.startswith("subscription_"): + capture_id = paypal_id.replace("subscription_", "", 1) + r = await self.client.get( + f"v2/payments/captures/{capture_id}", headers=self._auth_headers() + ) + r.raise_for_status() + return self._status_from_capture(r.json()) + else: + r = await self.client.get( + f"/v2/checkout/orders/{paypal_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() @@ -270,6 +302,14 @@ class PayPalWallet(FiatProvider): return FiatPaymentFailedStatus() return FiatPaymentPendingStatus() + def _status_from_capture(self, order: dict[str, Any]) -> FiatPaymentStatus: + status = (order.get("status") or "").upper() + if status in ["COMPLETED"]: + return FiatPaymentSuccessStatus() + if status in ["DECLINED", "FAILED", "CANCELLED", "CANCELED"]: + return FiatPaymentFailedStatus() + return FiatPaymentPendingStatus() + def _normalize_paypal_id(self, checking_id: str) -> str: return ( checking_id.replace("fiat_paypal_", "", 1) @@ -280,15 +320,20 @@ class PayPalWallet(FiatProvider): 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 + meta = [ + payment_options.wallet_id, + payment_options.tag, + payment_options.subscription_request_id, + ] + if payment_options.extra: + meta.append(payment_options.extra.get("link", None)) + + # Keep custom_id within PayPal's 127-char limit + memo_limit = 120 - len(json.dumps(meta)) + if memo_limit > 0 and payment_options.memo: + meta.append(payment_options.memo[:memo_limit]) + + return json.dumps(meta) def _parse_create_opts( self, raw_opts: dict[str, Any] diff --git a/lnbits/templates/components/admin/fiat_providers.vue b/lnbits/templates/components/admin/fiat_providers.vue index aea83880c..bc38d7bea 100644 --- a/lnbits/templates/components/admin/fiat_providers.vue +++ b/lnbits/templates/components/admin/fiat_providers.vue @@ -408,7 +408,6 @@
  • CHECKOUT.ORDER.APPROVED
  • PAYMENT.CAPTURE.COMPLETED
  • PAYMENT.SALE.COMPLETED
  • -
  • BILLING.SUBSCRIPTION.PAYMENT.SUCCEEDED
  • diff --git a/tests/api/test_users.py b/tests/api/test_users.py index 7131a38e0..891351287 100644 --- a/tests/api/test_users.py +++ b/tests/api/test_users.py @@ -708,7 +708,7 @@ async def test_user_activation( f"/users/api/v1/user/{user_id}/activate", headers={"Authorization": f"Bearer {superuser_token}"}, ) - print("### response", response.text) + assert response.status_code == 200 assert response.json().get("message") == "User activated."