Compare commits

..
Author SHA1 Message Date
Arc 1f46545dea Reapply "Merge remote-tracking branch 'origin/dev' into stripe_recurring"
This reverts commit 37d2a695a7.
2025-09-26 11:49:42 +01:00
Arc 37d2a695a7 Revert "Merge remote-tracking branch 'origin/dev' into stripe_recurring"
This reverts commit 63458ced1e, reversing
changes made to cb3e340a78.
2025-09-26 11:47:50 +01:00
Arc 63458ced1e Merge remote-tracking branch 'origin/dev' into stripe_recurring 2025-09-26 11:44:04 +01:00
Arc cb3e340a78 mypy fix 2025-09-26 11:42:18 +01:00
arcbtc ae24f7e43c Merge branch 'or_stripe' into stripe_recurring 2025-09-25 05:18:26 +01:00
arcbtc 609808f6a2 added helper for getting subscription id 2025-09-25 05:17:08 +01:00
arcbtc c760e6f63d added a helper to get the subscription id
if returned that can be used for the checking id
2025-09-25 05:13:04 +01:00
ArcandArc 991ac4d7fe make 2025-09-25 03:58:14 +01:00
ArcandArc c1c622524e Working 2025-09-25 03:58:14 +01:00
arcbtcandArc ccc784c8fc recuuring payments 2025-09-25 03:58:14 +01:00
Arc 7c72766bbd make 2025-09-16 22:49:55 +01:00
Arc 416d170996 Working 2025-09-16 22:45:55 +01:00
arcbtc 94ebc22dcc recuuring payments 2025-09-15 21:13:28 +01:00
24 changed files with 375 additions and 266 deletions
+1 -1
View File
@@ -1 +1 @@
custom: https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg custom: https://demo.lnbits.com/lnurlp/link/fH59GD
+17 -13
View File
@@ -5,19 +5,23 @@ on:
types: [published] types: [published]
jobs: jobs:
build-linux-package: build-linux-package:
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
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
@@ -26,6 +30,7 @@ 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:
@@ -34,6 +39,7 @@ 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
@@ -41,11 +47,18 @@ 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 \
@@ -53,10 +66,6 @@ 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)"
@@ -66,7 +75,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
# keep AppDir slim # Clean out non-dist content from the app dir to keep AppImage 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' \
@@ -82,14 +91,9 @@ 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:2.8.3 AS boltz FROM boltz/boltz-client:latest AS boltz
FROM lnbits/lnbits:latest FROM lnbits/lnbits:latest
+6 -19
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 jq libfuse2 sudo apt-get install 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 --yes sh <(curl --proto '=https' --tlsv1.2 -L https://nixos.org/nix/install) --daemon
# 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,29 +145,16 @@ 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
# Build LNbits # Clone and build LNbits
git clone https://github.com/lnbits/lnbits.git
cd lnbits
nix build nix build
mkdir data
``` ```
#### Running the server #### Running the server
+42 -64
View File
@@ -1,75 +1,53 @@
#!/usr/bin/env bash #!/bin/bash
set -euo pipefail
# --- Config you might tweak --- # Check install has not already run
REPO_URL="https://github.com/lnbits/lnbits.git" if [ ! -d lnbits/data ]; then
BRANCH="main"
APP_DIR="${PWD}/lnbits"
HOST="${HOST:-0.0.0.0}"
PORT="${PORT:-5000}"
ADMIN_UI="${LNBITS_ADMIN_UI:-true}"
# -------------------------------
export DEBIAN_FRONTEND=noninteractive # Update package list and install prerequisites non-interactively
sudo apt update -y
sudo apt install -y software-properties-common
# Ensure basic tooling # Add the deadsnakes PPA repository non-interactively
if ! command -v curl >/dev/null 2>&1 || ! command -v git >/dev/null 2>&1; then sudo add-apt-repository -y ppa:deadsnakes/ppa
sudo apt-get update -y
sudo apt-get install -y curl git
fi
# System build deps and secp headers # Install Python 3.10 and distutils non-interactively
if command -v apt-get >/dev/null 2>&1; then sudo apt install -y python3.10 python3.10-distutils
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) # Install UV
if ! command -v uv >/dev/null 2>&1; then
curl -LsSf https://astral.sh/uv/install.sh | sh curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
fi export PATH="/home/$USER/.local/bin:$PATH"
# Ensure PATH for current session
if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then if [ ! -d lnbits/wallets ]; then
export PATH="$HOME/.local/bin:$PATH" # 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
# Clone or reuse repo # Install the dependencies using UV
if [[ ! -d "$APP_DIR/.git" ]]; then uv sync --all-extras
git clone "$REPO_URL" "$APP_DIR"
fi
cd "$APP_DIR"
git fetch --all --prune
git checkout "$BRANCH"
git pull --ff-only || true
# First-run setup # Set environment variables for LNbits
mkdir -p data export LNBITS_ADMIN_UI=true
[[ -f .env ]] || cp .env.example .env || true export HOST=0.0.0.0
# Prefer system libsecp256k1 (avoid autotools path) # Run LNbits
export SECP_BUNDLED=0 uv run lnbits
# 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
+11 -37
View File
@@ -39,7 +39,6 @@ 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):
@@ -188,7 +187,6 @@ 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
@@ -258,7 +256,6 @@ 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,
) )
@@ -311,9 +308,6 @@ 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):
@@ -457,23 +451,9 @@ class InstallableExtension(BaseModel):
shutil.rmtree(self.ext_upgrade_dir, True) shutil.rmtree(self.ext_upgrade_dir, True)
def check_release_updates(self, release: ExtensionRelease | None): def check_latest_version(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
@@ -484,19 +464,13 @@ class InstallableExtension(BaseModel):
): ):
self.meta.latest_release = release self.meta.latest_release = release
def _check_payment_link(self, release: ExtensionRelease | None): def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None:
if not release: if not pay_link or not self.meta or not self.meta.payments:
return return None
if not release.is_version_compatible: return next(
return (p for p in self.meta.payments if p.pay_link == pay_link),
if not self.meta: None,
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 (
@@ -622,7 +596,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_release_updates(ext.meta.latest_release) existing_ext.check_latest_version(ext.meta.latest_release)
continue continue
meta = ext.meta or ExtensionMeta() meta = ext.meta or ExtensionMeta()
@@ -636,10 +610,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_release_updates(release) existing_ext.check_latest_version(release)
continue continue
ext = InstallableExtension.from_explicit_release(e) ext = InstallableExtension.from_explicit_release(e)
ext.check_release_updates(release) ext.check_latest_version(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,25 +76,19 @@
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">
+2 -33
View File
@@ -137,40 +137,8 @@
@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 style="justify-content: space-between; display: flex"> <div>
<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
@@ -948,6 +916,7 @@
: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>
+1 -2
View File
@@ -113,9 +113,8 @@ 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: str): async def img(data):
qr = pyqrcode.create(data) qr = pyqrcode.create(data)
stream = BytesIO() stream = BytesIO()
qr.svg(stream, scale=3) qr.svg(stream, scale=3)
+3 -4
View File
@@ -61,10 +61,9 @@ 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(HTTPStatus.NOT_FOUND, "Release not found") raise HTTPException(
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,9 +126,6 @@ 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
+1 -3
View File
@@ -296,9 +296,7 @@ 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:
if wallet and wallet.id == payment.wallet_id: return {"paid": False, "status": "failed", "details": payment}
return {"paid": False, "status": "failed", "details": payment}
return {"paid": False, "status": "failed"}
try: try:
status = await payment.check_status() status = await payment.check_status()
+236 -13
View File
@@ -6,6 +6,7 @@ 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
@@ -25,6 +26,34 @@ 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:
@@ -43,6 +72,22 @@ 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"
@@ -50,6 +95,7 @@ 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):
@@ -89,12 +135,10 @@ 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)
@@ -116,6 +160,11 @@ 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
@@ -170,6 +219,7 @@ 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,
@@ -223,6 +273,7 @@ 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,
@@ -265,8 +316,189 @@ 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_")
@@ -274,11 +506,9 @@ 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():
@@ -287,25 +517,18 @@ 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)
is_stale = (now_ts - created_ts) > 300 if (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
+1 -5
View File
@@ -168,8 +168,6 @@ 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',
@@ -665,7 +663,5 @@ 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'
} }
+1 -4
View File
@@ -468,10 +468,7 @@ window.AdminPageLogic = {
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
}, },
formatDate(date) { formatDate(date) {
return moment return moment.utc(date * 1000).fromNow()
.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).local().fromNow() obj.dateFrom = moment.utc(data.created_at).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).local().fromNow() obj.expirydateFrom = moment.utc(obj.expiry).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
+29 -30
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,13 +578,12 @@ 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 = E(null, {useWorker: !0, resize: !0})), (n.exports.create = E)
(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 || {}
@@ -592,5 +591,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).local().fromNow() return moment.utc(this.date).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).local().fromNow() p.timeFrom = moment.utc(p.created_at).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) +
+3 -9
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).local().fromNow() return moment.utc(date).fromNow()
}, },
formatFiatAmount(amount, currency) { formatFiatAmount(amount, currency) {
this.update.currency = currency this.update.currency = currency
@@ -476,14 +476,8 @@ window.WalletPageLogic = {
createdDate, createdDate,
'YYYY-MM-DDTHH:mm:ss.SSSZ' 'YYYY-MM-DDTHH:mm:ss.SSSZ'
) )
cleanInvoice.expireDateFrom = moment cleanInvoice.expireDateFrom = moment.utc(expireDate).fromNow()
.utc(expireDate) cleanInvoice.createdDateFrom = moment.utc(createdDate).fromNow()
.local()
.fromNow()
cleanInvoice.createdDateFrom = moment
.utc(createdDate)
.local()
.fromNow()
cleanInvoice.expired = false // TODO cleanInvoice.expired = false // TODO
} }
+2 -1
View File
@@ -649,7 +649,8 @@
</div> </div>
<div <div
v-if="showButtons" v-if="showButtons"
class="qrcode__buttons row q-gutter-x-sm items-center justify-end no-wrap full-width" class="qrcode__buttons row q-gutter-x-sm"
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" version = "1.3.0-rc8"
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.0" version = "1.3.0rc8"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiosqlite" }, { name = "aiosqlite" },