diff --git a/lnbits/core/views/callback_api.py b/lnbits/core/views/callback_api.py index 045b3fd9e..da8652659 100644 --- a/lnbits/core/views/callback_api.py +++ b/lnbits/core/views/callback_api.py @@ -359,6 +359,7 @@ async def handle_revolut_event(event: dict): payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}") if not payment: logger.warning(f"No payment found for Revolut order: '{order_id}'.") + await _handle_revolut_subscription_order_paid(order_id) return await check_fiat_status(payment) @@ -379,18 +380,42 @@ async def handle_revolut_event(event: dict): logger.warning(f"Unhandled Revolut event type: '{event_type}'.") -async def _handle_revolut_subscription_initiated(event: dict): +async def _handle_revolut_subscription_initiated( + event: dict, subscription: dict | None = None +): subscription_id = event.get("subscription_id") + if not subscription_id and subscription: + subscription_id = subscription.get("id") + if not subscription_id: logger.warning("Revolut subscription event missing subscription_id.") return + fiat_provider = await _get_revolut_provider() + if not fiat_provider: + return + + if subscription is None: + subscription = await fiat_provider.get_subscription(subscription_id) + await _handle_revolut_subscription(subscription, fiat_provider) + + +async def _get_revolut_provider() -> RevolutWallet | None: fiat_provider = await get_fiat_provider("revolut") if not isinstance(fiat_provider, RevolutWallet): logger.warning("Revolut fiat provider is not configured.") + return None + return fiat_provider + + +async def _handle_revolut_subscription( + subscription: dict, fiat_provider: RevolutWallet +): + subscription_id = subscription.get("id") + if not subscription_id: + logger.warning("Revolut subscription missing id.") return - subscription = await fiat_provider.get_subscription(subscription_id) reference = fiat_provider.deserialize_subscription_reference( subscription.get("external_reference") ) @@ -447,6 +472,32 @@ async def _handle_revolut_subscription_initiated(event: dict): await check_fiat_status(lnbits_payment) +async def _handle_revolut_subscription_order_paid(order_id: str): + fiat_provider = await _get_revolut_provider() + if not fiat_provider: + return + + order = await fiat_provider.get_order(order_id) + if order.get("type") != "payment" or order.get("state") != "completed": + logger.warning(f"Revolut order is not a completed payment: '{order_id}'.") + return + + channel_data = order.get("channel_data") or {} + subscription_id = channel_data.get("subscription_id") + if not subscription_id: + logger.warning(f"Revolut order missing subscription_id: '{order_id}'.") + return + + subscription = await fiat_provider.get_subscription(subscription_id) + if subscription.get("state") != "active": + logger.warning(f"Revolut subscription is not active: '{subscription_id}'.") + return + + await _handle_revolut_subscription_initiated( + {"subscription_id": subscription_id}, subscription + ) + + async def _create_revolut_subscription_payment( wallet_id: str, amount_minor: int, diff --git a/tests/api/test_callback_api.py b/tests/api/test_callback_api.py index cfca4cc0f..1b0f6a28c 100644 --- a/tests/api/test_callback_api.py +++ b/tests/api/test_callback_api.py @@ -272,6 +272,108 @@ async def test_callback_api_handles_revolut_subscription_event( fiat_status_mock.assert_awaited_once_with(payment) +@pytest.mark.anyio +async def test_callback_api_handles_revolut_subscription_order_event( + mocker, settings: Settings +): + wallet_id = "wallet_1" + payment = mocker.Mock() + payment.extra = {} + payment.msat = 925_000 + + settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com" + settings.revolut_api_secret_key = "revolut-secret" + settings.revolut_api_version = "2026-04-20" + revolut_provider = RevolutWallet() + subscription = { + "id": "SUBSCRIPTION_1", + "state": "active", + "current_cycle_id": "CYCLE_1", + "external_reference": json.dumps( + { + "wallet_id": wallet_id, + "tag": "members", + "subscription_request_id": "request_1", + "extra": {"link": "link-1"}, + "memo": "Revolut Members", + } + ), + } + order = { + "id": "ORDER_SUB_1", + "type": "payment", + "state": "completed", + "amount": 925, + "currency": "USD", + "checkout_url": "https://checkout.revolut.com/payment-link/sub_1", + "channel_data": { + "subscription_id": "SUBSCRIPTION_1", + "subscription_cycle_id": "CYCLE_1", + }, + } + get_order_mock = mocker.patch.object( + revolut_provider, "get_order", side_effect=[order, order] + ) + get_subscription_mock = mocker.patch.object( + revolut_provider, + "get_subscription", + return_value=subscription, + ) + mocker.patch.object( + revolut_provider, + "get_subscription_cycle", + return_value={"id": "CYCLE_1", "order_id": "ORDER_SUB_1"}, + ) + mocker.patch( + "lnbits.core.views.callback_api.get_fiat_provider", + mocker.AsyncMock(return_value=revolut_provider), + ) + get_payment_mock = mocker.patch( + "lnbits.core.views.callback_api.get_standalone_payment", + mocker.AsyncMock(side_effect=[None, None]), + ) + create_wallet_invoice_mock = mocker.patch( + "lnbits.core.views.callback_api.create_wallet_invoice", + mocker.AsyncMock(return_value=payment), + ) + mocker.patch("lnbits.core.views.callback_api.service_fee_fiat", return_value=2) + update_payment_mock = mocker.patch( + "lnbits.core.views.callback_api.update_payment", mocker.AsyncMock() + ) + fiat_status_mock = mocker.patch( + "lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock() + ) + + await handle_revolut_event( + { + "event": "ORDER_COMPLETED", + "order_id": "ORDER_SUB_1", + } + ) + + assert get_payment_mock.await_count == 2 + get_payment_mock.assert_any_await("fiat_revolut_order_ORDER_SUB_1") + assert get_order_mock.await_count == 2 + get_subscription_mock.assert_awaited_once_with("SUBSCRIPTION_1") + assert create_wallet_invoice_mock.await_count == 1 + called_wallet_id, invoice = create_wallet_invoice_mock.await_args.args + assert called_wallet_id == "wallet_1" + assert invoice.amount == 9.25 + assert invoice.memo == "Revolut Members" + assert invoice.external_id == "SUBSCRIPTION_1" + assert invoice.internal is True + assert invoice.extra["fiat_method"] == "subscription" + assert invoice.extra["subscription"]["checking_id"] == "order_ORDER_SUB_1" + assert payment.fiat_provider == "revolut" + assert payment.fee == -2 + assert payment.extra["fiat_checking_id"] == "order_ORDER_SUB_1" + assert payment.checking_id == "fiat_revolut_order_ORDER_SUB_1" + update_payment_mock.assert_awaited_once_with( + payment, "fiat_revolut_order_ORDER_SUB_1" + ) + fiat_status_mock.assert_awaited_once_with(payment) + + @pytest.mark.anyio async def test_callback_api_handles_subscription_flows_and_validation( mocker, settings: Settings