Compare commits

...
Author SHA1 Message Date
arcbtc d95babc750 fix: paypal approve 2026-05-08 03:10:04 +01:00
4 changed files with 171 additions and 10 deletions
+31 -1
View File
@@ -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()
+25 -7
View File
@@ -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
+10 -1
View File
@@ -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
+105 -1
View File
@@ -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(