Compare commits
1
Commits
dev
...
paypal_fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d95babc750 |
@@ -14,6 +14,8 @@ from lnbits.core.services.fiat_providers import (
|
|||||||
verify_paypal_webhook,
|
verify_paypal_webhook,
|
||||||
)
|
)
|
||||||
from lnbits.core.services.payments import create_fiat_invoice
|
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.fiat.base import FiatSubscriptionPaymentOptions
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
@@ -186,7 +188,11 @@ async def handle_paypal_event(event: dict):
|
|||||||
resource = event.get("resource", {})
|
resource = event.get("resource", {})
|
||||||
logger.info(f"Handling PayPal event: '{event_id}'. Type: '{event_type}'.")
|
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)
|
payment_hash = _paypal_extract_payment_hash(resource)
|
||||||
if not payment_hash:
|
if not payment_hash:
|
||||||
logger.warning("PayPal event missing 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}'.")
|
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):
|
async def _handle_paypal_subscription_payment(resource: dict):
|
||||||
amount_info = resource.get("amount") or {}
|
amount_info = resource.get("amount") or {}
|
||||||
currency = (amount_info.get("currency") or "").upper()
|
currency = (amount_info.get("currency") or "").upper()
|
||||||
|
|||||||
+25
-7
@@ -169,7 +169,7 @@ class PayPalWallet(FiatProvider):
|
|||||||
|
|
||||||
return FiatInvoiceResponse(
|
return FiatInvoiceResponse(
|
||||||
ok=True,
|
ok=True,
|
||||||
checking_id=f"fiat_paypal_{order_id}",
|
checking_id=order_id,
|
||||||
payment_request=approval_url,
|
payment_request=approval_url,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -285,6 +285,25 @@ class PayPalWallet(FiatProvider):
|
|||||||
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
|
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
|
||||||
raise NotImplementedError("PayPal does not support outgoing payments.")
|
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]:
|
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"PayPal does not support paid invoices stream. Use webhooks instead."
|
"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:
|
def _status_from_order(self, order: dict[str, Any]) -> FiatPaymentStatus:
|
||||||
status = (order.get("status") or "").upper()
|
status = (order.get("status") or "").upper()
|
||||||
if status in ["COMPLETED", "APPROVED"]:
|
if status == "COMPLETED":
|
||||||
return FiatPaymentSuccessStatus()
|
return FiatPaymentSuccessStatus()
|
||||||
if status in ["VOIDED", "CANCELLED", "CANCELED"]:
|
if status in ["VOIDED", "CANCELLED", "CANCELED"]:
|
||||||
return FiatPaymentFailedStatus()
|
return FiatPaymentFailedStatus()
|
||||||
@@ -311,11 +330,10 @@ class PayPalWallet(FiatProvider):
|
|||||||
return FiatPaymentPendingStatus()
|
return FiatPaymentPendingStatus()
|
||||||
|
|
||||||
def _normalize_paypal_id(self, checking_id: str) -> str:
|
def _normalize_paypal_id(self, checking_id: str) -> str:
|
||||||
return (
|
normalized = checking_id
|
||||||
checking_id.replace("fiat_paypal_", "", 1)
|
while normalized.startswith("fiat_paypal_"):
|
||||||
if checking_id.startswith("fiat_paypal_")
|
normalized = normalized.replace("fiat_paypal_", "", 1)
|
||||||
else checking_id
|
return normalized
|
||||||
)
|
|
||||||
|
|
||||||
def _serialize_metadata(
|
def _serialize_metadata(
|
||||||
self, payment_options: FiatSubscriptionPaymentOptions
|
self, payment_options: FiatSubscriptionPaymentOptions
|
||||||
|
|||||||
@@ -82,13 +82,22 @@ async def test_callback_api_handles_paid_events_with_real_payments(mocker):
|
|||||||
)
|
)
|
||||||
await handle_paypal_event(
|
await handle_paypal_event(
|
||||||
{
|
{
|
||||||
"id": "evt_paypal",
|
"id": "evt_paypal_approved",
|
||||||
"event_type": "CHECKOUT.ORDER.APPROVED",
|
"event_type": "CHECKOUT.ORDER.APPROVED",
|
||||||
"resource": {
|
"resource": {
|
||||||
"purchase_units": [{"invoice_id": payment.payment_hash}],
|
"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"})
|
await handle_stripe_event({"id": "evt_unhandled", "type": "customer.created"})
|
||||||
|
|
||||||
assert fiat_status_mock.await_count == 2
|
assert fiat_status_mock.await_count == 2
|
||||||
|
|||||||
@@ -23,7 +23,14 @@ from lnbits.core.services.fiat_providers import (
|
|||||||
test_connection as fiat_provider_connection,
|
test_connection as fiat_provider_connection,
|
||||||
)
|
)
|
||||||
from lnbits.core.services.users import create_user_account
|
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 lnbits.settings import Settings
|
||||||
from tests.helpers import get_random_string
|
from tests.helpers import get_random_string
|
||||||
|
|
||||||
@@ -280,6 +287,42 @@ async def test_create_wallet_fiat_invoice_success(
|
|||||||
assert status.success is True
|
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
|
@pytest.mark.anyio
|
||||||
async def test_fiat_service_fee(settings: Settings):
|
async def test_fiat_service_fee(settings: Settings):
|
||||||
# settings.stripe_limits.service_min_amount_sats = 0
|
# 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
|
@pytest.mark.anyio
|
||||||
async def test_test_connection_reports_provider_status(mocker: MockerFixture):
|
async def test_test_connection_reports_provider_status(mocker: MockerFixture):
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
|
|||||||
Reference in New Issue
Block a user