From 52304e0730de7e62c782c8549675f5c0f8611051 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Fri, 22 May 2026 14:29:13 +0100 Subject: [PATCH] feat: allow extra appending (#3974) Co-authored-by: Arc --- lnbits/core/crud/payments.py | 5 +- lnbits/core/models/__init__.py | 2 + lnbits/core/models/payments.py | 5 + lnbits/core/views/payment_api.py | 33 +++++++ tests/api/test_payment_api.py | 160 ++++++++++++++++++++++++++++++- 5 files changed, 200 insertions(+), 5 deletions(-) diff --git a/lnbits/core/crud/payments.py b/lnbits/core/crud/payments.py index 877a32374..eba8c373a 100644 --- a/lnbits/core/crud/payments.py +++ b/lnbits/core/crud/payments.py @@ -306,7 +306,7 @@ async def update_payment_checking_id( await (conn or db).execute( f""" UPDATE apipayments - SET checking_id = :new_id, updated_at = {db.timestamp_placeholder('now')} + SET checking_id = :new_id, updated_at = {db.timestamp_placeholder("now")} WHERE checking_id = :old_id """, # noqa: S608 { @@ -399,7 +399,6 @@ async def get_payment_count_stats( user_id: str | None = None, conn: Connection | None = None, ) -> list[PaymentCountStat]: - if not filters: filters = Filters() extra_stmts = [] @@ -432,7 +431,6 @@ async def get_daily_stats( user_id: str | None = None, conn: Connection | None = None, ) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]: - if not filters: filters = Filters() @@ -482,7 +480,6 @@ async def get_wallets_stats( user_id: str | None = None, conn: Connection | None = None, ) -> list[PaymentWalletStats]: - if not filters: filters = Filters() diff --git a/lnbits/core/models/__init__.py b/lnbits/core/models/__init__.py index c896b3d54..f845e5486 100644 --- a/lnbits/core/models/__init__.py +++ b/lnbits/core/models/__init__.py @@ -25,6 +25,7 @@ from .payments import ( PaymentState, PaymentWalletStats, SettleInvoice, + UpdatePaymentExtra, ) from .tinyurl import TinyURL from .users import ( @@ -90,6 +91,7 @@ __all__ = [ "SimpleStatus", "TinyURL", "UpdateBalance", + "UpdatePaymentExtra", "UpdateSuperuserPassword", "UpdateUser", "UpdateUserPassword", diff --git a/lnbits/core/models/payments.py b/lnbits/core/models/payments.py index 95abd6f20..b7c723066 100644 --- a/lnbits/core/models/payments.py +++ b/lnbits/core/models/payments.py @@ -35,6 +35,11 @@ class PaymentExtra(BaseModel): lnurl_response: str | None = None +class UpdatePaymentExtra(BaseModel): + payment_hash: str + extra: dict = Field(default_factory=dict) + + class PayInvoice(BaseModel): payment_request: str description: str | None = None diff --git a/lnbits/core/views/payment_api.py b/lnbits/core/views/payment_api.py index b8509c0f1..f9bcb4de2 100644 --- a/lnbits/core/views/payment_api.py +++ b/lnbits/core/views/payment_api.py @@ -34,6 +34,7 @@ from lnbits.core.models import ( PaymentWalletStats, SettleInvoice, SimpleStatus, + UpdatePaymentExtra, ) from lnbits.core.models.payments import UpdatePaymentLabels from lnbits.core.models.users import AccountId @@ -297,6 +298,38 @@ async def api_update_payment_labels( return SimpleStatus(success=True, message="Payment labels updated.") +@payment_router.patch( + "/extra", + name="Update payment extra", + description="Append new extra metadata to a payment.", + response_model=Payment, +) +async def api_update_payment_extra( + data: UpdatePaymentExtra, + key_type: WalletTypeInfo = Depends(require_admin_key), +) -> Payment: + payment = await get_standalone_payment( + data.payment_hash, wallet_id=key_type.wallet.id + ) + if payment is None: + raise HTTPException(HTTPStatus.NOT_FOUND, "Payment does not exist.") + if not payment.success: + raise HTTPException( + HTTPStatus.BAD_REQUEST, "Payment extra can only be updated after success." + ) + + duplicate_keys = sorted(set(payment.extra).intersection(data.extra)) + if duplicate_keys: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + f"Extra keys already exist: {', '.join(duplicate_keys)}.", + ) + + payment.extra.update(data.extra) + await update_payment(payment) + return payment + + @payment_router.get("/fee-reserve") async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse: invoice_obj = bolt11.decode(invoice) diff --git a/tests/api/test_payment_api.py b/tests/api/test_payment_api.py index 76c60bafd..3e0102014 100644 --- a/tests/api/test_payment_api.py +++ b/tests/api/test_payment_api.py @@ -6,7 +6,7 @@ import pytest from fastapi import HTTPException from pydantic import ValidationError -from lnbits.core.crud.payments import create_payment, get_payments +from lnbits.core.crud.payments import create_payment, get_payment, get_payments from lnbits.core.models import Account, CreateInvoice, PaymentFilters, PaymentState from lnbits.core.models.payments import CancelInvoice, CreatePayment, SettleInvoice from lnbits.core.models.users import AccountId @@ -218,6 +218,164 @@ async def test_payment_api_fee_reserve_and_hold_invoice_actions(mocker): cancel_mock.assert_awaited_once() +@pytest.mark.anyio +async def test_payment_extra_update_appends_new_keys( + client, + to_wallet, + adminkey_headers_to, +): + payment_hash = uuid4().hex + checking_id = await _create_payment( + to_wallet.id, + amount_msat=1_000, + payment_hash=payment_hash, + tag="splitpayments", + ) + + response = await client.patch( + "/api/v1/payments/extra", + headers=adminkey_headers_to, + json={ + "payment_hash": payment_hash, + "extra": {"child": "daughter", "compliance_note": "reviewed"}, + }, + ) + + assert response.status_code == 200 + extra = response.json()["extra"] + assert extra["tag"] == "splitpayments" + assert extra["child"] == "daughter" + assert extra["compliance_note"] == "reviewed" + + payment = await get_payment(checking_id) + assert payment.extra == extra + + +@pytest.mark.anyio +async def test_payment_extra_update_creates_extra_when_missing( + client, + to_wallet, + adminkey_headers_to, +): + payment_hash = uuid4().hex + checking_id = await _create_payment( + to_wallet.id, + amount_msat=1_000, + payment_hash=payment_hash, + ) + + response = await client.patch( + "/api/v1/payments/extra", + headers=adminkey_headers_to, + json={"payment_hash": payment_hash, "extra": {"note": "reviewed"}}, + ) + + assert response.status_code == 200 + assert response.json()["extra"] == {"note": "reviewed"} + + payment = await get_payment(checking_id) + assert payment.extra == {"note": "reviewed"} + + +@pytest.mark.anyio +async def test_payment_extra_update_rejects_existing_keys( + client, + to_wallet, + adminkey_headers_to, +): + payment_hash = uuid4().hex + checking_id = await _create_payment( + to_wallet.id, + amount_msat=1_000, + payment_hash=payment_hash, + tag="original", + ) + + response = await client.patch( + "/api/v1/payments/extra", + headers=adminkey_headers_to, + json={"payment_hash": payment_hash, "extra": {"tag": "overwritten"}}, + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Extra keys already exist: tag." + + payment = await get_payment(checking_id) + assert payment.extra == {"tag": "original"} + + +@pytest.mark.anyio +async def test_payment_extra_update_requires_admin_key( + client, + to_wallet, + inkey_headers_to, +): + payment_hash = uuid4().hex + await _create_payment( + to_wallet.id, + amount_msat=1_000, + payment_hash=payment_hash, + ) + + response = await client.patch( + "/api/v1/payments/extra", + headers=inkey_headers_to, + json={"payment_hash": payment_hash, "extra": {"note": "invoice key"}}, + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "Invalid adminkey." + + +@pytest.mark.anyio +async def test_payment_extra_update_is_wallet_scoped( + client, + from_wallet, + adminkey_headers_to, +): + payment_hash = uuid4().hex + await _create_payment( + from_wallet.id, + amount_msat=1_000, + payment_hash=payment_hash, + ) + + response = await client.patch( + "/api/v1/payments/extra", + headers=adminkey_headers_to, + json={"payment_hash": payment_hash, "extra": {"note": "wrong wallet"}}, + ) + + assert response.status_code == 404 + assert response.json()["detail"] == "Payment does not exist." + + +@pytest.mark.anyio +async def test_payment_extra_update_requires_successful_payment( + client, + to_wallet, + adminkey_headers_to, +): + payment_hash = uuid4().hex + await _create_payment( + to_wallet.id, + amount_msat=1_000, + payment_hash=payment_hash, + status=PaymentState.PENDING, + ) + + response = await client.patch( + "/api/v1/payments/extra", + headers=adminkey_headers_to, + json={"payment_hash": payment_hash, "extra": {"note": "too early"}}, + ) + + assert response.status_code == 400 + assert ( + response.json()["detail"] == "Payment extra can only be updated after success." + ) + + async def _create_payment( wallet_id: str, *,