Compare commits

..
Author SHA1 Message Date
arcbtc cddd000e3e feat: for QRs that are not case sensitive make uppercase 2026-02-18 16:17:01 +00:00
dni ⚡andGitHub c65d373f23 feat: restructure docker images, add sparkl2 docker image (#3794) 2026-02-18 13:52:19 +01:00
dni ⚡andGitHub 087e095bcf feat: hide first_install_token if is not required (#3795) 2026-02-18 13:52:02 +01:00
dni ⚡andGitHub 027a161494 chore: update lnbits to version v1.5.0-rc1 (#3793) 2026-02-17 12:26:50 +01:00
83ef05696e feat: Spark L2 (#3715)
Co-authored-by: blackcoffeexbt <87530449+blackcoffeexbt@users.noreply.github.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-02-17 12:25:38 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
42962308e6 chore(deps): bump pillow from 12.1.0 to 12.1.1 (#3785)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-17 11:33:28 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Vlad Stan
783ee256a1 chore(deps): bump axios from 1.13.2 to 1.13.5 (#3786)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-02-17 11:32:58 +01:00
4e805fd00e feat: modern theme stuff like rounded corners and card gradient (#3766)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-02-16 08:06:36 +01:00
dni ⚡andGitHub f54824cc44 fix: /first_install new superuser may duplicate an username (#3787) 2026-02-13 14:44:52 +00:00
ArcandGitHub 768d2cbe2a fix: fail closed on PayPal webhook verification errors (#3769) 2026-02-10 08:50:50 +01:00
krviandGitHub efd36d0221 fix: use canonical values in rate limit unit select (#3753) 2026-02-09 10:43:03 +02:00
Vlad StanandGitHub 4684939e13 fix: validation of invitation code (#3767) 2026-02-05 18:53:01 +02:00
Vlad StanandGitHub 193ee20da4 [feat] User invitation code (#3761) 2026-02-05 12:35:24 +02:00
Vlad StanandGitHub 2fe7ab4f83 [feat] Allow no exchange providers (#3763) 2026-02-05 11:11:48 +01:00
dni ⚡andGitHub 10c8ca4ca1 fix: datetime in db.py added utc offset (#3762) 2026-02-05 11:05:58 +01:00
Vlad StanandGitHub f079762b71 fix: handle validation errors when the response is binary (#3765) 2026-02-05 11:55:40 +02:00
Vlad StanandGitHub 2e042d2597 fix: extension paid/free label (#3764) 2026-02-05 11:32:46 +02:00
Vlad StanandGitHub 49b57c9f0b [feat] User activation (#3749) 2026-02-05 11:31:47 +02:00
dni ⚡andGitHub 656c6cac5b hotfix: websocket with old balance was sent. (#3759) 2026-02-03 12:23:49 +01:00
45e773b66f feat: FIRST_INSTALL_TOKEN to help services like Umbrel secure the first_install endpoint (#3751)
Co-authored-by: dni  <office@dnilabs.com>
2026-01-30 11:21:52 +01:00
f692ac6f12 refactor: payment.check_status() into check_payment_status(payment) (#3747)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-01-30 11:18:17 +01:00
dni ⚡andGitHub 78202a027b fix: uiCustomisation JS Error (#3752) 2026-01-30 09:27:54 +01:00
dni ⚡andGitHub 28fa0906a6 refactor: g.settings with a PublicSettings class for mapping, for reactive WINDOW_SETTINGS (#3644) 2026-01-30 09:19:01 +01:00
Vlad StanandGitHub 91354f8be4 feat: wallet featured button (#3740) 2026-01-29 10:49:19 +02:00
Vlad StanandGitHub 5f5d45e89f [feat] persist user ui customization (#3743) 2026-01-29 10:35:05 +02:00
dni ⚡andGitHub 52bb125a25 chore: remove ecdsa in favor of coincurve (#3746) 2026-01-29 10:02:16 +02:00
dni ⚡andGitHub 2ca1ed897c CI: use uv for publishing on pypi (#3745) 2026-01-29 08:54:42 +01:00
Vlad StanandGitHub 631f4e8455 [feat] admin impersonate user (#3741) 2026-01-28 16:59:23 +01:00
Vlad StanandGitHub b22f88b264 fix: DB migration conflict (#3739) 2026-01-27 08:31:38 +02:00
dni ⚡andGitHub c08bc02930 chore: update python packages (#3707) 2026-01-26 08:54:37 +01:00
Vlad StanandGitHub e020a7c5a0 feat: allow text upload (#3738) 2026-01-26 09:43:52 +02:00
Vlad StanandGitHub d2e8914835 fix: better validation error messages (#3736) 2026-01-21 14:28:57 +02:00
dni ⚡andGitHub 5e79ca35ab chore: remove windowMixin from components and pages (#3632) 2026-01-21 11:26:45 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4690d178a2 chore(deps): bump cryptography from 42.0.8 to 44.0.1 (#3735)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-21 11:02:28 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c5825aa4c3 chore(deps): bump aiohttp from 3.12.15 to 3.13.3 (#3734)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 17:27:12 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ebe0c4e3c3 chore(deps): bump urllib3 from 2.5.0 to 2.6.3 (#3733)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 17:26:45 +02:00
Djuri BaarsandGitHub 383bdb6312 [fix] Fix missing timezone information 2026-01-20 17:26:02 +02:00
a947686d79 [feat] Update extension builder for 1.4.1 (#3725)
Co-authored-by: dni  <office@dnilabs.com>
2026-01-20 14:47:54 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
28e9d32b44 chore(deps): bump werkzeug from 3.1.3 to 3.1.5 (#3732)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 14:46:06 +02:00
10 changed files with 85 additions and 124 deletions
+14 -35
View File
@@ -24,7 +24,7 @@ callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"])
async def api_generic_webhook_handler(
provider_name: str, request: Request
) -> SimpleStatus:
logger.info(f"Received callback from provider: '{provider_name}'.")
if provider_name.lower() == "stripe":
payload = await request.body()
sig_header = request.headers.get("Stripe-Signature")
@@ -181,10 +181,8 @@ async def _get_stripe_subscription_payment_options(
async def handle_paypal_event(event: dict):
event_id = event.get("id", "")
event_type = event.get("event_type", "")
resource = event.get("resource", {})
logger.info(f"Handling PayPal event: '{event_id}'. Type: '{event_type}'.")
if event_type in ("CHECKOUT.ORDER.APPROVED", "PAYMENT.CAPTURE.COMPLETED"):
payment_hash = _paypal_extract_payment_hash(resource)
@@ -198,17 +196,20 @@ async def handle_paypal_event(event: dict):
await check_fiat_status(payment)
return
if event_type in ("PAYMENT.SALE.COMPLETED"):
if event_type in (
"PAYMENT.SALE.COMPLETED",
"BILLING.SUBSCRIPTION.PAYMENT.SUCCEEDED",
):
await _handle_paypal_subscription_payment(resource)
return
logger.warning(f"Unhandled PayPal event type: '{event_type}'.")
logger.info(f"Unhandled PayPal event type: '{event_type}'.")
async def _handle_paypal_subscription_payment(resource: dict):
amount_info = resource.get("amount") or {}
currency = (amount_info.get("currency") or "").upper()
total = amount_info.get("total")
currency = (amount_info.get("currency_code") or "").upper()
total = amount_info.get("value")
if not currency or total is None:
raise ValueError("PayPal subscription event missing amount.")
@@ -216,14 +217,18 @@ async def _handle_paypal_subscription_payment(resource: dict):
if not custom_id:
raise ValueError("PayPal subscription event missing custom metadata.")
payment_options = _deserialize_paypal_metadata(custom_id)
try:
metadata = json.loads(custom_id)
except json.JSONDecodeError:
metadata = {}
payment_options = FiatSubscriptionPaymentOptions(**metadata)
if not payment_options.wallet_id:
raise ValueError("PayPal subscription event missing wallet_id.")
memo = payment_options.memo or ""
extra = {
**(payment_options.extra or {}),
"subscription_request_id": resource.get("billing_agreement_id"),
"fiat_method": "subscription",
"tag": payment_options.tag,
"subscription": {
@@ -254,29 +259,3 @@ def _paypal_extract_payment_hash(resource: dict) -> str | None:
if pu.get("custom_id"):
return pu.get("custom_id")
return None
def _deserialize_paypal_metadata(custom_id: str) -> FiatSubscriptionPaymentOptions:
try:
meta = json.loads(custom_id)
wallet_id = meta[0] if len(meta) > 0 else None
tag = meta[1] if len(meta) > 1 else None
subscription_request_id = meta[2] if len(meta) > 2 else None
extra_link = meta[3] if len(meta) > 3 else None
memo = meta[4] if len(meta) > 4 else None
extra = {
"link": extra_link,
"subscription_request_id": subscription_request_id,
}
return FiatSubscriptionPaymentOptions(
wallet_id=wallet_id,
tag=tag,
subscription_request_id=subscription_request_id,
extra=extra,
memo=memo,
)
except (json.JSONDecodeError, IndexError) as e:
logger.warning(f"Failed to deserialize PayPal metadata: {e}")
return FiatSubscriptionPaymentOptions()
-12
View File
@@ -1,7 +1,6 @@
from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.wallets import WalletTypeInfo
@@ -31,11 +30,6 @@ async def create_subscription(
data: CreateFiatSubscription,
key_type: WalletTypeInfo = Depends(require_admin_key),
) -> FiatSubscriptionResponse:
logger.debug(
f"Creating subscription with provider '{provider}. '"
f"Subscription ID '{data.subscription_id}'."
)
fiat_provider = await get_fiat_provider(provider)
if not fiat_provider:
raise HTTPException(404, "Fiat provider not found")
@@ -53,12 +47,6 @@ async def create_subscription(
subscription_response = await fiat_provider.create_subscription(
data.subscription_id, data.quantity, data.payment_options
)
if subscription_response.error_message:
logger.warning(
f"Failed to create subscription with provider '{provider}': "
f"{subscription_response.error_message}"
)
return subscription_response
+18 -63
View File
@@ -2,7 +2,7 @@ import asyncio
import json
import time
from collections.abc import AsyncGenerator
from typing import Any, Literal
from typing import Any
import httpx
from loguru import logger
@@ -24,8 +24,6 @@ from .base import (
FiatSubscriptionResponse,
)
FiatMethod = Literal["checkout", "subscription"]
class PayPalCheckoutOptions(BaseModel):
class Config:
@@ -36,21 +34,11 @@ class PayPalCheckoutOptions(BaseModel):
metadata: dict[str, Any] = Field(default_factory=dict)
class PayPalSubscriptionOptions(BaseModel):
class Config:
extra = "ignore"
checking_id: str | None = None
payment_request: str | None = None
class PayPalCreateInvoiceOptions(BaseModel):
class Config:
extra = "ignore"
fiat_method: FiatMethod = "checkout"
checkout: PayPalCheckoutOptions | None = None
subscription: PayPalSubscriptionOptions | None = None
class PayPalWallet(FiatProvider):
@@ -108,14 +96,6 @@ class PayPalWallet(FiatProvider):
opts = self._parse_create_opts(extra or {})
if opts is None:
return FiatInvoiceResponse(ok=False, error_message="Invalid PayPal options")
if opts.fiat_method == "subscription":
term = opts.subscription or PayPalSubscriptionOptions()
checking_id = term.checking_id or urlsafe_short_hash()
return FiatInvoiceResponse(
ok=True,
checking_id=f"subscription_{checking_id}",
payment_request=term.payment_request or "",
)
try:
await self._ensure_access_token()
@@ -191,7 +171,6 @@ class PayPalWallet(FiatProvider):
or "https://lnbits.com"
)
logger.debug(f"Creating PayPal subscription with ID '{subscription_id}'")
if not payment_options.subscription_request_id:
payment_options.subscription_request_id = urlsafe_short_hash()
payment_options.extra = payment_options.extra or {}
@@ -203,6 +182,7 @@ class PayPalWallet(FiatProvider):
await self._ensure_access_token()
payload = {
"plan_id": subscription_id,
"quantity": str(quantity),
"custom_id": self._serialize_metadata(payment_options),
"application_context": {
"return_url": success_url,
@@ -225,7 +205,7 @@ class PayPalWallet(FiatProvider):
return FiatSubscriptionResponse(
ok=True,
checkout_session_url=approval_url,
subscription_request_id=data.get("id"),
subscription_request_id=payment_options.subscription_request_id,
)
except Exception as exc:
logger.warning(exc)
@@ -239,10 +219,6 @@ class PayPalWallet(FiatProvider):
correlation_id: str,
**kwargs,
) -> FiatSubscriptionResponse:
logger.debug(
f"Cancelling PayPal subscription '{subscription_id}'. "
f"Correlation ID '{correlation_id}'."
)
try:
await self._ensure_access_token()
r = await self.client.post(
@@ -264,20 +240,12 @@ class PayPalWallet(FiatProvider):
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
try:
await self._ensure_access_token()
paypal_id = self._normalize_paypal_id(checking_id)
if paypal_id.startswith("subscription_"):
capture_id = paypal_id.replace("subscription_", "", 1)
r = await self.client.get(
f"v2/payments/captures/{capture_id}", headers=self._auth_headers()
)
r.raise_for_status()
return self._status_from_capture(r.json())
else:
r = await self.client.get(
f"/v2/checkout/orders/{paypal_id}", headers=self._auth_headers()
)
r.raise_for_status()
return self._status_from_order(r.json())
order_id = self._normalize_paypal_id(checking_id)
r = await self.client.get(
f"/v2/checkout/orders/{order_id}", headers=self._auth_headers()
)
r.raise_for_status()
return self._status_from_order(r.json())
except Exception as exc:
logger.debug(f"Error getting PayPal order status: {exc}")
return FiatPaymentPendingStatus()
@@ -302,14 +270,6 @@ class PayPalWallet(FiatProvider):
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
def _status_from_capture(self, order: dict[str, Any]) -> FiatPaymentStatus:
status = (order.get("status") or "").upper()
if status in ["COMPLETED"]:
return FiatPaymentSuccessStatus()
if status in ["DECLINED", "FAILED", "CANCELLED", "CANCELED"]:
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
def _normalize_paypal_id(self, checking_id: str) -> str:
return (
checking_id.replace("fiat_paypal_", "", 1)
@@ -320,20 +280,15 @@ class PayPalWallet(FiatProvider):
def _serialize_metadata(
self, payment_options: FiatSubscriptionPaymentOptions
) -> str:
meta = [
payment_options.wallet_id,
payment_options.tag,
payment_options.subscription_request_id,
]
if payment_options.extra:
meta.append(payment_options.extra.get("link", None))
# Keep custom_id within PayPal's 127-char limit
memo_limit = 120 - len(json.dumps(meta))
if memo_limit > 0 and payment_options.memo:
meta.append(payment_options.memo[:memo_limit])
return json.dumps(meta)
md = {
"wallet_id": payment_options.wallet_id,
"memo": payment_options.memo,
"extra": payment_options.extra,
"tag": payment_options.tag,
"subscription_request_id": payment_options.subscription_request_id,
}
raw = json.dumps(md)
return raw[:127] # PayPal custom_id limit
def _parse_create_opts(
self, raw_opts: dict[str, Any]
File diff suppressed because one or more lines are too long
+47 -9
View File
@@ -24,10 +24,6 @@ window.app.component('lnbits-qrcode', {
type: String,
default: ''
},
newTab: {
type: Boolean,
default: true
},
margin: {
type: Number,
default: 3
@@ -47,7 +43,52 @@ window.app.component('lnbits-qrcode', {
nfcSupported: typeof NDEFReader != 'undefined'
}
},
computed: {
qrValue() {
return this.normalizeQrValue(this.value)
}
},
methods: {
normalizeQrValue(value) {
if (typeof value !== 'string' || value.length === 0) {
return value
}
const lower = value.toLowerCase()
const upper = value.toUpperCase()
const isMixedCase = value !== lower && value !== upper
if (isMixedCase) {
return value
}
const prefix = 'lightning:'
if (lower.startsWith(prefix)) {
const prefixOriginal = value.slice(0, prefix.length)
const rest = value.slice(prefix.length)
const normalizedRest = this.normalizeBech32IfLower(rest)
if (normalizedRest !== rest) {
return prefixOriginal.toUpperCase() + normalizedRest
}
return value
}
return this.normalizeBech32IfLower(value)
},
normalizeBech32IfLower(value) {
if (typeof value !== 'string' || value.length === 0) {
return value
}
const lower = value.toLowerCase()
if (value !== lower) {
return value
}
// Bech32 strings are case-insensitive, but must be all upper or all lower.
// Uppercasing enables QR alphanumeric mode and reduces QR size for LN strings.
const bech32Like = /^ln[0-9a-z]*1[0-9ac-hj-np-z]+$/
return bech32Like.test(value) ? value.toUpperCase() : value
},
printQrCode() {
const svg = this.$refs.qrCode.$el.outerHTML
const printWindow = window.open('', '_blank')
@@ -84,13 +125,10 @@ window.app.component('lnbits-qrcode', {
printWindow.close()
},
clickQrCode(event) {
event.preventDefault()
event.stopPropagation()
if (this.href === '') {
this.utils.copyText(this.value)
return false
} else if (this.href) {
window.open(this.href, this.newTab ? '_blank' : '_self')
event.preventDefault()
event.stopPropagation()
return false
}
},
@@ -408,6 +408,7 @@
<li><code>CHECKOUT.ORDER.APPROVED</code></li>
<li><code>PAYMENT.CAPTURE.COMPLETED</code></li>
<li><code>PAYMENT.SALE.COMPLETED</code></li>
<li><code>BILLING.SUBSCRIPTION.PAYMENT.SUCCEEDED</code></li>
</ul>
</q-card-section>
</q-expansion-item>
@@ -12,7 +12,7 @@
>
<qrcode-vue
ref="qrCode"
:value="value"
:value="qrValue"
:margin="margin"
:size="size"
level="Q"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "lnbits"
version = "1.5.0"
version = "1.5.0-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" }]
+1 -1
View File
@@ -708,7 +708,7 @@ async def test_user_activation(
f"/users/api/v1/user/{user_id}/activate",
headers={"Authorization": f"Bearer {superuser_token}"},
)
print("### response", response.text)
assert response.status_code == 200
assert response.json().get("message") == "User activated."
Generated
+1 -1
View File
@@ -1247,7 +1247,7 @@ wheels = [
[[package]]
name = "lnbits"
version = "1.5.0"
version = "1.5.0rc1"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },