diff --git a/lnbits/core/services/payments.py b/lnbits/core/services/payments.py index 4987dd517..1394a9e6a 100644 --- a/lnbits/core/services/payments.py +++ b/lnbits/core/services/payments.py @@ -65,6 +65,7 @@ async def pay_invoice( tag: str = "", labels: list[str] | None = None, external_id: str | None = None, + webhook: str | None = None, conn: Connection | None = None, ) -> Payment: if settings.lnbits_only_allow_incoming_payments: @@ -99,6 +100,7 @@ async def pay_invoice( extra=extra, labels=labels, external_id=external_id, + webhook=webhook, ) async with db.reuse_conn(conn) if conn else db.connect() as new_conn: diff --git a/lnbits/core/views/payment_api.py b/lnbits/core/views/payment_api.py index 5239c7454..ea4ecccea 100644 --- a/lnbits/core/views/payment_api.py +++ b/lnbits/core/views/payment_api.py @@ -256,6 +256,8 @@ async def api_all_payments_paginated( `amount`, `unit`, and `memo`. To pay an arbitrary invoice from the funds already in the authorized account, specify `out: true` and use the `bolt11` field to supply the BOLT11 invoice to be paid. + The optional `webhook` field is honoured in both directions: LNbits POSTs + the payment to that URL once the payment reaches `success`. """, status_code=HTTPStatus.CREATED, responses={ @@ -281,6 +283,7 @@ async def api_payments_create( extra=invoice_data.extra, labels=invoice_data.labels, external_id=invoice_data.external_id, + webhook=invoice_data.webhook, ) return payment diff --git a/tests/api/test_api.py b/tests/api/test_api.py index 2401c71fe..1408090a5 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -341,6 +341,38 @@ async def test_pay_invoice( # assert payment.payment_hash == invoice["payment_hash"] +# check POST /api/v1/payments: the webhook is kept on an outgoing payment +@pytest.mark.anyio +async def test_pay_invoice_with_webhook( + client, + inkey_headers_to, + adminkey_headers_from, + mocker: MockerFixture, +): + mocker.patch( + "lnbits.core.services.notifications.dispatch_webhook", + AsyncMock(return_value=None), + ) + + invoice_data = await get_random_invoice_data() + create_response = await client.post( + "/api/v1/payments", json=invoice_data, headers=inkey_headers_to + ) + assert create_response.status_code < 300 + + webhook_url = "http://test.404.lnbits.com" + data = { + "out": True, + "bolt11": create_response.json()["bolt11"], + "webhook": webhook_url, + } + response = await client.post( + "/api/v1/payments", json=data, headers=adminkey_headers_from + ) + assert response.status_code < 300 + assert response.json()["webhook"] == webhook_url + + # check GET /api/v1/payments/: payment status @pytest.mark.anyio async def test_check_payment_without_key(client, invoice: Payment): diff --git a/tests/unit/test_pay_invoice.py b/tests/unit/test_pay_invoice.py index 5fdcfdf73..9b1461be1 100644 --- a/tests/unit/test_pay_invoice.py +++ b/tests/unit/test_pay_invoice.py @@ -533,6 +533,91 @@ async def test_pay_external_invoice_success( assert ws_notification.call_count == 1, "Websocket notification sent." +@pytest.mark.anyio +async def test_pay_external_invoice_with_webhook( + from_wallet: Wallet, mocker: MockerFixture, external_funding_source: FakeWallet +): + invoice_amount = 2105 + external_invoice = await external_funding_source.create_invoice(invoice_amount) + assert external_invoice.payment_request + assert external_invoice.checking_id + + preimage = "0000000000000000000000000000000000000000000000000000000000002105" + mocker.patch( + "lnbits.wallets.FakeWallet.pay_invoice", + AsyncMock( + return_value=PaymentResponse( + ok=True, checking_id=external_invoice.checking_id, preimage=preimage + ) + ), + ) + dispatch_webhook_mock = mocker.patch( + "lnbits.core.services.notifications.dispatch_webhook", + AsyncMock(return_value=None), + ) + + webhook_url = "http://test.404.lnbits.com" + payment = await pay_invoice( + wallet_id=from_wallet.id, + payment_request=external_invoice.payment_request, + webhook=webhook_url, + ) + + _payment = await get_standalone_payment(payment.payment_hash) + assert _payment + assert _payment.status == PaymentState.SUCCESS.value + assert _payment.amount == -invoice_amount * 1000 + assert _payment.webhook == webhook_url, "Webhook is stored on the outgoing payment." + + await asyncio.sleep(1) + + assert ( + dispatch_webhook_mock.call_count == 1 + ), "Webhook dispatched for the outgoing payment." + dispatched_payment = dispatch_webhook_mock.call_args_list[0][0][0] + assert dispatched_payment.payment_hash == payment.payment_hash + assert dispatched_payment.amount < 0, "Dispatched payment is the outgoing one." + assert dispatched_payment.webhook == webhook_url + + +@pytest.mark.anyio +async def test_pay_external_invoice_without_webhook_does_not_dispatch( + from_wallet: Wallet, mocker: MockerFixture, external_funding_source: FakeWallet +): + invoice_amount = 2106 + external_invoice = await external_funding_source.create_invoice(invoice_amount) + assert external_invoice.payment_request + assert external_invoice.checking_id + + preimage = "0000000000000000000000000000000000000000000000000000000000002106" + mocker.patch( + "lnbits.wallets.FakeWallet.pay_invoice", + AsyncMock( + return_value=PaymentResponse( + ok=True, checking_id=external_invoice.checking_id, preimage=preimage + ) + ), + ) + dispatch_webhook_mock = mocker.patch( + "lnbits.core.services.notifications.dispatch_webhook", + AsyncMock(return_value=None), + ) + + payment = await pay_invoice( + wallet_id=from_wallet.id, + payment_request=external_invoice.payment_request, + ) + + _payment = await get_standalone_payment(payment.payment_hash) + assert _payment + assert _payment.status == PaymentState.SUCCESS.value + assert _payment.webhook is None + + await asyncio.sleep(1) + + assert dispatch_webhook_mock.call_count == 0, "No webhook, no dispatch." + + @pytest.mark.anyio async def test_retry_pay_success( from_wallet: Wallet, mocker: MockerFixture, external_funding_source: FakeWallet