Compare commits

..
Author SHA1 Message Date
dni ⚡andGitHub 9cc6a96433 chore: update version to v1.3.0 (#3411) 2025-10-15 13:09:36 +02:00
e5ff928c3c feat: allow query param for qrcode (#3390)
Co-authored-by: dni  <office@dnilabs.com>
2025-10-15 12:48:32 +02:00
dni ⚡andGitHub f31ffba06c ci: lock boltz client to latest working version (#3367) 2025-10-15 12:25:11 +02:00
Tiago VasconcelosandGitHub 1163e44265 fix: overlapping input (#3404) 2025-10-15 09:18:57 +02:00
ArcandGitHub 266b16834b fix nix install doc (#3401) 2025-10-10 16:39:08 +01:00
ArcandGitHub ad606b2517 Fix: Glibc error by pegging ubuntu version, appimage include extras like breez_sdk (#3397) 2025-10-10 10:35:25 +01:00
ArcandGitHub 1ec28079d9 docs: jq needed for AppImage install (#3399) 2025-10-10 08:50:41 +02:00
Vlad StanandGitHub aeaddc1dc6 fix: donate link 2025-10-09 09:55:51 +03:00
Vlad StanandGitHub a9374d509c fix: hide payment details for failed payments (#3393) 2025-10-08 14:43:40 +02:00
Vlad StanandGitHub 3e72788fc8 chore: clean prin statement (#3391) 2025-10-07 14:39:24 +03:00
f48d24aca8 feat: show badge if an extension has paid releases (#3385)
Co-authored-by: dni  <office@dnilabs.com>
2025-10-06 17:06:18 +01:00
015262c9b3 chore: qr button tweak (quasar classes should always be used) (#3351)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-10-06 10:56:12 +02:00
e5e481f836 fix: correct timezone handling for transaction timestamps (#3373)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-10-06 10:53:03 +02:00
6c32ebf7e6 fix: remove uneven padding from Repo button (#3374)
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-02 13:12:08 +03:00
24 changed files with 269 additions and 378 deletions
+1 -1
View File
@@ -1 +1 @@
custom: https://demo.lnbits.com/lnurlp/link/fH59GD custom: https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg
+13 -17
View File
@@ -5,23 +5,19 @@ on:
types: [published] types: [published]
jobs: jobs:
build-linux-package: build-linux-package:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
steps: steps:
# Step 1: Checkout the repository
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
# Step 2: Set up Python (uv will still use this toolchain)
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: "3.12"
# Step 3: Install system deps (fuse) + uv
- name: Install system deps and uv - name: Install system deps and uv
run: | run: |
sudo apt-get update sudo apt-get update
@@ -30,7 +26,6 @@ jobs:
echo "$HOME/.local/bin" >> $GITHUB_PATH echo "$HOME/.local/bin" >> $GITHUB_PATH
shell: bash shell: bash
# Optional: Cache uv + venv to speed up CI
- name: Cache uv and venv - name: Cache uv and venv
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -39,7 +34,6 @@ jobs:
.venv .venv
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock', 'pyproject.toml') }} key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock', 'pyproject.toml') }}
# Step 4: Prepare packaging tree and clone LNbits
- name: Prepare packaging & clone LNbits - name: Prepare packaging & clone LNbits
run: | run: |
mv .github/packaging packaging mv .github/packaging packaging
@@ -47,18 +41,11 @@ jobs:
git clone https://github.com/lnbits/lnbits.git packaging/linux/AppDir/usr/lnbits git clone https://github.com/lnbits/lnbits.git packaging/linux/AppDir/usr/lnbits
shell: bash shell: bash
# Step 5: Build the LNbits binary with uv + PyInstaller
- name: Build LNbits binary (uv + PyInstaller) - name: Build LNbits binary (uv + PyInstaller)
run: | run: |
cd packaging/linux/AppDir/usr/lnbits cd packaging/linux/AppDir/usr/lnbits
uv sync --all-extras --no-dev
# Install project deps into .venv using uv
uv sync
# Install PyInstaller into the same environment
uv pip install pyinstaller uv pip install pyinstaller
# Build the LNbits binary
uv run pyinstaller \ uv run pyinstaller \
--onefile \ --onefile \
--name lnbits \ --name lnbits \
@@ -66,6 +53,10 @@ jobs:
--collect-all embit \ --collect-all embit \
--collect-all lnbits \ --collect-all lnbits \
--collect-all sqlalchemy \ --collect-all sqlalchemy \
--collect-all breez_sdk \
--collect-binaries breez_sdk \
--collect-all breez_sdk_liquid \
--collect-binaries breez_sdk_liquid \
--collect-all aiosqlite \ --collect-all aiosqlite \
--hidden-import=passlib.handlers.bcrypt \ --hidden-import=passlib.handlers.bcrypt \
"$(uv run which lnbits)" "$(uv run which lnbits)"
@@ -75,7 +66,7 @@ jobs:
chmod +x packaging/linux/AppDir/lnbits.desktop chmod +x packaging/linux/AppDir/lnbits.desktop
chmod +x packaging/linux/AppDir/usr/lnbits/dist/lnbits chmod +x packaging/linux/AppDir/usr/lnbits/dist/lnbits
# Clean out non-dist content from the app dir to keep AppImage slim # keep AppDir slim
find packaging/linux/AppDir/usr/lnbits -mindepth 1 -maxdepth 1 \ find packaging/linux/AppDir/usr/lnbits -mindepth 1 -maxdepth 1 \
! -name 'dist' \ ! -name 'dist' \
! -name 'lnbits' \ ! -name 'lnbits' \
@@ -91,9 +82,14 @@ jobs:
packaging/linux/AppDir "$APPIMAGE_NAME" packaging/linux/AppDir "$APPIMAGE_NAME"
chmod +x "$APPIMAGE_NAME" chmod +x "$APPIMAGE_NAME"
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
# 🔎 quick audit: show glibc versions referenced by the binary
echo "Runner glibc:"
ldd --version | head -n1 || true
echo "Symbols needed by lnbits:"
strings "$APPIMAGE_NAME" | grep -o 'GLIBC_[0-9.]*' | sort -u || true
shell: bash shell: bash
# Step 6: Upload Linux Release Asset
- name: Upload Linux Release Asset - name: Upload Linux Release Asset
uses: actions/upload-release-asset@v1 uses: actions/upload-release-asset@v1
with: with:
+1 -1
View File
@@ -1,4 +1,4 @@
FROM boltz/boltz-client:latest AS boltz FROM boltz/boltz-client:2.8.3 AS boltz
FROM lnbits/lnbits:latest FROM lnbits/lnbits:latest
+19 -6
View File
@@ -15,7 +15,7 @@ Note that by default LNbits uses SQLite as its database, which is simple and eff
Go to [releases](https://github.com/lnbits/lnbits/releases) and pull latest AppImage, or: Go to [releases](https://github.com/lnbits/lnbits/releases) and pull latest AppImage, or:
```sh ```sh
sudo apt-get install libfuse2 sudo apt-get install jq libfuse2
wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
chmod +x LNbits-latest.AppImage chmod +x LNbits-latest.AppImage
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
@@ -132,7 +132,7 @@ Now visit `0.0.0.0:5000` to make a super-user account.
```sh ```sh
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download) # Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
sh <(curl --proto '=https' --tlsv1.2 -L https://nixos.org/nix/install) --daemon sh <(curl --proto '=https' --tlsv1.2 -L https://nixos.org/nix/install) --daemon --yes
# Enable nix-command and flakes experimental features for nix: # Enable nix-command and flakes experimental features for nix:
grep -qxF 'experimental-features = nix-command flakes' /etc/nix/nix.conf || \ grep -qxF 'experimental-features = nix-command flakes' /etc/nix/nix.conf || \
@@ -145,16 +145,29 @@ echo "trusted-users = root $USER" | sudo tee -a /etc/nix/nix.conf
# Restart daemon so changes apply # Restart daemon so changes apply
sudo systemctl restart nix-daemon sudo systemctl restart nix-daemon
# Clone and build LNbits
git clone https://github.com/lnbits/lnbits.git
cd lnbits
# Make data directory and persist data/extension folders
mkdir data
PROJECT_DIR="$(pwd)"
{
echo "export PYTHONPATH=\"$PROJECT_DIR/ns:\$PYTHONPATH\""
echo "export LNBITS_DATA_FOLDER=\"$PROJECT_DIR/data\""
echo "export LNBITS_EXTENSIONS_PATH=\"$PROJECT_DIR\""
} >> ~/.bashrc
grep -qxF '. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh' ~/.bashrc || \
echo '. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh' >> ~/.bashrc
. ~/.bashrc
# Add cachix for cached binaries # Add cachix for cached binaries
nix-env -iA cachix -f https://cachix.org/api/v1/install nix-env -iA cachix -f https://cachix.org/api/v1/install
cachix use lnbits cachix use lnbits
# Clone and build LNbits # Build LNbits
git clone https://github.com/lnbits/lnbits.git
cd lnbits
nix build nix build
mkdir data
``` ```
#### Running the server #### Running the server
+67 -45
View File
@@ -1,53 +1,75 @@
#!/bin/bash #!/usr/bin/env bash
set -euo pipefail
# Check install has not already run # --- Config you might tweak ---
if [ ! -d lnbits/data ]; then REPO_URL="https://github.com/lnbits/lnbits.git"
BRANCH="main"
APP_DIR="${PWD}/lnbits"
HOST="${HOST:-0.0.0.0}"
PORT="${PORT:-5000}"
ADMIN_UI="${LNBITS_ADMIN_UI:-true}"
# -------------------------------
# Update package list and install prerequisites non-interactively export DEBIAN_FRONTEND=noninteractive
sudo apt update -y
sudo apt install -y software-properties-common
# Add the deadsnakes PPA repository non-interactively # Ensure basic tooling
sudo add-apt-repository -y ppa:deadsnakes/ppa if ! command -v curl >/dev/null 2>&1 || ! command -v git >/dev/null 2>&1; then
sudo apt-get update -y
# Install Python 3.10 and distutils non-interactively sudo apt-get install -y curl git
sudo apt install -y python3.10 python3.10-distutils
# Install UV
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="/home/$USER/.local/bin:$PATH"
if [ ! -d lnbits/wallets ]; then
# Clone the LNbits repository
git clone https://github.com/lnbits/lnbits.git
if [ $? -ne 0 ]; then
echo "Failed to clone the repository ... FAIL"
exit 1
fi
# Ensure we are in the lnbits directory
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
fi
git checkout main
# Make data folder
mkdir data
# Copy the .env.example to .env
cp .env.example .env
elif [ ! -d lnbits/wallets ]; then
# cd into lnbits
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
fi fi
# Install the dependencies using UV # System build deps and secp headers
uv sync --all-extras if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update -y
sudo apt-get install -y \
pkg-config \
build-essential \
libsecp256k1-dev \
automake \
autoconf \
libtool \
m4
fi
# Install uv (if missing)
if ! command -v uv >/dev/null 2>&1; then
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
fi
# Ensure PATH for current session
if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
export PATH="$HOME/.local/bin:$PATH"
fi
# Set environment variables for LNbits # Clone or reuse repo
export LNBITS_ADMIN_UI=true if [[ ! -d "$APP_DIR/.git" ]]; then
export HOST=0.0.0.0 git clone "$REPO_URL" "$APP_DIR"
fi
# Run LNbits cd "$APP_DIR"
uv run lnbits git fetch --all --prune
git checkout "$BRANCH"
git pull --ff-only || true
# First-run setup
mkdir -p data
[[ -f .env ]] || cp .env.example .env || true
# Prefer system libsecp256k1 (avoid autotools path)
export SECP_BUNDLED=0
# Sync dependencies with Python 3.12
uv sync --python 3.12 --all-extras --no-dev
# Environment
export LNBITS_ADMIN_UI="$ADMIN_UI"
export HOST="$HOST"
export PORT="$PORT"
# Open firewall (optional)
if command -v ufw >/dev/null 2>&1; then
sudo ufw allow "$PORT"/tcp || true
fi
# Run LNbits with Python 3.12 via uv
exec uv run --python 3.12 lnbits
+37 -11
View File
@@ -39,6 +39,7 @@ class ExplicitRelease(BaseModel):
info_notification: str | None info_notification: str | None
critical_notification: str | None critical_notification: str | None
details_link: str | None details_link: str | None
paid_features: str | None
pay_link: str | None pay_link: str | None
def is_version_compatible(self): def is_version_compatible(self):
@@ -187,6 +188,7 @@ class ExtensionRelease(BaseModel):
icon: str | None = None icon: str | None = None
details_link: str | None = None details_link: str | None = None
paid_features: str | None = None
pay_link: str | None = None pay_link: str | None = None
cost_sats: int | None = None cost_sats: int | None = None
paid_sats: int | None = 0 paid_sats: int | None = 0
@@ -256,6 +258,7 @@ class ExtensionRelease(BaseModel):
html_url=e.html_url, html_url=e.html_url,
details_link=e.details_link, details_link=e.details_link,
pay_link=e.pay_link, pay_link=e.pay_link,
paid_features=e.paid_features,
repo=e.repo, repo=e.repo,
icon=e.icon, icon=e.icon,
) )
@@ -308,6 +311,9 @@ class ExtensionMeta(BaseModel):
dependencies: list[str] = [] dependencies: list[str] = []
archive: str | None = None archive: str | None = None
featured: bool = False featured: bool = False
paid_features: str | None = None
has_paid_release: bool = False
has_free_release: bool = False
class InstallableExtension(BaseModel): class InstallableExtension(BaseModel):
@@ -451,9 +457,23 @@ class InstallableExtension(BaseModel):
shutil.rmtree(self.ext_upgrade_dir, True) shutil.rmtree(self.ext_upgrade_dir, True)
def check_latest_version(self, release: ExtensionRelease | None): def check_release_updates(self, release: ExtensionRelease | None):
self._check_latest_version(release)
self._check_payment_link(release)
def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None:
if not pay_link or not self.meta or not self.meta.payments:
return None
return next(
(p for p in self.meta.payments if p.pay_link == pay_link),
None,
)
def _check_latest_version(self, release: ExtensionRelease | None):
if not release: if not release:
return return
if not release.is_version_compatible:
return
if not self.meta or not self.meta.latest_release: if not self.meta or not self.meta.latest_release:
meta = self.meta or ExtensionMeta() meta = self.meta or ExtensionMeta()
meta.latest_release = release meta.latest_release = release
@@ -464,13 +484,19 @@ class InstallableExtension(BaseModel):
): ):
self.meta.latest_release = release self.meta.latest_release = release
def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None: def _check_payment_link(self, release: ExtensionRelease | None):
if not pay_link or not self.meta or not self.meta.payments: if not release:
return None return
return next( if not release.is_version_compatible:
(p for p in self.meta.payments if p.pay_link == pay_link), return
None, if not self.meta:
) self.meta = ExtensionMeta()
if release.pay_link:
self.meta.has_paid_release = True
else:
self.meta.has_free_release = True
if release.paid_features:
self.meta.paid_features = release.paid_features
def _restore_payment_info(self): def _restore_payment_info(self):
if ( if (
@@ -596,7 +622,7 @@ class InstallableExtension(BaseModel):
(ee for ee in extension_list if ee.id == r.id), None (ee for ee in extension_list if ee.id == r.id), None
) )
if existing_ext and ext.meta: if existing_ext and ext.meta:
existing_ext.check_latest_version(ext.meta.latest_release) existing_ext.check_release_updates(ext.meta.latest_release)
continue continue
meta = ext.meta or ExtensionMeta() meta = ext.meta or ExtensionMeta()
@@ -610,10 +636,10 @@ class InstallableExtension(BaseModel):
(ee for ee in extension_list if ee.id == e.id), None (ee for ee in extension_list if ee.id == e.id), None
) )
if existing_ext: if existing_ext:
existing_ext.check_latest_version(release) existing_ext.check_release_updates(release)
continue continue
ext = InstallableExtension.from_explicit_release(e) ext = InstallableExtension.from_explicit_release(e)
ext.check_latest_version(release) ext.check_release_updates(release)
meta = ext.meta or ExtensionMeta() meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured meta.featured = ext.id in manifest.featured
ext.meta = meta ext.meta = meta
@@ -76,19 +76,25 @@
icon="add" icon="add"
></q-btn> ></q-btn>
</q-input> </q-input>
<div>
<q-chip
v-for="identifier in formData.lnbits_nostr_notifications_identifiers"
:key="identifier"
removable
@remove="removeNostrNotificationIdentifier(identifier)"
color="primary"
text-color="white"
><span class="ellipsis" v-text="identifier"></span
></q-chip>
</div>
</q-item-section> </q-item-section>
</q-item> </q-item>
<div>
<q-chip
v-for="identifier in formData.lnbits_nostr_notifications_identifiers"
:key="identifier"
removable
@remove="removeNostrNotificationIdentifier(identifier)"
color="primary"
text-color="white"
class="ellipsis"
:label="identifier"
><q-tooltip
v-if="identifier"
anchor="top middle"
self="bottom middle"
><span v-text="identifier"></span></q-tooltip
></q-chip>
</div>
</div> </div>
<div class="col-sm-12 col-md-6"> <div class="col-sm-12 col-md-6">
+33 -2
View File
@@ -137,8 +137,40 @@
@click="showExtensionDetails(extension.id, extension.details_link)" @click="showExtensionDetails(extension.id, extension.details_link)"
v-text="extension.name" v-text="extension.name"
></div> ></div>
<div> <div style="justify-content: space-between; display: flex">
<lnbits-extension-rating :rating="0" /> <lnbits-extension-rating :rating="0" />
<q-btn-group size="xs" style="margin: 5px 0">
<q-btn
v-if="extension.hasFreeRelease"
color="green"
size="xs"
:label="$t('free')"
>
<q-tooltip>
<span v-text="$t('extension_has_free_release')"></span>
</q-tooltip>
</q-btn>
<q-btn
v-if="extension.hasPaidRelease || extension.paidFeatures"
color="primary"
size="xs"
:label="$t('paid')"
>
<q-tooltip>
<span
v-if="extension.hasPaidRelease"
v-text="$t('extension_has_paid_release')"
></span>
<br
v-if="extension.hasPaidRelease && extension.paidFeatures"
/>
<span
v-if="extension.paidFeatures"
v-text="extension.paidFeatures"
></span>
</q-tooltip>
</q-btn>
</q-btn-group>
</div> </div>
<div style="justify-content: space-between; display: flex"> <div style="justify-content: space-between; display: flex">
<q-toggle <q-toggle
@@ -916,7 +948,6 @@
:href="selectedExtensionDetails.repo" :href="selectedExtensionDetails.repo"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="q-pr-xs"
><q-tooltip>repository</q-tooltip></q-btn ><q-tooltip>repository</q-tooltip></q-btn
> >
</div> </div>
+2 -1
View File
@@ -113,8 +113,9 @@ async def api_fiat_as_sats(data: ConversionData):
return output return output
@api_router.get("/api/v1/qrcode", response_class=StreamingResponse)
@api_router.get("/api/v1/qrcode/{data}", response_class=StreamingResponse) @api_router.get("/api/v1/qrcode/{data}", response_class=StreamingResponse)
async def img(data): async def img(data: str):
qr = pyqrcode.create(data) qr = pyqrcode.create(data)
stream = BytesIO() stream = BytesIO()
qr.svg(stream, scale=3) qr.svg(stream, scale=3)
+4 -3
View File
@@ -61,9 +61,10 @@ async def api_install_extension(data: CreateExtension):
data.ext_id, data.source_repo, data.archive, data.version data.ext_id, data.source_repo, data.archive, data.version
) )
if not release: if not release:
raise HTTPException( raise HTTPException(HTTPStatus.NOT_FOUND, "Release not found")
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
) if not release.is_version_compatible:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Incompatible extension version.")
release.payment_hash = data.payment_hash release.payment_hash = data.payment_hash
ext_meta = ExtensionMeta(installed_release=release) ext_meta = ExtensionMeta(installed_release=release)
+3
View File
@@ -126,6 +126,9 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
if ext.meta and ext.meta.latest_release if ext.meta and ext.meta.latest_release
else None else None
), ),
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
"paidFeatures": ext.meta.paid_features if ext.meta else False,
"installedRelease": ( "installedRelease": (
dict(ext.meta.installed_release) dict(ext.meta.installed_release)
if ext.meta and ext.meta.installed_release if ext.meta and ext.meta.installed_release
+3 -1
View File
@@ -296,7 +296,9 @@ async def api_payment(payment_hash, x_api_key: str | None = Header(None)):
return {"paid": True, "preimage": payment.preimage} return {"paid": True, "preimage": payment.preimage}
if payment.failed: if payment.failed:
return {"paid": False, "status": "failed", "details": payment} if wallet and wallet.id == payment.wallet_id:
return {"paid": False, "status": "failed", "details": payment}
return {"paid": False, "status": "failed"}
try: try:
status = await payment.check_status() status = await payment.check_status()
+13 -236
View File
@@ -6,7 +6,6 @@ from typing import Any, Literal
from urllib.parse import urlencode from urllib.parse import urlencode
import httpx import httpx
from httpx import HTTPStatusError
from loguru import logger from loguru import logger
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, Field, ValidationError
@@ -26,34 +25,6 @@ from .base import (
FiatMethod = Literal["checkout", "terminal"] FiatMethod = Literal["checkout", "terminal"]
# ---- NEW: normalized subscription status type ----
StripeStatus = Literal[
"active",
"trialing",
"past_due",
"unpaid",
"canceled",
"incomplete",
"incomplete_expired",
"paused",
"not_found",
"pending",
"error",
"unknown",
]
# Typed map to ensure mypy sees return values as StripeStatus (not plain str)
_STRIPE_STATUS_MAP: dict[str, StripeStatus] = {
"active": "active",
"trialing": "trialing",
"past_due": "past_due",
"unpaid": "unpaid",
"canceled": "canceled",
"incomplete": "incomplete",
"incomplete_expired": "incomplete_expired",
"paused": "paused",
}
class StripeTerminalOptions(BaseModel): class StripeTerminalOptions(BaseModel):
class Config: class Config:
@@ -72,22 +43,6 @@ class StripeCheckoutOptions(BaseModel):
line_item_name: str | None = None line_item_name: str | None = None
# === Direct-debit subscription options ===
class StripeRecurringOptions(BaseModel):
class Config:
extra = "ignore"
price_id: str | None = None
price_lookup_key: str | None = None
payment_method_types: list[str] = Field(default_factory=lambda: ["bacs_debit"])
success_url: str | None = None
cancel_url: str | None = None
metadata: dict[str, str] = Field(default_factory=dict)
customer_email: str | None = None
trial_days: int | None = None
class StripeCreateInvoiceOptions(BaseModel): class StripeCreateInvoiceOptions(BaseModel):
class Config: class Config:
extra = "ignore" extra = "ignore"
@@ -95,7 +50,6 @@ class StripeCreateInvoiceOptions(BaseModel):
fiat_method: FiatMethod = "checkout" fiat_method: FiatMethod = "checkout"
terminal: StripeTerminalOptions | None = None terminal: StripeTerminalOptions | None = None
checkout: StripeCheckoutOptions | None = None checkout: StripeCheckoutOptions | None = None
recurring: StripeRecurringOptions | None = None
class StripeWallet(FiatProvider): class StripeWallet(FiatProvider):
@@ -135,10 +89,12 @@ class StripeWallet(FiatProvider):
r = await self.client.get(url="/v1/balance", timeout=15) r = await self.client.get(url="/v1/balance", timeout=15)
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
available = data.get("available") or [] available = data.get("available") or []
available_balance = 0 available_balance = 0
if available and isinstance(available, list): if available and isinstance(available, list):
available_balance = int(available[0].get("amount", 0)) available_balance = int(available[0].get("amount", 0))
return FiatStatusResponse(balance=available_balance) return FiatStatusResponse(balance=available_balance)
except json.JSONDecodeError: except json.JSONDecodeError:
return FiatStatusResponse("Server error: 'invalid json response'", 0) return FiatStatusResponse("Server error: 'invalid json response'", 0)
@@ -160,11 +116,6 @@ class StripeWallet(FiatProvider):
if not opts: if not opts:
return FiatInvoiceResponse(ok=False, error_message="Invalid Stripe options") return FiatInvoiceResponse(ok=False, error_message="Invalid Stripe options")
if opts.recurring is not None:
return await self._create_subscription_checkout_session(
payment_hash, memo, opts
)
if opts.fiat_method == "checkout": if opts.fiat_method == "checkout":
return await self._create_checkout_invoice( return await self._create_checkout_invoice(
amount_cents, currency, payment_hash, memo, opts amount_cents, currency, payment_hash, memo, opts
@@ -219,7 +170,6 @@ class StripeWallet(FiatProvider):
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
# ---------- One-off Checkout ----------
async def _create_checkout_invoice( async def _create_checkout_invoice(
self, self,
amount_cents: int, amount_cents: int,
@@ -273,7 +223,6 @@ class StripeWallet(FiatProvider):
ok=False, error_message=f"Unable to connect to {self.endpoint}." ok=False, error_message=f"Unable to connect to {self.endpoint}."
) )
# ---------- Terminal ----------
async def _create_terminal_invoice( async def _create_terminal_invoice(
self, self,
amount_cents: int, amount_cents: int,
@@ -316,189 +265,8 @@ class StripeWallet(FiatProvider):
ok=False, error_message=f"Unable to connect to {self.endpoint}." ok=False, error_message=f"Unable to connect to {self.endpoint}."
) )
# ---------- Subscription Checkout ----------
async def _create_subscription_checkout_session(
self,
payment_hash: str,
memo: str | None,
opts: StripeCreateInvoiceOptions,
) -> FiatInvoiceResponse:
rc = opts.recurring or StripeRecurringOptions()
try:
price_id = rc.price_id
if not price_id and rc.price_lookup_key:
price_id = await self._get_price_id_by_lookup_key(rc.price_lookup_key)
if not price_id:
return FiatInvoiceResponse(
ok=False,
error_message="Stripe: missing price_id or price_lookup_key",
)
success_url = (
rc.success_url
or (opts.checkout.success_url if opts.checkout else None)
or settings.stripe_payment_success_url
or "https://lnbits.com"
)
cancel_url = rc.cancel_url or success_url
form_data: list[tuple[str, str]] = [
("mode", "subscription"),
("success_url", success_url),
("cancel_url", cancel_url),
("payment_method_collection", "always"),
("metadata[payment_hash]", payment_hash),
("line_items[0][price]", price_id),
("line_items[0][quantity]", "1"),
]
if rc.trial_days:
form_data.append(
("subscription_data[trial_period_days]", str(rc.trial_days))
)
if rc.customer_email:
form_data.append(("customer_email", rc.customer_email))
form_data += self._encode_metadata("metadata", rc.metadata)
r = await self.client.post(
"/v1/checkout/sessions",
headers=self._build_headers_form(),
content=urlencode(form_data),
)
r.raise_for_status()
data = r.json()
session_id, url = data.get("id"), data.get("url")
if not session_id or not url:
return FiatInvoiceResponse(
ok=False,
error_message="Server error: missing id or url (subscription)",
)
return FiatInvoiceResponse(
ok=True, checking_id=session_id, payment_request=url
)
except HTTPStatusError as e:
body = e.response.text if e.response is not None else "<no body>"
logger.warning(f"Stripe subscription 400: {body}")
return FiatInvoiceResponse(ok=False, error_message=body)
except json.JSONDecodeError:
return FiatInvoiceResponse(
ok=False, error_message="Server error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
# ---------- Subscription status helpers (NEW) ----------
async def get_subscription_status(self, sub_or_session_id: str) -> StripeStatus:
"""
Accepts either a 'sub_...' or 'cs_...' id. If it's a 'cs_...',
returns 'pending' until the subscription exists; once it does,
returns the mapped subscription status.
"""
sid = self._normalize_stripe_id(sub_or_session_id)
try:
if sid.startswith("sub_"):
r = await self.client.get(f"/v1/subscriptions/{sid}")
if r.status_code == 404:
return "not_found"
r.raise_for_status()
return self._status_from_subscription(r.json())
if sid.startswith("cs_"):
r = await self.client.get(f"/v1/checkout/sessions/{sid}")
if r.status_code == 404:
return "not_found"
r.raise_for_status()
data = r.json()
subscription_id = data.get("subscription")
if not subscription_id:
return "pending"
r2 = await self.client.get(f"/v1/subscriptions/{subscription_id}")
if r2.status_code == 404:
return "not_found"
r2.raise_for_status()
return self._status_from_subscription(r2.json())
return "unknown"
except httpx.HTTPStatusError:
return "error"
except Exception:
return "error"
async def get_subscription_status_and_promote(
self, sub_or_session_id: str
) -> tuple[StripeStatus, str]:
"""
Returns (status, effective_id). If given a 'cs_...' and the Checkout
Session has created a subscription, returns the subscription status
AND the promoted 'sub_...' id so you can persist it. If given a 'sub_...',
returns its status and the same id.
"""
sid = self._normalize_stripe_id(sub_or_session_id)
try:
if sid.startswith("sub_"):
r = await self.client.get(f"/v1/subscriptions/{sid}")
if r.status_code == 404:
return ("not_found", sid)
r.raise_for_status()
return (self._status_from_subscription(r.json()), sid)
if sid.startswith("cs_"):
r = await self.client.get(f"/v1/checkout/sessions/{sid}")
if r.status_code == 404:
return ("not_found", sid)
r.raise_for_status()
data = r.json()
subscription_id = data.get("subscription")
if not subscription_id:
return ("pending", sid)
# Promote to the subscription id
r2 = await self.client.get(f"/v1/subscriptions/{subscription_id}")
if r2.status_code == 404:
return ("not_found", subscription_id)
r2.raise_for_status()
return (self._status_from_subscription(r2.json()), subscription_id)
return ("unknown", sid)
except httpx.HTTPStatusError:
return ("error", sid)
except Exception:
return ("error", sid)
def _status_from_subscription(self, sub: dict) -> StripeStatus:
status = (sub or {}).get("status")
if not status:
return "unknown"
return _STRIPE_STATUS_MAP.get(str(status).lower().strip(), "unknown")
# ---------- Helpers ----------
async def _get_price_id_by_lookup_key(self, lookup_key: str) -> str | None:
params = {"active": "true", "expand[]": "data.product", "limit": "1"}
qs = urlencode(params) + f"&lookup_keys[]={lookup_key}"
r = await self.client.get(f"/v1/prices?{qs}")
r.raise_for_status()
data = r.json()
items = (data or {}).get("data") or []
if not items:
return None
return items[0].get("id")
async def list_prices_for_product(self, product_id: str) -> list[dict]:
qs = urlencode({"product": product_id, "active": "true", "limit": "100"})
r = await self.client.get(f"/v1/prices?{qs}")
r.raise_for_status()
data = r.json()
return (data or {}).get("data") or []
def _normalize_stripe_id(self, checking_id: str) -> str: def _normalize_stripe_id(self, checking_id: str) -> str:
"""Remove our internal prefix so Stripe sees a real id."""
return ( return (
checking_id.replace("fiat_stripe_", "", 1) checking_id.replace("fiat_stripe_", "", 1)
if checking_id.startswith("fiat_stripe_") if checking_id.startswith("fiat_stripe_")
@@ -506,9 +274,11 @@ class StripeWallet(FiatProvider):
) )
def _status_from_checkout_session(self, data: dict) -> FiatPaymentStatus: def _status_from_checkout_session(self, data: dict) -> FiatPaymentStatus:
"""Map a Checkout Session to LNbits fiat status."""
if data.get("payment_status") == "paid": if data.get("payment_status") == "paid":
return FiatPaymentSuccessStatus() return FiatPaymentSuccessStatus()
# Consider an expired session a fail (existing 24h rule).
expires_at = data.get("expires_at") expires_at = data.get("expires_at")
_24h_ago = datetime.now(timezone.utc) - timedelta(hours=24) _24h_ago = datetime.now(timezone.utc) - timedelta(hours=24)
if expires_at and float(expires_at) < _24h_ago.timestamp(): if expires_at and float(expires_at) < _24h_ago.timestamp():
@@ -517,18 +287,25 @@ class StripeWallet(FiatProvider):
return FiatPaymentPendingStatus() return FiatPaymentPendingStatus()
def _status_from_payment_intent(self, pi: dict) -> FiatPaymentStatus: def _status_from_payment_intent(self, pi: dict) -> FiatPaymentStatus:
"""Map a PaymentIntent to LNbits fiat status (card_present friendly)."""
status = pi.get("status") status = pi.get("status")
if status == "succeeded": if status == "succeeded":
return FiatPaymentSuccessStatus() return FiatPaymentSuccessStatus()
if status in ("canceled", "payment_failed"): if status in ("canceled", "payment_failed"):
return FiatPaymentFailedStatus() return FiatPaymentFailedStatus()
if status == "requires_payment_method": if status == "requires_payment_method":
if pi.get("last_payment_error"): if pi.get("last_payment_error"):
return FiatPaymentFailedStatus() return FiatPaymentFailedStatus()
now_ts = datetime.now(timezone.utc).timestamp() now_ts = datetime.now(timezone.utc).timestamp()
created_ts = float(pi.get("created") or now_ts) created_ts = float(pi.get("created") or now_ts)
if (now_ts - created_ts) > 300: is_stale = (now_ts - created_ts) > 300
if is_stale:
return FiatPaymentFailedStatus() return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus() return FiatPaymentPendingStatus()
def _build_headers_form(self) -> dict[str, str]: def _build_headers_form(self) -> dict[str, str]:
+1 -1
View File
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -168,6 +168,8 @@ window.localisation.en = {
'Only admin accounts can create extensions', 'Only admin accounts can create extensions',
admin_only: 'Admin Only', admin_only: 'Admin Only',
new_version: 'New Version', new_version: 'New Version',
extension_has_free_release: 'Has free releases',
extension_has_paid_release: 'Has paid releases',
extension_depends_on: 'Depends on:', extension_depends_on: 'Depends on:',
extension_rating_soon: 'Ratings coming soon', extension_rating_soon: 'Ratings coming soon',
extension_installed_version: 'Installed version', extension_installed_version: 'Installed version',
@@ -663,5 +665,7 @@ window.localisation.en = {
callback_success_url_hint: callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful', 'The user will be redirected to this URL after the payment is successful',
connected: 'Connected', connected: 'Connected',
not_connected: 'Not Connected' not_connected: 'Not Connected',
free: 'Free',
paid: 'Paid'
} }
+4 -1
View File
@@ -468,7 +468,10 @@ window.AdminPageLogic = {
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
}, },
formatDate(date) { formatDate(date) {
return moment.utc(date * 1000).fromNow() return moment
.utc(date * 1000)
.local()
.fromNow()
}, },
sendTestEmail() { sendTestEmail() {
LNbits.api LNbits.api
+2 -2
View File
@@ -267,10 +267,10 @@ window.LNbits = {
} }
obj.date = moment.utc(data.created_at).local().format(window.dateFormat) obj.date = moment.utc(data.created_at).local().format(window.dateFormat)
obj.dateFrom = moment.utc(data.created_at).fromNow() obj.dateFrom = moment.utc(data.created_at).local().fromNow()
obj.expirydate = moment.utc(obj.expiry).local().format(window.dateFormat) obj.expirydate = moment.utc(obj.expiry).local().format(window.dateFormat)
obj.expirydateFrom = moment.utc(obj.expiry).fromNow() obj.expirydateFrom = moment.utc(obj.expiry).local().fromNow()
obj.msat = obj.amount obj.msat = obj.amount
obj.sat = obj.msat / 1000 obj.sat = obj.msat / 1000
obj.tag = obj.extra?.tag obj.tag = obj.extra?.tag
+30 -29
View File
@@ -154,7 +154,7 @@ function confettiStars() {
setTimeout(shoot, 200) setTimeout(shoot, 200)
} }
!(function (t, e) { !(function (t, e) {
!(function t(e, n, a, i) { ;(!(function t(e, n, a, i) {
var o = !!( var o = !!(
e.Worker && e.Worker &&
e.Blob && e.Blob &&
@@ -248,12 +248,12 @@ function confettiStars() {
function e(e, n) { function e(e, n) {
t.postMessage({options: e || {}, callback: n}) t.postMessage({options: e || {}, callback: n})
} }
;(t.init = function (e) { ;((t.init = function (e) {
var n = e.transferControlToOffscreen() var n = e.transferControlToOffscreen()
t.postMessage({canvas: n}, [n]) t.postMessage({canvas: n}, [n])
}), }),
(t.fire = function (n, a, i) { (t.fire = function (n, a, i) {
if (g) return e(n, null), g if (g) return (e(n, null), g)
var o = Math.random().toString(36).slice(2) var o = Math.random().toString(36).slice(2)
return (g = l(function (a) { return (g = l(function (a) {
function r(e) { function r(e) {
@@ -264,15 +264,15 @@ function confettiStars() {
i(), i(),
a()) a())
} }
t.addEventListener('message', r), ;(t.addEventListener('message', r),
e(n, o), e(n, o),
(m[o] = r.bind(null, {data: {callback: o}})) (m[o] = r.bind(null, {data: {callback: o}})))
})) }))
}), }),
(t.reset = function () { (t.reset = function () {
for (var e in (t.postMessage({reset: !0}), m)) for (var e in (t.postMessage({reset: !0}), m))
m[e](), delete m[e] (m[e](), delete m[e])
}) }))
})(h) })(h)
} }
return h return h
@@ -328,12 +328,12 @@ function confettiStars() {
) )
} }
function k(t) { function k(t) {
;(t.width = document.documentElement.clientWidth), ;((t.width = document.documentElement.clientWidth),
(t.height = document.documentElement.clientHeight) (t.height = document.documentElement.clientHeight))
} }
function I(t) { function I(t) {
var e = t.getBoundingClientRect() var e = t.getBoundingClientRect()
;(t.width = e.width), (t.height = e.height) ;((t.width = e.width), (t.height = e.height))
} }
function T(t, e, n, o, r) { function T(t, e, n, o, r) {
var c, var c,
@@ -342,10 +342,10 @@ function confettiStars() {
d = t.getContext('2d'), d = t.getContext('2d'),
f = l(function (e) { f = l(function (e) {
function l() { function l() {
;(c = s = null), d.clearRect(0, 0, o.width, o.height), r(), e() ;((c = s = null), d.clearRect(0, 0, o.width, o.height), r(), e())
} }
;(c = b.frame(function e() { ;((c = b.frame(function e() {
!a || ;(!a ||
(o.width === i.width && o.height === i.height) || (o.width === i.width && o.height === i.height) ||
((o.width = t.width = i.width), (o.height = t.height = i.height)), ((o.width = t.width = i.width), (o.height = t.height = i.height)),
o.width || o.width ||
@@ -354,7 +354,7 @@ function confettiStars() {
d.clearRect(0, 0, o.width, o.height), d.clearRect(0, 0, o.width, o.height),
(u = u.filter(function (t) { (u = u.filter(function (t) {
return (function (t, e) { return (function (t, e) {
;(e.x += Math.cos(e.angle2D) * e.velocity + e.drift), ;((e.x += Math.cos(e.angle2D) * e.velocity + e.drift),
(e.y += Math.sin(e.angle2D) * e.velocity + e.gravity), (e.y += Math.sin(e.angle2D) * e.velocity + e.gravity),
(e.wobble += 0.1), (e.wobble += 0.1),
(e.velocity *= e.decay), (e.velocity *= e.decay),
@@ -363,7 +363,7 @@ function confettiStars() {
(e.tiltCos = Math.cos(e.tiltAngle)), (e.tiltCos = Math.cos(e.tiltAngle)),
(e.random = Math.random() + 5), (e.random = Math.random() + 5),
(e.wobbleX = e.x + 10 * e.scalar * Math.cos(e.wobble)), (e.wobbleX = e.x + 10 * e.scalar * Math.cos(e.wobble)),
(e.wobbleY = e.y + 10 * e.scalar * Math.sin(e.wobble)) (e.wobbleY = e.y + 10 * e.scalar * Math.sin(e.wobble)))
var n = e.tick++ / e.totalTicks, var n = e.tick++ / e.totalTicks,
a = e.x + e.random * e.tiltCos, a = e.x + e.random * e.tiltCos,
i = e.y + e.random * e.tiltSin, i = e.y + e.random * e.tiltSin,
@@ -393,12 +393,12 @@ function confettiStars() {
2 * Math.PI 2 * Math.PI
) )
: (function (t, e, n, a, i, o, r, l, c) { : (function (t, e, n, a, i, o, r, l, c) {
t.save(), ;(t.save(),
t.translate(e, n), t.translate(e, n),
t.rotate(o), t.rotate(o),
t.scale(a, i), t.scale(a, i),
t.arc(0, 0, 1, r, l, c), t.arc(0, 0, 1, r, l, c),
t.restore() t.restore())
})( })(
t, t,
e.x, e.x,
@@ -420,18 +420,18 @@ function confettiStars() {
})(d, t) })(d, t)
})).length })).length
? (c = b.frame(e)) ? (c = b.frame(e))
: l() : l())
})), })),
(s = l) (s = l))
}) })
return { return {
addFettis: function (t) { addFettis: function (t) {
return (u = u.concat(t)), f return ((u = u.concat(t)), f)
}, },
canvas: t, canvas: t,
promise: f, promise: f,
reset: function () { reset: function () {
c && b.cancel(c), s && s() ;(c && b.cancel(c), s && s())
} }
} }
} }
@@ -466,7 +466,7 @@ function confettiStars() {
k = p(e, 'scalar'), k = p(e, 'scalar'),
I = (function (t) { I = (function (t) {
var e = p(t, 'origin', Object) var e = p(t, 'origin', Object)
return (e.x = p(e, 'x', Number)), (e.y = p(e, 'y', Number)), e return ((e.x = p(e, 'x', Number)), (e.y = p(e, 'y', Number)), e)
})(e), })(e),
E = d, E = d,
S = [], S = [],
@@ -531,7 +531,7 @@ function confettiStars() {
return l(function (t) { return l(function (t) {
t() t()
}) })
i && a ;(i && a
? (t = a.canvas) ? (t = a.canvas)
: i && : i &&
!t && !t &&
@@ -547,7 +547,7 @@ function confettiStars() {
) )
})(g)), })(g)),
document.body.appendChild(t)), document.body.appendChild(t)),
r && !d && u(t) r && !d && u(t))
var m = {width: t.width, height: t.height} var m = {width: t.width, height: t.height}
function b() { function b() {
if (s) { if (s) {
@@ -564,9 +564,9 @@ function confettiStars() {
m.width = m.height = null m.width = m.height = null
} }
function v() { function v() {
;(a = null), ;((a = null),
r && e.removeEventListener('resize', b), r && e.removeEventListener('resize', b),
i && t && (document.body.removeChild(t), (t = null), (d = !1)) i && t && (document.body.removeChild(t), (t = null), (d = !1)))
} }
return ( return (
s && !d && s.init(t), s && !d && s.init(t),
@@ -578,12 +578,13 @@ function confettiStars() {
} }
return ( return (
(g.reset = function () { (g.reset = function () {
s && s.reset(), a && a.reset() ;(s && s.reset(), a && a.reset())
}), }),
g g
) )
} }
;(n.exports = E(null, {useWorker: !0, resize: !0})), (n.exports.create = E) ;((n.exports = E(null, {useWorker: !0, resize: !0})),
(n.exports.create = E))
})( })(
(function () { (function () {
return void 0 !== t ? t : 'undefined' != typeof self ? self : this || {} return void 0 !== t ? t : 'undefined' != typeof self ? self : this || {}
@@ -591,5 +592,5 @@ function confettiStars() {
e, e,
!1 !1
), ),
(t.confetti = e.exports) (t.confetti = e.exports))
})(window, {}) })(window, {})
+1 -1
View File
@@ -600,7 +600,7 @@ window.app.component('lnbits-date', {
return LNbits.utils.formatDate(this.ts) return LNbits.utils.formatDate(this.ts)
}, },
dateFrom() { dateFrom() {
return moment.utc(this.date).fromNow() return moment.utc(this.date).local().fromNow()
} }
}, },
template: ` template: `
+1 -1
View File
@@ -164,7 +164,7 @@ window.PaymentsPageLogic = {
if (p.extra && p.extra.tag) { if (p.extra && p.extra.tag) {
p.tag = p.extra.tag p.tag = p.extra.tag
} }
p.timeFrom = moment.utc(p.created_at).fromNow() p.timeFrom = moment.utc(p.created_at).local().fromNow()
p.outgoing = p.amount < 0 p.outgoing = p.amount < 0
p.amount = p.amount =
new Intl.NumberFormat(window.LOCALE).format(p.amount / 1000) + new Intl.NumberFormat(window.LOCALE).format(p.amount / 1000) +
+9 -3
View File
@@ -179,7 +179,7 @@ window.WalletPageLogic = {
methods: { methods: {
dateFromNow(unix) { dateFromNow(unix) {
const date = new Date(unix * 1000) const date = new Date(unix * 1000)
return moment.utc(date).fromNow() return moment.utc(date).local().fromNow()
}, },
formatFiatAmount(amount, currency) { formatFiatAmount(amount, currency) {
this.update.currency = currency this.update.currency = currency
@@ -476,8 +476,14 @@ window.WalletPageLogic = {
createdDate, createdDate,
'YYYY-MM-DDTHH:mm:ss.SSSZ' 'YYYY-MM-DDTHH:mm:ss.SSSZ'
) )
cleanInvoice.expireDateFrom = moment.utc(expireDate).fromNow() cleanInvoice.expireDateFrom = moment
cleanInvoice.createdDateFrom = moment.utc(createdDate).fromNow() .utc(expireDate)
.local()
.fromNow()
cleanInvoice.createdDateFrom = moment
.utc(createdDate)
.local()
.fromNow()
cleanInvoice.expired = false // TODO cleanInvoice.expired = false // TODO
} }
+1 -2
View File
@@ -649,8 +649,7 @@
</div> </div>
<div <div
v-if="showButtons" v-if="showButtons"
class="qrcode__buttons row q-gutter-x-sm" class="qrcode__buttons row q-gutter-x-sm items-center justify-end no-wrap full-width"
style="justify-content: flex-end"
> >
<q-btn <q-btn
v-if="nfc && nfcSupported" v-if="nfc && nfcSupported"
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "lnbits" name = "lnbits"
version = "1.3.0-rc8" version = "1.3.0"
requires-python = ">=3.10,<3.13" requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system." description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }] authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
Generated
+1 -1
View File
@@ -1260,7 +1260,7 @@ wheels = [
[[package]] [[package]]
name = "lnbits" name = "lnbits"
version = "1.3.0rc8" version = "1.3.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiosqlite" }, { name = "aiosqlite" },