diff --git a/lnbits/core/views/callback_api.py b/lnbits/core/views/callback_api.py index 75c7d02b4..4b483c5c6 100644 --- a/lnbits/core/views/callback_api.py +++ b/lnbits/core/views/callback_api.py @@ -14,6 +14,8 @@ from lnbits.core.services.fiat_providers import ( verify_paypal_webhook, ) from lnbits.core.services.payments import create_fiat_invoice +from lnbits.fiat import get_fiat_provider +from lnbits.fiat.paypal import PayPalWallet from lnbits.fiat.base import FiatSubscriptionPaymentOptions from lnbits.settings import settings @@ -186,7 +188,11 @@ async def handle_paypal_event(event: dict): 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"): + if event_type == "CHECKOUT.ORDER.APPROVED": + await _handle_paypal_checkout_order_approved(resource) + return + + if event_type == "PAYMENT.CAPTURE.COMPLETED": payment_hash = _paypal_extract_payment_hash(resource) if not payment_hash: logger.warning("PayPal event missing payment hash.") @@ -205,6 +211,30 @@ async def handle_paypal_event(event: dict): logger.warning(f"Unhandled PayPal event type: '{event_type}'.") +async def _handle_paypal_checkout_order_approved(resource: dict): + payment_hash = _paypal_extract_payment_hash(resource) + if not payment_hash: + logger.warning("PayPal approved 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 + + fiat_provider = await get_fiat_provider("paypal") + if not isinstance(fiat_provider, PayPalWallet): + logger.warning("PayPal provider unavailable for approved order capture.") + return + + capture_status = await fiat_provider.capture_order( + payment.extra.get("fiat_checking_id") or payment.checking_id + ) + if capture_status.failed: + logger.warning(f"PayPal order capture failed for hash: '{payment_hash}'.") + return + + async def _handle_paypal_subscription_payment(resource: dict): amount_info = resource.get("amount") or {} currency = (amount_info.get("currency") or "").upper() diff --git a/lnbits/fiat/paypal.py b/lnbits/fiat/paypal.py index 33d054cef..838e51607 100644 --- a/lnbits/fiat/paypal.py +++ b/lnbits/fiat/paypal.py @@ -169,7 +169,7 @@ class PayPalWallet(FiatProvider): return FiatInvoiceResponse( ok=True, - checking_id=f"fiat_paypal_{order_id}", + checking_id=order_id, payment_request=approval_url, ) except Exception as exc: @@ -285,6 +285,25 @@ class PayPalWallet(FiatProvider): async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus: raise NotImplementedError("PayPal does not support outgoing payments.") + async def capture_order(self, checking_id: str) -> FiatPaymentStatus: + try: + await self._ensure_access_token() + paypal_id = self._normalize_paypal_id(checking_id) + if paypal_id.startswith("subscription_"): + logger.warning("PayPal subscriptions do not support order capture.") + return FiatPaymentPendingStatus() + + r = await self.client.post( + f"/v2/checkout/orders/{paypal_id}/capture", + json={}, + headers=self._auth_headers(), + ) + r.raise_for_status() + return self._status_from_order(r.json()) + except Exception as exc: + logger.warning(f"Error capturing PayPal order '{checking_id}': {exc}") + return await self.get_invoice_status(checking_id) + async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: logger.warning( "PayPal does not support paid invoices stream. Use webhooks instead." @@ -296,7 +315,7 @@ class PayPalWallet(FiatProvider): def _status_from_order(self, order: dict[str, Any]) -> FiatPaymentStatus: status = (order.get("status") or "").upper() - if status in ["COMPLETED", "APPROVED"]: + if status == "COMPLETED": return FiatPaymentSuccessStatus() if status in ["VOIDED", "CANCELLED", "CANCELED"]: return FiatPaymentFailedStatus() @@ -311,11 +330,10 @@ class PayPalWallet(FiatProvider): 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 - ) + normalized = checking_id + while normalized.startswith("fiat_paypal_"): + normalized = normalized.replace("fiat_paypal_", "", 1) + return normalized def _serialize_metadata( self, payment_options: FiatSubscriptionPaymentOptions diff --git a/tests/api/test_callback_api.py b/tests/api/test_callback_api.py index afe5227c9..96cbe818f 100644 --- a/tests/api/test_callback_api.py +++ b/tests/api/test_callback_api.py @@ -82,13 +82,22 @@ async def test_callback_api_handles_paid_events_with_real_payments(mocker): ) await handle_paypal_event( { - "id": "evt_paypal", + "id": "evt_paypal_approved", "event_type": "CHECKOUT.ORDER.APPROVED", "resource": { "purchase_units": [{"invoice_id": payment.payment_hash}], }, } ) + await handle_paypal_event( + { + "id": "evt_paypal", + "event_type": "PAYMENT.CAPTURE.COMPLETED", + "resource": { + "purchase_units": [{"invoice_id": payment.payment_hash}], + }, + } + ) await handle_stripe_event({"id": "evt_unhandled", "type": "customer.created"}) assert fiat_status_mock.await_count == 2 diff --git a/tests/unit/test_fiat_providers.py b/tests/unit/test_fiat_providers.py index c29eab8aa..3f8e78988 100644 --- a/tests/unit/test_fiat_providers.py +++ b/tests/unit/test_fiat_providers.py @@ -23,7 +23,14 @@ from lnbits.core.services.fiat_providers import ( test_connection as fiat_provider_connection, ) from lnbits.core.services.users import create_user_account -from lnbits.fiat.base import FiatInvoiceResponse, FiatPaymentStatus, FiatStatusResponse +from lnbits.core.views.callback_api import handle_paypal_event +from lnbits.fiat.paypal import PayPalWallet +from lnbits.fiat.base import ( + FiatInvoiceResponse, + FiatPaymentStatus, + FiatPaymentSuccessStatus, + FiatStatusResponse, +) from lnbits.settings import Settings from tests.helpers import get_random_string @@ -280,6 +287,42 @@ async def test_create_wallet_fiat_invoice_success( assert status.success is True +@pytest.mark.anyio +async def test_create_paypal_fiat_invoice_uses_raw_order_id( + to_wallet: Wallet, settings: Settings, mocker: MockerFixture +): + settings.paypal_enabled = True + settings.paypal_client_id = "client-id" + settings.paypal_client_secret = "client-secret" + settings.paypal_limits.service_min_amount_sats = 0 + settings.paypal_limits.service_max_amount_sats = 0 + settings.paypal_limits.service_faucet_wallet_id = None + + invoice_data = CreateInvoice( + unit="USD", amount=1.0, memo="Test", fiat_provider="paypal" + ) + fiat_mock_response = FiatInvoiceResponse( + ok=True, + checking_id="ORDER123", + payment_request="https://paypal.com/checkoutnow?token=ORDER123", + ) + + mocker.patch( + "lnbits.fiat.PayPalWallet.create_invoice", + AsyncMock(return_value=fiat_mock_response), + ) + mocker.patch( + "lnbits.utils.exchange_rates.get_fiat_rate_satoshis", + AsyncMock(return_value=1000), + ) + + payment = await payments.create_fiat_invoice(to_wallet.id, invoice_data) + + assert payment.fiat_provider == "paypal" + assert payment.extra.get("fiat_checking_id") == "ORDER123" + assert payment.checking_id == "fiat_paypal_ORDER123" + + @pytest.mark.anyio async def test_fiat_service_fee(settings: Settings): # settings.stripe_limits.service_min_amount_sats = 0 @@ -637,6 +680,67 @@ async def test_verify_paypal_webhook_raises_on_failed_verification( ) +def test_paypal_order_status_approved_is_pending(): + wallet = object.__new__(PayPalWallet) + + approved = wallet._status_from_order({"status": "APPROVED"}) + completed = wallet._status_from_order({"status": "COMPLETED"}) + + assert approved.pending is True + assert approved.success is False + assert completed.success is True + + +def test_paypal_normalize_id_removes_legacy_double_prefix(): + wallet = object.__new__(PayPalWallet) + + assert wallet._normalize_paypal_id("fiat_paypal_ORDER123") == "ORDER123" + assert wallet._normalize_paypal_id("fiat_paypal_fiat_paypal_ORDER123") == "ORDER123" + + +@pytest.mark.anyio +async def test_handle_paypal_approved_event_captures_order(mocker: MockerFixture): + payment = Payment( + checking_id="fiat_paypal_ORDER123", + payment_hash="hash_123", + wallet_id="wallet_id", + amount=1000, + fee=0, + bolt11="bolt11", + status=PaymentState.PENDING, + fiat_provider="paypal", + extra={"fiat_checking_id": "ORDER123"}, + ) + provider = mocker.Mock(spec=PayPalWallet) + provider.capture_order = AsyncMock(return_value=FiatPaymentSuccessStatus()) + + mocker.patch( + "lnbits.core.views.callback_api.get_standalone_payment", + AsyncMock(return_value=payment), + ) + mocker.patch( + "lnbits.core.views.callback_api.get_fiat_provider", + AsyncMock(return_value=provider), + ) + status_mock = mocker.patch( + "lnbits.core.views.callback_api.check_fiat_status", + AsyncMock(), + ) + + await handle_paypal_event( + { + "id": "evt_paypal_approved", + "event_type": "CHECKOUT.ORDER.APPROVED", + "resource": { + "purchase_units": [{"invoice_id": payment.payment_hash}], + }, + } + ) + + provider.capture_order.assert_awaited_once_with("ORDER123") + status_mock.assert_not_awaited() + + @pytest.mark.anyio async def test_test_connection_reports_provider_status(mocker: MockerFixture): mocker.patch(