Merge branch 'main' into diagon-alley
This commit is contained in:
commit
af906740ca
237 changed files with 16064 additions and 26363 deletions
|
|
@ -6,6 +6,10 @@ tests
|
||||||
venv
|
venv
|
||||||
tools
|
tools
|
||||||
|
|
||||||
|
lnbits/static/css/*
|
||||||
|
lnbits/static/bundle.js
|
||||||
|
lnbits/static/bundle.css
|
||||||
|
|
||||||
*.md
|
*.md
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
|
|
||||||
14
.env.example
14
.env.example
|
|
@ -25,6 +25,8 @@ LNBITS_DATA_FOLDER="./data"
|
||||||
|
|
||||||
LNBITS_FORCE_HTTPS=true
|
LNBITS_FORCE_HTTPS=true
|
||||||
LNBITS_SERVICE_FEE="0.0"
|
LNBITS_SERVICE_FEE="0.0"
|
||||||
|
LNBITS_RESERVE_FEE_MIN=2000 # value in millisats
|
||||||
|
LNBITS_RESERVE_FEE_PERCENT=1.0 # value in percent
|
||||||
|
|
||||||
# Change theme
|
# Change theme
|
||||||
LNBITS_SITE_TITLE="LNbits"
|
LNBITS_SITE_TITLE="LNbits"
|
||||||
|
|
@ -34,19 +36,23 @@ LNBITS_SITE_DESCRIPTION="Some description about your service, will display if ti
|
||||||
LNBITS_THEME_OPTIONS="classic, bitcoin, freedom, mint, autumn, monochrome, salvador"
|
LNBITS_THEME_OPTIONS="classic, bitcoin, freedom, mint, autumn, monochrome, salvador"
|
||||||
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||||
|
|
||||||
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet,
|
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, ClicheWallet
|
||||||
# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet
|
# LndRestWallet, CoreLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet
|
||||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||||
# just so you can see the UI before dealing with this file.
|
# just so you can see the UI before dealing with this file.
|
||||||
|
|
||||||
# Set one of these blocks depending on the wallet kind you chose above:
|
# Set one of these blocks depending on the wallet kind you chose above:
|
||||||
|
|
||||||
|
# ClicheWallet
|
||||||
|
CLICHE_ENDPOINT=ws://127.0.0.1:12000
|
||||||
|
|
||||||
# SparkWallet
|
# SparkWallet
|
||||||
SPARK_URL=http://localhost:9737/rpc
|
SPARK_URL=http://localhost:9737/rpc
|
||||||
SPARK_TOKEN=myaccesstoken
|
SPARK_TOKEN=myaccesstoken
|
||||||
|
|
||||||
# CLightningWallet
|
# CoreLightningWallet
|
||||||
CLIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
|
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
|
||||||
|
|
||||||
# LnbitsWallet
|
# LnbitsWallet
|
||||||
LNBITS_ENDPOINT=https://legend.lnbits.com
|
LNBITS_ENDPOINT=https://legend.lnbits.com
|
||||||
|
|
|
||||||
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
|
|
@ -1 +1 @@
|
||||||
custom: https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK
|
custom: https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK
|
||||||
|
|
|
||||||
35
.github/workflows/formatting.yml
vendored
35
.github/workflows/formatting.yml
vendored
|
|
@ -7,30 +7,19 @@ on:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
black:
|
checks:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- run: sudo apt-get install python3-venv
|
- uses: abatilo/actions-poetry@v2.1.3
|
||||||
- run: python3 -m venv venv
|
- name: Install packages
|
||||||
- run: ./venv/bin/pip install black
|
run: poetry install
|
||||||
- run: make checkblack
|
- name: Check black
|
||||||
isort:
|
run: make checkblack
|
||||||
runs-on: ubuntu-latest
|
- name: Check isort
|
||||||
steps:
|
run: make checkisort
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v3
|
||||||
- run: sudo apt-get install python3-venv
|
- name: Check prettier
|
||||||
- run: python3 -m venv venv
|
run: |
|
||||||
- run: ./venv/bin/pip install isort
|
npm install prettier
|
||||||
- run: make checkisort
|
make checkprettier
|
||||||
|
|
||||||
prettier:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: actions/setup-node@v3
|
|
||||||
- run: sudo apt-get install python3-venv
|
|
||||||
- run: python3 -m venv venv
|
|
||||||
- run: npm install prettier
|
|
||||||
- run: ./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js
|
|
||||||
|
|
|
||||||
19
.github/workflows/migrations.yml
vendored
19
.github/workflows/migrations.yml
vendored
|
|
@ -22,28 +22,25 @@ jobs:
|
||||||
--health-retries 5
|
--health-retries 5
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [3.8]
|
python-version: [3.9]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v2
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- uses: abatilo/actions-poetry@v2.1.3
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
|
||||||
VIRTUAL_ENV: ./venv
|
|
||||||
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
|
||||||
run: |
|
run: |
|
||||||
python -m venv ${{ env.VIRTUAL_ENV }}
|
poetry install
|
||||||
./venv/bin/python -m pip install --upgrade pip
|
sudo apt install unzip
|
||||||
./venv/bin/pip install -r requirements.txt
|
|
||||||
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
|
|
||||||
- name: Run migrations
|
- name: Run migrations
|
||||||
run: |
|
run: |
|
||||||
rm -rf ./data
|
rm -rf ./data
|
||||||
mkdir -p ./data
|
mkdir -p ./data
|
||||||
export LNBITS_DATA_FOLDER="./data"
|
export LNBITS_DATA_FOLDER="./data"
|
||||||
timeout 5s ./venv/bin/uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5001 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
unzip tests/data/mock_data.zip -d ./data
|
||||||
|
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5001 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
||||||
export LNBITS_DATABASE_URL="postgres://postgres:postgres@0.0.0.0:5432/postgres"
|
export LNBITS_DATABASE_URL="postgres://postgres:postgres@0.0.0.0:5432/postgres"
|
||||||
timeout 5s ./venv/bin/uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5001 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5001 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
||||||
./venv/bin/python tools/conv.py --dont-ignore-missing
|
poetry run python tools/conv.py
|
||||||
|
|
|
||||||
18
.github/workflows/mypy.yml
vendored
18
.github/workflows/mypy.yml
vendored
|
|
@ -5,10 +5,18 @@ on: [push, pull_request]
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ 'false' == 'true' }} # skip mypy for now
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: [3.9]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v2
|
||||||
- uses: jpetrucciani/mypy-check@master
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v2
|
||||||
with:
|
with:
|
||||||
mypy_flags: '--install-types --non-interactive'
|
python-version: ${{ matrix.python-version }}
|
||||||
path: lnbits
|
- uses: abatilo/actions-poetry@v2.1.3
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
poetry install
|
||||||
|
- name: Run tests
|
||||||
|
run: poetry run mypy
|
||||||
|
|
|
||||||
104
.github/workflows/regtest.yml
vendored
104
.github/workflows/regtest.yml
vendored
|
|
@ -7,37 +7,25 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [3.7]
|
python-version: [3.9]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v2
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- uses: abatilo/actions-poetry@v2.1.3
|
||||||
- name: Setup Regtest
|
- name: Setup Regtest
|
||||||
run: |
|
run: |
|
||||||
docker build -t lnbits-legend .
|
docker build -t lnbits-legend .
|
||||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||||
cd docker
|
cd docker
|
||||||
source docker-scripts.sh
|
chmod +x ./tests
|
||||||
lnbits-regtest-start
|
./tests
|
||||||
echo "sleeping 60 seconds"
|
|
||||||
sleep 60
|
|
||||||
echo "continue"
|
|
||||||
lnbits-regtest-init
|
|
||||||
bitcoin-cli-sim -generate 1
|
|
||||||
lncli-sim 1 listpeers
|
|
||||||
sudo chmod -R a+rwx .
|
sudo chmod -R a+rwx .
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
|
||||||
VIRTUAL_ENV: ./venv
|
|
||||||
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
|
||||||
run: |
|
run: |
|
||||||
python -m venv ${{ env.VIRTUAL_ENV }}
|
poetry install
|
||||||
./venv/bin/python -m pip install --upgrade pip
|
|
||||||
./venv/bin/pip install -r requirements.txt
|
|
||||||
./venv/bin/pip install pylightning
|
|
||||||
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
env:
|
env:
|
||||||
PYTHONUNBUFFERED: 1
|
PYTHONUNBUFFERED: 1
|
||||||
|
|
@ -45,53 +33,91 @@ jobs:
|
||||||
LNBITS_DATA_FOLDER: ./data
|
LNBITS_DATA_FOLDER: ./data
|
||||||
LNBITS_BACKEND_WALLET_CLASS: LndRestWallet
|
LNBITS_BACKEND_WALLET_CLASS: LndRestWallet
|
||||||
LND_REST_ENDPOINT: https://localhost:8081/
|
LND_REST_ENDPOINT: https://localhost:8081/
|
||||||
LND_REST_CERT: docker/data/lnd-1/tls.cert
|
LND_REST_CERT: ./docker/data/lnd-1/tls.cert
|
||||||
LND_REST_MACAROON: docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
|
LND_REST_MACAROON: ./docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
|
||||||
run: |
|
run: |
|
||||||
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
|
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
|
||||||
make test-real-wallet
|
make test-real-wallet
|
||||||
CLightningWallet:
|
- name: Upload coverage to Codecov
|
||||||
|
uses: codecov/codecov-action@v3
|
||||||
|
with:
|
||||||
|
file: ./coverage.xml
|
||||||
|
LndWallet:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [3.7]
|
python-version: [3.8]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v2
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- uses: abatilo/actions-poetry@v2.1.3
|
||||||
- name: Setup Regtest
|
- name: Setup Regtest
|
||||||
run: |
|
run: |
|
||||||
docker build -t lnbits-legend .
|
docker build -t lnbits-legend .
|
||||||
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||||
cd docker
|
cd docker
|
||||||
source docker-scripts.sh
|
chmod +x ./tests
|
||||||
lnbits-regtest-start
|
./tests
|
||||||
echo "sleeping 60 seconds"
|
|
||||||
sleep 60
|
|
||||||
echo "continue"
|
|
||||||
lnbits-regtest-init
|
|
||||||
bitcoin-cli-sim -generate 1
|
|
||||||
lncli-sim 1 listpeers
|
|
||||||
sudo chmod -R a+rwx .
|
sudo chmod -R a+rwx .
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
|
||||||
VIRTUAL_ENV: ./venv
|
|
||||||
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
|
||||||
run: |
|
run: |
|
||||||
python -m venv ${{ env.VIRTUAL_ENV }}
|
poetry install
|
||||||
./venv/bin/python -m pip install --upgrade pip
|
poetry add grpcio protobuf
|
||||||
./venv/bin/pip install -r requirements.txt
|
|
||||||
./venv/bin/pip install pylightning
|
|
||||||
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
env:
|
env:
|
||||||
PYTHONUNBUFFERED: 1
|
PYTHONUNBUFFERED: 1
|
||||||
PORT: 5123
|
PORT: 5123
|
||||||
LNBITS_DATA_FOLDER: ./data
|
LNBITS_DATA_FOLDER: ./data
|
||||||
LNBITS_BACKEND_WALLET_CLASS: CLightningWallet
|
LNBITS_BACKEND_WALLET_CLASS: LndWallet
|
||||||
CLIGHTNING_RPC: docker/data/clightning-1/regtest/lightning-rpc
|
LND_GRPC_ENDPOINT: localhost
|
||||||
|
LND_GRPC_PORT: 10009
|
||||||
|
LND_GRPC_CERT: docker/data/lnd-1/tls.cert
|
||||||
|
LND_GRPC_MACAROON: docker/data/lnd-1/data/chain/bitcoin/regtest/admin.macaroon
|
||||||
run: |
|
run: |
|
||||||
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
|
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
|
||||||
make test-real-wallet
|
make test-real-wallet
|
||||||
|
- name: Upload coverage to Codecov
|
||||||
|
uses: codecov/codecov-action@v3
|
||||||
|
with:
|
||||||
|
file: ./coverage.xml
|
||||||
|
CoreLightningWallet:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: [3.9]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- uses: abatilo/actions-poetry@v2.1.3
|
||||||
|
- name: Setup Regtest
|
||||||
|
run: |
|
||||||
|
docker build -t lnbits-legend .
|
||||||
|
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
|
||||||
|
cd docker
|
||||||
|
chmod +x ./tests
|
||||||
|
./tests
|
||||||
|
sudo chmod -R a+rwx .
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
poetry install
|
||||||
|
poetry add pyln-client
|
||||||
|
- name: Run tests
|
||||||
|
env:
|
||||||
|
PYTHONUNBUFFERED: 1
|
||||||
|
PORT: 5123
|
||||||
|
LNBITS_DATA_FOLDER: ./data
|
||||||
|
LNBITS_BACKEND_WALLET_CLASS: CoreLightningWallet
|
||||||
|
CORELIGHTNING_RPC: ./docker/data/clightning-1/regtest/lightning-rpc
|
||||||
|
run: |
|
||||||
|
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
|
||||||
|
make test-real-wallet
|
||||||
|
- name: Upload coverage to Codecov
|
||||||
|
uses: codecov/codecov-action@v3
|
||||||
|
with:
|
||||||
|
file: ./coverage.xml
|
||||||
|
|
|
||||||
53
.github/workflows/tests.yml
vendored
53
.github/workflows/tests.yml
vendored
|
|
@ -3,11 +3,11 @@ name: tests
|
||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sqlite:
|
venv-sqlite:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [3.7, 3.8]
|
python-version: [3.9]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
|
@ -23,6 +23,26 @@ jobs:
|
||||||
./venv/bin/python -m pip install --upgrade pip
|
./venv/bin/python -m pip install --upgrade pip
|
||||||
./venv/bin/pip install -r requirements.txt
|
./venv/bin/pip install -r requirements.txt
|
||||||
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
|
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
|
||||||
|
- name: Run tests
|
||||||
|
run: make test-venv
|
||||||
|
sqlite:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: [3.9]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- uses: abatilo/actions-poetry@v2.1.3
|
||||||
|
- name: Install dependencies
|
||||||
|
env:
|
||||||
|
VIRTUAL_ENV: ./venv
|
||||||
|
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
||||||
|
run: |
|
||||||
|
poetry install
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: make test
|
run: make test
|
||||||
postgres:
|
postgres:
|
||||||
|
|
@ -44,22 +64,17 @@ jobs:
|
||||||
--health-retries 5
|
--health-retries 5
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [3.7]
|
python-version: [3.9]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v2
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- uses: abatilo/actions-poetry@v2.1.3
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
|
||||||
VIRTUAL_ENV: ./venv
|
|
||||||
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
|
||||||
run: |
|
run: |
|
||||||
python -m venv ${{ env.VIRTUAL_ENV }}
|
poetry install
|
||||||
./venv/bin/python -m pip install --upgrade pip
|
|
||||||
./venv/bin/pip install -r requirements.txt
|
|
||||||
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
env:
|
env:
|
||||||
LNBITS_DATABASE_URL: postgres://postgres:postgres@0.0.0.0:5432/postgres
|
LNBITS_DATABASE_URL: postgres://postgres:postgres@0.0.0.0:5432/postgres
|
||||||
|
|
@ -68,21 +83,3 @@ jobs:
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v3
|
||||||
with:
|
with:
|
||||||
file: ./coverage.xml
|
file: ./coverage.xml
|
||||||
pipenv:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
python-version: [3.7]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
|
||||||
uses: actions/setup-python@v2
|
|
||||||
with:
|
|
||||||
python-version: ${{ matrix.python-version }}
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
pip install pipenv
|
|
||||||
pipenv install --dev
|
|
||||||
pipenv install importlib-metadata
|
|
||||||
- name: Run tests
|
|
||||||
run: make test-pipenv
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -15,7 +15,7 @@ __pycache__
|
||||||
.webassets-cache
|
.webassets-cache
|
||||||
htmlcov
|
htmlcov
|
||||||
test-reports
|
test-reports
|
||||||
tests/data
|
tests/data/*.sqlite3
|
||||||
|
|
||||||
*.swo
|
*.swo
|
||||||
*.swp
|
*.swp
|
||||||
|
|
@ -31,6 +31,10 @@ venv
|
||||||
|
|
||||||
__bundle__
|
__bundle__
|
||||||
|
|
||||||
|
coverage.xml
|
||||||
node_modules
|
node_modules
|
||||||
lnbits/static/bundle.*
|
lnbits/static/bundle.*
|
||||||
docker
|
docker
|
||||||
|
|
||||||
|
# Nix
|
||||||
|
*result*
|
||||||
|
|
|
||||||
51
Dockerfile
51
Dockerfile
|
|
@ -1,45 +1,12 @@
|
||||||
# Build image
|
FROM python:3.9-slim
|
||||||
FROM python:3.7-slim as builder
|
|
||||||
|
|
||||||
# Setup virtualenv
|
|
||||||
ENV VIRTUAL_ENV=/opt/venv
|
|
||||||
RUN python -m venv $VIRTUAL_ENV
|
|
||||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
|
||||||
|
|
||||||
# Install build deps
|
|
||||||
RUN apt-get update
|
RUN apt-get update
|
||||||
RUN apt-get install -y --no-install-recommends build-essential pkg-config libpq-dev
|
RUN apt-get install -y curl
|
||||||
RUN python -m pip install --upgrade pip
|
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||||
RUN pip install wheel
|
ENV PATH="/root/.local/bin:$PATH"
|
||||||
|
|
||||||
# Install runtime deps
|
|
||||||
COPY requirements.txt /tmp/requirements.txt
|
|
||||||
RUN pip install -r /tmp/requirements.txt
|
|
||||||
|
|
||||||
# Install c-lightning specific deps
|
|
||||||
RUN pip install pylightning
|
|
||||||
|
|
||||||
# Install LND specific deps
|
|
||||||
RUN pip install lndgrpc
|
|
||||||
|
|
||||||
# Production image
|
|
||||||
FROM python:3.7-slim as lnbits
|
|
||||||
|
|
||||||
# Run as non-root
|
|
||||||
USER 1000:1000
|
|
||||||
|
|
||||||
# Copy over virtualenv
|
|
||||||
ENV VIRTUAL_ENV="/opt/venv"
|
|
||||||
COPY --from=builder --chown=1000:1000 $VIRTUAL_ENV $VIRTUAL_ENV
|
|
||||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
|
||||||
|
|
||||||
# Copy in app source
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --chown=1000:1000 lnbits /app/lnbits
|
COPY . .
|
||||||
|
RUN poetry config virtualenvs.create false
|
||||||
ENV LNBITS_PORT="5000"
|
RUN poetry install --no-dev --no-root
|
||||||
ENV LNBITS_HOST="0.0.0.0"
|
RUN poetry run python build.py
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
CMD ["poetry", "run", "lnbits", "--port", "5000", "--host", "0.0.0.0"]
|
||||||
CMD ["sh", "-c", "uvicorn lnbits.__main__:app --port $LNBITS_PORT --host $LNBITS_HOST"]
|
|
||||||
|
|
|
||||||
48
Makefile
48
Makefile
|
|
@ -4,61 +4,47 @@ all: format check requirements.txt
|
||||||
|
|
||||||
format: prettier isort black
|
format: prettier isort black
|
||||||
|
|
||||||
check: mypy checkprettier checkblack
|
check: mypy checkprettier checkisort checkblack
|
||||||
|
|
||||||
prettier: $(shell find lnbits -name "*.js" -name ".html")
|
prettier: $(shell find lnbits -name "*.js" -name ".html")
|
||||||
./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js
|
./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
|
||||||
|
|
||||||
black: $(shell find lnbits -name "*.py")
|
black:
|
||||||
./venv/bin/black lnbits
|
poetry run black .
|
||||||
|
|
||||||
mypy: $(shell find lnbits -name "*.py")
|
mypy:
|
||||||
./venv/bin/mypy lnbits
|
poetry run mypy
|
||||||
./venv/bin/mypy lnbits/core
|
|
||||||
./venv/bin/mypy lnbits/extensions/*
|
|
||||||
|
|
||||||
isort: $(shell find lnbits -name "*.py")
|
isort:
|
||||||
./venv/bin/isort --profile black lnbits
|
poetry run isort .
|
||||||
|
|
||||||
checkprettier: $(shell find lnbits -name "*.js" -name ".html")
|
checkprettier: $(shell find lnbits -name "*.js" -name ".html")
|
||||||
./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js
|
./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
|
||||||
|
|
||||||
checkblack: $(shell find lnbits -name "*.py")
|
checkblack:
|
||||||
./venv/bin/black --check lnbits
|
poetry run black --check .
|
||||||
|
|
||||||
checkisort: $(shell find lnbits -name "*.py")
|
checkisort:
|
||||||
./venv/bin/isort --profile black --check-only lnbits
|
poetry run isort --check-only .
|
||||||
|
|
||||||
Pipfile.lock: Pipfile
|
|
||||||
./venv/bin/pipenv lock
|
|
||||||
|
|
||||||
requirements.txt: Pipfile.lock
|
|
||||||
cat Pipfile.lock | jq -r '.default | map_values(.version) | to_entries | map("\(.key)\(.value)") | join("\n")' > requirements.txt
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
rm -rf ./tests/data
|
|
||||||
mkdir -p ./tests/data
|
|
||||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||||
FAKE_WALLET_SECRET="ToTheMoon1" \
|
FAKE_WALLET_SECRET="ToTheMoon1" \
|
||||||
LNBITS_DATA_FOLDER="./tests/data" \
|
LNBITS_DATA_FOLDER="./tests/data" \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
./venv/bin/pytest --durations=1 -s --cov=lnbits --cov-report=xml tests
|
poetry run pytest
|
||||||
|
|
||||||
test-real-wallet:
|
test-real-wallet:
|
||||||
rm -rf ./tests/data
|
|
||||||
mkdir -p ./tests/data
|
|
||||||
LNBITS_DATA_FOLDER="./tests/data" \
|
LNBITS_DATA_FOLDER="./tests/data" \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
./venv/bin/pytest --durations=1 -s --cov=lnbits --cov-report=xml tests
|
poetry run pytest
|
||||||
|
|
||||||
test-pipenv:
|
test-venv:
|
||||||
rm -rf ./tests/data
|
|
||||||
mkdir -p ./tests/data
|
|
||||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||||
FAKE_WALLET_SECRET="ToTheMoon1" \
|
FAKE_WALLET_SECRET="ToTheMoon1" \
|
||||||
LNBITS_DATA_FOLDER="./tests/data" \
|
LNBITS_DATA_FOLDER="./tests/data" \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
pipenv run pytest --durations=1 -s --cov=lnbits --cov-report=xml tests
|
./venv/bin/pytest --durations=1 -s --cov=lnbits --cov-report=xml tests
|
||||||
|
|
||||||
bak:
|
bak:
|
||||||
# LNBITS_DATABASE_URL=postgres://postgres:postgres@0.0.0.0:5432/postgres
|
# LNBITS_DATABASE_URL=postgres://postgres:postgres@0.0.0.0:5432/postgres
|
||||||
|
|
|
||||||
43
Pipfile
43
Pipfile
|
|
@ -1,43 +0,0 @@
|
||||||
[[source]]
|
|
||||||
name = "pypi"
|
|
||||||
url = "https://pypi.org/simple"
|
|
||||||
verify_ssl = true
|
|
||||||
|
|
||||||
[requires]
|
|
||||||
python_version = "3.7"
|
|
||||||
|
|
||||||
[packages]
|
|
||||||
bitstring = "*"
|
|
||||||
cerberus = "*"
|
|
||||||
ecdsa = "*"
|
|
||||||
environs = "*"
|
|
||||||
lnurl = "==0.3.6"
|
|
||||||
loguru = "*"
|
|
||||||
pyscss = "*"
|
|
||||||
shortuuid = "*"
|
|
||||||
typing-extensions = "*"
|
|
||||||
httpx = "*"
|
|
||||||
sqlalchemy-aio = "*"
|
|
||||||
embit = "*"
|
|
||||||
pyqrcode = "*"
|
|
||||||
pypng = "*"
|
|
||||||
sqlalchemy = "==1.3.23"
|
|
||||||
psycopg2-binary = "*"
|
|
||||||
aiofiles = "*"
|
|
||||||
asyncio = "*"
|
|
||||||
fastapi = "*"
|
|
||||||
uvicorn = {extras = ["standard"], version = "*"}
|
|
||||||
sse-starlette = "*"
|
|
||||||
jinja2 = "==3.0.1"
|
|
||||||
pyngrok = "*"
|
|
||||||
secp256k1 = "*"
|
|
||||||
pycryptodomex = "*"
|
|
||||||
|
|
||||||
[dev-packages]
|
|
||||||
black = "==20.8b1"
|
|
||||||
pytest = "*"
|
|
||||||
pytest-cov = "*"
|
|
||||||
mypy = "*"
|
|
||||||
pytest-asyncio = "*"
|
|
||||||
requests = "*"
|
|
||||||
mock = "*"
|
|
||||||
1167
Pipfile.lock
generated
1167
Pipfile.lock
generated
File diff suppressed because it is too large
Load diff
10
README.md
10
README.md
|
|
@ -7,7 +7,7 @@ LNbits
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
# LNbits v0.3 BETA, free and open-source lightning-network wallet/accounts system
|
# LNbits v0.9 BETA, free and open-source lightning-network wallet/accounts system
|
||||||
|
|
||||||
(Join us on [https://t.me/lnbits](https://t.me/lnbits))
|
(Join us on [https://t.me/lnbits](https://t.me/lnbits))
|
||||||
|
|
||||||
|
|
@ -25,7 +25,7 @@ LNbits is a very simple Python server that sits on top of any funding source, an
|
||||||
|
|
||||||
LNbits can run on top of any lightning-network funding source, currently there is support for LND, c-lightning, Spark, LNpay, OpenNode, lntxbot, with more being added regularly.
|
LNbits can run on top of any lightning-network funding source, currently there is support for LND, c-lightning, Spark, LNpay, OpenNode, lntxbot, with more being added regularly.
|
||||||
|
|
||||||
See [lnbits.org](https://lnbits.org) for more detailed documentation.
|
See [legend.lnbits.org](https://legend.lnbits.org) for more detailed documentation.
|
||||||
|
|
||||||
Checkout the LNbits [YouTube](https://www.youtube.com/playlist?list=PLPj3KCksGbSYG0ciIQUWJru1dWstPHshe) video series.
|
Checkout the LNbits [YouTube](https://www.youtube.com/playlist?list=PLPj3KCksGbSYG0ciIQUWJru1dWstPHshe) video series.
|
||||||
|
|
||||||
|
|
@ -54,7 +54,7 @@ LNURL has a fallback scheme, so if scanned by a regular QR code reader it can de
|
||||||

|

|
||||||
|
|
||||||
Using **lnbits.com/?lightning="LNURL-withdraw"** will trigger a withdraw that builds an LNbits wallet.
|
Using **lnbits.com/?lightning="LNURL-withdraw"** will trigger a withdraw that builds an LNbits wallet.
|
||||||
Example use would be an ATM, which utilises LNURL, if the user scans the QR with a regular QR code scanner app, they will stilll be able to access the funds.
|
Example use would be an ATM, which utilises LNURL, if the user scans the QR with a regular QR code scanner app, they will still be able to access the funds.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
@ -67,10 +67,10 @@ Wallets can be easily generated and given out to people at events (one click mul
|
||||||
|
|
||||||
## Tip us
|
## Tip us
|
||||||
|
|
||||||
If you like this project and might even use or extend it, why not [send some tip love](https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)!
|
If you like this project and might even use or extend it, why not [send some tip love](https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)!
|
||||||
|
|
||||||
|
|
||||||
[docs]: https://lnbits.org/
|
[docs]: https://legend.lnbits.org/
|
||||||
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
|
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
|
||||||
[github-mypy]: https://github.com/lnbits/lnbits/actions?query=workflow%3Amypy
|
[github-mypy]: https://github.com/lnbits/lnbits/actions?query=workflow%3Amypy
|
||||||
[github-mypy-badge]: https://github.com/lnbits/lnbits/workflows/mypy/badge.svg
|
[github-mypy-badge]: https://github.com/lnbits/lnbits/workflows/mypy/badge.svg
|
||||||
|
|
|
||||||
105
build.py
Normal file
105
build.py
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import warnings
|
||||||
|
from os import path
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, List, NamedTuple, Optional
|
||||||
|
|
||||||
|
LNBITS_PATH = path.dirname(path.realpath(__file__)) + "/lnbits"
|
||||||
|
|
||||||
|
|
||||||
|
def get_js_vendored(prefer_minified: bool = False) -> List[str]:
|
||||||
|
paths = get_vendored(".js", prefer_minified)
|
||||||
|
|
||||||
|
def sorter(key: str):
|
||||||
|
if "moment@" in key:
|
||||||
|
return 1
|
||||||
|
if "vue@" in key:
|
||||||
|
return 2
|
||||||
|
if "vue-router@" in key:
|
||||||
|
return 3
|
||||||
|
if "polyfills" in key:
|
||||||
|
return 4
|
||||||
|
return 9
|
||||||
|
|
||||||
|
return sorted(paths, key=sorter)
|
||||||
|
|
||||||
|
|
||||||
|
def get_css_vendored(prefer_minified: bool = False) -> List[str]:
|
||||||
|
paths = get_vendored(".css", prefer_minified)
|
||||||
|
|
||||||
|
def sorter(key: str):
|
||||||
|
if "quasar@" in key:
|
||||||
|
return 1
|
||||||
|
if "vue@" in key:
|
||||||
|
return 2
|
||||||
|
if "chart.js@" in key:
|
||||||
|
return 100
|
||||||
|
return 9
|
||||||
|
|
||||||
|
return sorted(paths, key=sorter)
|
||||||
|
|
||||||
|
|
||||||
|
def get_vendored(ext: str, prefer_minified: bool = False) -> List[str]:
|
||||||
|
paths: List[str] = []
|
||||||
|
for path in glob.glob(
|
||||||
|
os.path.join(LNBITS_PATH, "static/vendor/**"), recursive=True
|
||||||
|
):
|
||||||
|
if path.endswith(".min" + ext):
|
||||||
|
# path is minified
|
||||||
|
unminified = path.replace(".min" + ext, ext)
|
||||||
|
if prefer_minified:
|
||||||
|
paths.append(path)
|
||||||
|
if unminified in paths:
|
||||||
|
paths.remove(unminified)
|
||||||
|
elif unminified not in paths:
|
||||||
|
paths.append(path)
|
||||||
|
|
||||||
|
elif path.endswith(ext):
|
||||||
|
# path is not minified
|
||||||
|
minified = path.replace(ext, ".min" + ext)
|
||||||
|
if not prefer_minified:
|
||||||
|
paths.append(path)
|
||||||
|
if minified in paths:
|
||||||
|
paths.remove(minified)
|
||||||
|
elif minified not in paths:
|
||||||
|
paths.append(path)
|
||||||
|
|
||||||
|
return sorted(paths)
|
||||||
|
|
||||||
|
|
||||||
|
def url_for_vendored(abspath: str) -> str:
|
||||||
|
return "/" + os.path.relpath(abspath, LNBITS_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def transpile_scss():
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore")
|
||||||
|
from scss.compiler import compile_string # type: ignore
|
||||||
|
|
||||||
|
with open(os.path.join(LNBITS_PATH, "static/scss/base.scss")) as scss:
|
||||||
|
with open(os.path.join(LNBITS_PATH, "static/css/base.css"), "w") as css:
|
||||||
|
css.write(compile_string(scss.read()))
|
||||||
|
|
||||||
|
|
||||||
|
def bundle_vendored():
|
||||||
|
for getfiles, outputpath in [
|
||||||
|
(get_js_vendored, os.path.join(LNBITS_PATH, "static/bundle.js")),
|
||||||
|
(get_css_vendored, os.path.join(LNBITS_PATH, "static/bundle.css")),
|
||||||
|
]:
|
||||||
|
output = ""
|
||||||
|
for path in getfiles():
|
||||||
|
with open(path) as f:
|
||||||
|
output += "/* " + url_for_vendored(path) + " */\n" + f.read() + ";\n"
|
||||||
|
with open(outputpath, "w") as f:
|
||||||
|
f.write(output)
|
||||||
|
|
||||||
|
|
||||||
|
def build():
|
||||||
|
transpile_scss()
|
||||||
|
bundle_vendored()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
build()
|
||||||
|
|
@ -1 +1 @@
|
||||||
lnbits.org
|
legend.lnbits.org
|
||||||
|
|
@ -3,7 +3,7 @@ title: "LNbits docs"
|
||||||
remote_theme: pmarsceill/just-the-docs
|
remote_theme: pmarsceill/just-the-docs
|
||||||
logo: "/logos/lnbits-full.png"
|
logo: "/logos/lnbits-full.png"
|
||||||
search_enabled: true
|
search_enabled: true
|
||||||
url: https://lnbits.org
|
url: https://legend.lnbits.org
|
||||||
aux_links:
|
aux_links:
|
||||||
"LNbits on GitHub":
|
"LNbits on GitHub":
|
||||||
- "//github.com/lnbits/lnbits"
|
- "//github.com/lnbits/lnbits"
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,4 @@ nav_order: 3
|
||||||
API reference
|
API reference
|
||||||
=============
|
=============
|
||||||
|
|
||||||
Coming soon...
|
[Swagger Docs](https://legend.lnbits.org/devs/swagger.html)
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,26 @@ Tests
|
||||||
|
|
||||||
This project has unit tests that help prevent regressions. Before you can run the tests, you must install a few dependencies:
|
This project has unit tests that help prevent regressions. Before you can run the tests, you must install a few dependencies:
|
||||||
```bash
|
```bash
|
||||||
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
|
poetry install
|
||||||
|
npm i
|
||||||
```
|
```
|
||||||
|
|
||||||
Then to run the tests:
|
Then to run the tests:
|
||||||
```bash
|
```bash
|
||||||
make test
|
make test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run formatting:
|
||||||
|
```bash
|
||||||
|
make format
|
||||||
|
```
|
||||||
|
|
||||||
|
Run mypy checks:
|
||||||
|
```bash
|
||||||
|
poetry run mypy
|
||||||
|
```
|
||||||
|
|
||||||
|
Run everything:
|
||||||
|
```bash
|
||||||
|
make all
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -28,17 +28,24 @@ Going over the example extension's structure:
|
||||||
Adding new dependencies
|
Adding new dependencies
|
||||||
-----------------------
|
-----------------------
|
||||||
|
|
||||||
If for some reason your extensions needs a new python package to work, you can add a new package using Pipenv:
|
If for some reason your extensions needs a new python package to work, you can add a new package using `venv`, or `poerty`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
$ pipenv install new_package_name
|
$ poetry add <package>
|
||||||
|
# or
|
||||||
|
$ ./venv/bin/pip install <package>
|
||||||
```
|
```
|
||||||
|
|
||||||
This will create a new entry in the `Pipenv` file.
|
|
||||||
**But we need an extra step to make sure LNbits doesn't break in production.**
|
**But we need an extra step to make sure LNbits doesn't break in production.**
|
||||||
All tests and deployments should run against the `requirements.txt` file so every time a new package is added
|
Dependencies need to be added to `pyproject.toml` and `requirements.txt`, then tested by running on `venv` and `poetry`.
|
||||||
it is necessary to run the Pipenv `lock` command and manually update the requirements file:
|
`nix` compatability can be tested with `nix build .#checks.x86_64-linux.vmTest`.
|
||||||
|
|
||||||
```sh
|
|
||||||
$ pipenv lock -r
|
SQLite to PostgreSQL migration
|
||||||
```
|
-----------------------
|
||||||
|
|
||||||
|
LNbits currently supports SQLite and PostgreSQL databases. There is a migration script `tools/conv.py` that helps users migrate from SQLite to PostgreSQL. This script also copies all extension databases to the new backend.
|
||||||
|
|
||||||
|
### Adding mock data to `mock_data.zip`
|
||||||
|
|
||||||
|
`mock_data.zip` contains a few lines of sample SQLite data and is used in automated GitHub test to see whether your migration in `conv.py` works. Run your extension and save a few lines of data into a SQLite `your_extension.sqlite3` file. Unzip `tests/data/mock_data.zip`, add `your_extension.sqlite3` and zip it again. Add the updated `mock_data.zip` to your PR.
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
---
|
|
||||||
layout: default
|
|
||||||
parent: For developers
|
|
||||||
title: Installation
|
|
||||||
nav_order: 1
|
|
||||||
---
|
|
||||||
|
|
||||||
# Installation
|
|
||||||
|
|
||||||
This guide has been moved to the [installation guide](../guide/installation.md).
|
|
||||||
To install the developer packages, use `pipenv install --dev`.
|
|
||||||
|
|
||||||
## Notes:
|
|
||||||
|
|
||||||
* We recommend using <a href="https://caddyserver.com/docs/install#debian-ubuntu-raspbian">Caddy</a> for a reverse-proxy if you want to serve your install through a domain, alternatively you can use [ngrok](https://ngrok.com/).
|
|
||||||
* <a href="https://linuxize.com/post/how-to-use-linux-screen/#starting-linux-screen">Screen</a> works well if you want LNbits to continue running when you close your terminal session.
|
|
||||||
29
docs/devs/swagger.html
Normal file
29
docs/devs/swagger.html
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<!-- Load the latest Swagger UI code and style from npm using unpkg.com -->
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@3/swagger-ui-bundle.js"></script>
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@3/swagger-ui.css"/>
|
||||||
|
<title>My New API</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui"></div> <!-- Div to hold the UI component -->
|
||||||
|
<script>
|
||||||
|
window.onload = function () {
|
||||||
|
// Begin Swagger UI call region
|
||||||
|
const ui = SwaggerUIBundle({
|
||||||
|
url: "https://legend.lnbits.com/openapi.json", //Location of Open API spec in the repo
|
||||||
|
dom_id: '#swagger-ui',
|
||||||
|
deepLinking: true,
|
||||||
|
presets: [
|
||||||
|
SwaggerUIBundle.presets.apis,
|
||||||
|
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
SwaggerUIBundle.plugins.DownloadUrl
|
||||||
|
],
|
||||||
|
})
|
||||||
|
window.ui = ui
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -4,49 +4,64 @@ title: Basic installation
|
||||||
nav_order: 2
|
nav_order: 2
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Basic installation
|
# Basic installation
|
||||||
|
|
||||||
You can choose between two python package managers, `venv` and `pipenv`. Both are fine but if you don't know what you're doing, just go for the first option.
|
You can choose between four package managers, `poetry`, `nix` and `venv`.
|
||||||
|
|
||||||
By default, LNbits will use SQLite as its database. You can also use PostgreSQL which is recommended for applications with a high load (see guide below).
|
By default, LNbits will use SQLite as its database. You can also use PostgreSQL which is recommended for applications with a high load (see guide below).
|
||||||
|
|
||||||
## Option 1: pipenv
|
## Option 1: poetry
|
||||||
|
|
||||||
You can also use Pipenv to manage your python packages.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/lnbits/lnbits-legend.git
|
git clone https://github.com/lnbits/lnbits-legend.git
|
||||||
cd lnbits-legend/
|
cd lnbits-legend/
|
||||||
|
|
||||||
sudo apt update && sudo apt install -y pipenv
|
# for making sure python 3.9 is installed, skip if installed
|
||||||
pipenv install --dev
|
sudo apt update
|
||||||
# pipenv --python 3.9 install --dev (if you wish to use a version of Python higher than 3.7)
|
sudo apt install software-properties-common
|
||||||
pipenv shell
|
sudo add-apt-repository ppa:deadsnakes/ppa
|
||||||
# pipenv --python 3.9 shell (if you wish to use a version of Python higher than 3.7)
|
sudo apt install python3.9 python3.9-distutils
|
||||||
|
|
||||||
# If any of the modules fails to install, try checking and upgrading your setupTool module
|
curl -sSL https://install.python-poetry.org | python3 -
|
||||||
# pip install -U setuptools wheel
|
export PATH="/home/ubuntu/.local/bin:$PATH" # or whatever is suggested in the poetry install notes printed to terminal
|
||||||
|
poetry env use python3.9
|
||||||
|
poetry install --no-dev
|
||||||
|
|
||||||
|
mkdir data
|
||||||
|
cp .env.example .env
|
||||||
|
sudo nano .env # set funding source
|
||||||
|
|
||||||
# install libffi/libpq in case "pipenv install" fails
|
|
||||||
# sudo apt-get install -y libffi-dev libpq-dev
|
|
||||||
|
|
||||||
mkdir data && cp .env.example .env
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Running the server
|
#### Running the server
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pipenv run python -m uvicorn lnbits.__main__:app --port 5000 --host 0.0.0.0
|
poetry run lnbits
|
||||||
|
# To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0'
|
||||||
```
|
```
|
||||||
|
|
||||||
Add the flag `--reload` for development (includes hot-reload).
|
## Option 2: Nix
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/lnbits/lnbits-legend.git
|
||||||
|
cd lnbits-legend/
|
||||||
|
# Modern debian distros usually include Nix, however you can install with:
|
||||||
|
# 'sh <(curl -L https://nixos.org/nix/install) --daemon', or use setup here https://nixos.org/download.html#nix-verify-installation
|
||||||
|
|
||||||
## Option 2: venv
|
nix build .#lnbits
|
||||||
|
mkdir data
|
||||||
|
|
||||||
Download this repo and install the dependencies:
|
```
|
||||||
|
|
||||||
|
#### Running the server
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# .env variables are currently passed when running
|
||||||
|
LNBITS_DATA_FOLDER=data LNBITS_BACKEND_WALLET_CLASS=LNbitsWallet LNBITS_ENDPOINT=https://legend.lnbits.com LNBITS_KEY=7b1a78d6c78f48b09a202f2dcb2d22eb ./result/bin/lnbits --port 9000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Option 3: venv
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/lnbits/lnbits-legend.git
|
git clone https://github.com/lnbits/lnbits-legend.git
|
||||||
|
|
@ -57,6 +72,8 @@ python3 -m venv venv
|
||||||
./venv/bin/pip install -r requirements.txt
|
./venv/bin/pip install -r requirements.txt
|
||||||
# create the data folder and the .env file
|
# create the data folder and the .env file
|
||||||
mkdir data && cp .env.example .env
|
mkdir data && cp .env.example .env
|
||||||
|
# build the static files
|
||||||
|
./venv/bin/python build.py
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Running the server
|
#### Running the server
|
||||||
|
|
@ -67,18 +84,29 @@ mkdir data && cp .env.example .env
|
||||||
|
|
||||||
If you want to host LNbits on the internet, run with the option `--host 0.0.0.0`.
|
If you want to host LNbits on the internet, run with the option `--host 0.0.0.0`.
|
||||||
|
|
||||||
|
## Option 4: Docker
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/lnbits/lnbits-legend.git
|
||||||
|
cd lnbits-legend
|
||||||
|
docker build -t lnbits-legend .
|
||||||
|
cp .env.example .env
|
||||||
|
mkdir data
|
||||||
|
docker run --detach --publish 5000:5000 --name lnbits-legend --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits-legend
|
||||||
|
```
|
||||||
|
|
||||||
### Troubleshooting
|
### Troubleshooting
|
||||||
|
|
||||||
Problems installing? These commands have helped us install LNbits.
|
Problems installing? These commands have helped us install LNbits.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo apt install pkg-config libffi-dev libpq-dev setuptools
|
sudo apt install pkg-config libffi-dev libpq-dev
|
||||||
|
|
||||||
# if the secp256k1 build fails:
|
# if the secp256k1 build fails:
|
||||||
# if you used venv (option 1)
|
# if you used venv
|
||||||
./venv/bin/pip install setuptools wheel
|
./venv/bin/pip install setuptools wheel
|
||||||
# if you used pipenv (option 2)
|
# if you used poetry
|
||||||
pipenv install setuptools wheel
|
poetry add setuptools wheel
|
||||||
# build essentials for debian/ubuntu
|
# build essentials for debian/ubuntu
|
||||||
sudo apt install python3-dev gcc build-essential
|
sudo apt install python3-dev gcc build-essential
|
||||||
```
|
```
|
||||||
|
|
@ -127,7 +155,7 @@ Take a look at [Polar](https://lightningpolar.com/) for an excellent way of spin
|
||||||
|
|
||||||
# Additional guides
|
# Additional guides
|
||||||
|
|
||||||
### SQLite to PostgreSQL migration
|
## SQLite to PostgreSQL migration
|
||||||
If you already have LNbits installed and running, on an SQLite database, we **highly** recommend you migrate to postgres if you are planning to run LNbits on scale.
|
If you already have LNbits installed and running, on an SQLite database, we **highly** recommend you migrate to postgres if you are planning to run LNbits on scale.
|
||||||
|
|
||||||
There's a script included that can do the migration easy. You should have Postgres already installed and there should be a password for the user (see Postgres install guide above). Additionally, your LNbits instance should run once on postgres to implement the database schema before the migration works:
|
There's a script included that can do the migration easy. You should have Postgres already installed and there should be a password for the user (see Postgres install guide above). Additionally, your LNbits instance should run once on postgres to implement the database schema before the migration works:
|
||||||
|
|
@ -149,7 +177,7 @@ python3 tools/conv.py
|
||||||
Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly.
|
Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly.
|
||||||
|
|
||||||
|
|
||||||
### LNbits as a systemd service
|
## LNbits as a systemd service
|
||||||
|
|
||||||
Systemd is great for taking care of your LNbits instance. It will start it on boot and restart it in case it crashes. If you want to run LNbits as a systemd service on your Debian/Ubuntu/Raspbian server, create a file at `/etc/systemd/system/lnbits.service` with the following content:
|
Systemd is great for taking care of your LNbits instance. It will start it on boot and restart it in case it crashes. If you want to run LNbits as a systemd service on your Debian/Ubuntu/Raspbian server, create a file at `/etc/systemd/system/lnbits.service` with the following content:
|
||||||
|
|
||||||
|
|
@ -188,18 +216,94 @@ sudo systemctl enable lnbits.service
|
||||||
sudo systemctl start lnbits.service
|
sudo systemctl start lnbits.service
|
||||||
```
|
```
|
||||||
|
|
||||||
### LNbits running on Umbrel behind Tor
|
## Running behind an apache2 reverse proxy over https
|
||||||
|
Install apache2 and enable apache2 mods
|
||||||
|
```sh
|
||||||
|
apt-get install apache2 certbot
|
||||||
|
a2enmod headers ssl proxy proxy-http
|
||||||
|
```
|
||||||
|
create a ssl certificate with letsencrypt
|
||||||
|
```sh
|
||||||
|
certbot certonly --webroot --agree-tos --text --non-interactive --webroot-path /var/www/html -d lnbits.org
|
||||||
|
```
|
||||||
|
create a apache2 vhost at: /etc/apache2/sites-enabled/lnbits.conf
|
||||||
|
```sh
|
||||||
|
cat <<EOF > /etc/apache2/sites-enabled/lnbits.conf
|
||||||
|
<VirtualHost *:443>
|
||||||
|
ServerName lnbits.org
|
||||||
|
SSLEngine On
|
||||||
|
SSLProxyEngine On
|
||||||
|
SSLCertificateFile /etc/letsencrypt/live/lnbits.org/fullchain.pem
|
||||||
|
SSLCertificateKeyFile /etc/letsencrypt/live/lnbits.org/privkey.pem
|
||||||
|
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||||
|
LogLevel info
|
||||||
|
ErrorLog /var/log/apache2/lnbits.log
|
||||||
|
CustomLog /var/log/apache2/lnbits-access.log combined
|
||||||
|
RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME}
|
||||||
|
RequestHeader set "X-Forwarded-SSL" expr=%{HTTPS}
|
||||||
|
ProxyPreserveHost On
|
||||||
|
ProxyPass / http://localhost:5000/
|
||||||
|
ProxyPassReverse / http://localhost:5000/
|
||||||
|
<Proxy *>
|
||||||
|
Order deny,allow
|
||||||
|
Allow from all
|
||||||
|
</Proxy>
|
||||||
|
</VirtualHost>
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
restart apache2
|
||||||
|
```sh
|
||||||
|
service restart apache2
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Using https without reverse proxy
|
||||||
|
The most common way of using LNbits via https is to use a reverse proxy such as Caddy, nginx, or ngriok. However, you can also run LNbits via https without additional software. This is useful for development purposes or if you want to use LNbits in your local network.
|
||||||
|
|
||||||
|
We have to create a self-signed certificate using `mkcert`. Note that this certiciate is not "trusted" by most browsers but that's fine (since you know that you have created it) and encryption is always better than clear text.
|
||||||
|
|
||||||
|
#### Install mkcert
|
||||||
|
You can find the install instructions for `mkcert` [here](https://github.com/FiloSottile/mkcert).
|
||||||
|
|
||||||
|
Install mkcert on Ubuntu:
|
||||||
|
```sh
|
||||||
|
sudo apt install libnss3-tools
|
||||||
|
curl -JLO "https://dl.filippo.io/mkcert/latest?for=linux/amd64"
|
||||||
|
chmod +x mkcert-v*-linux-amd64
|
||||||
|
sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert
|
||||||
|
```
|
||||||
|
#### Create certificate
|
||||||
|
To create a certificate, first `cd` into your LNbits folder and execute the following command on Linux:
|
||||||
|
```sh
|
||||||
|
openssl req -new -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out cert.pem -keyout key.pem
|
||||||
|
```
|
||||||
|
This will create two new files (`key.pem` and `cert.pem `).
|
||||||
|
|
||||||
|
Alternatively, you can use mkcert ([more info](https://kifarunix.com/how-to-create-self-signed-ssl-certificate-with-mkcert-on-ubuntu-18-04/)):
|
||||||
|
```sh
|
||||||
|
# add your local IP (192.x.x.x) as well if you want to use it in your local network
|
||||||
|
mkcert localhost 127.0.0.1 ::1
|
||||||
|
```
|
||||||
|
|
||||||
|
You can then pass the certificate files to uvicorn when you start LNbits:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./venv/bin/uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./key.pem --ssl-certfile ./cert.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## LNbits running on Umbrel behind Tor
|
||||||
|
|
||||||
If you want to run LNbits on your Umbrel but want it to be reached through clearnet, _Uxellodunum_ made an extensive [guide](https://community.getumbrel.com/t/guide-lnbits-without-tor/604) on how to do it.
|
If you want to run LNbits on your Umbrel but want it to be reached through clearnet, _Uxellodunum_ made an extensive [guide](https://community.getumbrel.com/t/guide-lnbits-without-tor/604) on how to do it.
|
||||||
|
|
||||||
### Docker installation
|
## Docker installation
|
||||||
|
|
||||||
To install using docker you first need to build the docker image as:
|
To install using docker you first need to build the docker image as:
|
||||||
|
|
||||||
```
|
```
|
||||||
git clone https://github.com/lnbits/lnbits.git
|
git clone https://github.com/lnbits/lnbits-legend.git
|
||||||
cd lnbits/ # ${PWD} referred as <lnbits_repo>
|
cd lnbits-legend
|
||||||
docker build -t lnbits .
|
docker build -t lnbits-legend .
|
||||||
```
|
```
|
||||||
|
|
||||||
You can launch the docker in a different directory, but make sure to copy `.env.example` from lnbits there
|
You can launch the docker in a different directory, but make sure to copy `.env.example` from lnbits there
|
||||||
|
|
@ -210,23 +314,15 @@ cp <lnbits_repo>/.env.example .env
|
||||||
|
|
||||||
and change the configuration in `.env` as required.
|
and change the configuration in `.env` as required.
|
||||||
|
|
||||||
Then create the data directory for the user ID 1000, which is the user that runs the lnbits within the docker container.
|
Then create the data directory
|
||||||
|
|
||||||
```
|
```
|
||||||
mkdir data
|
mkdir data
|
||||||
sudo chown 1000:1000 ./data/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then the image can be run as:
|
Then the image can be run as:
|
||||||
|
|
||||||
```
|
```
|
||||||
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits
|
docker run --detach --publish 5000:5000 --name lnbits-legend -e "LNBITS_BACKEND_WALLET_CLASS='FakeWallet'" --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits-legend
|
||||||
```
|
```
|
||||||
|
|
||||||
Finally you can access your lnbits on your machine at port 5000.
|
Finally you can access your lnbits on your machine at port 5000.
|
||||||
|
|
||||||
# Additional guides
|
|
||||||
|
|
||||||
## LNbits running on Umbrel behind Tor
|
|
||||||
|
|
||||||
If you want to run LNbits on your Umbrel but want it to be reached through clearnet, _Uxellodunum_ made an extensive [guide](https://community.getumbrel.com/t/guide-lnbits-without-tor/604) on how to do it.
|
|
||||||
|
|
|
||||||
|
|
@ -8,19 +8,17 @@ nav_order: 3
|
||||||
Backend wallets
|
Backend wallets
|
||||||
===============
|
===============
|
||||||
|
|
||||||
LNbits can run on top of many lightning-network funding sources. Currently there is support for
|
LNbits can run on top of many lightning-network funding sources. Currently there is support for CoreLightning, LND, LNbits, LNPay, lntxbot and OpenNode, with more being added regularly.
|
||||||
CLightning, LND, LNbits, LNPay, lntxbot and OpenNode, with more being added regularily.
|
|
||||||
|
|
||||||
A backend wallet can be configured using the following LNbits environment variables:
|
A backend wallet can be configured using the following LNbits environment variables:
|
||||||
|
|
||||||
|
|
||||||
### CLightning
|
### CoreLightning
|
||||||
|
|
||||||
Using this wallet requires the installation of the `pylightning` Python package.
|
Using this wallet requires the installation of the `pylightning` Python package.
|
||||||
If you want to use LNURLp you should use SparkWallet because of an issue with description_hash and CLightning.
|
|
||||||
|
|
||||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CLightningWallet**
|
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
|
||||||
- `CLIGHTNING_RPC`: /file/path/lightning-rpc
|
- `CORELIGHTNING_RPC`: /file/path/lightning-rpc
|
||||||
|
|
||||||
### Spark (c-lightning)
|
### Spark (c-lightning)
|
||||||
|
|
||||||
|
|
@ -28,6 +26,17 @@ If you want to use LNURLp you should use SparkWallet because of an issue with de
|
||||||
- `SPARK_URL`: http://10.147.17.230:9737/rpc
|
- `SPARK_URL`: http://10.147.17.230:9737/rpc
|
||||||
- `SPARK_TOKEN`: secret_access_key
|
- `SPARK_TOKEN`: secret_access_key
|
||||||
|
|
||||||
|
### LND (REST)
|
||||||
|
|
||||||
|
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
|
||||||
|
- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
|
||||||
|
- `LND_REST_CERT`: /file/path/tls.cert
|
||||||
|
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
- `LND_REST_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
|
||||||
|
|
||||||
### LND (gRPC)
|
### LND (gRPC)
|
||||||
|
|
||||||
Using this wallet requires the installation of the `grpcio` and `protobuf` Python packages.
|
Using this wallet requires the installation of the `grpcio` and `protobuf` Python packages.
|
||||||
|
|
@ -44,17 +53,6 @@ You can also use an AES-encrypted macaroon (more info) instead by using
|
||||||
|
|
||||||
To encrypt your macaroon, run `./venv/bin/python lnbits/wallets/macaroon/macaroon.py`.
|
To encrypt your macaroon, run `./venv/bin/python lnbits/wallets/macaroon/macaroon.py`.
|
||||||
|
|
||||||
### LND (REST)
|
|
||||||
|
|
||||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
|
|
||||||
- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
|
|
||||||
- `LND_REST_CERT`: /file/path/tls.cert
|
|
||||||
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
|
|
||||||
|
|
||||||
or
|
|
||||||
|
|
||||||
- `LND_REST_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
|
|
||||||
|
|
||||||
### LNbits
|
### LNbits
|
||||||
|
|
||||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LNbitsWallet**
|
- `LNBITS_BACKEND_WALLET_CLASS`: **LNbitsWallet**
|
||||||
|
|
|
||||||
77
flake.lock
generated
Normal file
77
flake.lock
generated
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1656928814,
|
||||||
|
"narHash": "sha256-RIFfgBuKz6Hp89yRr7+NR5tzIAbn52h8vT6vXkYjZoM=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "7e2a3b3dfd9af950a856d66b0a7d01e3c18aa249",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1657114324,
|
||||||
|
"narHash": "sha256-fWuaUNXrHcz/ciHRHlcSO92dvV3EVS0GJQUSBO5JIB4=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "a5c867d9fe9e4380452628e8f171c26b69fa9d3d",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1657261001,
|
||||||
|
"narHash": "sha256-sUZeuRYfhG59uD6xafM07bc7bAIkpcGq84Vj4B+cyms=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "0be91cefefde5701f8fa957904618a13e3bb51d8",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"poetry2nix": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1657149754,
|
||||||
|
"narHash": "sha256-iSnZoqwNDDVoO175whSuvl4sS9lAb/2zZ3Sa4ywo970=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "poetry2nix",
|
||||||
|
"rev": "fc1930e011dea149db81863aac22fe701f36f1b5",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "poetry2nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"poetry2nix": "poetry2nix"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
55
flake.nix
Normal file
55
flake.nix
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
{
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||||
|
poetry2nix.url = "github:nix-community/poetry2nix";
|
||||||
|
};
|
||||||
|
outputs = { self, nixpkgs, poetry2nix }@inputs:
|
||||||
|
let
|
||||||
|
supportedSystems = [ "x86_64-linux" "aarch64-linux" ];
|
||||||
|
forSystems = systems: f:
|
||||||
|
nixpkgs.lib.genAttrs systems
|
||||||
|
(system: f system (import nixpkgs { inherit system; overlays = [ poetry2nix.overlay self.overlays.default ]; }));
|
||||||
|
forAllSystems = forSystems supportedSystems;
|
||||||
|
projectName = "lnbits";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShells = forAllSystems (system: pkgs: {
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
nodePackages.prettier
|
||||||
|
];
|
||||||
|
};
|
||||||
|
});
|
||||||
|
overlays = {
|
||||||
|
default = final: prev: {
|
||||||
|
${projectName} = self.packages.${final.hostPlatform.system}.${projectName};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
packages = forAllSystems (system: pkgs: {
|
||||||
|
default = self.packages.${system}.${projectName};
|
||||||
|
${projectName} = pkgs.poetry2nix.mkPoetryApplication {
|
||||||
|
projectDir = ./.;
|
||||||
|
python = pkgs.python39;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
nixosModules = {
|
||||||
|
default = { pkgs, lib, config, ... }: {
|
||||||
|
imports = [ "${./nix/modules/${projectName}-service.nix}" ];
|
||||||
|
nixpkgs.overlays = [ self.overlays.default ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
checks = forAllSystems (system: pkgs:
|
||||||
|
let
|
||||||
|
vmTests = import ./nix/tests {
|
||||||
|
makeTest = (import (nixpkgs + "/nixos/lib/testing-python.nix") { inherit system; }).makeTest;
|
||||||
|
inherit inputs pkgs;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
pkgs.lib.optionalAttrs pkgs.stdenv.isLinux vmTests # vmTests can only be ran on Linux, so append them only if on Linux.
|
||||||
|
//
|
||||||
|
{
|
||||||
|
# Other checks here...
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ import uvloop
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
from .commands import bundle_vendored, migrate_databases, transpile_scss
|
from .commands import migrate_databases
|
||||||
from .settings import (
|
from .settings import (
|
||||||
DEBUG,
|
DEBUG,
|
||||||
HOST,
|
HOST,
|
||||||
|
|
@ -19,8 +19,6 @@ from .settings import (
|
||||||
uvloop.install()
|
uvloop.install()
|
||||||
|
|
||||||
asyncio.create_task(migrate_databases())
|
asyncio.create_task(migrate_databases())
|
||||||
transpile_scss()
|
|
||||||
bundle_vendored()
|
|
||||||
|
|
||||||
from .app import create_app
|
from .app import create_app
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
import warnings
|
import warnings
|
||||||
|
|
@ -17,7 +18,6 @@ from loguru import logger
|
||||||
import lnbits.settings
|
import lnbits.settings
|
||||||
from lnbits.core.tasks import register_task_listeners
|
from lnbits.core.tasks import register_task_listeners
|
||||||
|
|
||||||
from .commands import db_migrate, handle_assets
|
|
||||||
from .core import core_app
|
from .core import core_app
|
||||||
from .core.views.generic import core_html_routes
|
from .core.views.generic import core_html_routes
|
||||||
from .helpers import (
|
from .helpers import (
|
||||||
|
|
@ -45,10 +45,19 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||||
"""
|
"""
|
||||||
configure_logger()
|
configure_logger()
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI(
|
||||||
app.mount("/static", StaticFiles(directory="lnbits/static"), name="static")
|
title="LNbits API",
|
||||||
|
description="API for LNbits, the free and open source bitcoin wallet and accounts system with plugins.",
|
||||||
|
license_info={
|
||||||
|
"name": "MIT License",
|
||||||
|
"url": "https://raw.githubusercontent.com/lnbits/lnbits-legend/main/LICENSE",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
app.mount("/static", StaticFiles(packages=[("lnbits", "static")]), name="static")
|
||||||
app.mount(
|
app.mount(
|
||||||
"/core/static", StaticFiles(directory="lnbits/core/static"), name="core_static"
|
"/core/static",
|
||||||
|
StaticFiles(packages=[("lnbits.core", "static")]),
|
||||||
|
name="core_static",
|
||||||
)
|
)
|
||||||
|
|
||||||
origins = ["*"]
|
origins = ["*"]
|
||||||
|
|
@ -67,7 +76,11 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||||
# Only the browser sends "text/html" request
|
# Only the browser sends "text/html" request
|
||||||
# not fail proof, but everything else get's a JSON response
|
# not fail proof, but everything else get's a JSON response
|
||||||
|
|
||||||
if "text/html" in request.headers["accept"]:
|
if (
|
||||||
|
request.headers
|
||||||
|
and "accept" in request.headers
|
||||||
|
and "text/html" in request.headers["accept"]
|
||||||
|
):
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
"error.html",
|
"error.html",
|
||||||
{"request": request, "err": f"{exc.errors()} is not a valid UUID."},
|
{"request": request, "err": f"{exc.errors()} is not a valid UUID."},
|
||||||
|
|
@ -84,7 +97,6 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||||
check_funding_source(app)
|
check_funding_source(app)
|
||||||
register_assets(app)
|
register_assets(app)
|
||||||
register_routes(app)
|
register_routes(app)
|
||||||
# register_commands(app)
|
|
||||||
register_async_tasks(app)
|
register_async_tasks(app)
|
||||||
register_exception_handlers(app)
|
register_exception_handlers(app)
|
||||||
|
|
||||||
|
|
@ -94,16 +106,27 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||||
def check_funding_source(app: FastAPI) -> None:
|
def check_funding_source(app: FastAPI) -> None:
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def check_wallet_status():
|
async def check_wallet_status():
|
||||||
|
original_sigint_handler = signal.getsignal(signal.SIGINT)
|
||||||
|
|
||||||
|
def signal_handler(signal, frame):
|
||||||
|
logger.debug(f"SIGINT received, terminating LNbits.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
while True:
|
while True:
|
||||||
error_message, balance = await WALLET.status()
|
try:
|
||||||
if not error_message:
|
error_message, balance = await WALLET.status()
|
||||||
break
|
if not error_message:
|
||||||
logger.error(
|
break
|
||||||
f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
|
logger.error(
|
||||||
RuntimeWarning,
|
f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
|
||||||
)
|
RuntimeWarning,
|
||||||
logger.info("Retrying connection to backend in 5 seconds...")
|
)
|
||||||
await asyncio.sleep(5)
|
logger.info("Retrying connection to backend in 5 seconds...")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
signal.signal(signal.SIGINT, original_sigint_handler)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat."
|
f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat."
|
||||||
)
|
)
|
||||||
|
|
@ -137,12 +160,6 @@ def register_routes(app: FastAPI) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def register_commands(app: FastAPI):
|
|
||||||
"""Register Click commands."""
|
|
||||||
app.cli.add_command(db_migrate)
|
|
||||||
app.cli.add_command(handle_assets)
|
|
||||||
|
|
||||||
|
|
||||||
def register_assets(app: FastAPI):
|
def register_assets(app: FastAPI):
|
||||||
"""Serve each vendored asset separately or a bundle."""
|
"""Serve each vendored asset separately or a bundle."""
|
||||||
|
|
||||||
|
|
@ -184,7 +201,11 @@ def register_exception_handlers(app: FastAPI):
|
||||||
traceback.print_exception(etype, err, tb)
|
traceback.print_exception(etype, err, tb)
|
||||||
exc = traceback.format_exc()
|
exc = traceback.format_exc()
|
||||||
|
|
||||||
if "text/html" in request.headers["accept"]:
|
if (
|
||||||
|
request.headers
|
||||||
|
and "accept" in request.headers
|
||||||
|
and "text/html" in request.headers["accept"]
|
||||||
|
):
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
"error.html", {"request": request, "err": err}
|
"error.html", {"request": request, "err": err}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ def decode(pr: str) -> Invoice:
|
||||||
invoice = Invoice()
|
invoice = Invoice()
|
||||||
|
|
||||||
# decode the amount from the hrp
|
# decode the amount from the hrp
|
||||||
m = re.search("[^\d]+", hrp[2:])
|
m = re.search(r"[^\d]+", hrp[2:])
|
||||||
if m:
|
if m:
|
||||||
amountstr = hrp[2 + m.end() :]
|
amountstr = hrp[2 + m.end() :]
|
||||||
if amountstr != "":
|
if amountstr != "":
|
||||||
|
|
@ -216,7 +216,7 @@ def lnencode(addr, privkey):
|
||||||
expirybits = expirybits[5:]
|
expirybits = expirybits[5:]
|
||||||
data += tagged("x", expirybits)
|
data += tagged("x", expirybits)
|
||||||
elif k == "h":
|
elif k == "h":
|
||||||
data += tagged_bytes("h", hashlib.sha256(v.encode("utf-8")).digest())
|
data += tagged_bytes("h", v)
|
||||||
elif k == "n":
|
elif k == "n":
|
||||||
data += tagged_bytes("n", v)
|
data += tagged_bytes("n", v)
|
||||||
else:
|
else:
|
||||||
|
|
@ -296,7 +296,7 @@ def _unshorten_amount(amount: str) -> int:
|
||||||
# BOLT #11:
|
# BOLT #11:
|
||||||
# A reader SHOULD fail if `amount` contains a non-digit, or is followed by
|
# A reader SHOULD fail if `amount` contains a non-digit, or is followed by
|
||||||
# anything except a `multiplier` in the table above.
|
# anything except a `multiplier` in the table above.
|
||||||
if not re.fullmatch("\d+[pnum]?", str(amount)):
|
if not re.fullmatch(r"\d+[pnum]?", str(amount)):
|
||||||
raise ValueError("Invalid amount '{}'".format(amount))
|
raise ValueError("Invalid amount '{}'".format(amount))
|
||||||
|
|
||||||
if unit in units:
|
if unit in units:
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ from typing import Any, Dict, List, Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits import bolt11
|
from lnbits import bolt11
|
||||||
from lnbits.db import COCKROACH, POSTGRES, Connection
|
from lnbits.db import COCKROACH, POSTGRES, Connection
|
||||||
from lnbits.settings import DEFAULT_WALLET_NAME, LNBITS_ADMIN_USERS
|
from lnbits.settings import DEFAULT_WALLET_NAME, LNBITS_ADMIN_USERS
|
||||||
|
|
@ -113,7 +115,7 @@ async def create_wallet(
|
||||||
async def update_wallet(
|
async def update_wallet(
|
||||||
wallet_id: str, new_name: str, conn: Optional[Connection] = None
|
wallet_id: str, new_name: str, conn: Optional[Connection] = None
|
||||||
) -> Optional[Wallet]:
|
) -> Optional[Wallet]:
|
||||||
await (conn or db).execute(
|
return await (conn or db).execute(
|
||||||
"""
|
"""
|
||||||
UPDATE wallets SET
|
UPDATE wallets SET
|
||||||
name = ?
|
name = ?
|
||||||
|
|
@ -334,7 +336,7 @@ async def delete_expired_invoices(
|
||||||
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||||
if expiration_date > datetime.datetime.utcnow():
|
if expiration_date > datetime.datetime.utcnow():
|
||||||
continue
|
continue
|
||||||
|
logger.debug(f"Deleting expired invoice: {invoice.payment_hash}")
|
||||||
await (conn or db).execute(
|
await (conn or db).execute(
|
||||||
"""
|
"""
|
||||||
DELETE FROM apipayments
|
DELETE FROM apipayments
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,8 @@ class Payment(BaseModel):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tag(self) -> Optional[str]:
|
def tag(self) -> Optional[str]:
|
||||||
|
if self.extra is None:
|
||||||
|
return ""
|
||||||
return self.extra.get("tag")
|
return self.extra.get("tag")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -139,19 +141,25 @@ class Payment(BaseModel):
|
||||||
if self.is_uncheckable:
|
if self.is_uncheckable:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Checking {'outgoing' if self.is_out else 'incoming'} pending payment {self.checking_id}"
|
||||||
|
)
|
||||||
|
|
||||||
if self.is_out:
|
if self.is_out:
|
||||||
status = await WALLET.get_payment_status(self.checking_id)
|
status = await WALLET.get_payment_status(self.checking_id)
|
||||||
else:
|
else:
|
||||||
status = await WALLET.get_invoice_status(self.checking_id)
|
status = await WALLET.get_invoice_status(self.checking_id)
|
||||||
|
|
||||||
|
logger.debug(f"Status: {status}")
|
||||||
|
|
||||||
if self.is_out and status.failed:
|
if self.is_out and status.failed:
|
||||||
logger.info(
|
logger.info(
|
||||||
f" - deleting outgoing failed payment {self.checking_id}: {status}"
|
f"Deleting outgoing failed payment {self.checking_id}: {status}"
|
||||||
)
|
)
|
||||||
await self.delete()
|
await self.delete()
|
||||||
elif not status.pending:
|
elif not status.pending:
|
||||||
logger.info(
|
logger.info(
|
||||||
f" - marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}"
|
f"Marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}"
|
||||||
)
|
)
|
||||||
await self.set_pending(status.pending)
|
await self.set_pending(status.pending)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,22 @@ from typing import Dict, Optional, Tuple
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from fastapi import Depends
|
||||||
from lnurl import LnurlErrorResponse
|
from lnurl import LnurlErrorResponse
|
||||||
from lnurl import decode as decode_lnurl # type: ignore
|
from lnurl import decode as decode_lnurl # type: ignore
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits import bolt11
|
from lnbits import bolt11
|
||||||
from lnbits.db import Connection
|
from lnbits.db import Connection
|
||||||
|
from lnbits.decorators import (
|
||||||
|
WalletTypeInfo,
|
||||||
|
get_key_type,
|
||||||
|
require_admin_key,
|
||||||
|
require_invoice_key,
|
||||||
|
)
|
||||||
from lnbits.helpers import url_for, urlsafe_short_hash
|
from lnbits.helpers import url_for, urlsafe_short_hash
|
||||||
from lnbits.requestvars import g
|
from lnbits.requestvars import g
|
||||||
from lnbits.settings import FAKE_WALLET, WALLET
|
from lnbits.settings import FAKE_WALLET, RESERVE_FEE_MIN, RESERVE_FEE_PERCENT, WALLET
|
||||||
from lnbits.wallets.base import PaymentResponse, PaymentStatus
|
from lnbits.wallets.base import PaymentResponse, PaymentStatus
|
||||||
|
|
||||||
from . import db
|
from . import db
|
||||||
|
|
@ -47,6 +54,7 @@ async def create_invoice(
|
||||||
amount: int, # in satoshis
|
amount: int, # in satoshis
|
||||||
memo: str,
|
memo: str,
|
||||||
description_hash: Optional[bytes] = None,
|
description_hash: Optional[bytes] = None,
|
||||||
|
unhashed_description: Optional[bytes] = None,
|
||||||
extra: Optional[Dict] = None,
|
extra: Optional[Dict] = None,
|
||||||
webhook: Optional[str] = None,
|
webhook: Optional[str] = None,
|
||||||
internal: Optional[bool] = False,
|
internal: Optional[bool] = False,
|
||||||
|
|
@ -58,7 +66,10 @@ async def create_invoice(
|
||||||
wallet = FAKE_WALLET if internal else WALLET
|
wallet = FAKE_WALLET if internal else WALLET
|
||||||
|
|
||||||
ok, checking_id, payment_request, error_message = await wallet.create_invoice(
|
ok, checking_id, payment_request, error_message = await wallet.create_invoice(
|
||||||
amount=amount, memo=invoice_memo, description_hash=description_hash
|
amount=amount,
|
||||||
|
memo=invoice_memo,
|
||||||
|
description_hash=description_hash,
|
||||||
|
unhashed_description=unhashed_description,
|
||||||
)
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise InvoiceFailure(error_message or "unexpected backend error.")
|
raise InvoiceFailure(error_message or "unexpected backend error.")
|
||||||
|
|
@ -102,18 +113,15 @@ async def pay_invoice(
|
||||||
raise ValueError("Amount in invoice is too high.")
|
raise ValueError("Amount in invoice is too high.")
|
||||||
|
|
||||||
# put all parameters that don't change here
|
# put all parameters that don't change here
|
||||||
PaymentKwargs = TypedDict(
|
class PaymentKwargs(TypedDict):
|
||||||
"PaymentKwargs",
|
wallet_id: str
|
||||||
{
|
payment_request: str
|
||||||
"wallet_id": str,
|
payment_hash: str
|
||||||
"payment_request": str,
|
amount: int
|
||||||
"payment_hash": str,
|
memo: str
|
||||||
"amount": int,
|
extra: Optional[Dict]
|
||||||
"memo": str,
|
|
||||||
"extra": Optional[Dict],
|
payment_kwargs: PaymentKwargs = PaymentKwargs(
|
||||||
},
|
|
||||||
)
|
|
||||||
payment_kwargs: PaymentKwargs = dict(
|
|
||||||
wallet_id=wallet_id,
|
wallet_id=wallet_id,
|
||||||
payment_request=payment_request,
|
payment_request=payment_request,
|
||||||
payment_hash=invoice.payment_hash,
|
payment_hash=invoice.payment_hash,
|
||||||
|
|
@ -152,7 +160,7 @@ async def pay_invoice(
|
||||||
logger.debug("balance is too low, deleting temporary payment")
|
logger.debug("balance is too low, deleting temporary payment")
|
||||||
if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat:
|
if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat:
|
||||||
raise PaymentFailure(
|
raise PaymentFailure(
|
||||||
f"You must reserve at least 1% ({round(fee_reserve_msat/1000)} sat) to cover potential routing fees."
|
f"You must reserve at least ({round(fee_reserve_msat/1000)} sat) to cover potential routing fees."
|
||||||
)
|
)
|
||||||
raise PermissionError("Insufficient balance.")
|
raise PermissionError("Insufficient balance.")
|
||||||
|
|
||||||
|
|
@ -178,7 +186,7 @@ async def pay_invoice(
|
||||||
payment_request, fee_reserve_msat
|
payment_request, fee_reserve_msat
|
||||||
)
|
)
|
||||||
logger.debug(f"backend: pay_invoice finished {temp_id}")
|
logger.debug(f"backend: pay_invoice finished {temp_id}")
|
||||||
if payment.checking_id:
|
if payment.ok and payment.checking_id:
|
||||||
logger.debug(f"creating final payment {payment.checking_id}")
|
logger.debug(f"creating final payment {payment.checking_id}")
|
||||||
async with db.connect() as conn:
|
async with db.connect() as conn:
|
||||||
await create_payment(
|
await create_payment(
|
||||||
|
|
@ -192,7 +200,7 @@ async def pay_invoice(
|
||||||
logger.debug(f"deleting temporary payment {temp_id}")
|
logger.debug(f"deleting temporary payment {temp_id}")
|
||||||
await delete_payment(temp_id, conn=conn)
|
await delete_payment(temp_id, conn=conn)
|
||||||
else:
|
else:
|
||||||
logger.debug(f"backend payment failed, no checking_id {temp_id}")
|
logger.debug(f"backend payment failed")
|
||||||
async with db.connect() as conn:
|
async with db.connect() as conn:
|
||||||
logger.debug(f"deleting temporary payment {temp_id}")
|
logger.debug(f"deleting temporary payment {temp_id}")
|
||||||
await delete_payment(temp_id, conn=conn)
|
await delete_payment(temp_id, conn=conn)
|
||||||
|
|
@ -258,12 +266,15 @@ async def redeem_lnurl_withdraw(
|
||||||
|
|
||||||
|
|
||||||
async def perform_lnurlauth(
|
async def perform_lnurlauth(
|
||||||
callback: str, conn: Optional[Connection] = None
|
callback: str,
|
||||||
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
|
conn: Optional[Connection] = None,
|
||||||
) -> Optional[LnurlErrorResponse]:
|
) -> Optional[LnurlErrorResponse]:
|
||||||
cb = urlparse(callback)
|
cb = urlparse(callback)
|
||||||
|
|
||||||
k1 = unhexlify(parse_qs(cb.query)["k1"][0])
|
k1 = unhexlify(parse_qs(cb.query)["k1"][0])
|
||||||
key = g().wallet.lnurlauth_key(cb.netloc)
|
|
||||||
|
key = wallet.wallet.lnurlauth_key(cb.netloc)
|
||||||
|
|
||||||
def int_to_bytes_suitable_der(x: int) -> bytes:
|
def int_to_bytes_suitable_der(x: int) -> bytes:
|
||||||
"""for strict DER we need to encode the integer with some quirks"""
|
"""for strict DER we need to encode the integer with some quirks"""
|
||||||
|
|
@ -330,13 +341,16 @@ async def perform_lnurlauth(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def check_invoice_status(
|
async def check_transaction_status(
|
||||||
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
|
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
|
||||||
) -> PaymentStatus:
|
) -> PaymentStatus:
|
||||||
payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
||||||
if not payment:
|
if not payment:
|
||||||
return PaymentStatus(None)
|
return PaymentStatus(None)
|
||||||
status = await WALLET.get_invoice_status(payment.checking_id)
|
if payment.is_out:
|
||||||
|
status = await WALLET.get_payment_status(payment.checking_id)
|
||||||
|
else:
|
||||||
|
status = await WALLET.get_invoice_status(payment.checking_id)
|
||||||
if not payment.pending:
|
if not payment.pending:
|
||||||
return status
|
return status
|
||||||
if payment.is_out and status.failed:
|
if payment.is_out and status.failed:
|
||||||
|
|
@ -352,4 +366,4 @@ async def check_invoice_status(
|
||||||
|
|
||||||
# WARN: this same value must be used for balance check and passed to WALLET.pay_invoice(), it may cause a vulnerability if the values differ
|
# WARN: this same value must be used for balance check and passed to WALLET.pay_invoice(), it may cause a vulnerability if the values differ
|
||||||
def fee_reserve(amount_msat: int) -> int:
|
def fee_reserve(amount_msat: int) -> int:
|
||||||
return max(2000, int(amount_msat * 0.01))
|
return max(int(RESERVE_FEE_MIN), int(amount_msat * RESERVE_FEE_PERCENT / 100.0))
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,36 @@
|
||||||
new Vue({
|
new Vue({
|
||||||
el: '#vue',
|
el: '#vue',
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
searchTerm: '',
|
||||||
|
filteredExtensions: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.filteredExtensions = this.g.extensions
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
searchTerm(term) {
|
||||||
|
// Reset the filter
|
||||||
|
this.filteredExtensions = this.g.extensions
|
||||||
|
if (term !== '') {
|
||||||
|
// Filter the extensions list
|
||||||
|
function extensionNameContains(searchTerm) {
|
||||||
|
return function (extension) {
|
||||||
|
return (
|
||||||
|
extension.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
extension.shortDescription
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchTerm.toLowerCase())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.filteredExtensions = this.filteredExtensions.filter(
|
||||||
|
extensionNameContains(term)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
mixins: [windowMixin]
|
mixins: [windowMixin]
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ async def dispatch_webhook(payment: Payment):
|
||||||
data = payment.dict()
|
data = payment.dict()
|
||||||
try:
|
try:
|
||||||
logger.debug("sending webhook", payment.webhook)
|
logger.debug("sending webhook", payment.webhook)
|
||||||
r = await client.post(payment.webhook, json=data, timeout=40)
|
r = await client.post(payment.webhook, json=data, timeout=40) # type: ignore
|
||||||
await mark_webhook_sent(payment, r.status_code)
|
await mark_webhook_sent(payment, r.status_code)
|
||||||
except (httpx.ConnectError, httpx.RequestError):
|
except (httpx.ConnectError, httpx.RequestError):
|
||||||
await mark_webhook_sent(payment, -1)
|
await mark_webhook_sent(payment, -1)
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,23 @@
|
||||||
%} {% block scripts %} {{ window_vars(user) }}
|
%} {% block scripts %} {{ window_vars(user) }}
|
||||||
<script src="/core/static/js/extensions.js"></script>
|
<script src="/core/static/js/extensions.js"></script>
|
||||||
{% endblock %} {% block page %}
|
{% endblock %} {% block page %}
|
||||||
|
<div class="row q-col-gutter-md q-mb-md">
|
||||||
|
<div class="col-sm-3 col-xs-8 q-ml-auto">
|
||||||
|
<q-input v-model="searchTerm" label="Search extensions">
|
||||||
|
<q-icon
|
||||||
|
v-if="searchTerm !== ''"
|
||||||
|
name="close"
|
||||||
|
@click="searchTerm = ''"
|
||||||
|
class="cursor-pointer q-mt-lg"
|
||||||
|
/>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row q-col-gutter-md">
|
<div class="row q-col-gutter-md">
|
||||||
<div
|
<div
|
||||||
class="col-6 col-md-4 col-lg-3"
|
class="col-6 col-md-4 col-lg-3"
|
||||||
v-for="extension in g.extensions"
|
v-for="extension in filteredExtensions"
|
||||||
:key="extension.code"
|
:key="extension.code"
|
||||||
>
|
>
|
||||||
<q-card>
|
<q-card>
|
||||||
|
|
|
||||||
|
|
@ -689,7 +689,7 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
<q-tabs
|
<q-tabs
|
||||||
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-max"
|
class="lt-md fixed-bottom left-0 right-0 bg-primary text-white shadow-2 z-top"
|
||||||
active-class="px-0"
|
active-class="px-0"
|
||||||
indicator-color="transparent"
|
indicator-color="transparent"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -3,32 +3,30 @@ import hashlib
|
||||||
import json
|
import json
|
||||||
from binascii import unhexlify
|
from binascii import unhexlify
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Dict, List, Optional, Union
|
from io import BytesIO
|
||||||
|
from typing import Dict, List, Optional, Tuple, Union
|
||||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import Header, Query, Request
|
import pyqrcode
|
||||||
|
from fastapi import Depends, Header, Query, Request
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
from fastapi.param_functions import Depends
|
|
||||||
from fastapi.params import Body
|
from fastapi.params import Body
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from pydantic.fields import Field
|
from pydantic.fields import Field
|
||||||
from sse_starlette.sse import EventSourceResponse
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
from starlette.responses import HTMLResponse, StreamingResponse
|
||||||
|
|
||||||
from lnbits import bolt11, lnurl
|
from lnbits import bolt11, lnurl
|
||||||
from lnbits.bolt11 import Invoice
|
|
||||||
from lnbits.core.models import Payment, Wallet
|
from lnbits.core.models import Payment, Wallet
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
WalletAdminKeyChecker,
|
|
||||||
WalletInvoiceKeyChecker,
|
|
||||||
WalletTypeInfo,
|
WalletTypeInfo,
|
||||||
get_key_type,
|
get_key_type,
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
require_invoice_key,
|
require_invoice_key,
|
||||||
)
|
)
|
||||||
from lnbits.helpers import url_for, urlsafe_short_hash
|
from lnbits.helpers import url_for, urlsafe_short_hash
|
||||||
from lnbits.requestvars import g
|
|
||||||
from lnbits.settings import LNBITS_ADMIN_USERS, LNBITS_SITE_TITLE
|
from lnbits.settings import LNBITS_ADMIN_USERS, LNBITS_SITE_TITLE
|
||||||
from lnbits.utils.exchange_rates import (
|
from lnbits.utils.exchange_rates import (
|
||||||
currencies,
|
currencies,
|
||||||
|
|
@ -50,7 +48,7 @@ from ..crud import (
|
||||||
from ..services import (
|
from ..services import (
|
||||||
InvoiceFailure,
|
InvoiceFailure,
|
||||||
PaymentFailure,
|
PaymentFailure,
|
||||||
check_invoice_status,
|
check_transaction_status,
|
||||||
create_invoice,
|
create_invoice,
|
||||||
pay_invoice,
|
pay_invoice,
|
||||||
perform_lnurlauth,
|
perform_lnurlauth,
|
||||||
|
|
@ -125,7 +123,7 @@ async def api_payments(
|
||||||
offset=offset,
|
offset=offset,
|
||||||
)
|
)
|
||||||
for payment in pendingPayments:
|
for payment in pendingPayments:
|
||||||
await check_invoice_status(
|
await check_transaction_status(
|
||||||
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
|
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
|
||||||
)
|
)
|
||||||
return await get_payments(
|
return await get_payments(
|
||||||
|
|
@ -143,6 +141,7 @@ class CreateInvoiceData(BaseModel):
|
||||||
memo: Optional[str] = None
|
memo: Optional[str] = None
|
||||||
unit: Optional[str] = "sat"
|
unit: Optional[str] = "sat"
|
||||||
description_hash: Optional[str] = None
|
description_hash: Optional[str] = None
|
||||||
|
unhashed_description: Optional[str] = None
|
||||||
lnurl_callback: Optional[str] = None
|
lnurl_callback: Optional[str] = None
|
||||||
lnurl_balance_check: Optional[str] = None
|
lnurl_balance_check: Optional[str] = None
|
||||||
extra: Optional[dict] = None
|
extra: Optional[dict] = None
|
||||||
|
|
@ -154,9 +153,15 @@ class CreateInvoiceData(BaseModel):
|
||||||
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||||
if data.description_hash:
|
if data.description_hash:
|
||||||
description_hash = unhexlify(data.description_hash)
|
description_hash = unhexlify(data.description_hash)
|
||||||
|
unhashed_description = b""
|
||||||
|
memo = ""
|
||||||
|
elif data.unhashed_description:
|
||||||
|
unhashed_description = unhexlify(data.unhashed_description)
|
||||||
|
description_hash = b""
|
||||||
memo = ""
|
memo = ""
|
||||||
else:
|
else:
|
||||||
description_hash = b""
|
description_hash = b""
|
||||||
|
unhashed_description = b""
|
||||||
memo = data.memo or LNBITS_SITE_TITLE
|
memo = data.memo or LNBITS_SITE_TITLE
|
||||||
if data.unit == "sat":
|
if data.unit == "sat":
|
||||||
amount = int(data.amount)
|
amount = int(data.amount)
|
||||||
|
|
@ -172,6 +177,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||||
amount=amount,
|
amount=amount,
|
||||||
memo=memo,
|
memo=memo,
|
||||||
description_hash=description_hash,
|
description_hash=description_hash,
|
||||||
|
unhashed_description=unhashed_description,
|
||||||
extra=data.extra,
|
extra=data.extra,
|
||||||
webhook=data.webhook,
|
webhook=data.webhook,
|
||||||
internal=data.internal,
|
internal=data.internal,
|
||||||
|
|
@ -186,11 +192,8 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||||
|
|
||||||
lnurl_response: Union[None, bool, str] = None
|
lnurl_response: Union[None, bool, str] = None
|
||||||
if data.lnurl_callback:
|
if data.lnurl_callback:
|
||||||
if "lnurl_balance_check" in data:
|
if data.lnurl_balance_check is not None:
|
||||||
assert (
|
await save_balance_check(wallet.id, data.lnurl_balance_check)
|
||||||
data.lnurl_balance_check is not None
|
|
||||||
), "lnurl_balance_check is required"
|
|
||||||
save_balance_check(wallet.id, data.lnurl_balance_check)
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
try:
|
||||||
|
|
@ -247,13 +250,11 @@ async def api_payments_pay_invoice(bolt11: str, wallet: Wallet):
|
||||||
|
|
||||||
@core_app.post(
|
@core_app.post(
|
||||||
"/api/v1/payments",
|
"/api/v1/payments",
|
||||||
# deprecated=True,
|
|
||||||
# description="DEPRECATED. Use /api/v2/TBD and /api/v2/TBD instead",
|
|
||||||
status_code=HTTPStatus.CREATED,
|
status_code=HTTPStatus.CREATED,
|
||||||
)
|
)
|
||||||
async def api_payments_create(
|
async def api_payments_create(
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
invoiceData: CreateInvoiceData = Body(...),
|
invoiceData: CreateInvoiceData = Body(...), # type: ignore
|
||||||
):
|
):
|
||||||
if invoiceData.out is True and wallet.wallet_type == 0:
|
if invoiceData.out is True and wallet.wallet_type == 0:
|
||||||
if not invoiceData.bolt11:
|
if not invoiceData.bolt11:
|
||||||
|
|
@ -269,7 +270,7 @@ async def api_payments_create(
|
||||||
return await api_payments_create_invoice(invoiceData, wallet.wallet)
|
return await api_payments_create_invoice(invoiceData, wallet.wallet)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.UNAUTHORIZED,
|
||||||
detail="Invoice (or Admin) key required.",
|
detail="Invoice (or Admin) key required.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -284,7 +285,7 @@ class CreateLNURLData(BaseModel):
|
||||||
|
|
||||||
@core_app.post("/api/v1/payments/lnurl")
|
@core_app.post("/api/v1/payments/lnurl")
|
||||||
async def api_payments_pay_lnurl(
|
async def api_payments_pay_lnurl(
|
||||||
data: CreateLNURLData, wallet: WalletTypeInfo = Depends(get_key_type)
|
data: CreateLNURLData, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
):
|
):
|
||||||
domain = urlparse(data.callback).netloc
|
domain = urlparse(data.callback).netloc
|
||||||
|
|
||||||
|
|
@ -296,7 +297,7 @@ async def api_payments_pay_lnurl(
|
||||||
timeout=40,
|
timeout=40,
|
||||||
)
|
)
|
||||||
if r.is_error:
|
if r.is_error:
|
||||||
raise httpx.ConnectError
|
raise httpx.ConnectError("LNURL callback connection error")
|
||||||
except (httpx.ConnectError, httpx.RequestError):
|
except (httpx.ConnectError, httpx.RequestError):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
|
@ -310,6 +311,12 @@ async def api_payments_pay_lnurl(
|
||||||
detail=f"{domain} said: '{params.get('reason', '')}'",
|
detail=f"{domain} said: '{params.get('reason', '')}'",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not params.get("pr"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail=f"{domain} did not return a payment request.",
|
||||||
|
)
|
||||||
|
|
||||||
invoice = bolt11.decode(params["pr"])
|
invoice = bolt11.decode(params["pr"])
|
||||||
if invoice.amount_msat != data.amount:
|
if invoice.amount_msat != data.amount:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -317,11 +324,11 @@ async def api_payments_pay_lnurl(
|
||||||
detail=f"{domain} returned an invalid invoice. Expected {data.amount} msat, got {invoice.amount_msat}.",
|
detail=f"{domain} returned an invalid invoice. Expected {data.amount} msat, got {invoice.amount_msat}.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# if invoice.description_hash != data.description_hash:
|
if invoice.description_hash != data.description_hash:
|
||||||
# raise HTTPException(
|
raise HTTPException(
|
||||||
# status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
# detail=f"{domain} returned an invalid invoice. Expected description_hash == {data.description_hash}, got {invoice.description_hash}.",
|
detail=f"{domain} returned an invalid invoice. Expected description_hash == {data.description_hash}, got {invoice.description_hash}.",
|
||||||
# )
|
)
|
||||||
|
|
||||||
extra = {}
|
extra = {}
|
||||||
|
|
||||||
|
|
@ -353,7 +360,7 @@ async def subscribe(request: Request, wallet: Wallet):
|
||||||
logger.debug("adding sse listener", payment_queue)
|
logger.debug("adding sse listener", payment_queue)
|
||||||
api_invoice_listeners.append(payment_queue)
|
api_invoice_listeners.append(payment_queue)
|
||||||
|
|
||||||
send_queue: asyncio.Queue[tuple[str, Payment]] = asyncio.Queue(0)
|
send_queue: asyncio.Queue[Tuple[str, Payment]] = asyncio.Queue(0)
|
||||||
|
|
||||||
async def payment_received() -> None:
|
async def payment_received() -> None:
|
||||||
while True:
|
while True:
|
||||||
|
|
@ -392,21 +399,18 @@ async def api_payments_sse(
|
||||||
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
||||||
# We use X_Api_Key here because we want this call to work with and without keys
|
# We use X_Api_Key here because we want this call to work with and without keys
|
||||||
# If a valid key is given, we also return the field "details", otherwise not
|
# If a valid key is given, we also return the field "details", otherwise not
|
||||||
wallet = None
|
wallet = await get_wallet_for_key(X_Api_Key) if type(X_Api_Key) == str else None
|
||||||
try:
|
|
||||||
if X_Api_Key.extra:
|
# we have to specify the wallet id here, because postgres and sqlite return internal payments in different order
|
||||||
logger.warning("No key")
|
# and get_standalone_payment otherwise just fetches the first one, causing unpredictable results
|
||||||
except:
|
|
||||||
wallet = await get_wallet_for_key(X_Api_Key)
|
|
||||||
payment = await get_standalone_payment(
|
payment = await get_standalone_payment(
|
||||||
payment_hash, wallet_id=wallet.id if wallet else None
|
payment_hash, wallet_id=wallet.id if wallet else None
|
||||||
) # we have to specify the wallet id here, because postgres and sqlite return internal payments in different order
|
)
|
||||||
# and get_standalone_payment otherwise just fetches the first one, causing unpredictable results
|
|
||||||
if payment is None:
|
if payment is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||||
)
|
)
|
||||||
await check_invoice_status(payment.wallet_id, payment_hash)
|
await check_transaction_status(payment.wallet_id, payment_hash)
|
||||||
payment = await get_standalone_payment(
|
payment = await get_standalone_payment(
|
||||||
payment_hash, wallet_id=wallet.id if wallet else None
|
payment_hash, wallet_id=wallet.id if wallet else None
|
||||||
)
|
)
|
||||||
|
|
@ -435,10 +439,8 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
||||||
return {"paid": not payment.pending, "preimage": payment.preimage}
|
return {"paid": not payment.pending, "preimage": payment.preimage}
|
||||||
|
|
||||||
|
|
||||||
@core_app.get(
|
@core_app.get("/api/v1/lnurlscan/{code}")
|
||||||
"/api/v1/lnurlscan/{code}", dependencies=[Depends(WalletInvoiceKeyChecker())]
|
async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
)
|
|
||||||
async def api_lnurlscan(code: str):
|
|
||||||
try:
|
try:
|
||||||
url = lnurl.decode(code)
|
url = lnurl.decode(code)
|
||||||
domain = urlparse(url).netloc
|
domain = urlparse(url).netloc
|
||||||
|
|
@ -466,7 +468,7 @@ async def api_lnurlscan(code: str):
|
||||||
params.update(kind="auth")
|
params.update(kind="auth")
|
||||||
params.update(callback=url) # with k1 already in it
|
params.update(callback=url) # with k1 already in it
|
||||||
|
|
||||||
lnurlauth_key = g().wallet.lnurlauth_key(domain)
|
lnurlauth_key = wallet.wallet.lnurlauth_key(domain)
|
||||||
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
|
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
|
||||||
else:
|
else:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
|
|
@ -489,7 +491,8 @@ async def api_lnurlscan(code: str):
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tag = data["tag"]
|
tag: str = data.get("tag")
|
||||||
|
params.update(**data)
|
||||||
if tag == "channelRequest":
|
if tag == "channelRequest":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
|
@ -499,10 +502,7 @@ async def api_lnurlscan(code: str):
|
||||||
"message": "unsupported",
|
"message": "unsupported",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
elif tag == "withdrawRequest":
|
||||||
params.update(**data)
|
|
||||||
|
|
||||||
if tag == "withdrawRequest":
|
|
||||||
params.update(kind="withdraw")
|
params.update(kind="withdraw")
|
||||||
params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"])
|
params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"])
|
||||||
|
|
||||||
|
|
@ -520,8 +520,7 @@ async def api_lnurlscan(code: str):
|
||||||
query=urlencode(qs, doseq=True)
|
query=urlencode(qs, doseq=True)
|
||||||
)
|
)
|
||||||
params.update(callback=urlunparse(parsed_callback))
|
params.update(callback=urlunparse(parsed_callback))
|
||||||
|
elif tag == "payRequest":
|
||||||
if tag == "payRequest":
|
|
||||||
params.update(kind="pay")
|
params.update(kind="pay")
|
||||||
params.update(fixed=data["minSendable"] == data["maxSendable"])
|
params.update(fixed=data["minSendable"] == data["maxSendable"])
|
||||||
|
|
||||||
|
|
@ -539,8 +538,8 @@ async def api_lnurlscan(code: str):
|
||||||
params.update(image=data_uri)
|
params.update(image=data_uri)
|
||||||
if k == "text/email" or k == "text/identifier":
|
if k == "text/email" or k == "text/identifier":
|
||||||
params.update(targetUser=v)
|
params.update(targetUser=v)
|
||||||
|
|
||||||
params.update(commentAllowed=data.get("commentAllowed", 0))
|
params.update(commentAllowed=data.get("commentAllowed", 0))
|
||||||
|
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
|
|
@ -582,14 +581,19 @@ async def api_payments_decode(data: DecodePayment):
|
||||||
return {"message": "Failed to decode"}
|
return {"message": "Failed to decode"}
|
||||||
|
|
||||||
|
|
||||||
@core_app.post("/api/v1/lnurlauth", dependencies=[Depends(WalletAdminKeyChecker())])
|
class Callback(BaseModel):
|
||||||
async def api_perform_lnurlauth(callback: str):
|
callback: str = Query(...)
|
||||||
err = await perform_lnurlauth(callback)
|
|
||||||
|
|
||||||
|
@core_app.post("/api/v1/lnurlauth")
|
||||||
|
async def api_perform_lnurlauth(
|
||||||
|
callback: Callback, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
|
):
|
||||||
|
err = await perform_lnurlauth(callback.callback, wallet=wallet)
|
||||||
if err:
|
if err:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason
|
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason
|
||||||
)
|
)
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -608,8 +612,8 @@ class ConversionData(BaseModel):
|
||||||
async def api_fiat_as_sats(data: ConversionData):
|
async def api_fiat_as_sats(data: ConversionData):
|
||||||
output = {}
|
output = {}
|
||||||
if data.from_ == "sat":
|
if data.from_ == "sat":
|
||||||
output["sats"] = int(data.amount)
|
|
||||||
output["BTC"] = data.amount / 100000000
|
output["BTC"] = data.amount / 100000000
|
||||||
|
output["sats"] = int(data.amount)
|
||||||
for currency in data.to.split(","):
|
for currency in data.to.split(","):
|
||||||
output[currency.strip().upper()] = await satoshis_amount_as_fiat(
|
output[currency.strip().upper()] = await satoshis_amount_as_fiat(
|
||||||
data.amount, currency.strip()
|
data.amount, currency.strip()
|
||||||
|
|
@ -620,3 +624,24 @@ async def api_fiat_as_sats(data: ConversionData):
|
||||||
output["sats"] = await fiat_amount_as_satoshis(data.amount, data.from_)
|
output["sats"] = await fiat_amount_as_satoshis(data.amount, data.from_)
|
||||||
output["BTC"] = output["sats"] / 100000000
|
output["BTC"] = output["sats"] / 100000000
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
@core_app.get("/api/v1/qrcode/{data}", response_class=StreamingResponse)
|
||||||
|
async def img(request: Request, data):
|
||||||
|
qr = pyqrcode.create(data)
|
||||||
|
stream = BytesIO()
|
||||||
|
qr.svg(stream, scale=3)
|
||||||
|
stream.seek(0)
|
||||||
|
|
||||||
|
async def _generator(stream: BytesIO):
|
||||||
|
yield stream.getvalue()
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
_generator(stream),
|
||||||
|
headers={
|
||||||
|
"Content-Type": "image/svg+xml",
|
||||||
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Expires": "0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from lnbits.settings import (
|
||||||
SERVICE_FEE,
|
SERVICE_FEE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ...helpers import get_valid_extensions
|
||||||
from ..crud import (
|
from ..crud import (
|
||||||
create_account,
|
create_account,
|
||||||
create_wallet,
|
create_wallet,
|
||||||
|
|
@ -54,9 +55,9 @@ async def home(request: Request, lightning: str = None):
|
||||||
)
|
)
|
||||||
async def extensions(
|
async def extensions(
|
||||||
request: Request,
|
request: Request,
|
||||||
user: User = Depends(check_user_exists),
|
user: User = Depends(check_user_exists), # type: ignore
|
||||||
enable: str = Query(None),
|
enable: str = Query(None), # type: ignore
|
||||||
disable: str = Query(None),
|
disable: str = Query(None), # type: ignore
|
||||||
):
|
):
|
||||||
extension_to_enable = enable
|
extension_to_enable = enable
|
||||||
extension_to_disable = disable
|
extension_to_disable = disable
|
||||||
|
|
@ -66,6 +67,14 @@ async def extensions(
|
||||||
HTTPStatus.BAD_REQUEST, "You can either `enable` or `disable` an extension."
|
HTTPStatus.BAD_REQUEST, "You can either `enable` or `disable` an extension."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# check if extension exists
|
||||||
|
if extension_to_enable or extension_to_disable:
|
||||||
|
ext = extension_to_enable or extension_to_disable
|
||||||
|
if ext not in [e.code for e in get_valid_extensions()]:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTPStatus.BAD_REQUEST, f"Extension '{ext}' doesn't exist."
|
||||||
|
)
|
||||||
|
|
||||||
if extension_to_enable:
|
if extension_to_enable:
|
||||||
logger.info(f"Enabling extension: {extension_to_enable} for user {user.id}")
|
logger.info(f"Enabling extension: {extension_to_enable} for user {user.id}")
|
||||||
await update_user_extension(
|
await update_user_extension(
|
||||||
|
|
@ -79,7 +88,7 @@ async def extensions(
|
||||||
|
|
||||||
# Update user as his extensions have been updated
|
# Update user as his extensions have been updated
|
||||||
if extension_to_enable or extension_to_disable:
|
if extension_to_enable or extension_to_disable:
|
||||||
user = await get_user(user.id)
|
user = await get_user(user.id) # type: ignore
|
||||||
|
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
"core/extensions.html", {"request": request, "user": user.dict()}
|
"core/extensions.html", {"request": request, "user": user.dict()}
|
||||||
|
|
@ -100,10 +109,10 @@ nothing: create everything<br>
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
async def wallet(
|
async def wallet(
|
||||||
request: Request = Query(None),
|
request: Request = Query(None), # type: ignore
|
||||||
nme: Optional[str] = Query(None),
|
nme: Optional[str] = Query(None), # type: ignore
|
||||||
usr: Optional[UUID4] = Query(None),
|
usr: Optional[UUID4] = Query(None), # type: ignore
|
||||||
wal: Optional[UUID4] = Query(None),
|
wal: Optional[UUID4] = Query(None), # type: ignore
|
||||||
):
|
):
|
||||||
user_id = usr.hex if usr else None
|
user_id = usr.hex if usr else None
|
||||||
wallet_id = wal.hex if wal else None
|
wallet_id = wal.hex if wal else None
|
||||||
|
|
@ -112,7 +121,7 @@ async def wallet(
|
||||||
|
|
||||||
if not user_id:
|
if not user_id:
|
||||||
user = await get_user((await create_account()).id)
|
user = await get_user((await create_account()).id)
|
||||||
logger.info(f"Created new account for user {user.id}")
|
logger.info(f"Create user {user.id}") # type: ignore
|
||||||
else:
|
else:
|
||||||
user = await get_user(user_id)
|
user = await get_user(user_id)
|
||||||
if not user:
|
if not user:
|
||||||
|
|
@ -126,22 +135,24 @@ async def wallet(
|
||||||
if LNBITS_ADMIN_USERS and user_id in LNBITS_ADMIN_USERS:
|
if LNBITS_ADMIN_USERS and user_id in LNBITS_ADMIN_USERS:
|
||||||
user.admin = True
|
user.admin = True
|
||||||
if not wallet_id:
|
if not wallet_id:
|
||||||
if user.wallets and not wallet_name:
|
if user.wallets and not wallet_name: # type: ignore
|
||||||
wallet = user.wallets[0]
|
wallet = user.wallets[0] # type: ignore
|
||||||
else:
|
else:
|
||||||
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name)
|
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name) # type: ignore
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Created new wallet {wallet_name if wallet_name else '(no name)'} for user {user.id}"
|
f"Created new wallet {wallet_name if wallet_name else '(no name)'} for user {user.id}" # type: ignore
|
||||||
)
|
)
|
||||||
|
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
f"/wallet?usr={user.id}&wal={wallet.id}",
|
f"/wallet?usr={user.id}&wal={wallet.id}", # type: ignore
|
||||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Access wallet {wallet_name} of user {user.id}")
|
logger.debug(
|
||||||
wallet = user.get_wallet(wallet_id)
|
f"Access {'user '+ user.id + ' ' if user else ''} {'wallet ' + wallet_name if wallet_name else ''}"
|
||||||
if not wallet:
|
)
|
||||||
|
userwallet = user.get_wallet(wallet_id) # type: ignore
|
||||||
|
if not userwallet:
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
"error.html", {"request": request, "err": "Wallet not found"}
|
"error.html", {"request": request, "err": "Wallet not found"}
|
||||||
)
|
)
|
||||||
|
|
@ -150,10 +161,10 @@ async def wallet(
|
||||||
"core/wallet.html",
|
"core/wallet.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"user": user.dict(),
|
"user": user.dict(), # type: ignore
|
||||||
"wallet": wallet.dict(),
|
"wallet": userwallet.dict(),
|
||||||
"service_fee": service_fee,
|
"service_fee": service_fee,
|
||||||
"web_manifest": f"/manifest/{user.id}.webmanifest",
|
"web_manifest": f"/manifest/{user.id}.webmanifest", # type: ignore
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -207,20 +218,20 @@ async def lnurl_full_withdraw_callback(request: Request):
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get("/deletewallet", response_class=RedirectResponse)
|
@core_html_routes.get("/deletewallet", response_class=RedirectResponse)
|
||||||
async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query(...)):
|
async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query(...)): # type: ignore
|
||||||
user = await get_user(usr)
|
user = await get_user(usr)
|
||||||
user_wallet_ids = [u.id for u in user.wallets]
|
user_wallet_ids = [u.id for u in user.wallets] # type: ignore
|
||||||
|
|
||||||
if wal not in user_wallet_ids:
|
if wal not in user_wallet_ids:
|
||||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
||||||
else:
|
else:
|
||||||
await delete_wallet(user_id=user.id, wallet_id=wal)
|
await delete_wallet(user_id=user.id, wallet_id=wal) # type: ignore
|
||||||
user_wallet_ids.remove(wal)
|
user_wallet_ids.remove(wal)
|
||||||
logger.debug("Deleted wallet {wal} of user {user.id}")
|
logger.debug("Deleted wallet {wal} of user {user.id}")
|
||||||
|
|
||||||
if user_wallet_ids:
|
if user_wallet_ids:
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]),
|
url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]), # type: ignore
|
||||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -233,7 +244,7 @@ async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query
|
||||||
async def lnurl_balance_notify(request: Request, service: str):
|
async def lnurl_balance_notify(request: Request, service: str):
|
||||||
bc = await get_balance_check(request.query_params.get("wal"), service)
|
bc = await get_balance_check(request.query_params.get("wal"), service)
|
||||||
if bc:
|
if bc:
|
||||||
redeem_lnurl_withdraw(bc.wallet, bc.url)
|
await redeem_lnurl_withdraw(bc.wallet, bc.url)
|
||||||
|
|
||||||
|
|
||||||
@core_html_routes.get(
|
@core_html_routes.get(
|
||||||
|
|
@ -243,7 +254,7 @@ async def lnurlwallet(request: Request):
|
||||||
async with db.connect() as conn:
|
async with db.connect() as conn:
|
||||||
account = await create_account(conn=conn)
|
account = await create_account(conn=conn)
|
||||||
user = await get_user(account.id, conn=conn)
|
user = await get_user(account.id, conn=conn)
|
||||||
wallet = await create_wallet(user_id=user.id, conn=conn)
|
wallet = await create_wallet(user_id=user.id, conn=conn) # type: ignore
|
||||||
|
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
redeem_lnurl_withdraw(
|
redeem_lnurl_withdraw(
|
||||||
|
|
@ -256,7 +267,7 @@ async def lnurlwallet(request: Request):
|
||||||
)
|
)
|
||||||
|
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
f"/wallet?usr={user.id}&wal={wallet.id}",
|
f"/wallet?usr={user.id}&wal={wallet.id}", # type: ignore
|
||||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
from cerberus import Validator # type: ignore
|
from cerberus import Validator # type: ignore
|
||||||
from fastapi import status
|
from fastapi import status
|
||||||
|
|
@ -29,20 +30,21 @@ class KeyChecker(SecurityBase):
|
||||||
self._key_type = "invoice"
|
self._key_type = "invoice"
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
if api_key:
|
if api_key:
|
||||||
self.model: APIKey = APIKey(
|
key = APIKey(
|
||||||
**{"in": APIKeyIn.query},
|
**{"in": APIKeyIn.query},
|
||||||
name="X-API-KEY",
|
name="X-API-KEY",
|
||||||
description="Wallet API Key - QUERY",
|
description="Wallet API Key - QUERY",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.model: APIKey = APIKey(
|
key = APIKey(
|
||||||
**{"in": APIKeyIn.header},
|
**{"in": APIKeyIn.header},
|
||||||
name="X-API-KEY",
|
name="X-API-KEY",
|
||||||
description="Wallet API Key - HEADER",
|
description="Wallet API Key - HEADER",
|
||||||
)
|
)
|
||||||
self.wallet = None
|
self.wallet = None # type: ignore
|
||||||
|
self.model: APIKey = key
|
||||||
|
|
||||||
async def __call__(self, request: Request) -> Wallet:
|
async def __call__(self, request: Request):
|
||||||
try:
|
try:
|
||||||
key_value = (
|
key_value = (
|
||||||
self._api_key
|
self._api_key
|
||||||
|
|
@ -52,7 +54,7 @@ class KeyChecker(SecurityBase):
|
||||||
# FIXME: Find another way to validate the key. A fetch from DB should be avoided here.
|
# FIXME: Find another way to validate the key. A fetch from DB should be avoided here.
|
||||||
# Also, we should not return the wallet here - thats silly.
|
# Also, we should not return the wallet here - thats silly.
|
||||||
# Possibly store it in a Redis DB
|
# Possibly store it in a Redis DB
|
||||||
self.wallet = await get_wallet_for_key(key_value, self._key_type)
|
self.wallet = await get_wallet_for_key(key_value, self._key_type) # type: ignore
|
||||||
if not self.wallet:
|
if not self.wallet:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.UNAUTHORIZED,
|
status_code=HTTPStatus.UNAUTHORIZED,
|
||||||
|
|
@ -120,8 +122,8 @@ api_key_query = APIKeyQuery(
|
||||||
|
|
||||||
async def get_key_type(
|
async def get_key_type(
|
||||||
r: Request,
|
r: Request,
|
||||||
api_key_header: str = Security(api_key_header),
|
api_key_header: str = Security(api_key_header), # type: ignore
|
||||||
api_key_query: str = Security(api_key_query),
|
api_key_query: str = Security(api_key_query), # type: ignore
|
||||||
) -> WalletTypeInfo:
|
) -> WalletTypeInfo:
|
||||||
# 0: admin
|
# 0: admin
|
||||||
# 1: invoice
|
# 1: invoice
|
||||||
|
|
@ -134,9 +136,9 @@ async def get_key_type(
|
||||||
token = api_key_header if api_key_header else api_key_query
|
token = api_key_header if api_key_header else api_key_query
|
||||||
|
|
||||||
try:
|
try:
|
||||||
checker = WalletAdminKeyChecker(api_key=token)
|
admin_checker = WalletAdminKeyChecker(api_key=token)
|
||||||
await checker.__call__(r)
|
await admin_checker.__call__(r)
|
||||||
wallet = WalletTypeInfo(0, checker.wallet)
|
wallet = WalletTypeInfo(0, admin_checker.wallet) # type: ignore
|
||||||
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (
|
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (
|
||||||
LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS
|
LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS
|
||||||
):
|
):
|
||||||
|
|
@ -153,9 +155,9 @@ async def get_key_type(
|
||||||
raise
|
raise
|
||||||
|
|
||||||
try:
|
try:
|
||||||
checker = WalletInvoiceKeyChecker(api_key=token)
|
invoice_checker = WalletInvoiceKeyChecker(api_key=token)
|
||||||
await checker.__call__(r)
|
await invoice_checker.__call__(r)
|
||||||
wallet = WalletTypeInfo(1, checker.wallet)
|
wallet = WalletTypeInfo(1, invoice_checker.wallet) # type: ignore
|
||||||
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (
|
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (
|
||||||
LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS
|
LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS
|
||||||
):
|
):
|
||||||
|
|
@ -167,15 +169,16 @@ async def get_key_type(
|
||||||
if e.status_code == HTTPStatus.BAD_REQUEST:
|
if e.status_code == HTTPStatus.BAD_REQUEST:
|
||||||
raise
|
raise
|
||||||
if e.status_code == HTTPStatus.UNAUTHORIZED:
|
if e.status_code == HTTPStatus.UNAUTHORIZED:
|
||||||
return WalletTypeInfo(2, None)
|
return WalletTypeInfo(2, None) # type: ignore
|
||||||
except:
|
except:
|
||||||
raise
|
raise
|
||||||
|
return wallet
|
||||||
|
|
||||||
|
|
||||||
async def require_admin_key(
|
async def require_admin_key(
|
||||||
r: Request,
|
r: Request,
|
||||||
api_key_header: str = Security(api_key_header),
|
api_key_header: str = Security(api_key_header), # type: ignore
|
||||||
api_key_query: str = Security(api_key_query),
|
api_key_query: str = Security(api_key_query), # type: ignore
|
||||||
):
|
):
|
||||||
token = api_key_header if api_key_header else api_key_query
|
token = api_key_header if api_key_header else api_key_query
|
||||||
|
|
||||||
|
|
@ -193,10 +196,16 @@ async def require_admin_key(
|
||||||
|
|
||||||
async def require_invoice_key(
|
async def require_invoice_key(
|
||||||
r: Request,
|
r: Request,
|
||||||
api_key_header: str = Security(api_key_header),
|
api_key_header: str = Security(api_key_header), # type: ignore
|
||||||
api_key_query: str = Security(api_key_query),
|
api_key_query: str = Security(api_key_query), # type: ignore
|
||||||
):
|
):
|
||||||
token = api_key_header if api_key_header else api_key_query
|
token = api_key_header or api_key_query
|
||||||
|
|
||||||
|
if token is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invoice (or Admin) key required.",
|
||||||
|
)
|
||||||
|
|
||||||
wallet = await get_key_type(r, token)
|
wallet = await get_key_type(r, token)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ db = Database("ext_bleskomat")
|
||||||
bleskomat_static_files = [
|
bleskomat_static_files = [
|
||||||
{
|
{
|
||||||
"path": "/bleskomat/static",
|
"path": "/bleskomat/static",
|
||||||
"app": StaticFiles(directory="lnbits/extensions/bleskomat/static"),
|
"app": StaticFiles(packages=[("lnbits", "extensions/bleskomat/static")]),
|
||||||
"name": "bleskomat_static",
|
"name": "bleskomat_static",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -62,4 +62,5 @@
|
||||||
</p>
|
</p>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
<q-btn flat label="Swagger API" type="a" href="../docs#/Bleskomat"></q-btn>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ db = Database("ext_copilot")
|
||||||
copilot_static_files = [
|
copilot_static_files = [
|
||||||
{
|
{
|
||||||
"path": "/copilot/static",
|
"path": "/copilot/static",
|
||||||
"app": StaticFiles(directory="lnbits/extensions/copilot/static"),
|
"app": StaticFiles(packages=[("lnbits", "extensions/copilot/static")]),
|
||||||
"name": "copilot_static",
|
"name": "copilot_static",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -73,11 +73,9 @@ async def lnurl_callback(
|
||||||
wallet_id=cp.wallet,
|
wallet_id=cp.wallet,
|
||||||
amount=int(amount_received / 1000),
|
amount=int(amount_received / 1000),
|
||||||
memo=cp.lnurl_title,
|
memo=cp.lnurl_title,
|
||||||
description_hash=hashlib.sha256(
|
unhashed_description=(
|
||||||
(
|
LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
|
||||||
LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
|
).encode("utf-8"),
|
||||||
).encode("utf-8")
|
|
||||||
).digest(),
|
|
||||||
extra={"tag": "copilot", "copilotid": cp.id, "comment": comment},
|
extra={"tag": "copilot", "copilotid": cp.id, "comment": comment},
|
||||||
)
|
)
|
||||||
payResponse = {"pr": payment_request, "routes": []}
|
payResponse = {"pr": payment_request, "routes": []}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
label="API info"
|
label="API info"
|
||||||
:content-inset-level="0.5"
|
:content-inset-level="0.5"
|
||||||
>
|
>
|
||||||
|
<q-btn flat label="Swagger API" type="a" href="../docs#/copilot"></q-btn>
|
||||||
<q-expansion-item group="api" dense expand-separator label="Create copilot">
|
<q-expansion-item group="api" dense expand-separator label="Create copilot">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ db = Database("ext_discordbot")
|
||||||
discordbot_static_files = [
|
discordbot_static_files = [
|
||||||
{
|
{
|
||||||
"path": "/discordbot/static",
|
"path": "/discordbot/static",
|
||||||
"app": StaticFiles(directory="lnbits/extensions/discordbot/static"),
|
"app": StaticFiles(packages=[("lnbits", "extensions/discordbot/static")]),
|
||||||
"name": "discordbot_static",
|
"name": "discordbot_static",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
label="API info"
|
label="API info"
|
||||||
:content-inset-level="0.5"
|
:content-inset-level="0.5"
|
||||||
>
|
>
|
||||||
|
<q-btn flat label="Swagger API" type="a" href="../docs#/discordbot"></q-btn>
|
||||||
<q-expansion-item group="api" dense expand-separator label="GET users">
|
<q-expansion-item group="api" dense expand-separator label="GET users">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ async def create_ticket(
|
||||||
INSERT INTO events.ticket (id, wallet, event, name, email, registered, paid)
|
INSERT INTO events.ticket (id, wallet, event, name, email, registered, paid)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(payment_hash, wallet, event, name, email, False, False),
|
(payment_hash, wallet, event, name, email, False, True),
|
||||||
)
|
)
|
||||||
|
|
||||||
ticket = await get_ticket(payment_hash)
|
ticket = await get_ticket(payment_hash)
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,5 @@
|
||||||
</p>
|
</p>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
<q-btn flat label="Swagger API" type="a" href="../docs#/events"></q-btn>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
|
|
|
||||||
|
|
@ -135,15 +135,7 @@
|
||||||
var self = this
|
var self = this
|
||||||
axios
|
axios
|
||||||
|
|
||||||
.post(
|
.get('/events/api/v1/tickets/' + '{{ event_id }}')
|
||||||
'/events/api/v1/tickets/' + '{{ event_id }}/{{ event_price }}',
|
|
||||||
{
|
|
||||||
event: '{{ event_id }}',
|
|
||||||
event_name: '{{ event_name }}',
|
|
||||||
name: self.formDialog.data.name,
|
|
||||||
email: self.formDialog.data.email
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then(function (response) {
|
.then(function (response) {
|
||||||
self.paymentReq = response.data.payment_request
|
self.paymentReq = response.data.payment_request
|
||||||
self.paymentCheck = response.data.payment_hash
|
self.paymentCheck = response.data.payment_hash
|
||||||
|
|
@ -161,7 +153,17 @@
|
||||||
|
|
||||||
paymentChecker = setInterval(function () {
|
paymentChecker = setInterval(function () {
|
||||||
axios
|
axios
|
||||||
.get('/events/api/v1/tickets/' + self.paymentCheck)
|
.post(
|
||||||
|
'/events/api/v1/tickets/' +
|
||||||
|
'{{ event_id }}/' +
|
||||||
|
self.paymentCheck,
|
||||||
|
{
|
||||||
|
event: '{{ event_id }}',
|
||||||
|
event_name: '{{ event_name }}',
|
||||||
|
name: self.formDialog.data.name,
|
||||||
|
email: self.formDialog.data.email
|
||||||
|
}
|
||||||
|
)
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
if (res.data.paid) {
|
if (res.data.paid) {
|
||||||
clearInterval(paymentChecker)
|
clearInterval(paymentChecker)
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,10 @@
|
||||||
var self = this
|
var self = this
|
||||||
|
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request('GET', '/events/api/v1/register/ticket/' + res)
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/events/api/v1/register/ticket/' + res.split('//')[1]
|
||||||
|
)
|
||||||
.then(function (response) {
|
.then(function (response) {
|
||||||
self.$q.notify({
|
self.$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,8 @@
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<qrcode
|
<qrcode
|
||||||
:value="'{{ ticket_id }}'"
|
:value="'ticket://{{ ticket_id }}'"
|
||||||
:options="{width: 340}"
|
:options="{width: 500}"
|
||||||
class="rounded-borders"
|
|
||||||
></qrcode>
|
></qrcode>
|
||||||
<br />
|
<br />
|
||||||
<q-btn @click="printWindow" color="grey" class="q-ml-auto">
|
<q-btn @click="printWindow" color="grey" class="q-ml-auto">
|
||||||
|
|
|
||||||
|
|
@ -97,8 +97,8 @@ async def api_tickets(
|
||||||
return [ticket.dict() for ticket in await get_tickets(wallet_ids)]
|
return [ticket.dict() for ticket in await get_tickets(wallet_ids)]
|
||||||
|
|
||||||
|
|
||||||
@events_ext.post("/api/v1/tickets/{event_id}/{sats}")
|
@events_ext.get("/api/v1/tickets/{event_id}")
|
||||||
async def api_ticket_make_ticket(event_id, sats, data: CreateTicket):
|
async def api_ticket_make_ticket(event_id):
|
||||||
event = await get_event(event_id)
|
event = await get_event(event_id)
|
||||||
if not event:
|
if not event:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -107,37 +107,36 @@ async def api_ticket_make_ticket(event_id, sats, data: CreateTicket):
|
||||||
try:
|
try:
|
||||||
payment_hash, payment_request = await create_invoice(
|
payment_hash, payment_request = await create_invoice(
|
||||||
wallet_id=event.wallet,
|
wallet_id=event.wallet,
|
||||||
amount=int(sats),
|
amount=event.price_per_ticket,
|
||||||
memo=f"{event_id}",
|
memo=f"{event_id}",
|
||||||
extra={"tag": "events"},
|
extra={"tag": "events"},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
|
|
||||||
ticket = await create_ticket(
|
|
||||||
payment_hash=payment_hash,
|
|
||||||
wallet=event.wallet,
|
|
||||||
event=event_id,
|
|
||||||
name=data.name,
|
|
||||||
email=data.email,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not ticket:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail=f"Event could not be fetched."
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"payment_hash": payment_hash, "payment_request": payment_request}
|
return {"payment_hash": payment_hash, "payment_request": payment_request}
|
||||||
|
|
||||||
|
|
||||||
@events_ext.get("/api/v1/tickets/{payment_hash}")
|
@events_ext.post("/api/v1/tickets/{event_id}/{payment_hash}")
|
||||||
async def api_ticket_send_ticket(payment_hash):
|
async def api_ticket_send_ticket(event_id, payment_hash, data: CreateTicket):
|
||||||
ticket = await get_ticket(payment_hash)
|
event = await get_event(event_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
status = await api_payment(payment_hash)
|
status = await api_payment(payment_hash)
|
||||||
if status["paid"]:
|
if status["paid"]:
|
||||||
await set_ticket_paid(payment_hash=payment_hash)
|
ticket = await create_ticket(
|
||||||
|
payment_hash=payment_hash,
|
||||||
|
wallet=event.wallet,
|
||||||
|
event=event_id,
|
||||||
|
name=data.name,
|
||||||
|
email=data.email,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail=f"Event could not be fetched.",
|
||||||
|
)
|
||||||
|
|
||||||
return {"paid": True, "ticket_id": ticket.id}
|
return {"paid": True, "ticket_id": ticket.id}
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Not paid")
|
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Not paid")
|
||||||
|
|
|
||||||
19
lnbits/extensions/invoices/README.md
Normal file
19
lnbits/extensions/invoices/README.md
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Invoices
|
||||||
|
|
||||||
|
## Create invoices that you can send to your client to pay online over Lightning.
|
||||||
|
|
||||||
|
This extension allows users to create "traditional" invoices (not in the lightning sense) that contain one or more line items. Line items are denominated in a user-configurable fiat currency. Each invoice contains one or more payments up to the total of the invoice. Each invoice creates a public link that can be shared with a customer that they can use to (partially or in full) pay the invoice.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Create an invoice by clicking "NEW INVOICE"\
|
||||||
|

|
||||||
|
2. Fill the options for your INVOICE
|
||||||
|
- select the wallet
|
||||||
|
- select the fiat currency the invoice will be denominated in
|
||||||
|
- select a status for the invoice (default is draft)
|
||||||
|
- enter a company name, first name, last name, email, phone & address (optional)
|
||||||
|
- add one or more line items
|
||||||
|
- enter a name & price for each line item
|
||||||
|
3. You can then use share your invoice link with your customer to receive payment\
|
||||||
|

|
||||||
36
lnbits/extensions/invoices/__init__.py
Normal file
36
lnbits/extensions/invoices/__init__.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from starlette.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from lnbits.db import Database
|
||||||
|
from lnbits.helpers import template_renderer
|
||||||
|
from lnbits.tasks import catch_everything_and_restart
|
||||||
|
|
||||||
|
db = Database("ext_invoices")
|
||||||
|
|
||||||
|
invoices_static_files = [
|
||||||
|
{
|
||||||
|
"path": "/invoices/static",
|
||||||
|
"app": StaticFiles(directory="lnbits/extensions/invoices/static"),
|
||||||
|
"name": "invoices_static",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
invoices_ext: APIRouter = APIRouter(prefix="/invoices", tags=["invoices"])
|
||||||
|
|
||||||
|
|
||||||
|
def invoices_renderer():
|
||||||
|
return template_renderer(["lnbits/extensions/invoices/templates"])
|
||||||
|
|
||||||
|
|
||||||
|
from .tasks import wait_for_paid_invoices
|
||||||
|
|
||||||
|
|
||||||
|
def invoices_start():
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
||||||
|
|
||||||
|
|
||||||
|
from .views import * # noqa
|
||||||
|
from .views_api import * # noqa
|
||||||
6
lnbits/extensions/invoices/config.json
Normal file
6
lnbits/extensions/invoices/config.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "Invoices",
|
||||||
|
"short_description": "Create invoices for your clients.",
|
||||||
|
"icon": "request_quote",
|
||||||
|
"contributors": ["leesalminen"]
|
||||||
|
}
|
||||||
206
lnbits/extensions/invoices/crud.py
Normal file
206
lnbits/extensions/invoices/crud.py
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .models import (
|
||||||
|
CreateInvoiceData,
|
||||||
|
CreateInvoiceItemData,
|
||||||
|
CreatePaymentData,
|
||||||
|
Invoice,
|
||||||
|
InvoiceItem,
|
||||||
|
Payment,
|
||||||
|
UpdateInvoiceData,
|
||||||
|
UpdateInvoiceItemData,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_invoice(invoice_id: str) -> Optional[Invoice]:
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM invoices.invoices WHERE id = ?", (invoice_id,)
|
||||||
|
)
|
||||||
|
return Invoice.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_invoice_items(invoice_id: str) -> List[InvoiceItem]:
|
||||||
|
rows = await db.fetchall(
|
||||||
|
f"SELECT * FROM invoices.invoice_items WHERE invoice_id = ?", (invoice_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
return [InvoiceItem.from_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_invoice_item(item_id: str) -> InvoiceItem:
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM invoices.invoice_items WHERE id = ?", (item_id,)
|
||||||
|
)
|
||||||
|
return InvoiceItem.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_invoice_total(items: List[InvoiceItem]) -> int:
|
||||||
|
return sum(item.amount for item in items)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_invoices(wallet_ids: Union[str, List[str]]) -> List[Invoice]:
|
||||||
|
if isinstance(wallet_ids, str):
|
||||||
|
wallet_ids = [wallet_ids]
|
||||||
|
|
||||||
|
q = ",".join(["?"] * len(wallet_ids))
|
||||||
|
rows = await db.fetchall(
|
||||||
|
f"SELECT * FROM invoices.invoices WHERE wallet IN ({q})", (*wallet_ids,)
|
||||||
|
)
|
||||||
|
|
||||||
|
return [Invoice.from_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_invoice_payments(invoice_id: str) -> List[Payment]:
|
||||||
|
rows = await db.fetchall(
|
||||||
|
f"SELECT * FROM invoices.payments WHERE invoice_id = ?", (invoice_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
return [Payment.from_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_invoice_payment(payment_id: str) -> Payment:
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM invoices.payments WHERE id = ?", (payment_id,)
|
||||||
|
)
|
||||||
|
return Payment.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_payments_total(payments: List[Payment]) -> int:
|
||||||
|
return sum(item.amount for item in payments)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_invoice_internal(wallet_id: str, data: CreateInvoiceData) -> Invoice:
|
||||||
|
invoice_id = urlsafe_short_hash()
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO invoices.invoices (id, wallet, status, currency, company_name, first_name, last_name, email, phone, address)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
invoice_id,
|
||||||
|
wallet_id,
|
||||||
|
data.status,
|
||||||
|
data.currency,
|
||||||
|
data.company_name,
|
||||||
|
data.first_name,
|
||||||
|
data.last_name,
|
||||||
|
data.email,
|
||||||
|
data.phone,
|
||||||
|
data.address,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice = await get_invoice(invoice_id)
|
||||||
|
assert invoice, "Newly created invoice couldn't be retrieved"
|
||||||
|
return invoice
|
||||||
|
|
||||||
|
|
||||||
|
async def create_invoice_items(
|
||||||
|
invoice_id: str, data: List[CreateInvoiceItemData]
|
||||||
|
) -> List[InvoiceItem]:
|
||||||
|
for item in data:
|
||||||
|
item_id = urlsafe_short_hash()
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO invoices.invoice_items (id, invoice_id, description, amount)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
item_id,
|
||||||
|
invoice_id,
|
||||||
|
item.description,
|
||||||
|
int(item.amount * 100),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice_items = await get_invoice_items(invoice_id)
|
||||||
|
return invoice_items
|
||||||
|
|
||||||
|
|
||||||
|
async def update_invoice_internal(wallet_id: str, data: UpdateInvoiceData) -> Invoice:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE invoices.invoices
|
||||||
|
SET wallet = ?, currency = ?, status = ?, company_name = ?, first_name = ?, last_name = ?, email = ?, phone = ?, address = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
wallet_id,
|
||||||
|
data.currency,
|
||||||
|
data.status,
|
||||||
|
data.company_name,
|
||||||
|
data.first_name,
|
||||||
|
data.last_name,
|
||||||
|
data.email,
|
||||||
|
data.phone,
|
||||||
|
data.address,
|
||||||
|
data.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice = await get_invoice(data.id)
|
||||||
|
assert invoice, "Newly updated invoice couldn't be retrieved"
|
||||||
|
return invoice
|
||||||
|
|
||||||
|
|
||||||
|
async def update_invoice_items(
|
||||||
|
invoice_id: str, data: List[UpdateInvoiceItemData]
|
||||||
|
) -> List[InvoiceItem]:
|
||||||
|
updated_items = []
|
||||||
|
for item in data:
|
||||||
|
if item.id:
|
||||||
|
updated_items.append(item.id)
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE invoices.invoice_items
|
||||||
|
SET description = ?, amount = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(item.description, int(item.amount * 100), item.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
placeholders = ",".join("?" for i in range(len(updated_items)))
|
||||||
|
if not placeholders:
|
||||||
|
placeholders = "?"
|
||||||
|
updated_items = ("skip",)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
f"""
|
||||||
|
DELETE FROM invoices.invoice_items
|
||||||
|
WHERE invoice_id = ?
|
||||||
|
AND id NOT IN ({placeholders})
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
invoice_id,
|
||||||
|
*tuple(updated_items),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in data:
|
||||||
|
if not item.id:
|
||||||
|
await create_invoice_items(invoice_id=invoice_id, data=[item])
|
||||||
|
|
||||||
|
invoice_items = await get_invoice_items(invoice_id)
|
||||||
|
return invoice_items
|
||||||
|
|
||||||
|
|
||||||
|
async def create_invoice_payment(invoice_id: str, amount: int) -> Payment:
|
||||||
|
payment_id = urlsafe_short_hash()
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO invoices.payments (id, invoice_id, amount)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
payment_id,
|
||||||
|
invoice_id,
|
||||||
|
amount,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
payment = await get_invoice_payment(payment_id)
|
||||||
|
assert payment, "Newly created payment couldn't be retrieved"
|
||||||
|
return payment
|
||||||
55
lnbits/extensions/invoices/migrations.py
Normal file
55
lnbits/extensions/invoices/migrations.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
async def m001_initial_invoices(db):
|
||||||
|
|
||||||
|
# STATUS COLUMN OPTIONS: 'draft', 'open', 'paid', 'canceled'
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
f"""
|
||||||
|
CREATE TABLE invoices.invoices (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
|
||||||
|
currency TEXT NOT NULL,
|
||||||
|
|
||||||
|
company_name TEXT DEFAULT NULL,
|
||||||
|
first_name TEXT DEFAULT NULL,
|
||||||
|
last_name TEXT DEFAULT NULL,
|
||||||
|
email TEXT DEFAULT NULL,
|
||||||
|
phone TEXT DEFAULT NULL,
|
||||||
|
address TEXT DEFAULT NULL,
|
||||||
|
|
||||||
|
|
||||||
|
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
f"""
|
||||||
|
CREATE TABLE invoices.invoice_items (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
invoice_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
amount INTEGER NOT NULL,
|
||||||
|
|
||||||
|
FOREIGN KEY(invoice_id) REFERENCES {db.references_schema}invoices(id)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
f"""
|
||||||
|
CREATE TABLE invoices.payments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
invoice_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
amount INT NOT NULL,
|
||||||
|
|
||||||
|
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||||
|
|
||||||
|
FOREIGN KEY(invoice_id) REFERENCES {db.references_schema}invoices(id)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
104
lnbits/extensions/invoices/models.py
Normal file
104
lnbits/extensions/invoices/models.py
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
from enum import Enum
|
||||||
|
from sqlite3 import Row
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi.param_functions import Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceStatusEnum(str, Enum):
|
||||||
|
draft = "draft"
|
||||||
|
open = "open"
|
||||||
|
paid = "paid"
|
||||||
|
canceled = "canceled"
|
||||||
|
|
||||||
|
|
||||||
|
class CreateInvoiceItemData(BaseModel):
|
||||||
|
description: str
|
||||||
|
amount: float = Query(..., ge=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateInvoiceData(BaseModel):
|
||||||
|
status: InvoiceStatusEnum = InvoiceStatusEnum.draft
|
||||||
|
currency: str
|
||||||
|
company_name: Optional[str]
|
||||||
|
first_name: Optional[str]
|
||||||
|
last_name: Optional[str]
|
||||||
|
email: Optional[str]
|
||||||
|
phone: Optional[str]
|
||||||
|
address: Optional[str]
|
||||||
|
items: List[CreateInvoiceItemData]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
use_enum_values = True
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateInvoiceItemData(BaseModel):
|
||||||
|
id: Optional[str]
|
||||||
|
description: str
|
||||||
|
amount: float = Query(..., ge=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateInvoiceData(BaseModel):
|
||||||
|
id: str
|
||||||
|
wallet: str
|
||||||
|
status: InvoiceStatusEnum = InvoiceStatusEnum.draft
|
||||||
|
currency: str
|
||||||
|
company_name: Optional[str]
|
||||||
|
first_name: Optional[str]
|
||||||
|
last_name: Optional[str]
|
||||||
|
email: Optional[str]
|
||||||
|
phone: Optional[str]
|
||||||
|
address: Optional[str]
|
||||||
|
items: List[UpdateInvoiceItemData]
|
||||||
|
|
||||||
|
|
||||||
|
class Invoice(BaseModel):
|
||||||
|
id: str
|
||||||
|
wallet: str
|
||||||
|
status: InvoiceStatusEnum = InvoiceStatusEnum.draft
|
||||||
|
currency: str
|
||||||
|
company_name: Optional[str]
|
||||||
|
first_name: Optional[str]
|
||||||
|
last_name: Optional[str]
|
||||||
|
email: Optional[str]
|
||||||
|
phone: Optional[str]
|
||||||
|
address: Optional[str]
|
||||||
|
time: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
use_enum_values = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row) -> "Invoice":
|
||||||
|
return cls(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
invoice_id: str
|
||||||
|
description: str
|
||||||
|
amount: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row) -> "InvoiceItem":
|
||||||
|
return cls(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
class Payment(BaseModel):
|
||||||
|
id: str
|
||||||
|
invoice_id: str
|
||||||
|
amount: int
|
||||||
|
time: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row) -> "Payment":
|
||||||
|
return cls(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
class CreatePaymentData(BaseModel):
|
||||||
|
invoice_id: str
|
||||||
|
amount: int
|
||||||
65
lnbits/extensions/invoices/static/css/pay.css
Normal file
65
lnbits/extensions/invoices/static/css/pay.css
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
#invoicePage>.row:first-child>.col-md-6 {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
#invoicePage>.row:first-child>.col-md-6>.q-card {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#invoicePage .clear {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#printQrCode {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
#invoicePage>.row:first-child>.col-md-6:first-child>div {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#invoicePage>.row:first-child>.col-md-6:nth-child(2)>div {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
* {
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
header, button, #payButtonContainer {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
main, .q-page-container {
|
||||||
|
padding-top: 0px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-card {
|
||||||
|
box-shadow: none !important;
|
||||||
|
border: 1px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-item {
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-card__section {
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#printQrCode {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 0px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#invoicePage .clear {
|
||||||
|
margin-bottom: 10px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
51
lnbits/extensions/invoices/tasks.py
Normal file
51
lnbits/extensions/invoices/tasks.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from lnbits.core.models import Payment
|
||||||
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
from lnbits.tasks import internal_invoice_queue, register_invoice_listener
|
||||||
|
|
||||||
|
from .crud import (
|
||||||
|
create_invoice_payment,
|
||||||
|
get_invoice,
|
||||||
|
get_invoice_items,
|
||||||
|
get_invoice_payments,
|
||||||
|
get_invoice_total,
|
||||||
|
get_payments_total,
|
||||||
|
update_invoice_internal,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_paid_invoices():
|
||||||
|
invoice_queue = asyncio.Queue()
|
||||||
|
register_invoice_listener(invoice_queue)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
payment = await invoice_queue.get()
|
||||||
|
await on_invoice_paid(payment)
|
||||||
|
|
||||||
|
|
||||||
|
async def on_invoice_paid(payment: Payment) -> None:
|
||||||
|
if payment.extra.get("tag") != "invoices":
|
||||||
|
# not relevant
|
||||||
|
return
|
||||||
|
|
||||||
|
invoice_id = payment.extra.get("invoice_id")
|
||||||
|
|
||||||
|
payment = await create_invoice_payment(
|
||||||
|
invoice_id=invoice_id, amount=payment.extra.get("famount")
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice = await get_invoice(invoice_id)
|
||||||
|
|
||||||
|
invoice_items = await get_invoice_items(invoice_id)
|
||||||
|
invoice_total = await get_invoice_total(invoice_items)
|
||||||
|
|
||||||
|
invoice_payments = await get_invoice_payments(invoice_id)
|
||||||
|
payments_total = await get_payments_total(invoice_payments)
|
||||||
|
|
||||||
|
if payments_total >= invoice_total:
|
||||||
|
invoice.status = "paid"
|
||||||
|
await update_invoice_internal(invoice.wallet, invoice)
|
||||||
|
|
||||||
|
return
|
||||||
153
lnbits/extensions/invoices/templates/invoices/_api_docs.html
Normal file
153
lnbits/extensions/invoices/templates/invoices/_api_docs.html
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
<q-expansion-item
|
||||||
|
group="extras"
|
||||||
|
icon="swap_vertical_circle"
|
||||||
|
label="API info"
|
||||||
|
:content-inset-level="0.5"
|
||||||
|
>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="List Invoices">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">GET</span> /invoices/api/v1/invoices</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>[<invoice_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.base_url }}invoices/api/v1/invoices -H
|
||||||
|
"X-Api-Key: <invoice_key>"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="Fetch Invoice">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">GET</span>
|
||||||
|
/invoices/api/v1/invoice/{invoice_id}</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>{invoice_object}</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.base_url
|
||||||
|
}}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key:
|
||||||
|
<invoice_key>"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="Create Invoice">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">POST</span> /invoices/api/v1/invoice</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>{invoice_object}</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X POST {{ request.base_url }}invoices/api/v1/invoice -H
|
||||||
|
"X-Api-Key: <invoice_key>"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="Update Invoice">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">POST</span>
|
||||||
|
/invoices/api/v1/invoice/{invoice_id}</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>{invoice_object}</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X POST {{ request.base_url
|
||||||
|
}}invoices/api/v1/invoice/{invoice_id} -H "X-Api-Key:
|
||||||
|
<invoice_key>"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Create Invoice Payment"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">POST</span>
|
||||||
|
/invoices/api/v1/invoice/{invoice_id}/payments</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>{payment_object}</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X POST {{ request.base_url
|
||||||
|
}}invoices/api/v1/invoice/{invoice_id}/payments -H "X-Api-Key:
|
||||||
|
<invoice_key>"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Check Invoice Payment Status"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-blue">GET</span>
|
||||||
|
/invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash}</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.base_url
|
||||||
|
}}invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash} -H
|
||||||
|
"X-Api-Key: <invoice_key>"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
</q-expansion-item>
|
||||||
571
lnbits/extensions/invoices/templates/invoices/index.html
Normal file
571
lnbits/extensions/invoices/templates/invoices/index.html
Normal file
|
|
@ -0,0 +1,571 @@
|
||||||
|
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||||
|
%} {% block page %}
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<q-btn unelevated color="primary" @click="formDialog.show = true"
|
||||||
|
>New Invoice</q-btn
|
||||||
|
>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col">
|
||||||
|
<h5 class="text-subtitle1 q-my-none">Invoices</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:data="invoices"
|
||||||
|
row-key="id"
|
||||||
|
:columns="invoicesTable.columns"
|
||||||
|
:pagination.sync="invoicesTable.pagination"
|
||||||
|
>
|
||||||
|
{% raw %}
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
{{ col.label }}
|
||||||
|
</q-th>
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="edit"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||||
|
@click="showEditModal(props.row)"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="launch"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||||
|
type="a"
|
||||||
|
:href="'pay/' + props.row.id"
|
||||||
|
target="_blank"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
{{ col.value }}
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
{% endraw %}
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h6 class="text-subtitle1 q-my-none">
|
||||||
|
{{SITE_TITLE}} Invoices extension
|
||||||
|
</h6>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-list> {% include "invoices/_api_docs.html" %} </q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||||
|
<q-form @submit="saveInvoice" class="q-gutter-md">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
v-model="formDialog.data.wallet"
|
||||||
|
:options="g.user.walletOptions"
|
||||||
|
label="Wallet *"
|
||||||
|
></q-select>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
v-model="formDialog.data.currency"
|
||||||
|
:options="currencyOptions"
|
||||||
|
label="Currency *"
|
||||||
|
></q-select>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
v-model="formDialog.data.status"
|
||||||
|
:options="['draft', 'open', 'paid', 'canceled']"
|
||||||
|
label="Status *"
|
||||||
|
></q-select>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.company_name"
|
||||||
|
label="Company Name"
|
||||||
|
placeholder="LNBits Labs"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.first_name"
|
||||||
|
label="First Name"
|
||||||
|
placeholder="Satoshi"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.last_name"
|
||||||
|
label="Last Name"
|
||||||
|
placeholder="Nakamoto"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.email"
|
||||||
|
label="Email"
|
||||||
|
placeholder="satoshi@gmail.com"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.phone"
|
||||||
|
label="Phone"
|
||||||
|
placeholder="+81 (012)-345-6789"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.address"
|
||||||
|
label="Address"
|
||||||
|
placeholder="1600 Pennsylvania Ave."
|
||||||
|
type="textarea"
|
||||||
|
></q-input>
|
||||||
|
|
||||||
|
<q-list bordered separator>
|
||||||
|
<q-item
|
||||||
|
clickable
|
||||||
|
v-ripple
|
||||||
|
v-for="(item, index) in formDialog.invoiceItems"
|
||||||
|
:key="index"
|
||||||
|
>
|
||||||
|
<q-item-section>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
label="Item"
|
||||||
|
placeholder="Jelly Beans"
|
||||||
|
v-model="formDialog.invoiceItems[index].description"
|
||||||
|
></q-input>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
label="Amount"
|
||||||
|
placeholder="4.20"
|
||||||
|
v-model="formDialog.invoiceItems[index].amount"
|
||||||
|
></q-input>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="delete"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||||
|
@click="formDialog.invoiceItems.splice(index, 1)"
|
||||||
|
></q-btn>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item clickable v-ripple>
|
||||||
|
<q-btn flat icon="add" @click="formDialog.invoiceItems.push({})">
|
||||||
|
Add Line Item
|
||||||
|
</q-btn>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
:disable="formDialog.data.wallet == null || formDialog.data.currency == null"
|
||||||
|
type="submit"
|
||||||
|
v-if="typeof formDialog.data.id == 'undefined'"
|
||||||
|
>Create Invoice</q-btn
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
:disable="formDialog.data.wallet == null || formDialog.data.currency == null"
|
||||||
|
type="submit"
|
||||||
|
v-if="typeof formDialog.data.id !== 'undefined'"
|
||||||
|
>Save Invoice</q-btn
|
||||||
|
>
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
|
<script>
|
||||||
|
var mapInvoice = function (obj) {
|
||||||
|
obj.time = Quasar.utils.date.formatDate(
|
||||||
|
new Date(obj.time * 1000),
|
||||||
|
'YYYY-MM-DD HH:mm'
|
||||||
|
)
|
||||||
|
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
var mapInvoiceItems = function (obj) {
|
||||||
|
obj.amount = parseFloat(obj.amount / 100).toFixed(2)
|
||||||
|
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
invoices: [],
|
||||||
|
currencyOptions: [
|
||||||
|
'USD',
|
||||||
|
'EUR',
|
||||||
|
'GBP',
|
||||||
|
'AED',
|
||||||
|
'AFN',
|
||||||
|
'ALL',
|
||||||
|
'AMD',
|
||||||
|
'ANG',
|
||||||
|
'AOA',
|
||||||
|
'ARS',
|
||||||
|
'AUD',
|
||||||
|
'AWG',
|
||||||
|
'AZN',
|
||||||
|
'BAM',
|
||||||
|
'BBD',
|
||||||
|
'BDT',
|
||||||
|
'BGN',
|
||||||
|
'BHD',
|
||||||
|
'BIF',
|
||||||
|
'BMD',
|
||||||
|
'BND',
|
||||||
|
'BOB',
|
||||||
|
'BRL',
|
||||||
|
'BSD',
|
||||||
|
'BTN',
|
||||||
|
'BWP',
|
||||||
|
'BYN',
|
||||||
|
'BZD',
|
||||||
|
'CAD',
|
||||||
|
'CDF',
|
||||||
|
'CHF',
|
||||||
|
'CLF',
|
||||||
|
'CLP',
|
||||||
|
'CNH',
|
||||||
|
'CNY',
|
||||||
|
'COP',
|
||||||
|
'CRC',
|
||||||
|
'CUC',
|
||||||
|
'CUP',
|
||||||
|
'CVE',
|
||||||
|
'CZK',
|
||||||
|
'DJF',
|
||||||
|
'DKK',
|
||||||
|
'DOP',
|
||||||
|
'DZD',
|
||||||
|
'EGP',
|
||||||
|
'ERN',
|
||||||
|
'ETB',
|
||||||
|
'EUR',
|
||||||
|
'FJD',
|
||||||
|
'FKP',
|
||||||
|
'GBP',
|
||||||
|
'GEL',
|
||||||
|
'GGP',
|
||||||
|
'GHS',
|
||||||
|
'GIP',
|
||||||
|
'GMD',
|
||||||
|
'GNF',
|
||||||
|
'GTQ',
|
||||||
|
'GYD',
|
||||||
|
'HKD',
|
||||||
|
'HNL',
|
||||||
|
'HRK',
|
||||||
|
'HTG',
|
||||||
|
'HUF',
|
||||||
|
'IDR',
|
||||||
|
'ILS',
|
||||||
|
'IMP',
|
||||||
|
'INR',
|
||||||
|
'IQD',
|
||||||
|
'IRR',
|
||||||
|
'IRT',
|
||||||
|
'ISK',
|
||||||
|
'JEP',
|
||||||
|
'JMD',
|
||||||
|
'JOD',
|
||||||
|
'JPY',
|
||||||
|
'KES',
|
||||||
|
'KGS',
|
||||||
|
'KHR',
|
||||||
|
'KMF',
|
||||||
|
'KPW',
|
||||||
|
'KRW',
|
||||||
|
'KWD',
|
||||||
|
'KYD',
|
||||||
|
'KZT',
|
||||||
|
'LAK',
|
||||||
|
'LBP',
|
||||||
|
'LKR',
|
||||||
|
'LRD',
|
||||||
|
'LSL',
|
||||||
|
'LYD',
|
||||||
|
'MAD',
|
||||||
|
'MDL',
|
||||||
|
'MGA',
|
||||||
|
'MKD',
|
||||||
|
'MMK',
|
||||||
|
'MNT',
|
||||||
|
'MOP',
|
||||||
|
'MRO',
|
||||||
|
'MUR',
|
||||||
|
'MVR',
|
||||||
|
'MWK',
|
||||||
|
'MXN',
|
||||||
|
'MYR',
|
||||||
|
'MZN',
|
||||||
|
'NAD',
|
||||||
|
'NGN',
|
||||||
|
'NIO',
|
||||||
|
'NOK',
|
||||||
|
'NPR',
|
||||||
|
'NZD',
|
||||||
|
'OMR',
|
||||||
|
'PAB',
|
||||||
|
'PEN',
|
||||||
|
'PGK',
|
||||||
|
'PHP',
|
||||||
|
'PKR',
|
||||||
|
'PLN',
|
||||||
|
'PYG',
|
||||||
|
'QAR',
|
||||||
|
'RON',
|
||||||
|
'RSD',
|
||||||
|
'RUB',
|
||||||
|
'RWF',
|
||||||
|
'SAR',
|
||||||
|
'SBD',
|
||||||
|
'SCR',
|
||||||
|
'SDG',
|
||||||
|
'SEK',
|
||||||
|
'SGD',
|
||||||
|
'SHP',
|
||||||
|
'SLL',
|
||||||
|
'SOS',
|
||||||
|
'SRD',
|
||||||
|
'SSP',
|
||||||
|
'STD',
|
||||||
|
'SVC',
|
||||||
|
'SYP',
|
||||||
|
'SZL',
|
||||||
|
'THB',
|
||||||
|
'TJS',
|
||||||
|
'TMT',
|
||||||
|
'TND',
|
||||||
|
'TOP',
|
||||||
|
'TRY',
|
||||||
|
'TTD',
|
||||||
|
'TWD',
|
||||||
|
'TZS',
|
||||||
|
'UAH',
|
||||||
|
'UGX',
|
||||||
|
'USD',
|
||||||
|
'UYU',
|
||||||
|
'UZS',
|
||||||
|
'VEF',
|
||||||
|
'VES',
|
||||||
|
'VND',
|
||||||
|
'VUV',
|
||||||
|
'WST',
|
||||||
|
'XAF',
|
||||||
|
'XAG',
|
||||||
|
'XAU',
|
||||||
|
'XCD',
|
||||||
|
'XDR',
|
||||||
|
'XOF',
|
||||||
|
'XPD',
|
||||||
|
'XPF',
|
||||||
|
'XPT',
|
||||||
|
'YER',
|
||||||
|
'ZAR',
|
||||||
|
'ZMW',
|
||||||
|
'ZWL'
|
||||||
|
],
|
||||||
|
invoicesTable: {
|
||||||
|
columns: [
|
||||||
|
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||||
|
{name: 'status', align: 'left', label: 'Status', field: 'status'},
|
||||||
|
{name: 'time', align: 'left', label: 'Created', field: 'time'},
|
||||||
|
{name: 'wallet', align: 'left', label: 'Wallet', field: 'wallet'},
|
||||||
|
{
|
||||||
|
name: 'currency',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Currency',
|
||||||
|
field: 'currency'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'company_name',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Company Name',
|
||||||
|
field: 'company_name'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'first_name',
|
||||||
|
align: 'left',
|
||||||
|
label: 'First Name',
|
||||||
|
field: 'first_name'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'last_name',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Last Name',
|
||||||
|
field: 'last_name'
|
||||||
|
},
|
||||||
|
{name: 'email', align: 'left', label: 'Email', field: 'email'},
|
||||||
|
{name: 'phone', align: 'left', label: 'Phone', field: 'phone'},
|
||||||
|
{name: 'address', align: 'left', label: 'Address', field: 'address'}
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {},
|
||||||
|
invoiceItems: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
closeFormDialog: function () {
|
||||||
|
this.formDialog.data = {}
|
||||||
|
this.formDialog.invoiceItems = []
|
||||||
|
},
|
||||||
|
showEditModal: function (obj) {
|
||||||
|
this.formDialog.data = obj
|
||||||
|
this.formDialog.show = true
|
||||||
|
|
||||||
|
this.getInvoice(obj.id)
|
||||||
|
},
|
||||||
|
getInvoice: function (invoice_id) {
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request('GET', '/invoices/api/v1/invoice/' + invoice_id)
|
||||||
|
.then(function (response) {
|
||||||
|
self.formDialog.invoiceItems = response.data.items.map(function (
|
||||||
|
obj
|
||||||
|
) {
|
||||||
|
return mapInvoiceItems(obj)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getInvoices: function () {
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/invoices/api/v1/invoices?all_wallets=true',
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.invoices = response.data.map(function (obj) {
|
||||||
|
return mapInvoice(obj)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
saveInvoice: function () {
|
||||||
|
var data = this.formDialog.data
|
||||||
|
data.items = this.formDialog.invoiceItems
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'POST',
|
||||||
|
'/invoices/api/v1/invoice' + (data.id ? '/' + data.id : ''),
|
||||||
|
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
|
||||||
|
.inkey,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
if (!data.id) {
|
||||||
|
self.invoices.push(mapInvoice(response.data))
|
||||||
|
} else {
|
||||||
|
self.getInvoices()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.formDialog.invoiceItems = []
|
||||||
|
self.formDialog.show = false
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteTPoS: function (tposId) {
|
||||||
|
var self = this
|
||||||
|
var tpos = _.findWhere(this.tposs, {id: tposId})
|
||||||
|
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog('Are you sure you want to delete this TPoS?')
|
||||||
|
.onOk(function () {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'DELETE',
|
||||||
|
'/tpos/api/v1/tposs/' + tposId,
|
||||||
|
_.findWhere(self.g.user.wallets, {id: tpos.wallet}).adminkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.tposs = _.reject(self.tposs, function (obj) {
|
||||||
|
return obj.id == tposId
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
exportCSV: function () {
|
||||||
|
LNbits.utils.exportCSV(this.invoicesTable.columns, this.invoices)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
if (this.g.user.wallets.length) {
|
||||||
|
this.getInvoices()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
430
lnbits/extensions/invoices/templates/invoices/pay.html
Normal file
430
lnbits/extensions/invoices/templates/invoices/pay.html
Normal file
|
|
@ -0,0 +1,430 @@
|
||||||
|
{% extends "public.html" %} {% block toolbar_title %} Invoice
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="md"
|
||||||
|
@click.prevent="urlDialog.show = true"
|
||||||
|
icon="share"
|
||||||
|
color="white"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="md"
|
||||||
|
@click.prevent="printInvoice()"
|
||||||
|
icon="print"
|
||||||
|
color="white"
|
||||||
|
></q-btn>
|
||||||
|
{% endblock %} {% from "macros.jinja" import window_vars with context %} {%
|
||||||
|
block page %}
|
||||||
|
<link rel="stylesheet" href="/invoices/static/css/pay.css" />
|
||||||
|
<div id="invoicePage">
|
||||||
|
<div class="row q-gutter-y-md">
|
||||||
|
<div class="col-md-6 col-sm-12 col-xs-12">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<p>
|
||||||
|
<b>Invoice</b>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<q-list bordered separator>
|
||||||
|
<q-item clickable v-ripple>
|
||||||
|
<q-item-section><b>ID</b></q-item-section>
|
||||||
|
<q-item-section style="word-break: break-all"
|
||||||
|
>{{ invoice_id }}</q-item-section
|
||||||
|
>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item clickable v-ripple>
|
||||||
|
<q-item-section><b>Created At</b></q-item-section>
|
||||||
|
<q-item-section
|
||||||
|
>{{ datetime.utcfromtimestamp(invoice.time).strftime('%Y-%m-%d
|
||||||
|
%H:%M') }}</q-item-section
|
||||||
|
>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item clickable v-ripple>
|
||||||
|
<q-item-section><b>Status</b></q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<span>
|
||||||
|
<q-badge color=""> {{ invoice.status }} </q-badge>
|
||||||
|
</span>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item clickable v-ripple>
|
||||||
|
<q-item-section><b>Total</b></q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
{{ "{:0,.2f}".format(invoice_total / 100) }} {{ invoice.currency
|
||||||
|
}}
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item clickable v-ripple>
|
||||||
|
<q-item-section><b>Paid</b></q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<div class="row" style="align-items: center">
|
||||||
|
<div class="col-sm-6">
|
||||||
|
{{ "{:0,.2f}".format(payments_total / 100) }} {{
|
||||||
|
invoice.currency }}
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6" id="payButtonContainer">
|
||||||
|
{% if payments_total < invoice_total %}
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
@click="formDialog.show = true"
|
||||||
|
v-if="status == 'open'"
|
||||||
|
>
|
||||||
|
Pay Invoice
|
||||||
|
</q-btn>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 col-sm-12 col-xs-12">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<p>
|
||||||
|
<b>Bill To</b>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<q-list bordered separator>
|
||||||
|
<q-item clickable v-ripple>
|
||||||
|
<q-item-section><b>Company Name</b></q-item-section>
|
||||||
|
<q-item-section>{{ invoice.company_name }}</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item clickable v-ripple>
|
||||||
|
<q-item-section><b>Name</b></q-item-section>
|
||||||
|
<q-item-section
|
||||||
|
>{{ invoice.first_name }} {{ invoice.last_name
|
||||||
|
}}</q-item-section
|
||||||
|
>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item clickable v-ripple>
|
||||||
|
<q-item-section><b>Address</b></q-item-section>
|
||||||
|
<q-item-section>{{ invoice.address }}</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item clickable v-ripple>
|
||||||
|
<q-item-section><b>Email</b></q-item-section>
|
||||||
|
<q-item-section>{{ invoice.email }}</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item clickable v-ripple>
|
||||||
|
<q-item-section><b>Phone</b></q-item-section>
|
||||||
|
<q-item-section>{{ invoice.phone }}</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clear"></div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<p>
|
||||||
|
<b>Items</b>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<q-list bordered separator>
|
||||||
|
{% if invoice_items %}
|
||||||
|
<q-item clickable v-ripple>
|
||||||
|
<q-item-section><b>Item</b></q-item-section>
|
||||||
|
<q-item-section side><b>Amount</b></q-item-section>
|
||||||
|
</q-item>
|
||||||
|
{% endif %} {% for item in invoice_items %}
|
||||||
|
<q-item clickable v-ripple>
|
||||||
|
<q-item-section><b>{{item.description}}</b></q-item-section>
|
||||||
|
<q-item-section side>
|
||||||
|
{{ "{:0,.2f}".format(item.amount / 100) }} {{ invoice.currency
|
||||||
|
}}
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
{% endfor %} {% if not invoice_items %} No Invoice Items {% endif %}
|
||||||
|
</q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clear"></div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<p>
|
||||||
|
<b>Payments</b>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<q-list bordered separator>
|
||||||
|
{% if invoice_payments %}
|
||||||
|
<q-item clickable v-ripple>
|
||||||
|
<q-item-section><b>Date</b></q-item-section>
|
||||||
|
<q-item-section side><b>Amount</b></q-item-section>
|
||||||
|
</q-item>
|
||||||
|
{% endif %} {% for item in invoice_payments %}
|
||||||
|
<q-item clickable v-ripple>
|
||||||
|
<q-item-section
|
||||||
|
><b
|
||||||
|
>{{ datetime.utcfromtimestamp(item.time).strftime('%Y-%m-%d
|
||||||
|
%H:%M') }}</b
|
||||||
|
></q-item-section
|
||||||
|
>
|
||||||
|
<q-item-section side>
|
||||||
|
{{ "{:0,.2f}".format(item.amount / 100) }} {{ invoice.currency
|
||||||
|
}}
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
{% endfor %} {% if not invoice_payments %} No Invoice Payments {%
|
||||||
|
endif %}
|
||||||
|
</q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clear"></div>
|
||||||
|
|
||||||
|
<div class="row q-gutter-y-md q-gutter-md" id="printQrCode">
|
||||||
|
<div class="col-12 col-md">
|
||||||
|
<div class="text-center">
|
||||||
|
<p><b>Scan to View & Pay Online!</b></p>
|
||||||
|
<qrcode
|
||||||
|
value="{{ request.url }}"
|
||||||
|
:options="{width: 200}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<q-form @submit="createPayment" class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.payment_amount"
|
||||||
|
:rules="[val => val >= 0.01 || 'Minimum amount is 0.01']"
|
||||||
|
min="0.01"
|
||||||
|
label="Payment Amount"
|
||||||
|
placeholder="4.20"
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<span style="font-size: 12px"> {{ invoice.currency }} </span>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
:disable="formDialog.data.payment_amount == null"
|
||||||
|
type="submit"
|
||||||
|
>Create Payment</q-btn
|
||||||
|
>
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<q-dialog
|
||||||
|
v-model="qrCodeDialog.show"
|
||||||
|
position="top"
|
||||||
|
@hide="closeQrCodeDialog"
|
||||||
|
>
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card text-center">
|
||||||
|
<a :href="'lightning:' + qrCodeDialog.data.payment_request">
|
||||||
|
<q-responsive :ratio="1" class="q-mx-xs">
|
||||||
|
<qrcode
|
||||||
|
:value="qrCodeDialog.data.payment_request"
|
||||||
|
:options="{width: 400}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
</a>
|
||||||
|
<br />
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
@click="copyText('lightning:' + qrCodeDialog.data.payment_request, 'Invoice copied to clipboard!')"
|
||||||
|
>Copy Invoice</q-btn
|
||||||
|
>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<q-dialog v-model="urlDialog.show" position="top">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
||||||
|
<qrcode
|
||||||
|
value="{{ request.url }}"
|
||||||
|
:options="{width: 400}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
<div class="text-center q-mb-xl">
|
||||||
|
<p style="word-break: break-all">{{ request.url }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
@click="copyText('{{ request.url }}', 'Invoice Pay URL copied to clipboard!')"
|
||||||
|
>Copy URL</q-btn
|
||||||
|
>
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
<script>
|
||||||
|
var mapInvoice = function (obj) {
|
||||||
|
obj.time = Quasar.utils.date.formatDate(
|
||||||
|
new Date(obj.time * 1000),
|
||||||
|
'YYYY-MM-DD HH:mm'
|
||||||
|
)
|
||||||
|
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
var mapInvoiceItems = function (obj) {
|
||||||
|
obj.amount = parseFloat(obj.amount / 100).toFixed(2)
|
||||||
|
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
invoice_id: '{{ invoice.id }}',
|
||||||
|
wallet: '{{ invoice.wallet }}',
|
||||||
|
currency: '{{ invoice.currency }}',
|
||||||
|
status: '{{ invoice.status }}',
|
||||||
|
qrCodeDialog: {
|
||||||
|
data: {
|
||||||
|
payment_request: null,
|
||||||
|
},
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
formDialog: {
|
||||||
|
data: {
|
||||||
|
payment_amount: parseFloat({{invoice_total - payments_total}} / 100).toFixed(2)
|
||||||
|
},
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
urlDialog: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
printInvoice: function() {
|
||||||
|
window.print()
|
||||||
|
},
|
||||||
|
closeFormDialog: function() {
|
||||||
|
this.formDialog.show = false
|
||||||
|
},
|
||||||
|
closeQrCodeDialog: function() {
|
||||||
|
this.qrCodeDialog.show = false
|
||||||
|
},
|
||||||
|
createPayment: function () {
|
||||||
|
var self = this
|
||||||
|
var qrCodeDialog = this.qrCodeDialog
|
||||||
|
var formDialog = this.formDialog
|
||||||
|
var famount = parseInt(formDialog.data.payment_amount * 100)
|
||||||
|
|
||||||
|
axios
|
||||||
|
.post('/invoices/api/v1/invoice/' + this.invoice_id + '/payments', null, {
|
||||||
|
params: {
|
||||||
|
famount: famount,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(function (response) {
|
||||||
|
formDialog.show = false
|
||||||
|
formDialog.data = {}
|
||||||
|
|
||||||
|
qrCodeDialog.data = response.data
|
||||||
|
qrCodeDialog.show = true
|
||||||
|
|
||||||
|
console.log(qrCodeDialog.data)
|
||||||
|
|
||||||
|
qrCodeDialog.dismissMsg = self.$q.notify({
|
||||||
|
timeout: 0,
|
||||||
|
message: 'Waiting for payment...'
|
||||||
|
})
|
||||||
|
|
||||||
|
qrCodeDialog.paymentChecker = setInterval(function () {
|
||||||
|
axios
|
||||||
|
.get(
|
||||||
|
'/invoices/api/v1/invoice/' +
|
||||||
|
self.invoice_id +
|
||||||
|
'/payments/' +
|
||||||
|
response.data.payment_hash
|
||||||
|
)
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.data.paid) {
|
||||||
|
clearInterval(qrCodeDialog.paymentChecker)
|
||||||
|
qrCodeDialog.dismissMsg()
|
||||||
|
qrCodeDialog.show = false
|
||||||
|
|
||||||
|
setTimeout(function () {
|
||||||
|
window.location.reload()
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, 3000)
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
statusBadgeColor: function() {
|
||||||
|
switch(this.status) {
|
||||||
|
case 'draft':
|
||||||
|
return 'gray'
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'open':
|
||||||
|
return 'blue'
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'paid':
|
||||||
|
return 'green'
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'canceled':
|
||||||
|
return 'red'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
59
lnbits/extensions/invoices/views.py
Normal file
59
lnbits/extensions/invoices/views.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.params import Depends
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
|
from lnbits.core.models import User
|
||||||
|
from lnbits.decorators import check_user_exists
|
||||||
|
|
||||||
|
from . import invoices_ext, invoices_renderer
|
||||||
|
from .crud import (
|
||||||
|
get_invoice,
|
||||||
|
get_invoice_items,
|
||||||
|
get_invoice_payments,
|
||||||
|
get_invoice_total,
|
||||||
|
get_payments_total,
|
||||||
|
)
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
@invoices_ext.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||||
|
return invoices_renderer().TemplateResponse(
|
||||||
|
"invoices/index.html", {"request": request, "user": user.dict()}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@invoices_ext.get("/pay/{invoice_id}", response_class=HTMLResponse)
|
||||||
|
async def index(request: Request, invoice_id: str):
|
||||||
|
invoice = await get_invoice(invoice_id)
|
||||||
|
|
||||||
|
if not invoice:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice_items = await get_invoice_items(invoice_id)
|
||||||
|
invoice_total = await get_invoice_total(invoice_items)
|
||||||
|
|
||||||
|
invoice_payments = await get_invoice_payments(invoice_id)
|
||||||
|
payments_total = await get_payments_total(invoice_payments)
|
||||||
|
|
||||||
|
return invoices_renderer().TemplateResponse(
|
||||||
|
"invoices/pay.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"invoice_id": invoice_id,
|
||||||
|
"invoice": invoice.dict(),
|
||||||
|
"invoice_items": invoice_items,
|
||||||
|
"invoice_total": invoice_total,
|
||||||
|
"invoice_payments": invoice_payments,
|
||||||
|
"payments_total": payments_total,
|
||||||
|
"datetime": datetime,
|
||||||
|
},
|
||||||
|
)
|
||||||
136
lnbits/extensions/invoices/views_api.py
Normal file
136
lnbits/extensions/invoices/views_api.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from fastapi import Query
|
||||||
|
from fastapi.params import Depends
|
||||||
|
from loguru import logger
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
|
from lnbits.core.crud import get_user
|
||||||
|
from lnbits.core.services import create_invoice
|
||||||
|
from lnbits.core.views.api import api_payment
|
||||||
|
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||||
|
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||||
|
|
||||||
|
from . import invoices_ext
|
||||||
|
from .crud import (
|
||||||
|
create_invoice_internal,
|
||||||
|
create_invoice_items,
|
||||||
|
get_invoice,
|
||||||
|
get_invoice_items,
|
||||||
|
get_invoice_payments,
|
||||||
|
get_invoice_total,
|
||||||
|
get_invoices,
|
||||||
|
get_payments_total,
|
||||||
|
update_invoice_internal,
|
||||||
|
update_invoice_items,
|
||||||
|
)
|
||||||
|
from .models import CreateInvoiceData, UpdateInvoiceData
|
||||||
|
|
||||||
|
|
||||||
|
@invoices_ext.get("/api/v1/invoices", status_code=HTTPStatus.OK)
|
||||||
|
async def api_invoices(
|
||||||
|
all_wallets: bool = Query(None), wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
|
):
|
||||||
|
wallet_ids = [wallet.wallet.id]
|
||||||
|
if all_wallets:
|
||||||
|
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
|
||||||
|
|
||||||
|
return [invoice.dict() for invoice in await get_invoices(wallet_ids)]
|
||||||
|
|
||||||
|
|
||||||
|
@invoices_ext.get("/api/v1/invoice/{invoice_id}", status_code=HTTPStatus.OK)
|
||||||
|
async def api_invoice(invoice_id: str):
|
||||||
|
invoice = await get_invoice(invoice_id)
|
||||||
|
if not invoice:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
|
||||||
|
)
|
||||||
|
invoice_items = await get_invoice_items(invoice_id)
|
||||||
|
|
||||||
|
invoice_payments = await get_invoice_payments(invoice_id)
|
||||||
|
payments_total = await get_payments_total(invoice_payments)
|
||||||
|
|
||||||
|
invoice_dict = invoice.dict()
|
||||||
|
invoice_dict["items"] = invoice_items
|
||||||
|
invoice_dict["payments"] = payments_total
|
||||||
|
return invoice_dict
|
||||||
|
|
||||||
|
|
||||||
|
@invoices_ext.post("/api/v1/invoice", status_code=HTTPStatus.CREATED)
|
||||||
|
async def api_invoice_create(
|
||||||
|
data: CreateInvoiceData, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
|
):
|
||||||
|
invoice = await create_invoice_internal(wallet_id=wallet.wallet.id, data=data)
|
||||||
|
items = await create_invoice_items(invoice_id=invoice.id, data=data.items)
|
||||||
|
invoice_dict = invoice.dict()
|
||||||
|
invoice_dict["items"] = items
|
||||||
|
return invoice_dict
|
||||||
|
|
||||||
|
|
||||||
|
@invoices_ext.post("/api/v1/invoice/{invoice_id}", status_code=HTTPStatus.OK)
|
||||||
|
async def api_invoice_update(
|
||||||
|
data: UpdateInvoiceData,
|
||||||
|
invoice_id: str,
|
||||||
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
|
):
|
||||||
|
invoice = await update_invoice_internal(wallet_id=wallet.wallet.id, data=data)
|
||||||
|
items = await update_invoice_items(invoice_id=invoice.id, data=data.items)
|
||||||
|
invoice_dict = invoice.dict()
|
||||||
|
invoice_dict["items"] = items
|
||||||
|
return invoice_dict
|
||||||
|
|
||||||
|
|
||||||
|
@invoices_ext.post(
|
||||||
|
"/api/v1/invoice/{invoice_id}/payments", status_code=HTTPStatus.CREATED
|
||||||
|
)
|
||||||
|
async def api_invoices_create_payment(
|
||||||
|
famount: int = Query(..., ge=1), invoice_id: str = None
|
||||||
|
):
|
||||||
|
invoice = await get_invoice(invoice_id)
|
||||||
|
invoice_items = await get_invoice_items(invoice_id)
|
||||||
|
invoice_total = await get_invoice_total(invoice_items)
|
||||||
|
|
||||||
|
invoice_payments = await get_invoice_payments(invoice_id)
|
||||||
|
payments_total = await get_payments_total(invoice_payments)
|
||||||
|
|
||||||
|
if payments_total + famount > invoice_total:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST, detail="Amount exceeds invoice due."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not invoice:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
price_in_sats = await fiat_amount_as_satoshis(famount / 100, invoice.currency)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payment_hash, payment_request = await create_invoice(
|
||||||
|
wallet_id=invoice.wallet,
|
||||||
|
amount=price_in_sats,
|
||||||
|
memo=f"Payment for invoice {invoice_id}",
|
||||||
|
extra={"tag": "invoices", "invoice_id": invoice_id, "famount": famount},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
|
|
||||||
|
return {"payment_hash": payment_hash, "payment_request": payment_request}
|
||||||
|
|
||||||
|
|
||||||
|
@invoices_ext.get(
|
||||||
|
"/api/v1/invoice/{invoice_id}/payments/{payment_hash}", status_code=HTTPStatus.OK
|
||||||
|
)
|
||||||
|
async def api_invoices_check_payment(invoice_id: str, payment_hash: str):
|
||||||
|
invoice = await get_invoice(invoice_id)
|
||||||
|
if not invoice:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Invoice does not exist."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
status = await api_payment(payment_hash)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(exc)
|
||||||
|
return {"paid": False}
|
||||||
|
return status
|
||||||
|
|
@ -12,7 +12,7 @@ db = Database("ext_jukebox")
|
||||||
jukebox_static_files = [
|
jukebox_static_files = [
|
||||||
{
|
{
|
||||||
"path": "/jukebox/static",
|
"path": "/jukebox/static",
|
||||||
"app": StaticFiles(directory="lnbits/extensions/jukebox/static"),
|
"app": StaticFiles(packages=[("lnbits", "extensions/jukebox/static")]),
|
||||||
"name": "jukebox_static",
|
"name": "jukebox_static",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@
|
||||||
label="API info"
|
label="API info"
|
||||||
:content-inset-level="0.5"
|
:content-inset-level="0.5"
|
||||||
>
|
>
|
||||||
|
<q-btn flat label="Swagger API" type="a" href="../docs#/jukebox"></q-btn>
|
||||||
|
|
||||||
<q-expansion-item group="api" dense expand-separator label="List jukeboxes">
|
<q-expansion-item group="api" dense expand-separator label="List jukeboxes">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@
|
||||||
>
|
>
|
||||||
<q-step
|
<q-step
|
||||||
:name="1"
|
:name="1"
|
||||||
title="Pick wallet, price"
|
title="1. Pick Wallet and Price"
|
||||||
icon="account_balance_wallet"
|
icon="account_balance_wallet"
|
||||||
:done="step > 1"
|
:done="step > 1"
|
||||||
>
|
>
|
||||||
|
|
@ -170,16 +170,25 @@
|
||||||
<br />
|
<br />
|
||||||
</q-step>
|
</q-step>
|
||||||
|
|
||||||
<q-step :name="2" title="Add api keys" icon="vpn_key" :done="step > 2">
|
<q-step
|
||||||
|
:name="2"
|
||||||
|
title="2. Add API keys"
|
||||||
|
icon="vpn_key"
|
||||||
|
:done="step > 2"
|
||||||
|
>
|
||||||
<img src="/jukebox/static/spotapi.gif" />
|
<img src="/jukebox/static/spotapi.gif" />
|
||||||
To use this extension you need a Spotify client ID and client secret.
|
To use this extension you need a Spotify client ID and client secret.
|
||||||
You get these by creating an app in the Spotify developers dashboard
|
You get these by creating an app in the Spotify Developer Dashboard
|
||||||
<a
|
<br />
|
||||||
|
<br />
|
||||||
|
<q-btn
|
||||||
|
type="a"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
style="color: #43a047"
|
color="primary"
|
||||||
href="https://developer.spotify.com/dashboard/applications"
|
href="https://developer.spotify.com/dashboard/applications"
|
||||||
>here</a
|
>Open the Spotify Developer Dashboard</q-btn
|
||||||
>.
|
>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
class="q-pb-md q-pt-md"
|
class="q-pb-md q-pt-md"
|
||||||
|
|
@ -231,28 +240,39 @@
|
||||||
<br />
|
<br />
|
||||||
</q-step>
|
</q-step>
|
||||||
|
|
||||||
<q-step :name="3" title="Add Redirect URI" icon="link" :done="step > 3">
|
<q-step
|
||||||
|
:name="3"
|
||||||
|
title="3. Add Redirect URI"
|
||||||
|
icon="link"
|
||||||
|
:done="step > 3"
|
||||||
|
>
|
||||||
<img src="/jukebox/static/spotapi1.gif" />
|
<img src="/jukebox/static/spotapi1.gif" />
|
||||||
In the app go to edit-settings, set the redirect URI to this link
|
<p>
|
||||||
|
In the app go to edit-settings, set the redirect URI to this link
|
||||||
|
</p>
|
||||||
|
<q-card
|
||||||
|
class="cursor-pointer word-break"
|
||||||
|
@click="copyText(locationcb + jukeboxDialog.data.sp_id, 'Link copied to clipboard!')"
|
||||||
|
>
|
||||||
|
<q-card-section style="word-break: break-all">
|
||||||
|
{% raw %}{{ locationcb }}{{ jukeboxDialog.data.sp_id }}{% endraw
|
||||||
|
%}
|
||||||
|
</q-card-section>
|
||||||
|
<q-tooltip> Click to copy URL </q-tooltip>
|
||||||
|
</q-card>
|
||||||
<br />
|
<br />
|
||||||
<q-btn
|
<q-btn
|
||||||
dense
|
type="a"
|
||||||
outline
|
|
||||||
unelevated
|
|
||||||
color="primary"
|
|
||||||
size="xs"
|
|
||||||
@click="copyText(locationcb + jukeboxDialog.data.sp_id, 'Link copied to clipboard!')"
|
|
||||||
>{% raw %}{{ locationcb }}{{ jukeboxDialog.data.sp_id }}{% endraw
|
|
||||||
%}<q-tooltip> Click to copy URL </q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<br />
|
|
||||||
Settings can be found
|
|
||||||
<a
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
style="color: #43a047"
|
color="primary"
|
||||||
href="https://developer.spotify.com/dashboard/applications"
|
href="https://developer.spotify.com/dashboard/applications"
|
||||||
>here</a
|
>Open the Spotify Application Settings</q-btn
|
||||||
>.
|
>
|
||||||
|
<br /><br />
|
||||||
|
<p>
|
||||||
|
After adding the redirect URI, click the "Authorise access" button
|
||||||
|
below.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="row q-mt-md">
|
<div class="row q-mt-md">
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
|
|
@ -281,7 +301,7 @@
|
||||||
|
|
||||||
<q-step
|
<q-step
|
||||||
:name="4"
|
:name="4"
|
||||||
title="Select playlists"
|
title="4. Select Device and Playlists"
|
||||||
icon="queue_music"
|
icon="queue_music"
|
||||||
active-color="primary"
|
active-color="primary"
|
||||||
:done="step > 4"
|
:done="step > 4"
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ db = Database("ext_livestream")
|
||||||
livestream_static_files = [
|
livestream_static_files = [
|
||||||
{
|
{
|
||||||
"path": "/livestream/static",
|
"path": "/livestream/static",
|
||||||
"app": StaticFiles(directory="lnbits/extensions/livestream/static"),
|
"app": StaticFiles(packages=[("lnbits", "extensions/livestream/static")]),
|
||||||
"name": "livestream_static",
|
"name": "livestream_static",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -90,9 +90,7 @@ async def lnurl_callback(
|
||||||
wallet_id=ls.wallet,
|
wallet_id=ls.wallet,
|
||||||
amount=int(amount_received / 1000),
|
amount=int(amount_received / 1000),
|
||||||
memo=await track.fullname(),
|
memo=await track.fullname(),
|
||||||
description_hash=hashlib.sha256(
|
unhashed_description=(await track.lnurlpay_metadata()).encode("utf-8"),
|
||||||
(await track.lnurlpay_metadata()).encode("utf-8")
|
|
||||||
).digest(),
|
|
||||||
extra={"tag": "livestream", "track": track.id, "comment": comment},
|
extra={"tag": "livestream", "track": track.id, "comment": comment},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@
|
||||||
label="API info"
|
label="API info"
|
||||||
:content-inset-level="0.5"
|
:content-inset-level="0.5"
|
||||||
>
|
>
|
||||||
|
<q-btn flat label="Swagger API" type="a" href="../docs#/livestream"></q-btn>
|
||||||
|
|
||||||
<q-expansion-item
|
<q-expansion-item
|
||||||
group="api"
|
group="api"
|
||||||
dense
|
dense
|
||||||
|
|
|
||||||
|
|
@ -70,11 +70,9 @@ async def lnurl_callback(address_id, amount: int = Query(...)):
|
||||||
json={
|
json={
|
||||||
"out": False,
|
"out": False,
|
||||||
"amount": int(amount_received / 1000),
|
"amount": int(amount_received / 1000),
|
||||||
"description_hash": hashlib.sha256(
|
"description_hash": (
|
||||||
(await address.lnurlpay_metadata(domain=domain.domain)).encode(
|
await address.lnurlpay_metadata(domain=domain.domain)
|
||||||
"utf-8"
|
).encode("utf-8"),
|
||||||
)
|
|
||||||
).hexdigest(),
|
|
||||||
"extra": {"tag": f"Payment to {address.username}@{domain.domain}"},
|
"extra": {"tag": f"Payment to {address.username}@{domain.domain}"},
|
||||||
},
|
},
|
||||||
timeout=40,
|
timeout=40,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@
|
||||||
label="API info"
|
label="API info"
|
||||||
:content-inset-level="0.5"
|
:content-inset-level="0.5"
|
||||||
>
|
>
|
||||||
|
<q-btn flat label="Swagger API" type="a" href="../docs#/lnaddress"></q-btn>
|
||||||
<q-expansion-item group="api" dense expand-separator label="GET domains">
|
<q-expansion-item group="api" dense expand-separator label="GET domains">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from fastapi.params import Depends, Query
|
||||||
from starlette.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
from lnbits.core.crud import get_user
|
from lnbits.core.crud import get_user
|
||||||
from lnbits.core.services import check_invoice_status, create_invoice
|
from lnbits.core.services import check_transaction_status, create_invoice
|
||||||
from lnbits.decorators import WalletTypeInfo, get_key_type
|
from lnbits.decorators import WalletTypeInfo, get_key_type
|
||||||
from lnbits.extensions.lnaddress.models import CreateAddress, CreateDomain
|
from lnbits.extensions.lnaddress.models import CreateAddress, CreateDomain
|
||||||
|
|
||||||
|
|
@ -229,7 +229,7 @@ async def api_address_send_address(payment_hash):
|
||||||
address = await get_address(payment_hash)
|
address = await get_address(payment_hash)
|
||||||
domain = await get_domain(address.domain)
|
domain = await get_domain(address.domain)
|
||||||
try:
|
try:
|
||||||
status = await check_invoice_status(domain.wallet, payment_hash)
|
status = await check_transaction_status(domain.wallet, payment_hash)
|
||||||
is_paid = not status.pending
|
is_paid = not status.pending
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"paid": False, "error": str(e)}
|
return {"paid": False, "error": str(e)}
|
||||||
|
|
|
||||||
|
|
@ -31,5 +31,6 @@
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
<q-btn flat label="Swagger API" type="a" href="../docs#/lndhub"></q-btn>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
|
|
|
||||||
|
|
@ -19,4 +19,5 @@
|
||||||
</p>
|
</p>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
<q-btn flat label="Swagger API" type="a" href="../docs#/lnticket"></q-btn>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
|
|
|
||||||
|
|
@ -281,7 +281,13 @@
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
{% endraw %}
|
{% endraw %}
|
||||||
<q-card-actions align="right">
|
<q-card-actions align="right">
|
||||||
<q-btn flat label="CLOSE" color="primary" v-close-popup />
|
<q-btn
|
||||||
|
flat
|
||||||
|
label="CLOSE"
|
||||||
|
color="primary"
|
||||||
|
v-close-popup
|
||||||
|
@click="resetForm"
|
||||||
|
/>
|
||||||
</q-card-actions>
|
</q-card-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
@ -371,6 +377,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
resetForm() {
|
||||||
|
this.formDialog.data = {flatrate: false}
|
||||||
|
},
|
||||||
getTickets: function () {
|
getTickets: function () {
|
||||||
var self = this
|
var self = this
|
||||||
|
|
||||||
|
|
@ -463,7 +472,7 @@
|
||||||
.then(function (response) {
|
.then(function (response) {
|
||||||
self.forms.push(mapLNTicket(response.data))
|
self.forms.push(mapLNTicket(response.data))
|
||||||
self.formDialog.show = false
|
self.formDialog.show = false
|
||||||
self.formDialog.data = {}
|
self.resetForm()
|
||||||
})
|
})
|
||||||
.catch(function (error) {
|
.catch(function (error) {
|
||||||
LNbits.utils.notifyApiError(error)
|
LNbits.utils.notifyApiError(error)
|
||||||
|
|
@ -497,7 +506,7 @@
|
||||||
})
|
})
|
||||||
self.forms.push(mapLNTicket(response.data))
|
self.forms.push(mapLNTicket(response.data))
|
||||||
self.formDialog.show = false
|
self.formDialog.show = false
|
||||||
self.formDialog.data = {}
|
self.resetForm()
|
||||||
})
|
})
|
||||||
.catch(function (error) {
|
.catch(function (error) {
|
||||||
LNbits.utils.notifyApiError(error)
|
LNbits.utils.notifyApiError(error)
|
||||||
|
|
|
||||||
|
|
@ -205,9 +205,7 @@ async def lnurl_callback(
|
||||||
wallet_id=device.wallet,
|
wallet_id=device.wallet,
|
||||||
amount=lnurldevicepayment.sats / 1000,
|
amount=lnurldevicepayment.sats / 1000,
|
||||||
memo=device.title,
|
memo=device.title,
|
||||||
description_hash=hashlib.sha256(
|
unhashed_description=(await device.lnurlpay_metadata()).encode("utf-8"),
|
||||||
(await device.lnurlpay_metadata()).encode("utf-8")
|
|
||||||
).digest(),
|
|
||||||
extra={"tag": "PoS"},
|
extra={"tag": "PoS"},
|
||||||
)
|
)
|
||||||
lnurldevicepayment = await update_lnurldevicepayment(
|
lnurldevicepayment = await update_lnurldevicepayment(
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,12 @@
|
||||||
label="API info"
|
label="API info"
|
||||||
:content-inset-level="0.5"
|
:content-inset-level="0.5"
|
||||||
>
|
>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
label="Swagger API"
|
||||||
|
type="a"
|
||||||
|
href="../docs#/lnurldevice"
|
||||||
|
></q-btn>
|
||||||
<q-expansion-item
|
<q-expansion-item
|
||||||
group="api"
|
group="api"
|
||||||
dense
|
dense
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ db = Database("ext_lnurlp")
|
||||||
lnurlp_static_files = [
|
lnurlp_static_files = [
|
||||||
{
|
{
|
||||||
"path": "/lnurlp/static",
|
"path": "/lnurlp/static",
|
||||||
"app": StaticFiles(directory="lnbits/extensions/lnurlp/static"),
|
"app": StaticFiles(packages=[("lnbits", "extensions/lnurlp/static")]),
|
||||||
"name": "lnurlp_static",
|
"name": "lnurlp_static",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -87,9 +87,7 @@ async def api_lnurl_callback(request: Request, link_id):
|
||||||
wallet_id=link.wallet,
|
wallet_id=link.wallet,
|
||||||
amount=int(amount_received / 1000),
|
amount=int(amount_received / 1000),
|
||||||
memo=link.description,
|
memo=link.description,
|
||||||
description_hash=hashlib.sha256(
|
unhashed_description=link.lnurlpay_metadata.encode("utf-8"),
|
||||||
link.lnurlpay_metadata.encode("utf-8")
|
|
||||||
).digest(),
|
|
||||||
extra={
|
extra={
|
||||||
"tag": "lnurlp",
|
"tag": "lnurlp",
|
||||||
"link": link.id,
|
"link": link.id,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ new Vue({
|
||||||
rowsPerPage: 10
|
rowsPerPage: 10
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
nfcTagWriting: false,
|
||||||
formDialog: {
|
formDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
fixedAmount: true,
|
fixedAmount: true,
|
||||||
|
|
@ -205,6 +206,42 @@ new Vue({
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
LNbits.utils.notifyApiError(err)
|
LNbits.utils.notifyApiError(err)
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
writeNfcTag: async function (lnurl) {
|
||||||
|
try {
|
||||||
|
if (typeof NDEFReader == 'undefined') {
|
||||||
|
throw {
|
||||||
|
toString: function () {
|
||||||
|
return 'NFC not supported on this device or browser.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ndef = new NDEFReader()
|
||||||
|
|
||||||
|
this.nfcTagWriting = true
|
||||||
|
this.$q.notify({
|
||||||
|
message: 'Tap your NFC tag to write the LNURL-pay link to it.'
|
||||||
|
})
|
||||||
|
|
||||||
|
await ndef.write({
|
||||||
|
records: [{recordType: 'url', data: 'lightning:' + lnurl, lang: 'en'}]
|
||||||
|
})
|
||||||
|
|
||||||
|
this.nfcTagWriting = false
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'NFC tag written successfully.'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
this.nfcTagWriting = false
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: error
|
||||||
|
? error.toString()
|
||||||
|
: 'An unexpected error has occurred.'
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
label="API info"
|
label="API info"
|
||||||
:content-inset-level="0.5"
|
:content-inset-level="0.5"
|
||||||
>
|
>
|
||||||
|
<q-btn flat label="Swagger API" type="a" href="../docs#/lnurlp"></q-btn>
|
||||||
<q-expansion-item group="api" dense expand-separator label="List pay links">
|
<q-expansion-item group="api" dense expand-separator label="List pay links">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
|
|
@ -51,6 +52,7 @@
|
||||||
expand-separator
|
expand-separator
|
||||||
label="Create a pay link"
|
label="Create a pay link"
|
||||||
>
|
>
|
||||||
|
<q-btn flat label="Swagger API" type="a" href="../docs#/lnurlp"></q-btn>
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<code><span class="text-green">POST</span> /lnurlp/api/v1/links</code>
|
<code><span class="text-green">POST</span> /lnurlp/api/v1/links</code>
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,17 @@
|
||||||
</q-responsive>
|
</q-responsive>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg q-gutter-sm">
|
||||||
<q-btn outline color="grey" @click="copyText('{{ lnurl }}')"
|
<q-btn outline color="grey" @click="copyText('{{ lnurl }}')"
|
||||||
>Copy LNURL</q-btn
|
>Copy LNURL</q-btn
|
||||||
>
|
>
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
icon="nfc"
|
||||||
|
@click="writeNfcTag(' {{ lnurl }} ')"
|
||||||
|
:disable="nfcTagWriting"
|
||||||
|
></q-btn>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,8 @@
|
||||||
@click="openUpdateDialog(props.row.id)"
|
@click="openUpdateDialog(props.row.id)"
|
||||||
icon="edit"
|
icon="edit"
|
||||||
color="light-blue"
|
color="light-blue"
|
||||||
></q-btn>
|
>
|
||||||
|
</q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
flat
|
flat
|
||||||
dense
|
dense
|
||||||
|
|
@ -153,7 +154,8 @@
|
||||||
v-model.trim="formDialog.data.description"
|
v-model.trim="formDialog.data.description"
|
||||||
type="text"
|
type="text"
|
||||||
label="Item description *"
|
label="Item description *"
|
||||||
></q-input>
|
>
|
||||||
|
</q-input>
|
||||||
<div class="row q-col-gutter-sm">
|
<div class="row q-col-gutter-sm">
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
|
|
@ -171,7 +173,8 @@
|
||||||
type="number"
|
type="number"
|
||||||
:step="formDialog.data.currency && formDialog.data.currency !== 'satoshis' ? '0.01' : '1'"
|
:step="formDialog.data.currency && formDialog.data.currency !== 'satoshis' ? '0.01' : '1'"
|
||||||
label="Max *"
|
label="Max *"
|
||||||
></q-input>
|
>
|
||||||
|
</q-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="row q-col-gutter-sm">
|
<div class="row q-col-gutter-sm">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
|
|
@ -200,7 +203,8 @@
|
||||||
type="number"
|
type="number"
|
||||||
label="Comment maximum characters"
|
label="Comment maximum characters"
|
||||||
hint="Tell wallets to prompt users for a comment that will be sent along with the payment. LNURLp will store the comment and send it in the webhook."
|
hint="Tell wallets to prompt users for a comment that will be sent along with the payment. LNURLp will store the comment and send it in the webhook."
|
||||||
></q-input>
|
>
|
||||||
|
</q-input>
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
|
|
@ -224,7 +228,8 @@
|
||||||
type="text"
|
type="text"
|
||||||
label="Success URL (optional)"
|
label="Success URL (optional)"
|
||||||
hint="Will be shown as a clickable link to the user in his wallet after a successful payment, appended by the payment_hash as a query string."
|
hint="Will be shown as a clickable link to the user in his wallet after a successful payment, appended by the payment_hash as a query string."
|
||||||
></q-input>
|
>
|
||||||
|
</q-input>
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="formDialog.data.id"
|
v-if="formDialog.data.id"
|
||||||
|
|
@ -294,6 +299,14 @@
|
||||||
@click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')"
|
@click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')"
|
||||||
>Shareable link</q-btn
|
>Shareable link</q-btn
|
||||||
>
|
>
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
icon="nfc"
|
||||||
|
@click="writeNfcTag(qrCodeDialog.data.lnurl)"
|
||||||
|
:disable="nfcTagWriting"
|
||||||
|
>
|
||||||
|
</q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
outline
|
outline
|
||||||
color="grey"
|
color="grey"
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ async def api_link_create_or_update(
|
||||||
data.min *= data.fiat_base_multiplier
|
data.min *= data.fiat_base_multiplier
|
||||||
data.max *= data.fiat_base_multiplier
|
data.max *= data.fiat_base_multiplier
|
||||||
|
|
||||||
if "success_url" in data and data.success_url[:8] != "https://":
|
if data.success_url is not None and not data.success_url.startswith("https://"):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Success URL must be secure https://...",
|
detail="Success URL must be secure https://...",
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
|
@ -121,7 +121,7 @@ async def api_link_create_or_update(
|
||||||
return {**link.dict(), "lnurl": link.lnurl(request)}
|
return {**link.dict(), "lnurl": link.lnurl(request)}
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.delete("/api/v1/links/{link_id}")
|
@lnurlp_ext.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||||
async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type)):
|
async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
link = await get_pay_link(link_id)
|
link = await get_pay_link(link_id)
|
||||||
|
|
||||||
|
|
@ -136,7 +136,7 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type
|
||||||
)
|
)
|
||||||
|
|
||||||
await delete_pay_link(link_id)
|
await delete_pay_link(link_id)
|
||||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.get("/api/v1/rate/{currency}", status_code=HTTPStatus.OK)
|
@lnurlp_ext.get("/api/v1/rate/{currency}", status_code=HTTPStatus.OK)
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,5 @@
|
||||||
"name": "LNURLPayout",
|
"name": "LNURLPayout",
|
||||||
"short_description": "Autodump wallet funds to LNURLpay",
|
"short_description": "Autodump wallet funds to LNURLpay",
|
||||||
"icon": "exit_to_app",
|
"icon": "exit_to_app",
|
||||||
"contributors": ["arcbtc"]
|
"contributors": ["arcbtc","talvasconcelos"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
label="API info"
|
label="API info"
|
||||||
:content-inset-level="0.5"
|
:content-inset-level="0.5"
|
||||||
>
|
>
|
||||||
|
<q-btn flat label="Swagger API" type="a" href="../docs#/lnurlpayout"></q-btn>
|
||||||
<q-expansion-item group="api" dense expand-separator label="List lnurlpayout">
|
<q-expansion-item group="api" dense expand-separator label="List lnurlpayout">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ db = Database("ext_offlineshop")
|
||||||
offlineshop_static_files = [
|
offlineshop_static_files = [
|
||||||
{
|
{
|
||||||
"path": "/offlineshop/static",
|
"path": "/offlineshop/static",
|
||||||
"app": StaticFiles(directory="lnbits/extensions/offlineshop/static"),
|
"app": StaticFiles(packages=[("lnbits", "extensions/offlineshop/static")]),
|
||||||
"name": "offlineshop_static",
|
"name": "offlineshop_static",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -52,14 +52,20 @@ async def set_method(shop: int, method: str, wordlist: str = "") -> Optional[Sho
|
||||||
|
|
||||||
|
|
||||||
async def add_item(
|
async def add_item(
|
||||||
shop: int, name: str, description: str, image: Optional[str], price: int, unit: str
|
shop: int,
|
||||||
|
name: str,
|
||||||
|
description: str,
|
||||||
|
image: Optional[str],
|
||||||
|
price: int,
|
||||||
|
unit: str,
|
||||||
|
fiat_base_multiplier: int,
|
||||||
) -> int:
|
) -> int:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO offlineshop.items (shop, name, description, image, price, unit)
|
INSERT INTO offlineshop.items (shop, name, description, image, price, unit, fiat_base_multiplier)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(shop, name, description, image, price, unit),
|
(shop, name, description, image, price, unit, fiat_base_multiplier),
|
||||||
)
|
)
|
||||||
return result._result_proxy.lastrowid
|
return result._result_proxy.lastrowid
|
||||||
|
|
||||||
|
|
@ -72,6 +78,7 @@ async def update_item(
|
||||||
image: Optional[str],
|
image: Optional[str],
|
||||||
price: int,
|
price: int,
|
||||||
unit: str,
|
unit: str,
|
||||||
|
fiat_base_multiplier: int,
|
||||||
) -> int:
|
) -> int:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -80,10 +87,11 @@ async def update_item(
|
||||||
description = ?,
|
description = ?,
|
||||||
image = ?,
|
image = ?,
|
||||||
price = ?,
|
price = ?,
|
||||||
unit = ?
|
unit = ?,
|
||||||
|
fiat_base_multiplier = ?
|
||||||
WHERE shop = ? AND id = ?
|
WHERE shop = ? AND id = ?
|
||||||
""",
|
""",
|
||||||
(name, description, image, price, unit, shop, item_id),
|
(name, description, image, price, unit, fiat_base_multiplier, shop, item_id),
|
||||||
)
|
)
|
||||||
return item_id
|
return item_id
|
||||||
|
|
||||||
|
|
@ -92,12 +100,12 @@ async def get_item(id: int) -> Optional[Item]:
|
||||||
row = await db.fetchone(
|
row = await db.fetchone(
|
||||||
"SELECT * FROM offlineshop.items WHERE id = ? LIMIT 1", (id,)
|
"SELECT * FROM offlineshop.items WHERE id = ? LIMIT 1", (id,)
|
||||||
)
|
)
|
||||||
return Item(**dict(row)) if row else None
|
return Item.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def get_items(shop: int) -> List[Item]:
|
async def get_items(shop: int) -> List[Item]:
|
||||||
rows = await db.fetchall("SELECT * FROM offlineshop.items WHERE shop = ?", (shop,))
|
rows = await db.fetchall("SELECT * FROM offlineshop.items WHERE shop = ?", (shop,))
|
||||||
return [Item(**dict(row)) for row in rows]
|
return [Item.from_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
async def delete_item_from_shop(shop: int, item_id: int):
|
async def delete_item_from_shop(shop: int, item_id: int):
|
||||||
|
|
|
||||||
|
|
@ -73,9 +73,7 @@ async def lnurl_callback(request: Request, item_id: int):
|
||||||
wallet_id=shop.wallet,
|
wallet_id=shop.wallet,
|
||||||
amount=int(amount_received / 1000),
|
amount=int(amount_received / 1000),
|
||||||
memo=item.name,
|
memo=item.name,
|
||||||
description_hash=hashlib.sha256(
|
unhashed_description=(await item.lnurlpay_metadata()).encode("utf-8"),
|
||||||
(await item.lnurlpay_metadata()).encode("utf-8")
|
|
||||||
).digest(),
|
|
||||||
extra={"tag": "offlineshop", "item": item.id},
|
extra={"tag": "offlineshop", "item": item.id},
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
|
||||||
|
|
@ -27,3 +27,13 @@ async def m001_initial(db):
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def m002_fiat_base_multiplier(db):
|
||||||
|
"""
|
||||||
|
Store the multiplier for fiat prices. We store the price in cents and
|
||||||
|
remember to multiply by 100 when we use it to convert to Dollars.
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE offlineshop.items ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from sqlite3 import Row
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from lnurl import encode as lnurl_encode # type: ignore
|
from lnurl import encode as lnurl_encode # type: ignore
|
||||||
|
|
@ -87,8 +88,16 @@ class Item(BaseModel):
|
||||||
description: str
|
description: str
|
||||||
image: Optional[str]
|
image: Optional[str]
|
||||||
enabled: bool
|
enabled: bool
|
||||||
price: int
|
price: float
|
||||||
unit: str
|
unit: str
|
||||||
|
fiat_base_multiplier: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row) -> "Item":
|
||||||
|
data = dict(row)
|
||||||
|
if data["unit"] != "sat" and data["fiat_base_multiplier"]:
|
||||||
|
data["price"] /= data["fiat_base_multiplier"]
|
||||||
|
return cls(**data)
|
||||||
|
|
||||||
def lnurl(self, req: Request) -> str:
|
def lnurl(self, req: Request) -> str:
|
||||||
return lnurl_encode(req.url_for("offlineshop.lnurl_response", item_id=self.id))
|
return lnurl_encode(req.url_for("offlineshop.lnurl_response", item_id=self.id))
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,8 @@ new Vue({
|
||||||
description,
|
description,
|
||||||
image,
|
image,
|
||||||
price,
|
price,
|
||||||
unit
|
unit,
|
||||||
|
fiat_base_multiplier: unit == 'sat' ? 1 : 100
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
label="API info"
|
label="API info"
|
||||||
:content-inset-level="0.5"
|
:content-inset-level="0.5"
|
||||||
>
|
>
|
||||||
|
<q-btn flat label="Swagger API" type="a" href="../docs#/offlineshop"></q-btn>
|
||||||
<q-expansion-item
|
<q-expansion-item
|
||||||
group="api"
|
group="api"
|
||||||
dense
|
dense
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import Query
|
||||||
from fastapi.params import Depends
|
from fastapi.params import Depends
|
||||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
|
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
|
||||||
from pydantic.main import BaseModel
|
from pydantic.main import BaseModel
|
||||||
|
|
@ -34,7 +35,6 @@ async def api_shop_from_wallet(
|
||||||
):
|
):
|
||||||
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
|
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
|
||||||
items = await get_items(shop.id)
|
items = await get_items(shop.id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return {
|
return {
|
||||||
**shop.dict(),
|
**shop.dict(),
|
||||||
|
|
@ -51,8 +51,9 @@ class CreateItemsData(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
image: Optional[str]
|
image: Optional[str]
|
||||||
price: int
|
price: float
|
||||||
unit: str
|
unit: str
|
||||||
|
fiat_base_multiplier: int = Query(100, ge=1)
|
||||||
|
|
||||||
|
|
||||||
@offlineshop_ext.post("/api/v1/offlineshop/items")
|
@offlineshop_ext.post("/api/v1/offlineshop/items")
|
||||||
|
|
@ -61,9 +62,18 @@ async def api_add_or_update_item(
|
||||||
data: CreateItemsData, item_id=None, wallet: WalletTypeInfo = Depends(get_key_type)
|
data: CreateItemsData, item_id=None, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
):
|
):
|
||||||
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
|
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
|
||||||
|
if data.unit != "sat":
|
||||||
|
data.price = data.price * 100
|
||||||
if item_id == None:
|
if item_id == None:
|
||||||
|
|
||||||
await add_item(
|
await add_item(
|
||||||
shop.id, data.name, data.description, data.image, data.price, data.unit
|
shop.id,
|
||||||
|
data.name,
|
||||||
|
data.description,
|
||||||
|
data.image,
|
||||||
|
data.price,
|
||||||
|
data.unit,
|
||||||
|
data.fiat_base_multiplier,
|
||||||
)
|
)
|
||||||
return HTMLResponse(status_code=HTTPStatus.CREATED)
|
return HTMLResponse(status_code=HTTPStatus.CREATED)
|
||||||
else:
|
else:
|
||||||
|
|
@ -75,6 +85,7 @@ async def api_add_or_update_item(
|
||||||
data.image,
|
data.image,
|
||||||
data.price,
|
data.price,
|
||||||
data.unit,
|
data.unit,
|
||||||
|
data.fiat_base_multiplier,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
label="API info"
|
label="API info"
|
||||||
:content-inset-level="0.5"
|
:content-inset-level="0.5"
|
||||||
>
|
>
|
||||||
|
<q-btn flat label="Swagger API" type="a" href="../docs#/paywall"></q-btn>
|
||||||
<q-expansion-item group="api" dense expand-separator label="List paywalls">
|
<q-expansion-item group="api" dense expand-separator label="List paywalls">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from fastapi import Depends, Query
|
||||||
from starlette.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
from lnbits.core.crud import get_user, get_wallet
|
from lnbits.core.crud import get_user, get_wallet
|
||||||
from lnbits.core.services import check_invoice_status, create_invoice
|
from lnbits.core.services import check_transaction_status, create_invoice
|
||||||
from lnbits.decorators import WalletTypeInfo, get_key_type
|
from lnbits.decorators import WalletTypeInfo, get_key_type
|
||||||
|
|
||||||
from . import paywall_ext
|
from . import paywall_ext
|
||||||
|
|
@ -87,7 +87,7 @@ async def api_paywal_check_invoice(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Paywall does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Paywall does not exist."
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
status = await check_invoice_status(paywall.wallet, payment_hash)
|
status = await check_transaction_status(paywall.wallet, payment_hash)
|
||||||
is_paid = not status.pending
|
is_paid = not status.pending
|
||||||
except Exception:
|
except Exception:
|
||||||
return {"paid": False}
|
return {"paid": False}
|
||||||
|
|
|
||||||
|
|
@ -77,9 +77,7 @@ async def api_lnurlp_callback(
|
||||||
wallet_id=link.wallet,
|
wallet_id=link.wallet,
|
||||||
amount=int(amount_received / 1000),
|
amount=int(amount_received / 1000),
|
||||||
memo="Satsdice bet",
|
memo="Satsdice bet",
|
||||||
description_hash=hashlib.sha256(
|
unhashed_description=link.lnurlpay_metadata.encode("utf-8"),
|
||||||
link.lnurlpay_metadata.encode("utf-8")
|
|
||||||
).digest(),
|
|
||||||
extra={"tag": "satsdice", "link": link.id, "comment": "comment"},
|
extra={"tag": "satsdice", "link": link.id, "comment": "comment"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
label="API info"
|
label="API info"
|
||||||
:content-inset-level="0.5"
|
:content-inset-level="0.5"
|
||||||
>
|
>
|
||||||
|
<q-btn flat label="Swagger API" type="a" href="../docs#/satsdice"></q-btn>
|
||||||
<q-expansion-item group="api" dense expand-separator label="List satsdices">
|
<q-expansion-item group="api" dense expand-separator label="List satsdices">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue