Compare commits

..
Author SHA1 Message Date
dni ⚡andGitHub ce57d08163 chore: update to v1.5.5-rc1 (#3990) 2026-05-24 21:41:05 +02:00
52304e0730 feat: allow extra appending (#3974)
Co-authored-by: Arc <ben@arc.wales>
2026-05-22 14:29:13 +01:00
10 changed files with 207 additions and 64 deletions
+1 -4
View File
@@ -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()
+2
View File
@@ -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",
+5
View File
@@ -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
+3 -7
View File
@@ -20,7 +20,7 @@ from lnbits.decorators import (
check_first_install,
check_user_exists,
)
from lnbits.helpers import check_callback_url, extension_id_from_path, template_renderer
from lnbits.helpers import check_callback_url, template_renderer
from lnbits.settings import settings
from ..crud import get_user
@@ -198,9 +198,7 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
async def index(
request: Request, user: User = Depends(check_user_exists)
) -> HTMLResponse:
return template_renderer(
extension_id=extension_id_from_path(request.url.path)
).TemplateResponse(
return template_renderer().TemplateResponse(
request,
"base.html",
{
@@ -213,9 +211,7 @@ async def index(
@generic_router.get("/node/public")
@generic_router.get("/first_install", dependencies=[Depends(check_first_install)])
async def index_public(request: Request) -> HTMLResponse:
return template_renderer(
extension_id=extension_id_from_path(request.url.path)
).TemplateResponse(request, "base.html", {"public": True})
return template_renderer().TemplateResponse(request, "base.html", {"public": True})
@generic_router.get("/uuidv4/{hex_value}")
+33
View File
@@ -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)
+1 -47
View File
@@ -52,45 +52,7 @@ def static_url_for(static: str, path: str) -> str:
return f"/{static}/{path}?v={settings.server_startup_time}"
def extension_id_from_path(path: str) -> str | None:
parts = [part for part in path.split("/") if part]
if not parts:
return None
if len(parts) >= 3 and parts[0] == "upgrades":
return parts[2]
ext_id = parts[0]
ext_i18n_dir = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "static", "i18n"
)
if ext_i18n_dir.is_dir():
return ext_id
return None
def extension_i18n_urls(extension_id: str | None) -> list[str]:
if not extension_id:
return []
i18n_dir = Path(
settings.lnbits_extensions_path, "extensions", extension_id, "static", "i18n"
)
if not i18n_dir.is_dir():
return []
files = [file.name for file in i18n_dir.glob("*.js") if file.is_file()]
return [
static_url_for(f"{extension_id}/static", f"i18n/{filename}")
for filename in sorted(files, key=lambda name: (name != "en.js", name))
]
def template_renderer(
additional_folders: list | None = None,
extension_id: str | None = None,
) -> Jinja2Templates:
def template_renderer(additional_folders: list | None = None) -> Jinja2Templates:
folders = [
"lnbits/templates",
settings.extension_builder_working_dir_path.as_posix(),
@@ -124,14 +86,6 @@ def template_renderer(
t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
t.env.globals["INCLUDED_COMPONENTS"] = vendor_files["components"]
if not extension_id and additional_folders:
for folder in additional_folders:
parts = Path(folder).parts
if parts and parts[-1] == "templates" and len(parts) >= 2:
extension_id = parts[-2]
break
t.env.globals["INCLUDED_EXTENSION_I18N"] = extension_i18n_urls(extension_id)
# backwards compatibility for extensions (tpos)
t.env.globals["LNBITS_DENOMINATION"] = settings.lnbits_denomination
+1 -3
View File
@@ -90,9 +90,7 @@
window.g.isPublicPage = false
{% endif %}
</script>
{% endif %} {% for url in INCLUDED_EXTENSION_I18N %}
<script src="{{ url }}"></script>
{% endfor %}
{% endif %}
<!-- app init -->
<script>
window.app = Vue.createApp({
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "lnbits"
version = "1.5.4"
version = "1.5.5-rc1"
requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
+159 -1
View File
@@ -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,
*,
Generated
+1 -1
View File
@@ -1275,7 +1275,7 @@ wheels = [
[[package]]
name = "lnbits"
version = "1.5.4"
version = "1.5.5rc1"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },