mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2024-11-20 02:28:10 +01:00
Merge branch 'main' into secure_communication
This commit is contained in:
commit
fb7030c30f
@ -6,6 +6,10 @@ tests
|
||||
venv
|
||||
tools
|
||||
|
||||
lnbits/static/css/*
|
||||
lnbits/static/bundle.js
|
||||
lnbits/static/bundle.css
|
||||
|
||||
*.md
|
||||
*.log
|
||||
|
||||
|
@ -25,6 +25,8 @@ LNBITS_DATA_FOLDER="./data"
|
||||
|
||||
LNBITS_FORCE_HTTPS=true
|
||||
LNBITS_SERVICE_FEE="0.0"
|
||||
LNBITS_RESERVE_FEE_MIN=2000 # value in millisats
|
||||
LNBITS_RESERVE_FEE_PERCENT=1.0 # value in percent
|
||||
|
||||
# Change theme
|
||||
LNBITS_SITE_TITLE="LNbits"
|
||||
|
35
.github/workflows/formatting.yml
vendored
35
.github/workflows/formatting.yml
vendored
@ -7,30 +7,19 @@ on:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
black:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- run: sudo apt-get install python3-venv
|
||||
- run: python3 -m venv venv
|
||||
- run: ./venv/bin/pip install black
|
||||
- run: make checkblack
|
||||
isort:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: abatilo/actions-poetry@v2.1.3
|
||||
- name: Install packages
|
||||
run: poetry install
|
||||
- name: Check black
|
||||
run: make checkblack
|
||||
- name: Check isort
|
||||
run: make checkisort
|
||||
- uses: actions/setup-node@v3
|
||||
- run: sudo apt-get install python3-venv
|
||||
- run: python3 -m venv venv
|
||||
- run: ./venv/bin/pip install isort
|
||||
- run: make checkisort
|
||||
|
||||
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
|
||||
- name: Check prettier
|
||||
run: |
|
||||
npm install prettier
|
||||
make checkprettier
|
||||
|
24
.github/workflows/migrations.yml
vendored
24
.github/workflows/migrations.yml
vendored
@ -9,9 +9,9 @@ jobs:
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: lnbits
|
||||
POSTGRES_PASSWORD: lnbits
|
||||
POSTGRES_DB: migration
|
||||
ports:
|
||||
# maps tcp port 5432 on service container to the host
|
||||
- 5432:5432
|
||||
@ -29,23 +29,11 @@ jobs:
|
||||
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: |
|
||||
python -m venv ${{ env.VIRTUAL_ENV }}
|
||||
./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
|
||||
poetry install
|
||||
sudo apt install unzip
|
||||
- name: Run migrations
|
||||
run: |
|
||||
rm -rf ./data
|
||||
mkdir -p ./data
|
||||
export LNBITS_DATA_FOLDER="./data"
|
||||
unzip tests/data/mock_data.zip -d ./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
|
||||
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
|
||||
./venv/bin/python tools/conv.py
|
||||
make test-migration
|
||||
|
17
.github/workflows/mypy.yml
vendored
17
.github/workflows/mypy.yml
vendored
@ -5,9 +5,18 @@ on: [push, pull_request]
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.9]
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: jpetrucciani/mypy-check@master
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
mypy_flags: '--install-types --non-interactive'
|
||||
path: 'lnbits'
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: abatilo/actions-poetry@v2.1.3
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
run: poetry run mypy
|
||||
|
68
.github/workflows/regtest.yml
vendored
68
.github/workflows/regtest.yml
vendored
@ -14,23 +14,18 @@ jobs:
|
||||
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
|
||||
env:
|
||||
VIRTUAL_ENV: ./venv
|
||||
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
||||
run: |
|
||||
python -m venv ${{ env.VIRTUAL_ENV }}
|
||||
./venv/bin/python -m pip install --upgrade pip
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
./venv/bin/pip install pyln-client
|
||||
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
|
||||
poetry install
|
||||
- name: Run tests
|
||||
env:
|
||||
PYTHONUNBUFFERED: 1
|
||||
@ -46,7 +41,48 @@ jobs:
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
file: ./coverage.xml
|
||||
LndWallet:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.8]
|
||||
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 grpcio protobuf
|
||||
- name: Run tests
|
||||
env:
|
||||
PYTHONUNBUFFERED: 1
|
||||
PORT: 5123
|
||||
LNBITS_DATA_FOLDER: ./data
|
||||
LNBITS_BACKEND_WALLET_CLASS: LndWallet
|
||||
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: |
|
||||
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
|
||||
CoreLightningWallet:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@ -58,23 +94,19 @@ jobs:
|
||||
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
|
||||
env:
|
||||
VIRTUAL_ENV: ./venv
|
||||
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
||||
run: |
|
||||
python -m venv ${{ env.VIRTUAL_ENV }}
|
||||
./venv/bin/python -m pip install --upgrade pip
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
./venv/bin/pip install pyln-client
|
||||
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
|
||||
poetry install
|
||||
poetry add pyln-client
|
||||
- name: Run tests
|
||||
env:
|
||||
PYTHONUNBUFFERED: 1
|
||||
@ -88,4 +120,4 @@ jobs:
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
file: ./coverage.xml
|
||||
|
96
.github/workflows/tests.yml
vendored
96
.github/workflows/tests.yml
vendored
@ -23,9 +23,29 @@ jobs:
|
||||
./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
|
||||
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
|
||||
run: make test
|
||||
venv-postgres:
|
||||
postgres:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
@ -51,15 +71,10 @@ jobs:
|
||||
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: |
|
||||
python -m venv ${{ env.VIRTUAL_ENV }}
|
||||
./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
|
||||
poetry install
|
||||
- name: Run tests
|
||||
env:
|
||||
LNBITS_DATABASE_URL: postgres://postgres:postgres@0.0.0.0:5432/postgres
|
||||
@ -68,68 +83,3 @@ jobs:
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
poetry-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 }}
|
||||
- name: Install dependencies
|
||||
env:
|
||||
VIRTUAL_ENV: ./venv
|
||||
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
||||
run: |
|
||||
python -m venv ${{ env.VIRTUAL_ENV }}
|
||||
./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
|
||||
run: make test
|
||||
poetry-postgres:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
# maps tcp port 5432 on service container to the host
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
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 }}
|
||||
- name: Install dependencies
|
||||
env:
|
||||
VIRTUAL_ENV: ./venv
|
||||
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
||||
run: |
|
||||
python -m venv ${{ env.VIRTUAL_ENV }}
|
||||
./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
|
||||
env:
|
||||
LNBITS_DATABASE_URL: postgres://postgres:postgres@0.0.0.0:5432/postgres
|
||||
run: make test
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
55
Dockerfile
55
Dockerfile
@ -1,49 +1,12 @@
|
||||
# Build image
|
||||
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
|
||||
FROM python:3.9-slim
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y --no-install-recommends build-essential pkg-config libpq-dev
|
||||
RUN python -m pip install --upgrade pip
|
||||
RUN pip install wheel
|
||||
|
||||
# Install runtime deps
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
RUN pip install -r /tmp/requirements.txt
|
||||
|
||||
# Install c-lightning specific deps
|
||||
RUN pip install pyln-client
|
||||
|
||||
# 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
|
||||
RUN apt-get install -y curl
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
WORKDIR /app
|
||||
COPY --chown=1000:1000 lnbits /app/lnbits
|
||||
|
||||
# Staticfiles
|
||||
COPY --chown=1000:1000 build.py /app
|
||||
RUN python build.py
|
||||
|
||||
ENV LNBITS_PORT="5000"
|
||||
ENV LNBITS_HOST="0.0.0.0"
|
||||
|
||||
COPY . .
|
||||
RUN poetry config virtualenvs.create false
|
||||
RUN poetry install --no-dev --no-root
|
||||
RUN poetry run python build.py
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["sh", "-c", "uvicorn lnbits.__main__:app --port $LNBITS_PORT --host $LNBITS_HOST"]
|
||||
CMD ["poetry", "run", "lnbits", "--port", "5000", "--host", "0.0.0.0"]
|
||||
|
63
Makefile
63
Makefile
@ -4,58 +4,69 @@ all: format check requirements.txt
|
||||
|
||||
format: prettier isort black
|
||||
|
||||
check: mypy checkprettier checkblack
|
||||
check: mypy checkprettier checkisort checkblack
|
||||
|
||||
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 lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
|
||||
|
||||
black: $(shell find lnbits -name "*.py")
|
||||
./venv/bin/black lnbits
|
||||
black:
|
||||
poetry run black .
|
||||
|
||||
mypy: $(shell find lnbits -name "*.py")
|
||||
./venv/bin/mypy lnbits
|
||||
./venv/bin/mypy lnbits/core
|
||||
./venv/bin/mypy lnbits/extensions/*
|
||||
mypy:
|
||||
poetry run mypy
|
||||
|
||||
isort: $(shell find lnbits -name "*.py")
|
||||
./venv/bin/isort --profile black lnbits
|
||||
isort:
|
||||
poetry run isort .
|
||||
|
||||
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 lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
|
||||
|
||||
checkblack: $(shell find lnbits -name "*.py")
|
||||
./venv/bin/black --check lnbits
|
||||
checkblack:
|
||||
poetry run black --check .
|
||||
|
||||
checkisort: $(shell find lnbits -name "*.py")
|
||||
./venv/bin/isort --profile black --check-only lnbits
|
||||
|
||||
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
|
||||
checkisort:
|
||||
poetry run isort --check-only .
|
||||
|
||||
test:
|
||||
mkdir -p ./tests/data
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
FAKE_WALLET_SECRET="ToTheMoon1" \
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
./venv/bin/pytest --durations=1 -s --cov=lnbits --cov-report=xml tests
|
||||
DEBUG=true \
|
||||
poetry run pytest
|
||||
|
||||
test-real-wallet:
|
||||
mkdir -p ./tests/data
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
./venv/bin/pytest --durations=1 -s --cov=lnbits --cov-report=xml tests
|
||||
DEBUG=true \
|
||||
poetry run pytest
|
||||
|
||||
test-pipenv:
|
||||
mkdir -p ./tests/data
|
||||
test-venv:
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
FAKE_WALLET_SECRET="ToTheMoon1" \
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
pipenv run pytest --durations=1 -s --cov=lnbits --cov-report=xml tests
|
||||
DEBUG=true \
|
||||
./venv/bin/pytest --durations=1 -s --cov=lnbits --cov-report=xml tests
|
||||
|
||||
test-migration:
|
||||
rm -rf ./migration-data
|
||||
mkdir -p ./migration-data
|
||||
unzip tests/data/mock_data.zip -d ./migration-data
|
||||
HOST=0.0.0.0 \
|
||||
PORT=5002 \
|
||||
LNBITS_DATA_FOLDER="./migration-data" \
|
||||
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
||||
HOST=0.0.0.0 \
|
||||
PORT=5002 \
|
||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||
timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
|
||||
LNBITS_DATA_FOLDER="./migration-data" \
|
||||
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
|
||||
poetry run python tools/conv.py
|
||||
|
||||
migration:
|
||||
poetry run python tools/conv.py
|
||||
|
||||
bak:
|
||||
# LNBITS_DATABASE_URL=postgres://postgres:postgres@0.0.0.0:5432/postgres
|
||||
|
21
build.py
21
build.py
@ -1,13 +1,14 @@
|
||||
import warnings
|
||||
import subprocess
|
||||
import glob
|
||||
import os
|
||||
import subprocess
|
||||
import warnings
|
||||
from os import path
|
||||
from typing import Any, List, NamedTuple, Optional
|
||||
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)
|
||||
|
||||
@ -71,6 +72,7 @@ def get_vendored(ext: str, prefer_minified: bool = False) -> List[str]:
|
||||
def url_for_vendored(abspath: str) -> str:
|
||||
return "/" + os.path.relpath(abspath, LNBITS_PATH)
|
||||
|
||||
|
||||
def transpile_scss():
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
@ -80,6 +82,7 @@ def transpile_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")),
|
||||
@ -96,15 +99,7 @@ def bundle_vendored():
|
||||
def build():
|
||||
transpile_scss()
|
||||
bundle_vendored()
|
||||
# root = Path("lnbits/static/foo")
|
||||
# root.mkdir(parents=True)
|
||||
# root.joinpath("example.css").write_text("")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
build()
|
||||
|
||||
#def build(setup_kwargs):
|
||||
# """Build """
|
||||
# transpile_scss()
|
||||
# bundle_vendored()
|
||||
# subprocess.run(["ls", "-la", "./lnbits/static"])
|
||||
build()
|
||||
|
@ -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:
|
||||
```bash
|
||||
./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock
|
||||
poetry install
|
||||
npm i
|
||||
```
|
||||
|
||||
Then to run the tests:
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
Run formatting:
|
||||
```bash
|
||||
make format
|
||||
```
|
||||
|
||||
Run mypy checks:
|
||||
```bash
|
||||
poetry run mypy
|
||||
```
|
||||
|
||||
Run everything:
|
||||
```bash
|
||||
make all
|
||||
```
|
||||
|
@ -44,25 +44,29 @@ Dependencies need to be added to `pyproject.toml` and `requirements.txt`, then t
|
||||
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. Unfortunately, it is not automatic (yet) which is why a new extension **must** add its migration to this script in order for all GitHub checks to pass. It is rather easy to add a migration though, just copy/paste one of the examples and replace the column names with the ones found in your extension `migrations.py`. The next step is to add a mock SQLite database with a few lines of sample data to `tests/data/mock_data.zip`.
|
||||
|
||||
### Adding migration to `conv.py`
|
||||
|
||||
Here is an example block from the `subdomains` exteion:
|
||||
|
||||
```python
|
||||
elif schema == "subdomain":
|
||||
# SUBDOMAIN
|
||||
res = sq.execute("SELECT * FROM subdomain;")
|
||||
q = f"""
|
||||
INSERT INTO subdomains.subdomain (id, domain, email, subdomain, ip, wallet, sats, duration, paid, record_type, time)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::boolean, %s, to_timestamp(%s));
|
||||
"""
|
||||
insert_to_pg(q, res.fetchall())
|
||||
```
|
||||
|
||||
Note how boolean columns must use `%s::boolean` and timestamps use `to_timestamp(%s)`. If your extension uses amounts (like the column `sats` above) it should use a PostgreSQL column of type `int8` or `numeric` (aka `BIGINT`). SQLite doesn't know the difference.
|
||||
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.db` file. Unzip `tests/data/mock_data.zip`, add `your_extension.db` and zip it again. Add the updated `mock_data.zip` to your PR.
|
||||
`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`, updated `database.sqlite3` and zip it again. Add the updated `mock_data.zip` to your PR.
|
||||
|
||||
### running migration locally
|
||||
you will need a running postgres database
|
||||
|
||||
#### create lnbits user for migration database
|
||||
```console
|
||||
sudo su - postgres -c "psql -c 'CREATE ROLE lnbits LOGIN PASSWORD 'lnbits';'"
|
||||
```
|
||||
#### create migration database
|
||||
```console
|
||||
sudo su - postgres -c "psql -c 'CREATE DATABASE migration;'"
|
||||
```
|
||||
#### run the migration
|
||||
```console
|
||||
make test-migration
|
||||
```
|
||||
sudo su - postgres -c "psql -c 'CREATE ROLE lnbits LOGIN PASSWORD 'lnbits';'"
|
||||
#### clean migration database afterwards, fails if you try again
|
||||
```console
|
||||
sudo su - postgres -c "psql -c 'DROP DATABASE IF EXISTS migration;'"
|
||||
```
|
||||
|
@ -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 for running tests etc before pr'ing, use `./venv/bin/pip install pytest pytest-asyncio pytest-cov requests mock black mypy isort`.
|
||||
|
||||
## 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.
|
@ -20,22 +20,22 @@ cd lnbits-legend/
|
||||
sudo apt update
|
||||
sudo apt install software-properties-common
|
||||
sudo add-apt-repository ppa:deadsnakes/ppa
|
||||
sudo apt install python3.9
|
||||
sudo apt install python3.9 python3.9-distutils
|
||||
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
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
|
||||
poetry install --no-dev
|
||||
|
||||
mkdir data
|
||||
mkdir data
|
||||
cp .env.example .env
|
||||
sudo nano .env # set funding source
|
||||
|
||||
|
||||
```
|
||||
```
|
||||
|
||||
#### Running the server
|
||||
|
||||
|
||||
```sh
|
||||
poetry run lnbits
|
||||
# To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0'
|
||||
@ -49,7 +49,7 @@ 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
|
||||
|
||||
nix build .#lnbits
|
||||
nix build .#lnbits
|
||||
mkdir data
|
||||
|
||||
```
|
||||
@ -82,7 +82,7 @@ mkdir data && cp .env.example .env
|
||||
./venv/bin/uvicorn lnbits.__main__:app --port 5000
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@ -97,16 +97,16 @@ docker run --detach --publish 5000:5000 --name lnbits-legend --volume ${PWD}/.en
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
Problems installing? These commands have helped us install LNbits.
|
||||
Problems installing? These commands have helped us install LNbits.
|
||||
|
||||
```sh
|
||||
sudo apt install pkg-config libffi-dev libpq-dev
|
||||
|
||||
# if the secp256k1 build fails:
|
||||
# if you used venv
|
||||
./venv/bin/pip install setuptools wheel
|
||||
./venv/bin/pip install setuptools wheel
|
||||
# if you used poetry
|
||||
poetry add setuptools wheel
|
||||
poetry add setuptools wheel
|
||||
# build essentials for debian/ubuntu
|
||||
sudo apt install python3-dev gcc build-essential
|
||||
```
|
||||
@ -141,13 +141,13 @@ LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
|
||||
|
||||
# Using LNbits
|
||||
|
||||
Now you can visit your LNbits at http://localhost:5000/.
|
||||
Now you can visit your LNbits at http://localhost:5000/.
|
||||
|
||||
Now modify the `.env` file with any settings you prefer and add a proper [funding source](./wallets.md) by modifying the value of `LNBITS_BACKEND_WALLET_CLASS` and providing the extra information and credentials related to the chosen funding source.
|
||||
Now modify the `.env` file with any settings you prefer and add a proper [funding source](./wallets.md) by modifying the value of `LNBITS_BACKEND_WALLET_CLASS` and providing the extra information and credentials related to the chosen funding source.
|
||||
|
||||
Then you can restart it and it will be using the new settings.
|
||||
|
||||
You might also need to install additional packages or perform additional setup steps, depending on the chosen backend. See [the short guide](./wallets.md) on each different funding source.
|
||||
You might also need to install additional packages or perform additional setup steps, depending on the chosen backend. See [the short guide](./wallets.md) on each different funding source.
|
||||
|
||||
Take a look at [Polar](https://lightningpolar.com/) for an excellent way of spinning up a Lightning Network dev environment.
|
||||
|
||||
@ -170,8 +170,9 @@ LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
|
||||
|
||||
# START LNbits
|
||||
# STOP LNbits
|
||||
# on the LNBits folder, locate and edit 'tools/conv.py' with the relevant credentials
|
||||
python3 tools/conv.py
|
||||
poetry run python tools/conv.py
|
||||
# or
|
||||
make migration
|
||||
```
|
||||
|
||||
Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly.
|
||||
@ -189,21 +190,20 @@ Systemd is great for taking care of your LNbits instance. It will start it on bo
|
||||
Description=LNbits
|
||||
# you can uncomment these lines if you know what you're doing
|
||||
# it will make sure that lnbits starts after lnd (replace with your own backend service)
|
||||
#Wants=lnd.service
|
||||
#After=lnd.service
|
||||
#Wants=lnd.service
|
||||
#After=lnd.service
|
||||
|
||||
[Service]
|
||||
# replace with the absolute path of your lnbits installation
|
||||
WorkingDirectory=/home/bitcoin/lnbits
|
||||
# same here
|
||||
ExecStart=/home/bitcoin/lnbits/venv/bin/uvicorn lnbits.__main__:app --port 5000
|
||||
WorkingDirectory=/home/lnbits/lnbits-legend
|
||||
# same here. run `which poetry` if you can't find the poetry binary
|
||||
ExecStart=/home/lnbits/.local/bin/poetry run lnbits
|
||||
# replace with the user that you're running lnbits on
|
||||
User=bitcoin
|
||||
User=lnbits
|
||||
Restart=always
|
||||
TimeoutSec=120
|
||||
RestartSec=30
|
||||
# this makes sure that you receive logs in real time
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -216,13 +216,54 @@ sudo systemctl enable lnbits.service
|
||||
sudo systemctl start lnbits.service
|
||||
```
|
||||
|
||||
## 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.
|
||||
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).
|
||||
You can find the install instructions for `mkcert` [here](https://github.com/FiloSottile/mkcert).
|
||||
|
||||
Install mkcert on Ubuntu:
|
||||
```sh
|
||||
@ -232,16 +273,22 @@ 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 ([more info](https://kifarunix.com/how-to-create-self-signed-ssl-certificate-with-mkcert-on-ubuntu-18-04/))
|
||||
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
|
||||
mkcert localhost 127.0.0.1 ::1
|
||||
```
|
||||
|
||||
This will create two new files (`localhost-key.pem` and `localhost.pem `) which you can then pass to uvicorn when you start LNbits:
|
||||
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 ./localhost-key.pem --ssl-certfile ./localhost.pem
|
||||
./venv/bin/uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./key.pem --ssl-certfile ./cert.pem
|
||||
```
|
||||
|
||||
|
||||
@ -254,9 +301,9 @@ If you want to run LNbits on your Umbrel but want it to be reached through clear
|
||||
To install using docker you first need to build the docker image as:
|
||||
|
||||
```
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits/ # ${PWD} referred as <lnbits_repo>
|
||||
docker build -t lnbits .
|
||||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits-legend
|
||||
docker build -t lnbits-legend .
|
||||
```
|
||||
|
||||
You can launch the docker in a different directory, but make sure to copy `.env.example` from lnbits there
|
||||
@ -267,17 +314,15 @@ cp <lnbits_repo>/.env.example .env
|
||||
|
||||
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
|
||||
sudo chown 1000:1000 ./data/
|
||||
```
|
||||
|
||||
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.
|
||||
|
@ -37,6 +37,22 @@ or
|
||||
|
||||
- `LND_REST_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
|
||||
|
||||
### LND (gRPC)
|
||||
|
||||
Using this wallet requires the installation of the `grpcio` and `protobuf` Python packages.
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LndWallet**
|
||||
- `LND_GRPC_ENDPOINT`: ip_address
|
||||
- `LND_GRPC_PORT`: port
|
||||
- `LND_GRPC_CERT`: /file/path/tls.cert
|
||||
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
|
||||
|
||||
You can also use an AES-encrypted macaroon (more info) instead by using
|
||||
|
||||
- `LND_GRPC_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
|
||||
|
||||
To encrypt your macaroon, run `./venv/bin/python lnbits/wallets/macaroon/macaroon.py`.
|
||||
|
||||
### LNbits
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LNbitsWallet**
|
||||
|
@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import traceback
|
||||
import warnings
|
||||
@ -75,7 +76,11 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||
# Only the browser sends "text/html" request
|
||||
# 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(
|
||||
"error.html",
|
||||
{"request": request, "err": f"{exc.errors()} is not a valid UUID."},
|
||||
@ -101,16 +106,27 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||
def check_funding_source(app: FastAPI) -> None:
|
||||
@app.on_event("startup")
|
||||
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:
|
||||
error_message, balance = await WALLET.status()
|
||||
if not error_message:
|
||||
break
|
||||
logger.error(
|
||||
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)
|
||||
try:
|
||||
error_message, balance = await WALLET.status()
|
||||
if not error_message:
|
||||
break
|
||||
logger.error(
|
||||
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)
|
||||
except:
|
||||
pass
|
||||
signal.signal(signal.SIGINT, original_sigint_handler)
|
||||
logger.info(
|
||||
f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat."
|
||||
)
|
||||
@ -185,7 +201,11 @@ def register_exception_handlers(app: FastAPI):
|
||||
traceback.print_exception(etype, err, tb)
|
||||
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(
|
||||
"error.html", {"request": request, "err": err}
|
||||
)
|
||||
|
@ -216,7 +216,7 @@ def lnencode(addr, privkey):
|
||||
expirybits = expirybits[5:]
|
||||
data += tagged("x", expirybits)
|
||||
elif k == "h":
|
||||
data += tagged_bytes("h", hashlib.sha256(v.encode("utf-8")).digest())
|
||||
data += tagged_bytes("h", v)
|
||||
elif k == "n":
|
||||
data += tagged_bytes("n", v)
|
||||
else:
|
||||
|
@ -4,6 +4,8 @@ from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.db import COCKROACH, POSTGRES, Connection
|
||||
from lnbits.settings import DEFAULT_WALLET_NAME, LNBITS_ADMIN_USERS
|
||||
@ -334,7 +336,7 @@ async def delete_expired_invoices(
|
||||
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||
if expiration_date > datetime.datetime.utcnow():
|
||||
continue
|
||||
|
||||
logger.debug(f"Deleting expired invoice: {invoice.payment_hash}")
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
DELETE FROM apipayments
|
||||
|
@ -141,19 +141,25 @@ class Payment(BaseModel):
|
||||
if self.is_uncheckable:
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"Checking {'outgoing' if self.is_out else 'incoming'} pending payment {self.checking_id}"
|
||||
)
|
||||
|
||||
if self.is_out:
|
||||
status = await WALLET.get_payment_status(self.checking_id)
|
||||
else:
|
||||
status = await WALLET.get_invoice_status(self.checking_id)
|
||||
|
||||
logger.debug(f"Status: {status}")
|
||||
|
||||
if self.is_out and status.failed:
|
||||
logger.info(
|
||||
f" - deleting outgoing failed payment {self.checking_id}: {status}"
|
||||
f"Deleting outgoing failed payment {self.checking_id}: {status}"
|
||||
)
|
||||
await self.delete()
|
||||
elif not status.pending:
|
||||
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)
|
||||
|
||||
|
@ -21,7 +21,7 @@ from lnbits.decorators import (
|
||||
)
|
||||
from lnbits.helpers import url_for, urlsafe_short_hash
|
||||
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 . import db
|
||||
@ -54,6 +54,7 @@ async def create_invoice(
|
||||
amount: int, # in satoshis
|
||||
memo: str,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
extra: Optional[Dict] = None,
|
||||
webhook: Optional[str] = None,
|
||||
internal: Optional[bool] = False,
|
||||
@ -65,7 +66,10 @@ async def create_invoice(
|
||||
wallet = FAKE_WALLET if internal else WALLET
|
||||
|
||||
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:
|
||||
raise InvoiceFailure(error_message or "unexpected backend error.")
|
||||
@ -156,7 +160,7 @@ async def pay_invoice(
|
||||
logger.debug("balance is too low, deleting temporary payment")
|
||||
if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat:
|
||||
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.")
|
||||
|
||||
@ -182,7 +186,7 @@ async def pay_invoice(
|
||||
payment_request, fee_reserve_msat
|
||||
)
|
||||
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}")
|
||||
async with db.connect() as conn:
|
||||
await create_payment(
|
||||
@ -196,7 +200,7 @@ async def pay_invoice(
|
||||
logger.debug(f"deleting temporary payment {temp_id}")
|
||||
await delete_payment(temp_id, conn=conn)
|
||||
else:
|
||||
logger.debug(f"backend payment failed, no checking_id {temp_id}")
|
||||
logger.debug(f"backend payment failed")
|
||||
async with db.connect() as conn:
|
||||
logger.debug(f"deleting temporary payment {temp_id}")
|
||||
await delete_payment(temp_id, conn=conn)
|
||||
@ -337,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
|
||||
) -> PaymentStatus:
|
||||
payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
||||
if not payment:
|
||||
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:
|
||||
return status
|
||||
if payment.is_out and status.failed:
|
||||
@ -359,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
|
||||
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))
|
||||
|
@ -668,7 +668,17 @@ new Vue({
|
||||
})
|
||||
},
|
||||
exportCSV: function () {
|
||||
LNbits.utils.exportCSV(this.paymentsTable.columns, this.payments)
|
||||
// status is important for export but it is not in paymentsTable
|
||||
// because it is manually added with payment detail link and icons
|
||||
// and would cause duplication in the list
|
||||
let columns = this.paymentsTable.columns
|
||||
columns.unshift({
|
||||
name: 'pending',
|
||||
align: 'left',
|
||||
label: 'Pending',
|
||||
field: 'pending'
|
||||
})
|
||||
LNbits.utils.exportCSV(columns, this.payments)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import asyncio
|
||||
import binascii
|
||||
import hashlib
|
||||
import json
|
||||
from binascii import unhexlify
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
@ -48,7 +48,7 @@ from ..crud import (
|
||||
from ..services import (
|
||||
InvoiceFailure,
|
||||
PaymentFailure,
|
||||
check_invoice_status,
|
||||
check_transaction_status,
|
||||
create_invoice,
|
||||
pay_invoice,
|
||||
perform_lnurlauth,
|
||||
@ -123,7 +123,7 @@ async def api_payments(
|
||||
offset=offset,
|
||||
)
|
||||
for payment in pendingPayments:
|
||||
await check_invoice_status(
|
||||
await check_transaction_status(
|
||||
wallet_id=payment.wallet_id, payment_hash=payment.payment_hash
|
||||
)
|
||||
return await get_payments(
|
||||
@ -141,6 +141,7 @@ class CreateInvoiceData(BaseModel):
|
||||
memo: Optional[str] = None
|
||||
unit: Optional[str] = "sat"
|
||||
description_hash: Optional[str] = None
|
||||
unhashed_description: Optional[str] = None
|
||||
lnurl_callback: Optional[str] = None
|
||||
lnurl_balance_check: Optional[str] = None
|
||||
extra: Optional[dict] = None
|
||||
@ -151,10 +152,28 @@ class CreateInvoiceData(BaseModel):
|
||||
|
||||
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
if data.description_hash:
|
||||
description_hash = unhexlify(data.description_hash)
|
||||
try:
|
||||
description_hash = binascii.unhexlify(data.description_hash)
|
||||
except binascii.Error:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'description_hash' must be a valid hex string",
|
||||
)
|
||||
unhashed_description = b""
|
||||
memo = ""
|
||||
elif data.unhashed_description:
|
||||
try:
|
||||
unhashed_description = binascii.unhexlify(data.unhashed_description)
|
||||
except binascii.Error:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'unhashed_description' must be a valid hex string",
|
||||
)
|
||||
description_hash = b""
|
||||
memo = ""
|
||||
else:
|
||||
description_hash = b""
|
||||
unhashed_description = b""
|
||||
memo = data.memo or LNBITS_SITE_TITLE
|
||||
if data.unit == "sat":
|
||||
amount = int(data.amount)
|
||||
@ -170,6 +189,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
amount=amount,
|
||||
memo=memo,
|
||||
description_hash=description_hash,
|
||||
unhashed_description=unhashed_description,
|
||||
extra=data.extra,
|
||||
webhook=data.webhook,
|
||||
internal=data.internal,
|
||||
@ -186,11 +206,6 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
if data.lnurl_callback:
|
||||
if data.lnurl_balance_check is not None:
|
||||
await save_balance_check(wallet.id, data.lnurl_balance_check)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="lnurl_balance_check not set.",
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
@ -267,7 +282,7 @@ async def api_payments_create(
|
||||
return await api_payments_create_invoice(invoiceData, wallet.wallet)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
|
||||
@ -407,7 +422,7 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
||||
raise HTTPException(
|
||||
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_hash, wallet_id=wallet.id if wallet else None
|
||||
)
|
||||
|
@ -148,7 +148,9 @@ async def wallet(
|
||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||
)
|
||||
|
||||
logger.debug(f"Access wallet {wallet_name}{'of user '+ user.id if user else ''}")
|
||||
logger.debug(
|
||||
f"Access {'user '+ user.id + ' ' if user else ''} {'wallet ' + wallet_name if wallet_name else ''}"
|
||||
)
|
||||
userwallet = user.get_wallet(wallet_id) # type: ignore
|
||||
if not userwallet:
|
||||
return template_renderer().TemplateResponse(
|
||||
|
@ -130,10 +130,13 @@ async def get_key_type(
|
||||
# 2: invalid
|
||||
pathname = r["path"].split("/")[1]
|
||||
|
||||
if not api_key_header and not api_key_query:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
|
||||
token = api_key_header or api_key_query
|
||||
|
||||
token = api_key_header if api_key_header else api_key_query
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
|
||||
try:
|
||||
admin_checker = WalletAdminKeyChecker(api_key=token)
|
||||
@ -180,7 +183,14 @@ async def require_admin_key(
|
||||
api_key_header: str = Security(api_key_header), # type: ignore
|
||||
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 not token:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Admin key required.",
|
||||
)
|
||||
|
||||
wallet = await get_key_type(r, token)
|
||||
|
||||
@ -199,7 +209,14 @@ async def require_invoice_key(
|
||||
api_key_header: str = Security(api_key_header), # type: ignore
|
||||
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 not token:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Invoice (or Admin) key required.",
|
||||
)
|
||||
|
||||
wallet = await get_key_type(r, token)
|
||||
|
||||
|
@ -73,7 +73,7 @@ async def lnurl_callback(
|
||||
wallet_id=cp.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo=cp.lnurl_title,
|
||||
description_hash=(
|
||||
unhashed_description=(
|
||||
LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
|
||||
).encode("utf-8"),
|
||||
extra={"tag": "copilot", "copilotid": cp.id, "comment": comment},
|
||||
|
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"\
|
||||
![create new invoice](https://imgur.com/a/Dce3wrr.png)
|
||||
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\
|
||||
![invoice link](https://imgur.com/a/L0JOj4T.png)
|
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
|
@ -90,7 +90,7 @@ async def lnurl_callback(
|
||||
wallet_id=ls.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo=await track.fullname(),
|
||||
description_hash=(await track.lnurlpay_metadata()).encode("utf-8"),
|
||||
unhashed_description=(await track.lnurlpay_metadata()).encode("utf-8"),
|
||||
extra={"tag": "livestream", "track": track.id, "comment": comment},
|
||||
)
|
||||
|
||||
|
@ -13,7 +13,7 @@
|
||||
Charge people for using your domain name...<br />
|
||||
|
||||
<a
|
||||
href="https://github.com/lnbits/lnbits/tree/master/lnbits/extensions/lnaddress"
|
||||
href="https://github.com/lnbits/lnbits-legend/tree/main/lnbits/extensions/lnaddress"
|
||||
>More details</a
|
||||
>
|
||||
<br />
|
||||
|
@ -6,7 +6,7 @@ from fastapi.params import Depends, Query
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
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.extensions.lnaddress.models import CreateAddress, CreateDomain
|
||||
|
||||
@ -229,7 +229,7 @@ async def api_address_send_address(payment_hash):
|
||||
address = await get_address(payment_hash)
|
||||
domain = await get_domain(address.domain)
|
||||
try:
|
||||
status = await check_invoice_status(domain.wallet, payment_hash)
|
||||
status = await check_transaction_status(domain.wallet, payment_hash)
|
||||
is_paid = not status.pending
|
||||
except Exception as e:
|
||||
return {"paid": False, "error": str(e)}
|
||||
|
@ -205,7 +205,7 @@ async def lnurl_callback(
|
||||
wallet_id=device.wallet,
|
||||
amount=lnurldevicepayment.sats / 1000,
|
||||
memo=device.title,
|
||||
description_hash=(await device.lnurlpay_metadata()).encode("utf-8"),
|
||||
unhashed_description=(await device.lnurlpay_metadata()).encode("utf-8"),
|
||||
extra={"tag": "PoS"},
|
||||
)
|
||||
lnurldevicepayment = await update_lnurldevicepayment(
|
||||
|
@ -87,7 +87,7 @@ async def api_lnurl_callback(request: Request, link_id):
|
||||
wallet_id=link.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo=link.description,
|
||||
description_hash=link.lnurlpay_metadata.encode("utf-8"),
|
||||
unhashed_description=link.lnurlpay_metadata.encode("utf-8"),
|
||||
extra={
|
||||
"tag": "lnurlp",
|
||||
"link": link.id,
|
||||
|
@ -296,16 +296,17 @@
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
icon="link"
|
||||
@click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')"
|
||||
>Shareable link</q-btn
|
||||
>
|
||||
><q-tooltip>Copy sharable link</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
icon="nfc"
|
||||
@click="writeNfcTag(qrCodeDialog.data.lnurl)"
|
||||
:disable="nfcTagWriting"
|
||||
>
|
||||
><q-tooltip>Write to NFC</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
outline
|
||||
@ -314,7 +315,8 @@
|
||||
type="a"
|
||||
:href="qrCodeDialog.data.print_url"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
><q-tooltip>Print</q-tooltip></q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
|
@ -96,7 +96,7 @@ async def api_link_create_or_update(
|
||||
data.min *= data.fiat_base_multiplier
|
||||
data.max *= data.fiat_base_multiplier
|
||||
|
||||
if data.success_url is not None and data.success_url.startswith("https://"):
|
||||
if data.success_url is not None and not data.success_url.startswith("https://"):
|
||||
raise HTTPException(
|
||||
detail="Success URL must be secure https://...",
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
@ -121,7 +121,7 @@ async def api_link_create_or_update(
|
||||
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)):
|
||||
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)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@lnurlp_ext.get("/api/v1/rate/{currency}", status_code=HTTPStatus.OK)
|
||||
|
@ -73,7 +73,7 @@ async def lnurl_callback(request: Request, item_id: int):
|
||||
wallet_id=shop.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo=item.name,
|
||||
description_hash=(await item.lnurlpay_metadata()).encode("utf-8"),
|
||||
unhashed_description=(await item.lnurlpay_metadata()).encode("utf-8"),
|
||||
extra={"tag": "offlineshop", "item": item.id},
|
||||
)
|
||||
except Exception as exc:
|
||||
|
@ -4,7 +4,7 @@ from fastapi import Depends, Query
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
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 . import paywall_ext
|
||||
@ -87,7 +87,7 @@ async def api_paywal_check_invoice(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Paywall does not exist."
|
||||
)
|
||||
try:
|
||||
status = await check_invoice_status(paywall.wallet, payment_hash)
|
||||
status = await check_transaction_status(paywall.wallet, payment_hash)
|
||||
is_paid = not status.pending
|
||||
except Exception:
|
||||
return {"paid": False}
|
||||
|
@ -77,7 +77,7 @@ async def api_lnurlp_callback(
|
||||
wallet_id=link.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo="Satsdice bet",
|
||||
description_hash=link.lnurlpay_metadata.encode("utf-8"),
|
||||
unhashed_description=link.lnurlpay_metadata.encode("utf-8"),
|
||||
extra={"tag": "satsdice", "link": link.id, "comment": "comment"},
|
||||
)
|
||||
|
||||
|
@ -232,7 +232,7 @@
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
icon="share"
|
||||
icon="link"
|
||||
@click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')"
|
||||
><q-tooltip>Copy shareable link</q-tooltip></q-btn
|
||||
>
|
||||
|
@ -13,7 +13,7 @@
|
||||
Charge people for using your subdomain name...<br />
|
||||
|
||||
<a
|
||||
href="https://github.com/lnbits/lnbits/tree/master/lnbits/extensions/subdomains"
|
||||
href="https://github.com/lnbits/lnbits-legend/tree/main/lnbits/extensions/subdomains"
|
||||
>More details</a
|
||||
>
|
||||
<br />
|
||||
|
@ -5,7 +5,7 @@ from fastapi.params import Depends
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
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.extensions.subdomains.models import CreateDomain, CreateSubdomain
|
||||
|
||||
@ -161,7 +161,7 @@ async def api_subdomain_make_subdomain(domain_id, data: CreateSubdomain):
|
||||
async def api_subdomain_send_subdomain(payment_hash):
|
||||
subdomain = await get_subdomain(payment_hash)
|
||||
try:
|
||||
status = await check_invoice_status(subdomain.wallet, payment_hash)
|
||||
status = await check_transaction_status(subdomain.wallet, payment_hash)
|
||||
is_paid = not status.pending
|
||||
except Exception:
|
||||
return {"paid": False}
|
||||
|
@ -76,10 +76,10 @@ async def get_tipjars(wallet_id: str) -> Optional[list]:
|
||||
|
||||
async def delete_tipjar(tipjar_id: int) -> None:
|
||||
"""Delete a TipJar and all corresponding Tips"""
|
||||
await db.execute("DELETE FROM tipjar.TipJars WHERE id = ?", (tipjar_id,))
|
||||
rows = await db.fetchall("SELECT * FROM tipjar.Tips WHERE tipjar = ?", (tipjar_id,))
|
||||
for row in rows:
|
||||
await delete_tip(row["id"])
|
||||
await db.execute("DELETE FROM tipjar.TipJars WHERE id = ?", (tipjar_id,))
|
||||
|
||||
|
||||
async def get_tip(tip_id: str) -> Optional[Tip]:
|
||||
|
@ -23,3 +23,7 @@ class TPoS(BaseModel):
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "TPoS":
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
class PayLnurlWData(BaseModel):
|
||||
lnurl: str
|
||||
|
@ -14,7 +14,7 @@
|
||||
<div class="row justify-center full-width">
|
||||
<div class="col-12 col-sm-8 col-md-6 col-lg-4 text-center">
|
||||
<h3 class="q-mb-md">{% raw %}{{ famount }}{% endraw %}</h3>
|
||||
<h5 class="q-mt-none">
|
||||
<h5 class="q-mt-none q-mb-sm">
|
||||
{% raw %}{{ fsat }}{% endraw %} <small>sat</small>
|
||||
</h5>
|
||||
</div>
|
||||
@ -174,6 +174,13 @@
|
||||
>
|
||||
{% endraw %}
|
||||
</h5>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
icon="nfc"
|
||||
@click="readNfcTag()"
|
||||
:disable="nfcTagReading"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
@ -281,6 +288,7 @@
|
||||
exchangeRate: null,
|
||||
stack: [],
|
||||
tipAmount: 0.0,
|
||||
nfcTagReading: false,
|
||||
invoiceDialog: {
|
||||
show: false,
|
||||
data: null,
|
||||
@ -356,7 +364,7 @@
|
||||
this.showInvoice()
|
||||
},
|
||||
submitForm: function () {
|
||||
if (this.tip_options) {
|
||||
if (this.tip_options.length) {
|
||||
this.showTipModal()
|
||||
} else {
|
||||
this.showInvoice()
|
||||
@ -410,6 +418,98 @@
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
readNfcTag: function () {
|
||||
try {
|
||||
const self = this
|
||||
|
||||
if (typeof NDEFReader == 'undefined') {
|
||||
throw {
|
||||
toString: function () {
|
||||
return 'NFC not supported on this device or browser.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ndef = new NDEFReader()
|
||||
|
||||
const readerAbortController = new AbortController()
|
||||
readerAbortController.signal.onabort = event => {
|
||||
console.log('All NFC Read operations have been aborted.')
|
||||
}
|
||||
|
||||
this.nfcTagReading = true
|
||||
this.$q.notify({
|
||||
message: 'Tap your NFC tag to pay this invoice with LNURLw.'
|
||||
})
|
||||
|
||||
return ndef.scan({signal: readerAbortController.signal}).then(() => {
|
||||
ndef.onreadingerror = () => {
|
||||
self.nfcTagReading = false
|
||||
|
||||
this.$q.notify({
|
||||
type: 'negative',
|
||||
message: 'There was an error reading this NFC tag.'
|
||||
})
|
||||
|
||||
readerAbortController.abort()
|
||||
}
|
||||
|
||||
ndef.onreading = ({message}) => {
|
||||
//Decode NDEF data from tag
|
||||
const textDecoder = new TextDecoder('utf-8')
|
||||
|
||||
const record = message.records.find(el => {
|
||||
const payload = textDecoder.decode(el.data)
|
||||
return payload.toUpperCase().indexOf('LNURL') !== -1
|
||||
})
|
||||
|
||||
const lnurl = textDecoder.decode(record.data)
|
||||
|
||||
//User feedback, show loader icon
|
||||
self.nfcTagReading = false
|
||||
self.payInvoice(lnurl, readerAbortController)
|
||||
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'NFC tag read successfully.'
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
this.nfcTagReading = false
|
||||
this.$q.notify({
|
||||
type: 'negative',
|
||||
message: error
|
||||
? error.toString()
|
||||
: 'An unexpected error has occurred.'
|
||||
})
|
||||
}
|
||||
},
|
||||
payInvoice: function (lnurl, readerAbortController) {
|
||||
const self = this
|
||||
|
||||
return axios
|
||||
.post(
|
||||
'/tpos/api/v1/tposs/' +
|
||||
self.tposId +
|
||||
'/invoices/' +
|
||||
self.invoiceDialog.data.payment_request +
|
||||
'/pay',
|
||||
{
|
||||
lnurl: lnurl
|
||||
}
|
||||
)
|
||||
.then(response => {
|
||||
if (!response.data.success) {
|
||||
this.$q.notify({
|
||||
type: 'negative',
|
||||
message: response.data.detail
|
||||
})
|
||||
}
|
||||
|
||||
readerAbortController.abort()
|
||||
})
|
||||
},
|
||||
getRates: function () {
|
||||
var self = this
|
||||
axios.get('https://api.opennode.co/v1/rates').then(function (response) {
|
||||
|
@ -1,7 +1,9 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
import httpx
|
||||
from fastapi import Query
|
||||
from fastapi.params import Depends
|
||||
from lnurl import decode as decode_lnurl
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
@ -12,7 +14,7 @@ from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
|
||||
from . import tpos_ext
|
||||
from .crud import create_tpos, delete_tpos, get_tpos, get_tposs
|
||||
from .models import CreateTposData
|
||||
from .models import CreateTposData, PayLnurlWData
|
||||
|
||||
|
||||
@tpos_ext.get("/api/v1/tposs", status_code=HTTPStatus.OK)
|
||||
@ -79,6 +81,66 @@ async def api_tpos_create_invoice(
|
||||
return {"payment_hash": payment_hash, "payment_request": payment_request}
|
||||
|
||||
|
||||
@tpos_ext.post(
|
||||
"/api/v1/tposs/{tpos_id}/invoices/{payment_request}/pay", status_code=HTTPStatus.OK
|
||||
)
|
||||
async def api_tpos_pay_invoice(
|
||||
lnurl_data: PayLnurlWData, payment_request: str = None, tpos_id: str = None
|
||||
):
|
||||
tpos = await get_tpos(tpos_id)
|
||||
|
||||
if not tpos:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
|
||||
)
|
||||
|
||||
lnurl = (
|
||||
lnurl_data.lnurl.replace("lnurlw://", "")
|
||||
.replace("lightning://", "")
|
||||
.replace("LIGHTNING://", "")
|
||||
.replace("lightning:", "")
|
||||
.replace("LIGHTNING:", "")
|
||||
)
|
||||
|
||||
if lnurl.lower().startswith("lnurl"):
|
||||
lnurl = decode_lnurl(lnurl)
|
||||
else:
|
||||
lnurl = "https://" + lnurl
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.get(lnurl, follow_redirects=True)
|
||||
if r.is_error:
|
||||
lnurl_response = {"success": False, "detail": "Error loading"}
|
||||
else:
|
||||
resp = r.json()
|
||||
if resp["tag"] != "withdrawRequest":
|
||||
lnurl_response = {"success": False, "detail": "Wrong tag type"}
|
||||
else:
|
||||
r2 = await client.get(
|
||||
resp["callback"],
|
||||
follow_redirects=True,
|
||||
params={
|
||||
"k1": resp["k1"],
|
||||
"pr": payment_request,
|
||||
},
|
||||
)
|
||||
resp2 = r2.json()
|
||||
if r2.is_error:
|
||||
lnurl_response = {
|
||||
"success": False,
|
||||
"detail": "Error loading callback",
|
||||
}
|
||||
elif resp2["status"] == "ERROR":
|
||||
lnurl_response = {"success": False, "detail": resp2["reason"]}
|
||||
else:
|
||||
lnurl_response = {"success": True, "detail": resp2}
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
lnurl_response = {"success": False, "detail": "Unexpected error occurred"}
|
||||
|
||||
return lnurl_response
|
||||
|
||||
|
||||
@tpos_ext.get(
|
||||
"/api/v1/tposs/{tpos_id}/invoices/{payment_hash}", status_code=HTTPStatus.OK
|
||||
)
|
||||
|
@ -111,7 +111,7 @@
|
||||
<q-td colspan="100%">
|
||||
<div class="row items-center q-mt-md q-mb-lg">
|
||||
<div class="col-2 q-pr-lg"></div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
<div class="col-2 q-pr-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
@ -123,6 +123,16 @@
|
||||
QR Code</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-2 q-pr-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
icon="content_copy"
|
||||
@click="copyText(props.row.address)"
|
||||
class="q-ml-sm"
|
||||
>Copy</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-2 q-pr-lg">
|
||||
<q-btn
|
||||
outline
|
||||
|
@ -74,6 +74,16 @@ async function addressList(path) {
|
||||
satBtc(val, showUnit = true) {
|
||||
return satOrBtc(val, showUnit, this.satsDenominated)
|
||||
},
|
||||
// todo: bad. base.js not present in custom components
|
||||
copyText: function (text, message, position) {
|
||||
var notify = this.$q.notify
|
||||
Quasar.utils.copyToClipboard(text).then(function () {
|
||||
notify({
|
||||
message: message || 'Copied to clipboard!',
|
||||
position: position || 'bottom'
|
||||
})
|
||||
})
|
||||
},
|
||||
getWalletName: function (walletId) {
|
||||
const wallet = (this.accounts || []).find(wl => wl.id === walletId)
|
||||
return wallet ? wallet.title : 'unknown'
|
||||
|
@ -123,7 +123,6 @@
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="hwwConfigAndConnect" class="q-gutter-md">
|
||||
<span>Enter Config</span>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
@ -131,6 +130,7 @@
|
||||
label="Name (optional)"
|
||||
></q-input>
|
||||
<q-separator></q-separator>
|
||||
|
||||
<serial-port-config
|
||||
ref="serialPortConfig"
|
||||
:config="hww.config"
|
||||
|
@ -1,16 +1,17 @@
|
||||
<div>
|
||||
<q-card>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col-2 q-ml-lg">
|
||||
|
||||
<div class="col-md-2 col-xs-4 q-ml-lg">
|
||||
<q-btn unelevated @click="show = true" color="primary" icon="settings">
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<div class="col-md-8 col-xs-4">
|
||||
<div class="row justify-center q-gutter-x-md items-center">
|
||||
<div class="text-h3">{{satBtc(total)}}</div>
|
||||
<div :class="{'text-h4': $q.screen.gt.md}">{{satBtc(total)}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-2 float-right">
|
||||
<div class="col-md-2 col-xs-4 q-pr-lg">
|
||||
<slot name="serial"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -58,124 +58,10 @@ async function walletConfig(path) {
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
deriveSecretKey: async function (privateKey, publicKey) {
|
||||
return window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'ECDH',
|
||||
public: publicKey
|
||||
},
|
||||
privateKey,
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
length: 256
|
||||
},
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
)
|
||||
},
|
||||
old: async function () {
|
||||
// const alicesKeyPair = await window.crypto.subtle.generateKey(
|
||||
// {
|
||||
// name: 'ECDH',
|
||||
// namedCurve: 'P-256'
|
||||
// },
|
||||
// true,
|
||||
// ['deriveKey']
|
||||
// )
|
||||
// const bobsKeyPair = await window.crypto.subtle.generateKey(
|
||||
// {
|
||||
// name: 'ECDH',
|
||||
// namedCurve: 'P-256'
|
||||
// },
|
||||
// true,
|
||||
// ['deriveKey']
|
||||
// )
|
||||
// console.log('### alicesKeyPair', alicesKeyPair)
|
||||
// console.log('### alicesKeyPair.privateKey', alicesKeyPair.privateKey)
|
||||
// console.log('### alicesKeyPair.publicKey', alicesKeyPair.publicKey)
|
||||
// const alicesPriveKey = await window.crypto.subtle.exportKey(
|
||||
// 'jwk',
|
||||
// alicesKeyPair.privateKey
|
||||
// )
|
||||
// console.log('### alicesPriveKey', alicesPriveKey)
|
||||
// const alicesPriveKeyBase64 = toStdBase64(alicesPriveKey.d)
|
||||
// console.log(
|
||||
// '### alicesPriveKeyBase64',
|
||||
// alicesPriveKeyBase64,
|
||||
// base64ToHex(alicesPriveKeyBase64)
|
||||
// )
|
||||
// const bobPublicKey = await window.crypto.subtle.exportKey(
|
||||
// 'raw',
|
||||
// bobsKeyPair.publicKey
|
||||
// )
|
||||
// console.log('### bobPublicKey hex', buf2hex(bobPublicKey))
|
||||
// const sharedSecret01 = await this.deriveSecretKey(alicesKeyPair.privateKey, bobsKeyPair.publicKey)
|
||||
// console.log('### sharedSecret01', sharedSecret01)
|
||||
// const sharedSecret01Raw = await window.crypto.subtle.exportKey('jwk', sharedSecret01)
|
||||
// console.log('### sharedSecret01Raw', sharedSecret01Raw)
|
||||
// const sharedSecret02 = await this.deriveSecretKey(bobsKeyPair.privateKey, alicesKeyPair.publicKey)
|
||||
// console.log('### sharedSecret02', sharedSecret02)
|
||||
// const sharedSecret02Raw = await window.crypto.subtle.exportKey('jwk', sharedSecret02)
|
||||
// console.log('### sharedSecret02Raw', sharedSecret02Raw)
|
||||
// const sharedSecret = nobleSecp256k1.getSharedSecret(
|
||||
// alicesPriveKey,
|
||||
// buf2hex(bobPublicKey)
|
||||
// )
|
||||
// console.log('###', getSharedSecret)
|
||||
},
|
||||
importSecretKey: async function (rawKey) {
|
||||
return window.crypto.subtle.importKey('raw', rawKey, 'AES-GCM', true, [
|
||||
'encrypt',
|
||||
'decrypt'
|
||||
])
|
||||
}
|
||||
},
|
||||
|
||||
created: async function () {
|
||||
await this.getConfig()
|
||||
// ### in: 6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e5130c81c46a35ce411e5fbc1191a0a52eff69f2445df4f9b17ad2b417be66c3710
|
||||
// ### in2: 073059e23605fe508919d892501974905f8b5e13728db63b1b7b9326951abbe4f722c104bc82a4bcf3f1e15ede8ab7d5eb00be8c46271e1f65867f984e9cb5f1
|
||||
|
||||
// const alicesPrivateKey =
|
||||
// '359A8CA1418C49DD26DC7D92C789AC33347F64C6B7789C666098805AF3CC60E5'
|
||||
|
||||
// const bobsPrivateKey =
|
||||
// 'AB52F1F981F639BD83F884703BC690B10DB709FF48806680A0D3FBC6475E6093'
|
||||
|
||||
// const alicesPublicKey = nobleSecp256k1.Point.fromPrivateKey(
|
||||
// alicesPrivateKey
|
||||
// )
|
||||
// console.log('### alicesPublicKey', alicesPublicKey.toHex())
|
||||
|
||||
// const bobsPublicKey = nobleSecp256k1.Point.fromPrivateKey(bobsPrivateKey)
|
||||
// console.log('### bobsPublicKey', bobsPublicKey.toHex())
|
||||
|
||||
// const sharedSecret = nobleSecp256k1.getSharedSecret(
|
||||
// alicesPrivateKey,
|
||||
// bobsPublicKey
|
||||
// )
|
||||
|
||||
// console.log('### sharedSecret naked', sharedSecret)
|
||||
|
||||
// console.log(
|
||||
// '### sharedSecret a',
|
||||
// nobleSecp256k1.utils.bytesToHex(sharedSecret)
|
||||
// )
|
||||
// console.log(
|
||||
// '### sharedSecret b',
|
||||
// nobleSecp256k1.Point.fromHex(sharedSecret)
|
||||
// )
|
||||
// console.log(
|
||||
// '### sharedSecret b',
|
||||
// nobleSecp256k1.Point.fromHex(sharedSecret).toHex(true)
|
||||
// )
|
||||
|
||||
// const alicesPrivateKeyBytes = nobleSecp256k1.utils.hexToBytes(
|
||||
// alicesPrivateKey
|
||||
// )
|
||||
// const x = await this.importSecretKey(alicesPrivateKeyBytes)
|
||||
// console.log('### x', x)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -2,7 +2,7 @@
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<div class="col-4">
|
||||
<q-btn-dropdown
|
||||
split
|
||||
unelevated
|
||||
@ -30,9 +30,8 @@
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="col-auto q-pr-lg"></div>
|
||||
<div class="col-auto q-pl-lg">
|
||||
<div class="col-4 q-pl-lg"></div>
|
||||
<div class="col-4 q-pl-lg">
|
||||
<q-input
|
||||
borderless
|
||||
dense
|
||||
|
@ -19,6 +19,7 @@ const COMMAND_CHECK_PAIRING = '/check-pairing'
|
||||
const DEFAULT_RECEIVE_GAP_LIMIT = 20
|
||||
const PAIRING_CONTROL_TEXT = 'lnbits'
|
||||
|
||||
|
||||
const blockTimeToDate = blockTime =>
|
||||
blockTime ? moment(blockTime * 1000).format('LLL') : ''
|
||||
|
||||
|
@ -13,6 +13,7 @@
|
||||
:network="config.network"
|
||||
:sats-denominated="config.sats_denominated"
|
||||
@signed:psbt="updateSignedPsbt"
|
||||
class="q-pr-lg float-right"
|
||||
></serial-signer>
|
||||
</template>
|
||||
</wallet-config>
|
||||
@ -33,7 +34,7 @@
|
||||
{% raw %}
|
||||
<q-card>
|
||||
<div class="row q-pt-sm q-pb-sm items-center no-wrap q-mb-md">
|
||||
<div class="col-3 q-pl-md">
|
||||
<div class="col-md-3 col-sm-5 q-pl-md">
|
||||
<q-btn
|
||||
unelevated
|
||||
class="btn-full"
|
||||
@ -43,14 +44,14 @@
|
||||
>Scan Blockchain</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="col-md-6 col-sm-2 q-pl-md">
|
||||
<q-spinner
|
||||
v-if="scan.scanning == true"
|
||||
color="primary"
|
||||
size="2.55em"
|
||||
></q-spinner>
|
||||
</div>
|
||||
<div class="col-3 q-pr-md">
|
||||
<div class="col-md-3 col-sm-5 q-pr-md">
|
||||
<q-btn
|
||||
v-if="!showPayment"
|
||||
unelevated
|
||||
@ -170,14 +171,25 @@
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
<p v-if="currentAddress">
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="ms"
|
||||
icon="content_copy"
|
||||
@click="copyText(props.row.address)"
|
||||
class="q-ml-sm"
|
||||
></q-btn>
|
||||
|
||||
{{ currentAddress.address }}
|
||||
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="ms"
|
||||
icon="launch"
|
||||
type="a"
|
||||
:href="mempoolHostname + '/address/' + currentAddress.address"
|
||||
:href="'https://' + mempoolHostname + '/address/' + currentAddress.address"
|
||||
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
</p>
|
||||
@ -239,5 +251,6 @@
|
||||
<script src="{{ url_for('watchonly_static', path='components/serial-port-config/serial-port-config.js') }}"></script>
|
||||
<script src="{{ url_for('watchonly_static', path='js/crypto/noble-secp256k1.js') }}"></script>
|
||||
<script src="{{ url_for('watchonly_static', path='js/crypto/aes.js') }}"></script>
|
||||
|
||||
<script src="{{ url_for('watchonly_static', path='js/index.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
@ -70,7 +70,7 @@ new Vue({
|
||||
show: false,
|
||||
data: {
|
||||
is_unique: true,
|
||||
use_custom: true,
|
||||
use_custom: false,
|
||||
title: 'Vouchers',
|
||||
min_withdrawable: 0,
|
||||
wait_time: 1
|
||||
@ -125,7 +125,6 @@ new Vue({
|
||||
var link = _.findWhere(this.withdrawLinks, {id: linkId})
|
||||
|
||||
this.qrCodeDialog.data = _.clone(link)
|
||||
console.log(this.qrCodeDialog.data)
|
||||
this.qrCodeDialog.data.url =
|
||||
window.location.protocol + '//' + window.location.host
|
||||
this.qrCodeDialog.show = true
|
||||
@ -140,6 +139,11 @@ new Vue({
|
||||
id: this.formDialog.data.wallet
|
||||
})
|
||||
var data = _.omit(this.formDialog.data, 'wallet')
|
||||
|
||||
if (!data.use_custom) {
|
||||
data.custom_url = null
|
||||
}
|
||||
|
||||
if (data.use_custom && !data?.custom_url) {
|
||||
data.custom_url = CUSTOM_URL
|
||||
}
|
||||
@ -168,6 +172,10 @@ new Vue({
|
||||
data.title = 'vouchers'
|
||||
data.is_unique = true
|
||||
|
||||
if (!data.use_custom) {
|
||||
data.custom_url = null
|
||||
}
|
||||
|
||||
if (data.use_custom && !data?.custom_url) {
|
||||
data.custom_url = '/static/images/default_voucher.png'
|
||||
}
|
||||
|
@ -241,7 +241,7 @@
|
||||
v-model="formDialog.data.custom_url"
|
||||
type="text"
|
||||
label="Custom design .png (optional)"
|
||||
hint="Enter a URL if you want to use a custom design or leave blank for showing only the QR"
|
||||
hint="Enter a URL if you want to use a custom design or leave blank for LNbits designed vouchers!"
|
||||
></q-input>
|
||||
<q-list>
|
||||
<q-item tag="label" class="rounded-borders">
|
||||
@ -353,7 +353,7 @@
|
||||
v-model="simpleformDialog.data.custom_url"
|
||||
type="text"
|
||||
label="Custom design .png (optional)"
|
||||
hint="Enter a URL if you want to use a custom design or leave blank for showing only the QR"
|
||||
hint="Enter a URL if you want to use a custom design or leave blank for LNbits designed vouchers!"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
@ -418,16 +418,18 @@
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
icon="link"
|
||||
@click="copyText(qrCodeDialog.data.withdraw_url, 'Link copied to clipboard!')"
|
||||
>Shareable link</q-btn
|
||||
>
|
||||
><q-tooltip>Copy sharable link</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
icon="nfc"
|
||||
@click="writeNfcTag(qrCodeDialog.data.lnurl)"
|
||||
:disable="nfcTagWriting"
|
||||
></q-btn>
|
||||
><q-tooltip>Write to NFC</q-tooltip></q-btn
|
||||
>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@ -435,7 +437,8 @@
|
||||
type="a"
|
||||
:href="qrCodeDialog.data.print_url"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
><q-tooltip>Print</q-tooltip></q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
|
@ -113,7 +113,7 @@ async def api_link_create_or_update(
|
||||
return {**link.dict(), **{"lnurl": link.lnurl(req)}}
|
||||
|
||||
|
||||
@withdraw_ext.delete("/api/v1/links/{link_id}")
|
||||
@withdraw_ext.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||
async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admin_key)):
|
||||
link = await get_withdraw_link(link_id)
|
||||
|
||||
@ -128,7 +128,7 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admi
|
||||
)
|
||||
|
||||
await delete_withdraw_link(link_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@withdraw_ext.get("/api/v1/links/{the_hash}/{lnurl_id}", status_code=HTTPStatus.OK)
|
||||
|
@ -1,18 +1,49 @@
|
||||
import time
|
||||
|
||||
import click
|
||||
import uvicorn
|
||||
|
||||
from lnbits.settings import HOST, PORT
|
||||
|
||||
@click.command()
|
||||
@click.option("--port", default="5000", help="Port to run LNBits on")
|
||||
@click.option("--host", default="127.0.0.1", help="Host to run LNBits on")
|
||||
def main(port, host):
|
||||
|
||||
@click.command(
|
||||
context_settings=dict(
|
||||
ignore_unknown_options=True,
|
||||
allow_extra_args=True,
|
||||
)
|
||||
)
|
||||
@click.option("--port", default=PORT, help="Port to listen on")
|
||||
@click.option("--host", default=HOST, help="Host to run LNBits on")
|
||||
@click.option("--ssl-keyfile", default=None, help="Path to SSL keyfile")
|
||||
@click.option("--ssl-certfile", default=None, help="Path to SSL certificate")
|
||||
@click.pass_context
|
||||
def main(ctx, port: int, host: str, ssl_keyfile: str, ssl_certfile: str):
|
||||
"""Launched with `poetry run lnbits` at root level"""
|
||||
uvicorn.run("lnbits.__main__:app", port=port, host=host)
|
||||
# this beautiful beast parses all command line arguments and passes them to the uvicorn server
|
||||
d = dict()
|
||||
for a in ctx.args:
|
||||
item = a.split("=")
|
||||
if len(item) > 1: # argument like --key=value
|
||||
print(a, item)
|
||||
d[item[0].strip("--").replace("-", "_")] = (
|
||||
int(item[1]) # need to convert to int if it's a number
|
||||
if item[1].isdigit()
|
||||
else item[1]
|
||||
)
|
||||
else:
|
||||
d[a.strip("--")] = True # argument like --key
|
||||
|
||||
config = uvicorn.Config(
|
||||
"lnbits.__main__:app",
|
||||
port=port,
|
||||
host=host,
|
||||
ssl_keyfile=ssl_keyfile,
|
||||
ssl_certfile=ssl_certfile,
|
||||
**d
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
server.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
# def main():
|
||||
# """Launched with `poetry run start` at root level"""
|
||||
# uvicorn.run("lnbits.__main__:app")
|
||||
|
@ -55,6 +55,8 @@ FAKE_WALLET = getattr(wallets_module, "FakeWallet")()
|
||||
DEFAULT_WALLET_NAME = env.str("LNBITS_DEFAULT_WALLET_NAME", default="LNbits wallet")
|
||||
PREFER_SECURE_URLS = env.bool("LNBITS_FORCE_HTTPS", default=True)
|
||||
|
||||
RESERVE_FEE_MIN = env.int("LNBITS_RESERVE_FEE_MIN", default=2000)
|
||||
RESERVE_FEE_PERCENT = env.float("LNBITS_RESERVE_FEE_PERCENT", default=1.0)
|
||||
SERVICE_FEE = env.float("LNBITS_SERVICE_FEE", default=0.0)
|
||||
|
||||
try:
|
||||
|
@ -179,6 +179,11 @@ Vue.component('lnbits-extension-list', {
|
||||
|
||||
Vue.component('lnbits-payment-details', {
|
||||
props: ['payment'],
|
||||
data: function () {
|
||||
return {
|
||||
LNBITS_DENOMINATION: LNBITS_DENOMINATION
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="q-py-md" style="text-align: left">
|
||||
<div class="row justify-center q-mb-md">
|
||||
|
@ -6,6 +6,7 @@ from .cln import CoreLightningWallet as CLightningWallet
|
||||
from .eclair import EclairWallet
|
||||
from .fake import FakeWallet
|
||||
from .lnbits import LNbitsWallet
|
||||
from .lndgrpc import LndWallet
|
||||
from .lndrest import LndRestWallet
|
||||
from .lnpay import LNPayWallet
|
||||
from .lntxbot import LntxbotWallet
|
||||
|
@ -46,12 +46,19 @@ class ClicheWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
) -> InvoiceResponse:
|
||||
if description_hash:
|
||||
description_hash_hashed = hashlib.sha256(description_hash).hexdigest()
|
||||
if unhashed_description or description_hash:
|
||||
description_hash_str = (
|
||||
description_hash.hex()
|
||||
if description_hash
|
||||
else hashlib.sha256(unhashed_description).hexdigest()
|
||||
if unhashed_description
|
||||
else None
|
||||
)
|
||||
ws = create_connection(self.endpoint)
|
||||
ws.send(
|
||||
f"create-invoice --msatoshi {amount*1000} --description_hash {description_hash_hashed}"
|
||||
f"create-invoice --msatoshi {amount*1000} --description_hash {description_hash_str}"
|
||||
)
|
||||
r = ws.recv()
|
||||
else:
|
||||
|
@ -82,21 +82,26 @@ class CoreLightningWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
) -> InvoiceResponse:
|
||||
label = "lbl{}".format(random.random())
|
||||
msat = amount * 1000
|
||||
msat: int = int(amount * 1000)
|
||||
try:
|
||||
if description_hash and not self.supports_description_hash:
|
||||
raise Unsupported("description_hash")
|
||||
if description_hash and not unhashed_description:
|
||||
raise Unsupported(
|
||||
"'description_hash' unsupported by CLN, provide 'unhashed_description'"
|
||||
)
|
||||
if unhashed_description and not self.supports_description_hash:
|
||||
raise Unsupported("unhashed_description")
|
||||
r = self.ln.invoice(
|
||||
msatoshi=msat,
|
||||
label=label,
|
||||
description=description_hash.decode("utf-8")
|
||||
if description_hash
|
||||
description=unhashed_description.decode("utf-8")
|
||||
if unhashed_description
|
||||
else memo,
|
||||
exposeprivatechannels=True,
|
||||
deschashonly=True
|
||||
if description_hash
|
||||
if unhashed_description
|
||||
else False, # we can't pass None here
|
||||
)
|
||||
|
||||
|
@ -69,11 +69,14 @@ class EclairWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
) -> InvoiceResponse:
|
||||
|
||||
data: Dict = {"amountMsat": amount * 1000}
|
||||
if description_hash:
|
||||
data["description_hash"] = hashlib.sha256(description_hash).hexdigest()
|
||||
data["description_hash"] = description_hash.hex()
|
||||
elif unhashed_description:
|
||||
data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest()
|
||||
else:
|
||||
data["description"] = memo or ""
|
||||
|
||||
|
@ -35,6 +35,7 @@ class FakeWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
) -> InvoiceResponse:
|
||||
# we set a default secret since FakeWallet is used for internal=True invoices
|
||||
# and the user might not have configured a secret yet
|
||||
@ -61,7 +62,10 @@ class FakeWallet(Wallet):
|
||||
data["timestamp"] = datetime.now().timestamp()
|
||||
if description_hash:
|
||||
data["tags_set"] = ["h"]
|
||||
data["description_hash"] = description_hash.decode("utf-8")
|
||||
data["description_hash"] = description_hash
|
||||
elif unhashed_description:
|
||||
data["tags_set"] = ["d"]
|
||||
data["description_hash"] = hashlib.sha256(unhashed_description).digest()
|
||||
else:
|
||||
data["tags_set"] = ["d"]
|
||||
data["memo"] = memo
|
||||
|
@ -57,10 +57,13 @@ class LNbitsWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
) -> InvoiceResponse:
|
||||
data: Dict = {"out": False, "amount": amount}
|
||||
if description_hash:
|
||||
data["description_hash"] = hashlib.sha256(description_hash).hexdigest()
|
||||
data["description_hash"] = description_hash.hex()
|
||||
elif unhashed_description:
|
||||
data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest()
|
||||
else:
|
||||
data["memo"] = memo or ""
|
||||
|
||||
|
File diff suppressed because one or more lines are too long
665
lnbits/wallets/lnd_grpc_files/router_pb2.py
Normal file
665
lnbits/wallets/lnd_grpc_files/router_pb2.py
Normal file
File diff suppressed because one or more lines are too long
871
lnbits/wallets/lnd_grpc_files/router_pb2_grpc.py
Normal file
871
lnbits/wallets/lnd_grpc_files/router_pb2_grpc.py
Normal file
@ -0,0 +1,871 @@
|
||||
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
||||
"""Client and server classes corresponding to protobuf-defined services."""
|
||||
import grpc
|
||||
|
||||
import lnbits.wallets.lnd_grpc_files.lightning_pb2 as lightning__pb2
|
||||
import lnbits.wallets.lnd_grpc_files.router_pb2 as router__pb2
|
||||
|
||||
|
||||
class RouterStub(object):
|
||||
"""Router is a service that offers advanced interaction with the router
|
||||
subsystem of the daemon.
|
||||
"""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.SendPaymentV2 = channel.unary_stream(
|
||||
"/routerrpc.Router/SendPaymentV2",
|
||||
request_serializer=router__pb2.SendPaymentRequest.SerializeToString,
|
||||
response_deserializer=lightning__pb2.Payment.FromString,
|
||||
)
|
||||
self.TrackPaymentV2 = channel.unary_stream(
|
||||
"/routerrpc.Router/TrackPaymentV2",
|
||||
request_serializer=router__pb2.TrackPaymentRequest.SerializeToString,
|
||||
response_deserializer=lightning__pb2.Payment.FromString,
|
||||
)
|
||||
self.EstimateRouteFee = channel.unary_unary(
|
||||
"/routerrpc.Router/EstimateRouteFee",
|
||||
request_serializer=router__pb2.RouteFeeRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.RouteFeeResponse.FromString,
|
||||
)
|
||||
self.SendToRoute = channel.unary_unary(
|
||||
"/routerrpc.Router/SendToRoute",
|
||||
request_serializer=router__pb2.SendToRouteRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.SendToRouteResponse.FromString,
|
||||
)
|
||||
self.SendToRouteV2 = channel.unary_unary(
|
||||
"/routerrpc.Router/SendToRouteV2",
|
||||
request_serializer=router__pb2.SendToRouteRequest.SerializeToString,
|
||||
response_deserializer=lightning__pb2.HTLCAttempt.FromString,
|
||||
)
|
||||
self.ResetMissionControl = channel.unary_unary(
|
||||
"/routerrpc.Router/ResetMissionControl",
|
||||
request_serializer=router__pb2.ResetMissionControlRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.ResetMissionControlResponse.FromString,
|
||||
)
|
||||
self.QueryMissionControl = channel.unary_unary(
|
||||
"/routerrpc.Router/QueryMissionControl",
|
||||
request_serializer=router__pb2.QueryMissionControlRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.QueryMissionControlResponse.FromString,
|
||||
)
|
||||
self.XImportMissionControl = channel.unary_unary(
|
||||
"/routerrpc.Router/XImportMissionControl",
|
||||
request_serializer=router__pb2.XImportMissionControlRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.XImportMissionControlResponse.FromString,
|
||||
)
|
||||
self.GetMissionControlConfig = channel.unary_unary(
|
||||
"/routerrpc.Router/GetMissionControlConfig",
|
||||
request_serializer=router__pb2.GetMissionControlConfigRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.GetMissionControlConfigResponse.FromString,
|
||||
)
|
||||
self.SetMissionControlConfig = channel.unary_unary(
|
||||
"/routerrpc.Router/SetMissionControlConfig",
|
||||
request_serializer=router__pb2.SetMissionControlConfigRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.SetMissionControlConfigResponse.FromString,
|
||||
)
|
||||
self.QueryProbability = channel.unary_unary(
|
||||
"/routerrpc.Router/QueryProbability",
|
||||
request_serializer=router__pb2.QueryProbabilityRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.QueryProbabilityResponse.FromString,
|
||||
)
|
||||
self.BuildRoute = channel.unary_unary(
|
||||
"/routerrpc.Router/BuildRoute",
|
||||
request_serializer=router__pb2.BuildRouteRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.BuildRouteResponse.FromString,
|
||||
)
|
||||
self.SubscribeHtlcEvents = channel.unary_stream(
|
||||
"/routerrpc.Router/SubscribeHtlcEvents",
|
||||
request_serializer=router__pb2.SubscribeHtlcEventsRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.HtlcEvent.FromString,
|
||||
)
|
||||
self.SendPayment = channel.unary_stream(
|
||||
"/routerrpc.Router/SendPayment",
|
||||
request_serializer=router__pb2.SendPaymentRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.PaymentStatus.FromString,
|
||||
)
|
||||
self.TrackPayment = channel.unary_stream(
|
||||
"/routerrpc.Router/TrackPayment",
|
||||
request_serializer=router__pb2.TrackPaymentRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.PaymentStatus.FromString,
|
||||
)
|
||||
self.HtlcInterceptor = channel.stream_stream(
|
||||
"/routerrpc.Router/HtlcInterceptor",
|
||||
request_serializer=router__pb2.ForwardHtlcInterceptResponse.SerializeToString,
|
||||
response_deserializer=router__pb2.ForwardHtlcInterceptRequest.FromString,
|
||||
)
|
||||
self.UpdateChanStatus = channel.unary_unary(
|
||||
"/routerrpc.Router/UpdateChanStatus",
|
||||
request_serializer=router__pb2.UpdateChanStatusRequest.SerializeToString,
|
||||
response_deserializer=router__pb2.UpdateChanStatusResponse.FromString,
|
||||
)
|
||||
|
||||
|
||||
class RouterServicer(object):
|
||||
"""Router is a service that offers advanced interaction with the router
|
||||
subsystem of the daemon.
|
||||
"""
|
||||
|
||||
def SendPaymentV2(self, request, context):
|
||||
"""
|
||||
SendPaymentV2 attempts to route a payment described by the passed
|
||||
PaymentRequest to the final destination. The call returns a stream of
|
||||
payment updates.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def TrackPaymentV2(self, request, context):
|
||||
"""
|
||||
TrackPaymentV2 returns an update stream for the payment identified by the
|
||||
payment hash.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def EstimateRouteFee(self, request, context):
|
||||
"""
|
||||
EstimateRouteFee allows callers to obtain a lower bound w.r.t how much it
|
||||
may cost to send an HTLC to the target end destination.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def SendToRoute(self, request, context):
|
||||
"""
|
||||
Deprecated, use SendToRouteV2. SendToRoute attempts to make a payment via
|
||||
the specified route. This method differs from SendPayment in that it
|
||||
allows users to specify a full route manually. This can be used for
|
||||
things like rebalancing, and atomic swaps. It differs from the newer
|
||||
SendToRouteV2 in that it doesn't return the full HTLC information.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def SendToRouteV2(self, request, context):
|
||||
"""
|
||||
SendToRouteV2 attempts to make a payment via the specified route. This
|
||||
method differs from SendPayment in that it allows users to specify a full
|
||||
route manually. This can be used for things like rebalancing, and atomic
|
||||
swaps.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def ResetMissionControl(self, request, context):
|
||||
"""
|
||||
ResetMissionControl clears all mission control state and starts with a clean
|
||||
slate.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def QueryMissionControl(self, request, context):
|
||||
"""
|
||||
QueryMissionControl exposes the internal mission control state to callers.
|
||||
It is a development feature.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def XImportMissionControl(self, request, context):
|
||||
"""
|
||||
XImportMissionControl is an experimental API that imports the state provided
|
||||
to the internal mission control's state, using all results which are more
|
||||
recent than our existing values. These values will only be imported
|
||||
in-memory, and will not be persisted across restarts.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def GetMissionControlConfig(self, request, context):
|
||||
"""
|
||||
GetMissionControlConfig returns mission control's current config.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def SetMissionControlConfig(self, request, context):
|
||||
"""
|
||||
SetMissionControlConfig will set mission control's config, if the config
|
||||
provided is valid.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def QueryProbability(self, request, context):
|
||||
"""
|
||||
QueryProbability returns the current success probability estimate for a
|
||||
given node pair and amount.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def BuildRoute(self, request, context):
|
||||
"""
|
||||
BuildRoute builds a fully specified route based on a list of hop public
|
||||
keys. It retrieves the relevant channel policies from the graph in order to
|
||||
calculate the correct fees and time locks.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def SubscribeHtlcEvents(self, request, context):
|
||||
"""
|
||||
SubscribeHtlcEvents creates a uni-directional stream from the server to
|
||||
the client which delivers a stream of htlc events.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def SendPayment(self, request, context):
|
||||
"""
|
||||
Deprecated, use SendPaymentV2. SendPayment attempts to route a payment
|
||||
described by the passed PaymentRequest to the final destination. The call
|
||||
returns a stream of payment status updates.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def TrackPayment(self, request, context):
|
||||
"""
|
||||
Deprecated, use TrackPaymentV2. TrackPayment returns an update stream for
|
||||
the payment identified by the payment hash.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def HtlcInterceptor(self, request_iterator, context):
|
||||
"""*
|
||||
HtlcInterceptor dispatches a bi-directional streaming RPC in which
|
||||
Forwarded HTLC requests are sent to the client and the client responds with
|
||||
a boolean that tells LND if this htlc should be intercepted.
|
||||
In case of interception, the htlc can be either settled, cancelled or
|
||||
resumed later by using the ResolveHoldForward endpoint.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def UpdateChanStatus(self, request, context):
|
||||
"""
|
||||
UpdateChanStatus attempts to manually set the state of a channel
|
||||
(enabled, disabled, or auto). A manual "disable" request will cause the
|
||||
channel to stay disabled until a subsequent manual request of either
|
||||
"enable" or "auto".
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
|
||||
def add_RouterServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
"SendPaymentV2": grpc.unary_stream_rpc_method_handler(
|
||||
servicer.SendPaymentV2,
|
||||
request_deserializer=router__pb2.SendPaymentRequest.FromString,
|
||||
response_serializer=lightning__pb2.Payment.SerializeToString,
|
||||
),
|
||||
"TrackPaymentV2": grpc.unary_stream_rpc_method_handler(
|
||||
servicer.TrackPaymentV2,
|
||||
request_deserializer=router__pb2.TrackPaymentRequest.FromString,
|
||||
response_serializer=lightning__pb2.Payment.SerializeToString,
|
||||
),
|
||||
"EstimateRouteFee": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.EstimateRouteFee,
|
||||
request_deserializer=router__pb2.RouteFeeRequest.FromString,
|
||||
response_serializer=router__pb2.RouteFeeResponse.SerializeToString,
|
||||
),
|
||||
"SendToRoute": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.SendToRoute,
|
||||
request_deserializer=router__pb2.SendToRouteRequest.FromString,
|
||||
response_serializer=router__pb2.SendToRouteResponse.SerializeToString,
|
||||
),
|
||||
"SendToRouteV2": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.SendToRouteV2,
|
||||
request_deserializer=router__pb2.SendToRouteRequest.FromString,
|
||||
response_serializer=lightning__pb2.HTLCAttempt.SerializeToString,
|
||||
),
|
||||
"ResetMissionControl": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.ResetMissionControl,
|
||||
request_deserializer=router__pb2.ResetMissionControlRequest.FromString,
|
||||
response_serializer=router__pb2.ResetMissionControlResponse.SerializeToString,
|
||||
),
|
||||
"QueryMissionControl": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.QueryMissionControl,
|
||||
request_deserializer=router__pb2.QueryMissionControlRequest.FromString,
|
||||
response_serializer=router__pb2.QueryMissionControlResponse.SerializeToString,
|
||||
),
|
||||
"XImportMissionControl": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.XImportMissionControl,
|
||||
request_deserializer=router__pb2.XImportMissionControlRequest.FromString,
|
||||
response_serializer=router__pb2.XImportMissionControlResponse.SerializeToString,
|
||||
),
|
||||
"GetMissionControlConfig": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetMissionControlConfig,
|
||||
request_deserializer=router__pb2.GetMissionControlConfigRequest.FromString,
|
||||
response_serializer=router__pb2.GetMissionControlConfigResponse.SerializeToString,
|
||||
),
|
||||
"SetMissionControlConfig": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.SetMissionControlConfig,
|
||||
request_deserializer=router__pb2.SetMissionControlConfigRequest.FromString,
|
||||
response_serializer=router__pb2.SetMissionControlConfigResponse.SerializeToString,
|
||||
),
|
||||
"QueryProbability": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.QueryProbability,
|
||||
request_deserializer=router__pb2.QueryProbabilityRequest.FromString,
|
||||
response_serializer=router__pb2.QueryProbabilityResponse.SerializeToString,
|
||||
),
|
||||
"BuildRoute": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.BuildRoute,
|
||||
request_deserializer=router__pb2.BuildRouteRequest.FromString,
|
||||
response_serializer=router__pb2.BuildRouteResponse.SerializeToString,
|
||||
),
|
||||
"SubscribeHtlcEvents": grpc.unary_stream_rpc_method_handler(
|
||||
servicer.SubscribeHtlcEvents,
|
||||
request_deserializer=router__pb2.SubscribeHtlcEventsRequest.FromString,
|
||||
response_serializer=router__pb2.HtlcEvent.SerializeToString,
|
||||
),
|
||||
"SendPayment": grpc.unary_stream_rpc_method_handler(
|
||||
servicer.SendPayment,
|
||||
request_deserializer=router__pb2.SendPaymentRequest.FromString,
|
||||
response_serializer=router__pb2.PaymentStatus.SerializeToString,
|
||||
),
|
||||
"TrackPayment": grpc.unary_stream_rpc_method_handler(
|
||||
servicer.TrackPayment,
|
||||
request_deserializer=router__pb2.TrackPaymentRequest.FromString,
|
||||
response_serializer=router__pb2.PaymentStatus.SerializeToString,
|
||||
),
|
||||
"HtlcInterceptor": grpc.stream_stream_rpc_method_handler(
|
||||
servicer.HtlcInterceptor,
|
||||
request_deserializer=router__pb2.ForwardHtlcInterceptResponse.FromString,
|
||||
response_serializer=router__pb2.ForwardHtlcInterceptRequest.SerializeToString,
|
||||
),
|
||||
"UpdateChanStatus": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.UpdateChanStatus,
|
||||
request_deserializer=router__pb2.UpdateChanStatusRequest.FromString,
|
||||
response_serializer=router__pb2.UpdateChanStatusResponse.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
"routerrpc.Router", rpc_method_handlers
|
||||
)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class Router(object):
|
||||
"""Router is a service that offers advanced interaction with the router
|
||||
subsystem of the daemon.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def SendPaymentV2(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_stream(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/SendPaymentV2",
|
||||
router__pb2.SendPaymentRequest.SerializeToString,
|
||||
lightning__pb2.Payment.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def TrackPaymentV2(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_stream(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/TrackPaymentV2",
|
||||
router__pb2.TrackPaymentRequest.SerializeToString,
|
||||
lightning__pb2.Payment.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def EstimateRouteFee(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/EstimateRouteFee",
|
||||
router__pb2.RouteFeeRequest.SerializeToString,
|
||||
router__pb2.RouteFeeResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def SendToRoute(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/SendToRoute",
|
||||
router__pb2.SendToRouteRequest.SerializeToString,
|
||||
router__pb2.SendToRouteResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def SendToRouteV2(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/SendToRouteV2",
|
||||
router__pb2.SendToRouteRequest.SerializeToString,
|
||||
lightning__pb2.HTLCAttempt.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def ResetMissionControl(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/ResetMissionControl",
|
||||
router__pb2.ResetMissionControlRequest.SerializeToString,
|
||||
router__pb2.ResetMissionControlResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def QueryMissionControl(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/QueryMissionControl",
|
||||
router__pb2.QueryMissionControlRequest.SerializeToString,
|
||||
router__pb2.QueryMissionControlResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def XImportMissionControl(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/XImportMissionControl",
|
||||
router__pb2.XImportMissionControlRequest.SerializeToString,
|
||||
router__pb2.XImportMissionControlResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def GetMissionControlConfig(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/GetMissionControlConfig",
|
||||
router__pb2.GetMissionControlConfigRequest.SerializeToString,
|
||||
router__pb2.GetMissionControlConfigResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def SetMissionControlConfig(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/SetMissionControlConfig",
|
||||
router__pb2.SetMissionControlConfigRequest.SerializeToString,
|
||||
router__pb2.SetMissionControlConfigResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def QueryProbability(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/QueryProbability",
|
||||
router__pb2.QueryProbabilityRequest.SerializeToString,
|
||||
router__pb2.QueryProbabilityResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def BuildRoute(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/BuildRoute",
|
||||
router__pb2.BuildRouteRequest.SerializeToString,
|
||||
router__pb2.BuildRouteResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def SubscribeHtlcEvents(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_stream(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/SubscribeHtlcEvents",
|
||||
router__pb2.SubscribeHtlcEventsRequest.SerializeToString,
|
||||
router__pb2.HtlcEvent.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def SendPayment(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_stream(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/SendPayment",
|
||||
router__pb2.SendPaymentRequest.SerializeToString,
|
||||
router__pb2.PaymentStatus.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def TrackPayment(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_stream(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/TrackPayment",
|
||||
router__pb2.TrackPaymentRequest.SerializeToString,
|
||||
router__pb2.PaymentStatus.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def HtlcInterceptor(
|
||||
request_iterator,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.stream_stream(
|
||||
request_iterator,
|
||||
target,
|
||||
"/routerrpc.Router/HtlcInterceptor",
|
||||
router__pb2.ForwardHtlcInterceptResponse.SerializeToString,
|
||||
router__pb2.ForwardHtlcInterceptRequest.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def UpdateChanStatus(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/routerrpc.Router/UpdateChanStatus",
|
||||
router__pb2.UpdateChanStatusRequest.SerializeToString,
|
||||
router__pb2.UpdateChanStatusResponse.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
@ -2,10 +2,11 @@ imports_ok = True
|
||||
try:
|
||||
import grpc
|
||||
from google import protobuf
|
||||
from grpc import RpcError
|
||||
except ImportError: # pragma: nocover
|
||||
imports_ok = False
|
||||
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
@ -19,6 +20,8 @@ from .macaroon import AESCipher, load_macaroon
|
||||
if imports_ok:
|
||||
import lnbits.wallets.lnd_grpc_files.lightning_pb2 as ln
|
||||
import lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc as lnrpc
|
||||
import lnbits.wallets.lnd_grpc_files.router_pb2 as router
|
||||
import lnbits.wallets.lnd_grpc_files.router_pb2_grpc as routerrpc
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
@ -111,6 +114,7 @@ class LndWallet(Wallet):
|
||||
f"{self.endpoint}:{self.port}", composite_creds
|
||||
)
|
||||
self.rpc = lnrpc.LightningStub(channel)
|
||||
self.routerpc = routerrpc.RouterStub(channel)
|
||||
|
||||
def metadata_callback(self, _, callback):
|
||||
callback([("macaroon", self.macaroon)], None)
|
||||
@ -118,6 +122,8 @@ class LndWallet(Wallet):
|
||||
async def status(self) -> StatusResponse:
|
||||
try:
|
||||
resp = await self.rpc.ChannelBalance(ln.ChannelBalanceRequest())
|
||||
except RpcError as exc:
|
||||
return StatusResponse(str(exc._details), 0)
|
||||
except Exception as exc:
|
||||
return StatusResponse(str(exc), 0)
|
||||
|
||||
@ -128,13 +134,15 @@ class LndWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
) -> InvoiceResponse:
|
||||
params: Dict = {"value": amount, "expiry": 600, "private": True}
|
||||
|
||||
if description_hash:
|
||||
params["description_hash"] = base64.b64encode(
|
||||
hashlib.sha256(description_hash).digest()
|
||||
) # as bytes directly
|
||||
params["description_hash"] = description_hash
|
||||
elif unhashed_description:
|
||||
params["description_hash"] = hashlib.sha256(
|
||||
unhashed_description
|
||||
).digest() # as bytes directly
|
||||
else:
|
||||
params["memo"] = memo or ""
|
||||
|
||||
@ -150,18 +158,39 @@ class LndWallet(Wallet):
|
||||
return InvoiceResponse(True, checking_id, payment_request, None)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
fee_limit_fixed = ln.FeeLimit(fixed=fee_limit_msat // 1000)
|
||||
req = ln.SendRequest(payment_request=bolt11, fee_limit=fee_limit_fixed)
|
||||
resp = await self.rpc.SendPaymentSync(req)
|
||||
# fee_limit_fixed = ln.FeeLimit(fixed=fee_limit_msat // 1000)
|
||||
req = router.SendPaymentRequest(
|
||||
payment_request=bolt11,
|
||||
fee_limit_msat=fee_limit_msat,
|
||||
timeout_seconds=30,
|
||||
no_inflight_updates=True,
|
||||
)
|
||||
try:
|
||||
resp = await self.routerpc.SendPaymentV2(req).read()
|
||||
except RpcError as exc:
|
||||
return PaymentResponse(False, "", 0, None, exc._details)
|
||||
except Exception as exc:
|
||||
return PaymentResponse(False, "", 0, None, str(exc))
|
||||
|
||||
if resp.payment_error:
|
||||
return PaymentResponse(False, "", 0, None, resp.payment_error)
|
||||
# PaymentStatus from https://github.com/lightningnetwork/lnd/blob/master/channeldb/payments.go#L178
|
||||
statuses = {
|
||||
0: None, # NON_EXISTENT
|
||||
1: None, # IN_FLIGHT
|
||||
2: True, # SUCCEEDED
|
||||
3: False, # FAILED
|
||||
}
|
||||
|
||||
r_hash = hashlib.sha256(resp.payment_preimage).digest()
|
||||
checking_id = stringify_checking_id(r_hash)
|
||||
fee_msat = resp.payment_route.total_fees_msat
|
||||
preimage = resp.payment_preimage.hex()
|
||||
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
|
||||
if resp.status in [0, 1, 3]:
|
||||
fee_msat = 0
|
||||
preimage = ""
|
||||
checking_id = ""
|
||||
elif resp.status == 2: # SUCCEEDED
|
||||
fee_msat = resp.htlcs[-1].route.total_fees_msat
|
||||
preimage = resp.payment_preimage
|
||||
checking_id = resp.payment_hash
|
||||
return PaymentResponse(
|
||||
statuses[resp.status], checking_id, fee_msat, preimage, None
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
@ -180,20 +209,55 @@ class LndWallet(Wallet):
|
||||
return PaymentStatus(None)
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
return PaymentStatus(True)
|
||||
"""
|
||||
This routine checks the payment status using routerpc.TrackPaymentV2.
|
||||
"""
|
||||
try:
|
||||
r_hash = parse_checking_id(checking_id)
|
||||
if len(r_hash) != 32:
|
||||
raise binascii.Error
|
||||
except binascii.Error:
|
||||
# this may happen if we switch between backend wallets
|
||||
# that use different checking_id formats
|
||||
return PaymentStatus(None)
|
||||
|
||||
# for some reason our checking_ids are in base64 but the payment hashes
|
||||
# returned here are in hex, lnd is weird
|
||||
checking_id = checking_id.replace("_", "/")
|
||||
checking_id = base64.b64decode(checking_id).hex()
|
||||
|
||||
resp = self.routerpc.TrackPaymentV2(
|
||||
router.TrackPaymentRequest(payment_hash=r_hash)
|
||||
)
|
||||
|
||||
# HTLCAttempt.HTLCStatus:
|
||||
# https://github.com/lightningnetwork/lnd/blob/master/lnrpc/lightning.proto#L3641
|
||||
statuses = {
|
||||
0: None, # IN_FLIGHT
|
||||
1: True, # "SUCCEEDED"
|
||||
2: False, # "FAILED"
|
||||
}
|
||||
|
||||
try:
|
||||
async for payment in resp:
|
||||
return PaymentStatus(statuses[payment.htlcs[-1].status])
|
||||
except: # most likely the payment wasn't found
|
||||
return PaymentStatus(None)
|
||||
|
||||
return PaymentStatus(None)
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
request = ln.InvoiceSubscription()
|
||||
try:
|
||||
async for i in self.rpc.SubscribeInvoices(request):
|
||||
if not i.settled:
|
||||
continue
|
||||
while True:
|
||||
request = ln.InvoiceSubscription()
|
||||
try:
|
||||
async for i in self.rpc.SubscribeInvoices(request):
|
||||
if not i.settled:
|
||||
continue
|
||||
|
||||
checking_id = stringify_checking_id(i.r_hash)
|
||||
yield checking_id
|
||||
except error:
|
||||
logger.error(error)
|
||||
|
||||
logger.error(
|
||||
"lost connection to lnd InvoiceSubscription, please restart lnbits."
|
||||
)
|
||||
checking_id = stringify_checking_id(i.r_hash)
|
||||
yield checking_id
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"lost connection to lnd invoices stream: '{exc}', retrying in 5 seconds"
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
|
@ -73,11 +73,17 @@ class LndRestWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
data: Dict = {"value": amount, "private": True}
|
||||
if description_hash:
|
||||
data["description_hash"] = base64.b64encode(description_hash).decode(
|
||||
"ascii"
|
||||
)
|
||||
elif unhashed_description:
|
||||
data["description_hash"] = base64.b64encode(
|
||||
hashlib.sha256(description_hash).digest()
|
||||
hashlib.sha256(unhashed_description).digest()
|
||||
).decode("ascii")
|
||||
else:
|
||||
data["memo"] = memo or ""
|
||||
@ -142,15 +148,10 @@ class LndRestWallet(Wallet):
|
||||
return PaymentStatus(True)
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
async with httpx.AsyncClient(verify=self.cert) as client:
|
||||
r = await client.get(
|
||||
url=f"{self.endpoint}/v1/payments",
|
||||
headers=self.auth,
|
||||
params={"max_payments": "20", "reversed": True},
|
||||
)
|
||||
|
||||
if r.is_error:
|
||||
return PaymentStatus(None)
|
||||
"""
|
||||
This routine checks the payment status using routerpc.TrackPaymentV2.
|
||||
"""
|
||||
url = f"{self.endpoint}/v2/router/track/{checking_id}"
|
||||
|
||||
# check payment.status:
|
||||
# https://api.lightning.community/rest/index.html?python#peersynctype
|
||||
@ -161,14 +162,27 @@ class LndRestWallet(Wallet):
|
||||
"FAILED": False,
|
||||
}
|
||||
|
||||
# for some reason our checking_ids are in base64 but the payment hashes
|
||||
# returned here are in hex, lnd is weird
|
||||
checking_id = checking_id.replace("_", "/")
|
||||
checking_id = base64.b64decode(checking_id).hex()
|
||||
|
||||
for p in r.json()["payments"]:
|
||||
if p["payment_hash"] == checking_id:
|
||||
return PaymentStatus(statuses[p["status"]])
|
||||
async with httpx.AsyncClient(
|
||||
timeout=None, headers=self.auth, verify=self.cert
|
||||
) as client:
|
||||
async with client.stream("GET", url) as r:
|
||||
async for l in r.aiter_lines():
|
||||
try:
|
||||
line = json.loads(l)
|
||||
if line.get("error"):
|
||||
logger.error(
|
||||
line["error"]["message"]
|
||||
if "message" in line["error"]
|
||||
else line["error"]
|
||||
)
|
||||
return PaymentStatus(None)
|
||||
payment = line.get("result")
|
||||
if payment is not None and payment.get("status"):
|
||||
return PaymentStatus(statuses[payment["status"]])
|
||||
else:
|
||||
return PaymentStatus(None)
|
||||
except:
|
||||
continue
|
||||
|
||||
return PaymentStatus(None)
|
||||
|
||||
@ -191,10 +205,8 @@ class LndRestWallet(Wallet):
|
||||
|
||||
payment_hash = base64.b64decode(inv["r_hash"]).hex()
|
||||
yield payment_hash
|
||||
except (OSError, httpx.ConnectError, httpx.ReadError):
|
||||
pass
|
||||
|
||||
logger.error(
|
||||
"lost connection to lnd invoices stream, retrying in 5 seconds"
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"lost connection to lnd invoices stream: '{exc}', retrying in 5 seconds"
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
|
@ -52,10 +52,14 @@ class LNPayWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
data: Dict = {"num_satoshis": f"{amount}"}
|
||||
if description_hash:
|
||||
data["description_hash"] = hashlib.sha256(description_hash).hexdigest()
|
||||
data["description_hash"] = description_hash.hex()
|
||||
elif unhashed_description:
|
||||
data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest()
|
||||
else:
|
||||
data["memo"] = memo or ""
|
||||
|
||||
|
@ -52,10 +52,14 @@ class LntxbotWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
data: Dict = {"amt": str(amount)}
|
||||
if description_hash:
|
||||
data["description_hash"] = hashlib.sha256(description_hash).hexdigest()
|
||||
data["description_hash"] = description_hash.hex()
|
||||
elif unhashed_description:
|
||||
data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest()
|
||||
else:
|
||||
data["memo"] = memo or ""
|
||||
|
||||
|
@ -54,8 +54,10 @@ class OpenNodeWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
if description_hash:
|
||||
if description_hash or unhashed_description:
|
||||
raise Unsupported("description_hash")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
|
@ -93,6 +93,8 @@ class SparkWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
label = "lbs{}".format(random.random())
|
||||
checking_id = label
|
||||
@ -102,7 +104,13 @@ class SparkWallet(Wallet):
|
||||
r = await self.invoicewithdescriptionhash(
|
||||
msatoshi=amount * 1000,
|
||||
label=label,
|
||||
description_hash=hashlib.sha256(description_hash).hexdigest(),
|
||||
description_hash=description_hash.hex(),
|
||||
)
|
||||
elif unhashed_description:
|
||||
r = await self.invoicewithdescriptionhash(
|
||||
msatoshi=amount * 1000,
|
||||
label=label,
|
||||
description_hash=hashlib.sha256(unhashed_description).hexdigest(),
|
||||
)
|
||||
else:
|
||||
r = await self.invoice(
|
||||
|
@ -18,6 +18,7 @@ class VoidWallet(Wallet):
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
**kwargs,
|
||||
) -> InvoiceResponse:
|
||||
raise Unsupported("")
|
||||
|
||||
@ -31,10 +32,10 @@ class VoidWallet(Wallet):
|
||||
raise Unsupported("")
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
raise Unsupported("")
|
||||
return PaymentStatus(None)
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
raise Unsupported("")
|
||||
return PaymentStatus(None)
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
yield ""
|
||||
|
8
mypy.ini
8
mypy.ini
@ -1,8 +0,0 @@
|
||||
[mypy]
|
||||
ignore_missing_imports = True
|
||||
exclude = (?x)(
|
||||
^lnbits/extensions.
|
||||
| ^lnbits/wallets/lnd_grpc_files.
|
||||
)
|
||||
[mypy-lnbits.wallets.lnd_grpc_files.*]
|
||||
follow_imports = skip
|
553
poetry.lock
generated
553
poetry.lock
generated
@ -1,6 +1,6 @@
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
description = "File support for asyncio."
|
||||
category = "main"
|
||||
optional = false
|
||||
@ -17,6 +17,7 @@ python-versions = ">=3.6.2"
|
||||
[package.dependencies]
|
||||
idna = ">=2.8"
|
||||
sniffio = ">=1.1"
|
||||
typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
|
||||
|
||||
[package.extras]
|
||||
doc = ["packaging", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"]
|
||||
@ -31,9 +32,20 @@ category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
|
||||
|
||||
[package.extras]
|
||||
tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"]
|
||||
|
||||
[[package]]
|
||||
name = "atomicwrites"
|
||||
version = "1.4.1"
|
||||
description = "Atomic file writes."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "21.2.0"
|
||||
@ -64,6 +76,29 @@ category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "black"
|
||||
version = "22.6.0"
|
||||
description = "The uncompromising code formatter."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6.2"
|
||||
|
||||
[package.dependencies]
|
||||
click = ">=8.0.0"
|
||||
mypy-extensions = ">=0.4.3"
|
||||
pathspec = ">=0.9.0"
|
||||
platformdirs = ">=2"
|
||||
tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""}
|
||||
typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""}
|
||||
typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}
|
||||
|
||||
[package.extras]
|
||||
colorama = ["colorama (>=0.4.3)"]
|
||||
d = ["aiohttp (>=3.7.4)"]
|
||||
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
|
||||
uvloop = ["uvloop (>=0.15.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "cerberus"
|
||||
version = "1.3.4"
|
||||
@ -112,6 +147,7 @@ python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
colorama = {version = "*", markers = "platform_system == \"Windows\""}
|
||||
importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
@ -121,6 +157,20 @@ category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "6.4.2"
|
||||
description = "Code coverage measurement for Python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
|
||||
|
||||
[package.extras]
|
||||
toml = ["tomli"]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.17.0"
|
||||
@ -190,49 +240,52 @@ python-versions = ">=3.6"
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "0.13.7"
|
||||
version = "0.15.0"
|
||||
description = "A minimal low-level HTTP client."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
anyio = ">=3.0.0,<4.0.0"
|
||||
certifi = "*"
|
||||
h11 = ">=0.11,<0.13"
|
||||
sniffio = ">=1.0.0,<2.0.0"
|
||||
|
||||
[package.extras]
|
||||
http2 = ["h2 (>=3,<5)"]
|
||||
socks = ["socksio (>=1.0.0,<2.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "httptools"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
description = "A collection of framework independent HTTP protocol utils."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
python-versions = ">=3.5.0"
|
||||
|
||||
[package.extras]
|
||||
test = ["Cython (==0.29.22)"]
|
||||
test = ["Cython (>=0.29.24,<0.30.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.19.0"
|
||||
version = "0.23.0"
|
||||
description = "The next generation HTTP client."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
certifi = "*"
|
||||
charset-normalizer = "*"
|
||||
httpcore = ">=0.13.3,<0.14.0"
|
||||
httpcore = ">=0.15.0,<0.16.0"
|
||||
rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]}
|
||||
sniffio = "*"
|
||||
|
||||
[package.extras]
|
||||
brotli = ["brotlicffi", "brotli"]
|
||||
cli = ["click (>=8.0.0,<9.0.0)", "rich (>=10,<13)", "pygments (>=2.0.0,<3.0.0)"]
|
||||
http2 = ["h2 (>=3,<5)"]
|
||||
socks = ["socksio (>=1.0.0,<2.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
@ -251,6 +304,7 @@ optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""}
|
||||
zipp = ">=0.5"
|
||||
|
||||
[package.extras]
|
||||
@ -258,6 +312,28 @@ docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
|
||||
perf = ["ipython"]
|
||||
testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "1.1.1"
|
||||
description = "iniconfig: brain-dead simple config-ini parsing"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "isort"
|
||||
version = "5.10.1"
|
||||
description = "A Python utility / library to sort Python imports."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6.1,<4.0"
|
||||
|
||||
[package.extras]
|
||||
pipfile_deprecated_finder = ["pipreqs", "requirementslib"]
|
||||
requirements_deprecated_finder = ["pipreqs", "pip-api"]
|
||||
colors = ["colorama (>=0.4.3,<0.5.0)"]
|
||||
plugins = ["setuptools"]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.0.1"
|
||||
@ -283,6 +359,7 @@ python-versions = ">=3.6"
|
||||
[package.dependencies]
|
||||
bech32 = "*"
|
||||
pydantic = "*"
|
||||
typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
|
||||
|
||||
[[package]]
|
||||
name = "loguru"
|
||||
@ -297,7 +374,7 @@ colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""}
|
||||
win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""}
|
||||
|
||||
[package.extras]
|
||||
dev = ["codecov (>=2.0.15)", "colorama (>=0.3.4)", "flake8 (>=3.7.7)", "tox (>=3.9.0)", "tox-travis (>=0.12)", "pytest (>=4.6.2)", "pytest-cov (>=2.7.1)", "Sphinx (>=2.2.1)", "sphinx-autobuild (>=0.7.1)", "sphinx-rtd-theme (>=0.4.3)", "black (>=19.10b0)", "isort (>=5.1.1)"]
|
||||
dev = ["isort (>=5.1.1)", "black (>=19.10b0)", "sphinx-rtd-theme (>=0.4.3)", "sphinx-autobuild (>=0.7.1)", "Sphinx (>=2.2.1)", "pytest-cov (>=2.7.1)", "pytest (>=4.6.2)", "tox-travis (>=0.12)", "tox (>=3.9.0)", "flake8 (>=3.7.7)", "colorama (>=0.3.4)", "codecov (>=2.0.15)"]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
@ -309,18 +386,61 @@ python-versions = ">=3.6"
|
||||
|
||||
[[package]]
|
||||
name = "marshmallow"
|
||||
version = "3.13.0"
|
||||
version = "3.17.0"
|
||||
description = "A lightweight library for converting complex datatypes to and from native Python datatypes."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
packaging = ">=17.0"
|
||||
|
||||
[package.extras]
|
||||
dev = ["pytest", "pytz", "simplejson", "mypy (==0.910)", "flake8 (==3.9.2)", "flake8-bugbear (==21.4.3)", "pre-commit (>=2.4,<3.0)", "tox"]
|
||||
docs = ["sphinx (==4.1.1)", "sphinx-issues (==1.2.0)", "alabaster (==0.7.12)", "sphinx-version-warning (==1.1.2)", "autodocsumm (==0.2.6)"]
|
||||
lint = ["mypy (==0.910)", "flake8 (==3.9.2)", "flake8-bugbear (==21.4.3)", "pre-commit (>=2.4,<3.0)"]
|
||||
dev = ["pytest", "pytz", "simplejson", "mypy (==0.961)", "flake8 (==4.0.1)", "flake8-bugbear (==22.6.22)", "pre-commit (>=2.4,<3.0)", "tox"]
|
||||
docs = ["sphinx (==4.5.0)", "sphinx-issues (==3.0.1)", "alabaster (==0.7.12)", "sphinx-version-warning (==1.1.2)", "autodocsumm (==0.2.8)"]
|
||||
lint = ["mypy (==0.961)", "flake8 (==4.0.1)", "flake8-bugbear (==22.6.22)", "pre-commit (>=2.4,<3.0)"]
|
||||
tests = ["pytest", "pytz", "simplejson"]
|
||||
|
||||
[[package]]
|
||||
name = "mock"
|
||||
version = "4.0.3"
|
||||
description = "Rolling backport of unittest.mock for all Pythons"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.extras]
|
||||
build = ["twine", "wheel", "blurb"]
|
||||
docs = ["sphinx"]
|
||||
test = ["pytest (<5.4)", "pytest-cov"]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "0.971"
|
||||
description = "Optional static typing for Python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
mypy-extensions = ">=0.4.3"
|
||||
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
|
||||
typed-ast = {version = ">=1.4.0,<2", markers = "python_version < \"3.8\""}
|
||||
typing-extensions = ">=3.10"
|
||||
|
||||
[package.extras]
|
||||
dmypy = ["psutil (>=4.0)"]
|
||||
python2 = ["typed-ast (>=1.4.0,<2)"]
|
||||
reports = ["lxml"]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "0.4.3"
|
||||
description = "Experimental type system extensions for programs checked with the mypy typechecker."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "outcome"
|
||||
version = "1.1.0"
|
||||
@ -332,6 +452,52 @@ python-versions = ">=3.6"
|
||||
[package.dependencies]
|
||||
attrs = ">=19.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "21.3"
|
||||
description = "Core utilities for Python packages"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "0.9.0"
|
||||
description = "Utility library for gitignore style pattern matching of file paths."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "2.5.2"
|
||||
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.extras]
|
||||
docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"]
|
||||
test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.0.0"
|
||||
description = "plugin and hook calling mechanisms for python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
|
||||
|
||||
[package.extras]
|
||||
testing = ["pytest-benchmark", "pytest"]
|
||||
dev = ["tox", "pre-commit"]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg2-binary"
|
||||
version = "2.9.1"
|
||||
@ -340,6 +506,14 @@ category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[[package]]
|
||||
name = "py"
|
||||
version = "1.11.0"
|
||||
description = "library with cross-python path, ini-parsing, io, code, log facilities"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.21"
|
||||
@ -371,6 +545,17 @@ typing-extensions = ">=3.7.4.3"
|
||||
dotenv = ["python-dotenv (>=0.10.4)"]
|
||||
email = ["email-validator (>=1.0.3)"]
|
||||
|
||||
[[package]]
|
||||
name = "pyparsing"
|
||||
version = "3.0.9"
|
||||
description = "pyparsing module - Classes and methods to define and execute parsing grammars"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6.8"
|
||||
|
||||
[package.extras]
|
||||
diagrams = ["railroad-diagrams", "jinja2"]
|
||||
|
||||
[[package]]
|
||||
name = "pypng"
|
||||
version = "0.0.21"
|
||||
@ -401,6 +586,58 @@ python-versions = "*"
|
||||
[package.dependencies]
|
||||
six = "*"
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "7.1.2"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
|
||||
attrs = ">=19.2.0"
|
||||
colorama = {version = "*", markers = "sys_platform == \"win32\""}
|
||||
importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
|
||||
iniconfig = "*"
|
||||
packaging = "*"
|
||||
pluggy = ">=0.12,<2.0"
|
||||
py = ">=1.8.2"
|
||||
tomli = ">=1.0.0"
|
||||
|
||||
[package.extras]
|
||||
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "0.19.0"
|
||||
description = "Pytest support for asyncio"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
pytest = ">=6.1.0"
|
||||
typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""}
|
||||
|
||||
[package.extras]
|
||||
testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)", "flaky (>=3.5.0)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "3.0.0"
|
||||
description = "Pytest plugin for measuring coverage."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
coverage = {version = ">=5.2.1", extras = ["toml"]}
|
||||
pytest = ">=4.6"
|
||||
|
||||
[package.extras]
|
||||
testing = ["virtualenv", "pytest-xdist", "six", "process-tests", "hunter", "fields"]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "0.19.0"
|
||||
@ -505,7 +742,7 @@ pymysql = ["pymysql (<1)", "pymysql"]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy-aio"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
description = "Async support for SQLAlchemy."
|
||||
category = "main"
|
||||
optional = false
|
||||
@ -514,7 +751,7 @@ python-versions = ">=3.6"
|
||||
[package.dependencies]
|
||||
outcome = "*"
|
||||
represent = ">=1.4"
|
||||
sqlalchemy = "*"
|
||||
sqlalchemy = "<1.4"
|
||||
|
||||
[package.extras]
|
||||
test = ["pytest (>=5.4)", "pytest-asyncio (>=0.14)", "pytest-trio (>=0.6)"]
|
||||
@ -544,6 +781,30 @@ typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""
|
||||
[package.extras]
|
||||
full = ["itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests"]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.0.1"
|
||||
description = "A lil' TOML parser"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "typed-ast"
|
||||
version = "1.5.4"
|
||||
description = "a fork of Python 2 and 3 ast modules with type comment support"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[[package]]
|
||||
name = "types-protobuf"
|
||||
version = "3.19.22"
|
||||
description = "Typing stubs for protobuf"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "3.10.0.2"
|
||||
@ -563,6 +824,7 @@ python-versions = ">=3.7"
|
||||
[package.dependencies]
|
||||
click = ">=7.0"
|
||||
h11 = ">=0.8"
|
||||
typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
|
||||
|
||||
[package.extras]
|
||||
standard = ["websockets (>=10.0)", "httptools (>=0.4.0)", "watchfiles (>=0.13)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "colorama (>=0.4)"]
|
||||
@ -634,13 +896,13 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes
|
||||
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "3.9.*"
|
||||
content-hash = "fec673ac17295c99a1c13769d82c0b7e2d0e8831bfa1e2b9aa5e2ce13605fa62"
|
||||
python-versions = "^3.9 | ^3.8 | ^3.7"
|
||||
content-hash = "cadb8f2e46f0c083e91956f4f0f70b53b6c106f1c0b47972b57132dfee357367"
|
||||
|
||||
[metadata.files]
|
||||
aiofiles = [
|
||||
{file = "aiofiles-0.7.0-py3-none-any.whl", hash = "sha256:c67a6823b5f23fcab0a2595a289cec7d8c863ffcb4322fb8cd6b90400aedfdbc"},
|
||||
{file = "aiofiles-0.7.0.tar.gz", hash = "sha256:a1c4fc9b2ff81568c83e21392a82f344ea9d23da906e4f6a52662764545e19d4"},
|
||||
{file = "aiofiles-0.8.0-py3-none-any.whl", hash = "sha256:7a973fc22b29e9962d0897805ace5856e6a566ab1f0c8e5c91ff6c866519c937"},
|
||||
{file = "aiofiles-0.8.0.tar.gz", hash = "sha256:8334f23235248a3b2e83b2c3a78a22674f39969b96397126cc93664d9a901e59"},
|
||||
]
|
||||
anyio = [
|
||||
{file = "anyio-3.6.1-py3-none-any.whl", hash = "sha256:cb29b9c70620506a9a8f87a309591713446953302d7d995344d0d7c6c0c9a7be"},
|
||||
@ -650,6 +912,9 @@ asgiref = [
|
||||
{file = "asgiref-3.4.1-py3-none-any.whl", hash = "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214"},
|
||||
{file = "asgiref-3.4.1.tar.gz", hash = "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9"},
|
||||
]
|
||||
atomicwrites = [
|
||||
{file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"},
|
||||
]
|
||||
attrs = [
|
||||
{file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"},
|
||||
{file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"},
|
||||
@ -663,6 +928,31 @@ bitstring = [
|
||||
{file = "bitstring-3.1.9-py3-none-any.whl", hash = "sha256:0de167daa6a00c9386255a7cac931b45e6e24e0ad7ea64f1f92a64ac23ad4578"},
|
||||
{file = "bitstring-3.1.9.tar.gz", hash = "sha256:a5848a3f63111785224dca8bb4c0a75b62ecdef56a042c8d6be74b16f7e860e7"},
|
||||
]
|
||||
black = [
|
||||
{file = "black-22.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f586c26118bc6e714ec58c09df0157fe2d9ee195c764f630eb0d8e7ccce72e69"},
|
||||
{file = "black-22.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b270a168d69edb8b7ed32c193ef10fd27844e5c60852039599f9184460ce0807"},
|
||||
{file = "black-22.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6797f58943fceb1c461fb572edbe828d811e719c24e03375fd25170ada53825e"},
|
||||
{file = "black-22.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c85928b9d5f83b23cee7d0efcb310172412fbf7cb9d9ce963bd67fd141781def"},
|
||||
{file = "black-22.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:f6fe02afde060bbeef044af7996f335fbe90b039ccf3f5eb8f16df8b20f77666"},
|
||||
{file = "black-22.6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cfaf3895a9634e882bf9d2363fed5af8888802d670f58b279b0bece00e9a872d"},
|
||||
{file = "black-22.6.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94783f636bca89f11eb5d50437e8e17fbc6a929a628d82304c80fa9cd945f256"},
|
||||
{file = "black-22.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:2ea29072e954a4d55a2ff58971b83365eba5d3d357352a07a7a4df0d95f51c78"},
|
||||
{file = "black-22.6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e439798f819d49ba1c0bd9664427a05aab79bfba777a6db94fd4e56fae0cb849"},
|
||||
{file = "black-22.6.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:187d96c5e713f441a5829e77120c269b6514418f4513a390b0499b0987f2ff1c"},
|
||||
{file = "black-22.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:074458dc2f6e0d3dab7928d4417bb6957bb834434516f21514138437accdbe90"},
|
||||
{file = "black-22.6.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a218d7e5856f91d20f04e931b6f16d15356db1c846ee55f01bac297a705ca24f"},
|
||||
{file = "black-22.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:568ac3c465b1c8b34b61cd7a4e349e93f91abf0f9371eda1cf87194663ab684e"},
|
||||
{file = "black-22.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6c1734ab264b8f7929cef8ae5f900b85d579e6cbfde09d7387da8f04771b51c6"},
|
||||
{file = "black-22.6.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9a3ac16efe9ec7d7381ddebcc022119794872abce99475345c5a61aa18c45ad"},
|
||||
{file = "black-22.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:b9fd45787ba8aa3f5e0a0a98920c1012c884622c6c920dbe98dbd05bc7c70fbf"},
|
||||
{file = "black-22.6.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7ba9be198ecca5031cd78745780d65a3f75a34b2ff9be5837045dce55db83d1c"},
|
||||
{file = "black-22.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3db5b6409b96d9bd543323b23ef32a1a2b06416d525d27e0f67e74f1446c8f2"},
|
||||
{file = "black-22.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:560558527e52ce8afba936fcce93a7411ab40c7d5fe8c2463e279e843c0328ee"},
|
||||
{file = "black-22.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b154e6bbde1e79ea3260c4b40c0b7b3109ffcdf7bc4ebf8859169a6af72cd70b"},
|
||||
{file = "black-22.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:4af5bc0e1f96be5ae9bd7aaec219c901a94d6caa2484c21983d043371c733fc4"},
|
||||
{file = "black-22.6.0-py3-none-any.whl", hash = "sha256:ac609cf8ef5e7115ddd07d85d988d074ed00e10fbc3445aee393e70164a2219c"},
|
||||
{file = "black-22.6.0.tar.gz", hash = "sha256:6c6d39e28aed379aec40da1c65434c77d75e65bb59a1e1c283de545fb4e7c6c9"},
|
||||
]
|
||||
cerberus = [
|
||||
{file = "Cerberus-1.3.4.tar.gz", hash = "sha256:d1b21b3954b2498d9a79edf16b3170a3ac1021df88d197dc2ce5928ba519237c"},
|
||||
]
|
||||
@ -734,6 +1024,49 @@ colorama = [
|
||||
{file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"},
|
||||
{file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"},
|
||||
]
|
||||
coverage = [
|
||||
{file = "coverage-6.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a9032f9b7d38bdf882ac9f66ebde3afb8145f0d4c24b2e600bc4c6304aafb87e"},
|
||||
{file = "coverage-6.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e0524adb49c716ca763dbc1d27bedce36b14f33e6b8af6dba56886476b42957c"},
|
||||
{file = "coverage-6.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4548be38a1c810d79e097a38107b6bf2ff42151900e47d49635be69943763d8"},
|
||||
{file = "coverage-6.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f23876b018dfa5d3e98e96f5644b109090f16a4acb22064e0f06933663005d39"},
|
||||
{file = "coverage-6.4.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fe75dcfcb889b6800f072f2af5a331342d63d0c1b3d2bf0f7b4f6c353e8c9c0"},
|
||||
{file = "coverage-6.4.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2f8553878a24b00d5ab04b7a92a2af50409247ca5c4b7a2bf4eabe94ed20d3ee"},
|
||||
{file = "coverage-6.4.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:d774d9e97007b018a651eadc1b3970ed20237395527e22cbeb743d8e73e0563d"},
|
||||
{file = "coverage-6.4.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d56f105592188ce7a797b2bd94b4a8cb2e36d5d9b0d8a1d2060ff2a71e6b9bbc"},
|
||||
{file = "coverage-6.4.2-cp310-cp310-win32.whl", hash = "sha256:d230d333b0be8042ac34808ad722eabba30036232e7a6fb3e317c49f61c93386"},
|
||||
{file = "coverage-6.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:5ef42e1db047ca42827a85e34abe973971c635f83aed49611b7f3ab49d0130f0"},
|
||||
{file = "coverage-6.4.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:25b7ec944f114f70803d6529394b64f8749e93cbfac0fe6c5ea1b7e6c14e8a46"},
|
||||
{file = "coverage-6.4.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bb00521ab4f99fdce2d5c05a91bddc0280f0afaee0e0a00425e28e209d4af07"},
|
||||
{file = "coverage-6.4.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dff52b3e7f76ada36f82124703f4953186d9029d00d6287f17c68a75e2e6039"},
|
||||
{file = "coverage-6.4.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147605e1702d996279bb3cc3b164f408698850011210d133a2cb96a73a2f7996"},
|
||||
{file = "coverage-6.4.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:422fa44070b42fef9fb8dabd5af03861708cdd6deb69463adc2130b7bf81332f"},
|
||||
{file = "coverage-6.4.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8af6c26ba8df6338e57bedbf916d76bdae6308e57fc8f14397f03b5da8622b4e"},
|
||||
{file = "coverage-6.4.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:5336e0352c0b12c7e72727d50ff02557005f79a0b8dcad9219c7c4940a930083"},
|
||||
{file = "coverage-6.4.2-cp37-cp37m-win32.whl", hash = "sha256:0f211df2cba951ffcae210ee00e54921ab42e2b64e0bf2c0befc977377fb09b7"},
|
||||
{file = "coverage-6.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:a13772c19619118903d65a91f1d5fea84be494d12fd406d06c849b00d31bf120"},
|
||||
{file = "coverage-6.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f7bd0ffbcd03dc39490a1f40b2669cc414fae0c4e16b77bb26806a4d0b7d1452"},
|
||||
{file = "coverage-6.4.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0895ea6e6f7f9939166cc835df8fa4599e2d9b759b02d1521b574e13b859ac32"},
|
||||
{file = "coverage-6.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4e7ced84a11c10160c0697a6cc0b214a5d7ab21dfec1cd46e89fbf77cc66fae"},
|
||||
{file = "coverage-6.4.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80db4a47a199c4563d4a25919ff29c97c87569130375beca3483b41ad5f698e8"},
|
||||
{file = "coverage-6.4.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3def6791adf580d66f025223078dc84c64696a26f174131059ce8e91452584e1"},
|
||||
{file = "coverage-6.4.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4f89d8e03c8a3757aae65570d14033e8edf192ee9298303db15955cadcff0c63"},
|
||||
{file = "coverage-6.4.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6d0b48aff8e9720bdec315d67723f0babd936a7211dc5df453ddf76f89c59933"},
|
||||
{file = "coverage-6.4.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2b20286c2b726f94e766e86a3fddb7b7e37af5d0c635bdfa7e4399bc523563de"},
|
||||
{file = "coverage-6.4.2-cp38-cp38-win32.whl", hash = "sha256:d714af0bdba67739598849c9f18efdcc5a0412f4993914a0ec5ce0f1e864d783"},
|
||||
{file = "coverage-6.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:5f65e5d3ff2d895dab76b1faca4586b970a99b5d4b24e9aafffc0ce94a6022d6"},
|
||||
{file = "coverage-6.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a697977157adc052284a7160569b36a8bbec09db3c3220642e6323b47cec090f"},
|
||||
{file = "coverage-6.4.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c77943ef768276b61c96a3eb854eba55633c7a3fddf0a79f82805f232326d33f"},
|
||||
{file = "coverage-6.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54d8d0e073a7f238f0666d3c7c0d37469b2aa43311e4024c925ee14f5d5a1cbe"},
|
||||
{file = "coverage-6.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22325010d8824594820d6ce84fa830838f581a7fd86a9235f0d2ed6deb61e29"},
|
||||
{file = "coverage-6.4.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24b04d305ea172ccb21bee5bacd559383cba2c6fcdef85b7701cf2de4188aa55"},
|
||||
{file = "coverage-6.4.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:866ebf42b4c5dbafd64455b0a1cd5aa7b4837a894809413b930026c91e18090b"},
|
||||
{file = "coverage-6.4.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e36750fbbc422c1c46c9d13b937ab437138b998fe74a635ec88989afb57a3978"},
|
||||
{file = "coverage-6.4.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:79419370d6a637cb18553ecb25228893966bd7935a9120fa454e7076f13b627c"},
|
||||
{file = "coverage-6.4.2-cp39-cp39-win32.whl", hash = "sha256:b5e28db9199dd3833cc8a07fa6cf429a01227b5d429facb56eccd765050c26cd"},
|
||||
{file = "coverage-6.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:edfdabe7aa4f97ed2b9dd5dde52d2bb29cb466993bb9d612ddd10d0085a683cf"},
|
||||
{file = "coverage-6.4.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:e2618cb2cf5a7cc8d698306e42ebcacd02fb7ef8cfc18485c59394152c70be97"},
|
||||
{file = "coverage-6.4.2.tar.gz", hash = "sha256:6c3ccfe89c36f3e5b9837b9ee507472310164f352c9fe332120b764c9d60adbe"},
|
||||
]
|
||||
ecdsa = [
|
||||
{file = "ecdsa-0.17.0-py2.py3-none-any.whl", hash = "sha256:5cf31d5b33743abe0dfc28999036c849a69d548f994b535e527ee3cb7f3ef676"},
|
||||
{file = "ecdsa-0.17.0.tar.gz", hash = "sha256:b9f500bb439e4153d0330610f5d26baaf18d17b8ced1bc54410d189385ea68aa"},
|
||||
@ -754,29 +1087,48 @@ h11 = [
|
||||
{file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"},
|
||||
]
|
||||
httpcore = [
|
||||
{file = "httpcore-0.13.7-py3-none-any.whl", hash = "sha256:369aa481b014cf046f7067fddd67d00560f2f00426e79569d99cb11245134af0"},
|
||||
{file = "httpcore-0.13.7.tar.gz", hash = "sha256:036f960468759e633574d7c121afba48af6419615d36ab8ede979f1ad6276fa3"},
|
||||
{file = "httpcore-0.15.0-py3-none-any.whl", hash = "sha256:1105b8b73c025f23ff7c36468e4432226cbb959176eab66864b8e31c4ee27fa6"},
|
||||
{file = "httpcore-0.15.0.tar.gz", hash = "sha256:18b68ab86a3ccf3e7dc0f43598eaddcf472b602aba29f9aa6ab85fe2ada3980b"},
|
||||
]
|
||||
httptools = [
|
||||
{file = "httptools-0.2.0-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:79dbc21f3612a78b28384e989b21872e2e3cf3968532601544696e4ed0007ce5"},
|
||||
{file = "httptools-0.2.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:78d03dd39b09c99ec917d50189e6743adbfd18c15d5944392d2eabda688bf149"},
|
||||
{file = "httptools-0.2.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:a23166e5ae2775709cf4f7ad4c2048755ebfb272767d244e1a96d55ac775cca7"},
|
||||
{file = "httptools-0.2.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:3ab1f390d8867f74b3b5ee2a7ecc9b8d7f53750bd45714bf1cb72a953d7dfa77"},
|
||||
{file = "httptools-0.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a7594f9a010cdf1e16a58b3bf26c9da39bbf663e3b8d46d39176999d71816658"},
|
||||
{file = "httptools-0.2.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:01b392a166adcc8bc2f526a939a8aabf89fe079243e1543fd0e7dc1b58d737cb"},
|
||||
{file = "httptools-0.2.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:80ffa04fe8c8dfacf6e4cef8277347d35b0442c581f5814f3b0cf41b65c43c6e"},
|
||||
{file = "httptools-0.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d5682eeb10cca0606c4a8286a3391d4c3c5a36f0c448e71b8bd05be4e1694bfb"},
|
||||
{file = "httptools-0.2.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:a289c27ccae399a70eacf32df9a44059ca2ba4ac444604b00a19a6c1f0809943"},
|
||||
{file = "httptools-0.2.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:813871f961edea6cb2fe312f2d9b27d12a51ba92545380126f80d0de1917ea15"},
|
||||
{file = "httptools-0.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:cc9be041e428c10f8b6ab358c6b393648f9457094e1dcc11b4906026d43cd380"},
|
||||
{file = "httptools-0.2.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:b08d00d889a118f68f37f3c43e359aab24ee29eb2e3fe96d64c6a2ba8b9d6557"},
|
||||
{file = "httptools-0.2.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:fd3b8905e21431ad306eeaf56644a68fdd621bf8f3097eff54d0f6bdf7262065"},
|
||||
{file = "httptools-0.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:200fc1cdf733a9ff554c0bb97a4047785cfaad9875307d6087001db3eb2b417f"},
|
||||
{file = "httptools-0.2.0.tar.gz", hash = "sha256:94505026be56652d7a530ab03d89474dc6021019d6b8682281977163b3471ea0"},
|
||||
{file = "httptools-0.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fcddfe70553be717d9745990dfdb194e22ee0f60eb8f48c0794e7bfeda30d2d5"},
|
||||
{file = "httptools-0.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1ee0b459257e222b878a6c09ccf233957d3a4dcb883b0847640af98d2d9aac23"},
|
||||
{file = "httptools-0.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ceafd5e960b39c7e0d160a1936b68eb87c5e79b3979d66e774f0c77d4d8faaed"},
|
||||
{file = "httptools-0.4.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fdb9f9ed79bc6f46b021b3319184699ba1a22410a82204e6e89c774530069683"},
|
||||
{file = "httptools-0.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:abe829275cdd4174b4c4e65ad718715d449e308d59793bf3a931ee1bf7e7b86c"},
|
||||
{file = "httptools-0.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7af6bdbd21a2a25d6784f6d67f44f5df33ef39b6159543b9f9064d365c01f919"},
|
||||
{file = "httptools-0.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:5d1fe6b6661022fd6cac541f54a4237496b246e6f1c0a6b41998ee08a1135afe"},
|
||||
{file = "httptools-0.4.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:48e48530d9b995a84d1d89ae6b3ec4e59ea7d494b150ac3bbc5e2ac4acce92cd"},
|
||||
{file = "httptools-0.4.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a113789e53ac1fa26edf99856a61e4c493868e125ae0dd6354cf518948fbbd5c"},
|
||||
{file = "httptools-0.4.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8e2eb957787cbb614a0f006bfc5798ff1d90ac7c4dd24854c84edbdc8c02369e"},
|
||||
{file = "httptools-0.4.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:7ee9f226acab9085037582c059d66769862706e8e8cd2340470ceb8b3850873d"},
|
||||
{file = "httptools-0.4.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:701e66b59dd21a32a274771238025d58db7e2b6ecebbab64ceff51b8e31527ae"},
|
||||
{file = "httptools-0.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6a1a7dfc1f9c78a833e2c4904757a0f47ce25d08634dd2a52af394eefe5f9777"},
|
||||
{file = "httptools-0.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:903f739c9fb78dab8970b0f3ea51f21955b24b45afa77b22ff0e172fc11ef111"},
|
||||
{file = "httptools-0.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54bbd295f031b866b9799dd39cb45deee81aca036c9bff9f58ca06726f6494f1"},
|
||||
{file = "httptools-0.4.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3194f6d6443befa8d4db16c1946b2fc428a3ceb8ab32eb6f09a59f86104dc1a0"},
|
||||
{file = "httptools-0.4.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:cd1295f52971097f757edfbfce827b6dbbfb0f7a74901ee7d4933dff5ad4c9af"},
|
||||
{file = "httptools-0.4.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:20a45bcf22452a10fa8d58b7dbdb474381f6946bf5b8933e3662d572bc61bae4"},
|
||||
{file = "httptools-0.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d1f27bb0f75bef722d6e22dc609612bfa2f994541621cd2163f8c943b6463dfe"},
|
||||
{file = "httptools-0.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:7f7bfb74718f52d5ed47d608d507bf66d3bc01d4a8b3e6dd7134daaae129357b"},
|
||||
{file = "httptools-0.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a522d12e2ddbc2e91842ffb454a1aeb0d47607972c7d8fc88bd0838d97fb8a2a"},
|
||||
{file = "httptools-0.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2db44a0b294d317199e9f80123e72c6b005c55b625b57fae36de68670090fa48"},
|
||||
{file = "httptools-0.4.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c286985b5e194ca0ebb2908d71464b9be8f17cc66d6d3e330e8d5407248f56ad"},
|
||||
{file = "httptools-0.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d3a4e165ca6204f34856b765d515d558dc84f1352033b8721e8d06c3e44930c3"},
|
||||
{file = "httptools-0.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:72aa3fbe636b16d22e04b5a9d24711b043495e0ecfe58080addf23a1a37f3409"},
|
||||
{file = "httptools-0.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:9967d9758df505975913304c434cb9ab21e2c609ad859eb921f2f615a038c8de"},
|
||||
{file = "httptools-0.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f72b5d24d6730035128b238decdc4c0f2104b7056a7ca55cf047c106842ec890"},
|
||||
{file = "httptools-0.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:29bf97a5c532da9c7a04de2c7a9c31d1d54f3abd65a464119b680206bbbb1055"},
|
||||
{file = "httptools-0.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98993805f1e3cdb53de4eed02b55dcc953cdf017ba7bbb2fd89226c086a6d855"},
|
||||
{file = "httptools-0.4.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d9b90bf58f3ba04e60321a23a8723a1ff2a9377502535e70495e5ada8e6e6722"},
|
||||
{file = "httptools-0.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1a99346ebcb801b213c591540837340bdf6fd060a8687518d01c607d338b7424"},
|
||||
{file = "httptools-0.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:645373c070080e632480a3d251d892cb795be3d3a15f86975d0f1aca56fd230d"},
|
||||
{file = "httptools-0.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:34d2903dd2a3dd85d33705b6fde40bf91fc44411661283763fd0746723963c83"},
|
||||
{file = "httptools-0.4.0.tar.gz", hash = "sha256:2c9a930c378b3d15d6b695fb95ebcff81a7395b4f9775c4f10a076beb0b2c1ff"},
|
||||
]
|
||||
httpx = [
|
||||
{file = "httpx-0.19.0-py3-none-any.whl", hash = "sha256:9bd728a6c5ec0a9e243932a9983d57d3cc4a87bb4f554e1360fce407f78f9435"},
|
||||
{file = "httpx-0.19.0.tar.gz", hash = "sha256:92ecd2c00c688b529eda11cedb15161eaf02dee9116712f621c70d9a40b2cdd0"},
|
||||
{file = "httpx-0.23.0-py3-none-any.whl", hash = "sha256:42974f577483e1e932c3cdc3cd2303e883cbfba17fe228b0f63589764d7b9c4b"},
|
||||
{file = "httpx-0.23.0.tar.gz", hash = "sha256:f28eac771ec9eb4866d3fb4ab65abd42d38c424739e80c08d8d20570de60b0ef"},
|
||||
]
|
||||
idna = [
|
||||
{file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"},
|
||||
@ -786,6 +1138,14 @@ importlib-metadata = [
|
||||
{file = "importlib_metadata-4.8.1-py3-none-any.whl", hash = "sha256:b618b6d2d5ffa2f16add5697cf57a46c76a56229b0ed1c438322e4e95645bd15"},
|
||||
{file = "importlib_metadata-4.8.1.tar.gz", hash = "sha256:f284b3e11256ad1e5d03ab86bb2ccd6f5339688ff17a4d797a0fe7df326f23b1"},
|
||||
]
|
||||
iniconfig = [
|
||||
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
|
||||
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
|
||||
]
|
||||
isort = [
|
||||
{file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"},
|
||||
{file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"},
|
||||
]
|
||||
jinja2 = [
|
||||
{file = "Jinja2-3.0.1-py3-none-any.whl", hash = "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4"},
|
||||
{file = "Jinja2-3.0.1.tar.gz", hash = "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4"},
|
||||
@ -870,13 +1230,62 @@ markupsafe = [
|
||||
{file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"},
|
||||
]
|
||||
marshmallow = [
|
||||
{file = "marshmallow-3.13.0-py2.py3-none-any.whl", hash = "sha256:dd4724335d3c2b870b641ffe4a2f8728a1380cd2e7e2312756715ffeaa82b842"},
|
||||
{file = "marshmallow-3.13.0.tar.gz", hash = "sha256:c67929438fd73a2be92128caa0325b1b5ed8b626d91a094d2f7f2771bf1f1c0e"},
|
||||
{file = "marshmallow-3.17.0-py3-none-any.whl", hash = "sha256:00040ab5ea0c608e8787137627a8efae97fabd60552a05dc889c888f814e75eb"},
|
||||
{file = "marshmallow-3.17.0.tar.gz", hash = "sha256:635fb65a3285a31a30f276f30e958070f5214c7196202caa5c7ecf28f5274bc7"},
|
||||
]
|
||||
mock = [
|
||||
{file = "mock-4.0.3-py3-none-any.whl", hash = "sha256:122fcb64ee37cfad5b3f48d7a7d51875d7031aaf3d8be7c42e2bee25044eee62"},
|
||||
{file = "mock-4.0.3.tar.gz", hash = "sha256:7d3fbbde18228f4ff2f1f119a45cdffa458b4c0dee32eb4d2bb2f82554bac7bc"},
|
||||
]
|
||||
mypy = [
|
||||
{file = "mypy-0.971-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f2899a3cbd394da157194f913a931edfd4be5f274a88041c9dc2d9cdcb1c315c"},
|
||||
{file = "mypy-0.971-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:98e02d56ebe93981c41211c05adb630d1d26c14195d04d95e49cd97dbc046dc5"},
|
||||
{file = "mypy-0.971-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:19830b7dba7d5356d3e26e2427a2ec91c994cd92d983142cbd025ebe81d69cf3"},
|
||||
{file = "mypy-0.971-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:02ef476f6dcb86e6f502ae39a16b93285fef97e7f1ff22932b657d1ef1f28655"},
|
||||
{file = "mypy-0.971-cp310-cp310-win_amd64.whl", hash = "sha256:25c5750ba5609a0c7550b73a33deb314ecfb559c350bb050b655505e8aed4103"},
|
||||
{file = "mypy-0.971-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d3348e7eb2eea2472db611486846742d5d52d1290576de99d59edeb7cd4a42ca"},
|
||||
{file = "mypy-0.971-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3fa7a477b9900be9b7dd4bab30a12759e5abe9586574ceb944bc29cddf8f0417"},
|
||||
{file = "mypy-0.971-cp36-cp36m-win_amd64.whl", hash = "sha256:2ad53cf9c3adc43cf3bea0a7d01a2f2e86db9fe7596dfecb4496a5dda63cbb09"},
|
||||
{file = "mypy-0.971-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:855048b6feb6dfe09d3353466004490b1872887150c5bb5caad7838b57328cc8"},
|
||||
{file = "mypy-0.971-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:23488a14a83bca6e54402c2e6435467a4138785df93ec85aeff64c6170077fb0"},
|
||||
{file = "mypy-0.971-cp37-cp37m-win_amd64.whl", hash = "sha256:4b21e5b1a70dfb972490035128f305c39bc4bc253f34e96a4adf9127cf943eb2"},
|
||||
{file = "mypy-0.971-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:9796a2ba7b4b538649caa5cecd398d873f4022ed2333ffde58eaf604c4d2cb27"},
|
||||
{file = "mypy-0.971-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5a361d92635ad4ada1b1b2d3630fc2f53f2127d51cf2def9db83cba32e47c856"},
|
||||
{file = "mypy-0.971-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b793b899f7cf563b1e7044a5c97361196b938e92f0a4343a5d27966a53d2ec71"},
|
||||
{file = "mypy-0.971-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d1ea5d12c8e2d266b5fb8c7a5d2e9c0219fedfeb493b7ed60cd350322384ac27"},
|
||||
{file = "mypy-0.971-cp38-cp38-win_amd64.whl", hash = "sha256:23c7ff43fff4b0df93a186581885c8512bc50fc4d4910e0f838e35d6bb6b5e58"},
|
||||
{file = "mypy-0.971-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1f7656b69974a6933e987ee8ffb951d836272d6c0f81d727f1d0e2696074d9e6"},
|
||||
{file = "mypy-0.971-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d2022bfadb7a5c2ef410d6a7c9763188afdb7f3533f22a0a32be10d571ee4bbe"},
|
||||
{file = "mypy-0.971-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef943c72a786b0f8d90fd76e9b39ce81fb7171172daf84bf43eaf937e9f220a9"},
|
||||
{file = "mypy-0.971-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d744f72eb39f69312bc6c2abf8ff6656973120e2eb3f3ec4f758ed47e414a4bf"},
|
||||
{file = "mypy-0.971-cp39-cp39-win_amd64.whl", hash = "sha256:77a514ea15d3007d33a9e2157b0ba9c267496acf12a7f2b9b9f8446337aac5b0"},
|
||||
{file = "mypy-0.971-py3-none-any.whl", hash = "sha256:0d054ef16b071149917085f51f89555a576e2618d5d9dd70bd6eea6410af3ac9"},
|
||||
{file = "mypy-0.971.tar.gz", hash = "sha256:40b0f21484238269ae6a57200c807d80debc6459d444c0489a102d7c6a75fa56"},
|
||||
]
|
||||
mypy-extensions = [
|
||||
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
|
||||
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
|
||||
]
|
||||
outcome = [
|
||||
{file = "outcome-1.1.0-py2.py3-none-any.whl", hash = "sha256:c7dd9375cfd3c12db9801d080a3b63d4b0a261aa996c4c13152380587288d958"},
|
||||
{file = "outcome-1.1.0.tar.gz", hash = "sha256:e862f01d4e626e63e8f92c38d1f8d5546d3f9cce989263c521b2e7990d186967"},
|
||||
]
|
||||
packaging = [
|
||||
{file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
|
||||
{file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
|
||||
]
|
||||
pathspec = [
|
||||
{file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
|
||||
{file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
|
||||
]
|
||||
platformdirs = [
|
||||
{file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"},
|
||||
{file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"},
|
||||
]
|
||||
pluggy = [
|
||||
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
|
||||
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
|
||||
]
|
||||
psycopg2-binary = [
|
||||
{file = "psycopg2-binary-2.9.1.tar.gz", hash = "sha256:b0221ca5a9837e040ebf61f48899926b5783668b7807419e4adae8175a31f773"},
|
||||
{file = "psycopg2_binary-2.9.1-cp310-cp310-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:24b0b6688b9f31a911f2361fe818492650795c9e5d3a1bc647acbd7440142a4f"},
|
||||
@ -915,6 +1324,10 @@ psycopg2-binary = [
|
||||
{file = "psycopg2_binary-2.9.1-cp39-cp39-win32.whl", hash = "sha256:0b7dae87f0b729922e06f85f667de7bf16455d411971b2043bbd9577af9d1975"},
|
||||
{file = "psycopg2_binary-2.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:b4d7679a08fea64573c969f6994a2631908bb2c0e69a7235648642f3d2e39a68"},
|
||||
]
|
||||
py = [
|
||||
{file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
|
||||
{file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
|
||||
]
|
||||
pycparser = [
|
||||
{file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
|
||||
{file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
|
||||
@ -972,6 +1385,10 @@ pydantic = [
|
||||
{file = "pydantic-1.8.2-py3-none-any.whl", hash = "sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833"},
|
||||
{file = "pydantic-1.8.2.tar.gz", hash = "sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b"},
|
||||
]
|
||||
pyparsing = [
|
||||
{file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
|
||||
{file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
|
||||
]
|
||||
pypng = [
|
||||
{file = "pypng-0.0.21-py3-none-any.whl", hash = "sha256:76f8a1539ec56451da7ab7121f12a361969fe0f2d48d703d198ce2a99d6c5afd"},
|
||||
]
|
||||
@ -982,6 +1399,18 @@ pyqrcode = [
|
||||
pyscss = [
|
||||
{file = "pyScss-1.3.7.tar.gz", hash = "sha256:f1df571569021a23941a538eb154405dde80bed35dc1ea7c5f3e18e0144746bf"},
|
||||
]
|
||||
pytest = [
|
||||
{file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"},
|
||||
{file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"},
|
||||
]
|
||||
pytest-asyncio = [
|
||||
{file = "pytest-asyncio-0.19.0.tar.gz", hash = "sha256:ac4ebf3b6207259750bc32f4c1d8fcd7e79739edbc67ad0c58dd150b1d072fed"},
|
||||
{file = "pytest_asyncio-0.19.0-py3-none-any.whl", hash = "sha256:7a97e37cfe1ed296e2e84941384bdd37c376453912d397ed39293e0916f521fa"},
|
||||
]
|
||||
pytest-cov = [
|
||||
{file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"},
|
||||
{file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"},
|
||||
]
|
||||
python-dotenv = [
|
||||
{file = "python-dotenv-0.19.0.tar.gz", hash = "sha256:f521bc2ac9a8e03c736f62911605c5d83970021e3fa95b37d769e2bbbe9b6172"},
|
||||
{file = "python_dotenv-0.19.0-py2.py3-none-any.whl", hash = "sha256:aae25dc1ebe97c420f50b81fb0e5c949659af713f31fdb63c749ca68748f34b1"},
|
||||
@ -1103,8 +1532,8 @@ sqlalchemy = [
|
||||
{file = "SQLAlchemy-1.3.23.tar.gz", hash = "sha256:6fca33672578666f657c131552c4ef8979c1606e494f78cd5199742dfb26918b"},
|
||||
]
|
||||
sqlalchemy-aio = [
|
||||
{file = "sqlalchemy_aio-0.16.0-py2.py3-none-any.whl", hash = "sha256:f767320427c22c66fa5840a1f17f3261110a8ddc8560558f4fbf12d31a66b17b"},
|
||||
{file = "sqlalchemy_aio-0.16.0.tar.gz", hash = "sha256:7f77366f55d34891c87386dd0962a28b948b684e8ea5edb7daae4187c0b291bf"},
|
||||
{file = "sqlalchemy_aio-0.17.0-py3-none-any.whl", hash = "sha256:3f4aa392c38f032d6734826a4138a0f02ed3122d442ed142be1e5964f2a33b60"},
|
||||
{file = "sqlalchemy_aio-0.17.0.tar.gz", hash = "sha256:f531c7982662d71dfc0b117e77bb2ed544e25cd5361e76cf9f5208edcfb71f7b"},
|
||||
]
|
||||
sse-starlette = [
|
||||
{file = "sse-starlette-0.6.2.tar.gz", hash = "sha256:1c0cc62cc7d021a386dc06a16a9ddc3e2861d19da6bc2e654e65cc111e820456"},
|
||||
@ -1113,6 +1542,40 @@ starlette = [
|
||||
{file = "starlette-0.19.1-py3-none-any.whl", hash = "sha256:5a60c5c2d051f3a8eb546136aa0c9399773a689595e099e0877704d5888279bf"},
|
||||
{file = "starlette-0.19.1.tar.gz", hash = "sha256:c6d21096774ecb9639acad41b86b7706e52ba3bf1dc13ea4ed9ad593d47e24c7"},
|
||||
]
|
||||
tomli = [
|
||||
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
|
||||
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
|
||||
]
|
||||
typed-ast = [
|
||||
{file = "typed_ast-1.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4"},
|
||||
{file = "typed_ast-1.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:211260621ab1cd7324e0798d6be953d00b74e0428382991adfddb352252f1d62"},
|
||||
{file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:267e3f78697a6c00c689c03db4876dd1efdfea2f251a5ad6555e82a26847b4ac"},
|
||||
{file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c542eeda69212fa10a7ada75e668876fdec5f856cd3d06829e6aa64ad17c8dfe"},
|
||||
{file = "typed_ast-1.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:a9916d2bb8865f973824fb47436fa45e1ebf2efd920f2b9f99342cb7fab93f72"},
|
||||
{file = "typed_ast-1.5.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:79b1e0869db7c830ba6a981d58711c88b6677506e648496b1f64ac7d15633aec"},
|
||||
{file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a94d55d142c9265f4ea46fab70977a1944ecae359ae867397757d836ea5a3f47"},
|
||||
{file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:183afdf0ec5b1b211724dfef3d2cad2d767cbefac291f24d69b00546c1837fb6"},
|
||||
{file = "typed_ast-1.5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:639c5f0b21776605dd6c9dbe592d5228f021404dafd377e2b7ac046b0349b1a1"},
|
||||
{file = "typed_ast-1.5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf4afcfac006ece570e32d6fa90ab74a17245b83dfd6655a6f68568098345ff6"},
|
||||
{file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed855bbe3eb3715fca349c80174cfcfd699c2f9de574d40527b8429acae23a66"},
|
||||
{file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6778e1b2f81dfc7bc58e4b259363b83d2e509a65198e85d5700dfae4c6c8ff1c"},
|
||||
{file = "typed_ast-1.5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:0261195c2062caf107831e92a76764c81227dae162c4f75192c0d489faf751a2"},
|
||||
{file = "typed_ast-1.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2efae9db7a8c05ad5547d522e7dbe62c83d838d3906a3716d1478b6c1d61388d"},
|
||||
{file = "typed_ast-1.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7d5d014b7daa8b0bf2eaef684295acae12b036d79f54178b92a2b6a56f92278f"},
|
||||
{file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:370788a63915e82fd6f212865a596a0fefcbb7d408bbbb13dea723d971ed8bdc"},
|
||||
{file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e964b4ff86550a7a7d56345c7864b18f403f5bd7380edf44a3c1fb4ee7ac6c6"},
|
||||
{file = "typed_ast-1.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:683407d92dc953c8a7347119596f0b0e6c55eb98ebebd9b23437501b28dcbb8e"},
|
||||
{file = "typed_ast-1.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4879da6c9b73443f97e731b617184a596ac1235fe91f98d279a7af36c796da35"},
|
||||
{file = "typed_ast-1.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3e123d878ba170397916557d31c8f589951e353cc95fb7f24f6bb69adc1a8a97"},
|
||||
{file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebd9d7f80ccf7a82ac5f88c521115cc55d84e35bf8b446fcd7836eb6b98929a3"},
|
||||
{file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98f80dee3c03455e92796b58b98ff6ca0b2a6f652120c263efdba4d6c5e58f72"},
|
||||
{file = "typed_ast-1.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1"},
|
||||
{file = "typed_ast-1.5.4.tar.gz", hash = "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2"},
|
||||
]
|
||||
types-protobuf = [
|
||||
{file = "types-protobuf-3.19.22.tar.gz", hash = "sha256:d2b26861b0cb46a3c8669b0df507b7ef72e487da66d61f9f3576aa76ce028a83"},
|
||||
{file = "types_protobuf-3.19.22-py3-none-any.whl", hash = "sha256:d291388678af91bb045fafa864f142dc4ac22f5d4cdca097c7d8d8a32fa9b3ab"},
|
||||
]
|
||||
typing-extensions = [
|
||||
{file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"},
|
||||
{file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"},
|
||||
|
@ -9,8 +9,8 @@ generate-setup-file = false
|
||||
script = "build.py"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "3.9.*"
|
||||
aiofiles = "0.7.0"
|
||||
python = "^3.9 | ^3.8 | ^3.7"
|
||||
aiofiles = "0.8.0"
|
||||
asgiref = "3.4.1"
|
||||
attrs = "21.2.0"
|
||||
bech32 = "1.2.0"
|
||||
@ -24,15 +24,15 @@ embit = "0.4.9"
|
||||
environs = "9.3.3"
|
||||
fastapi = "0.78.0"
|
||||
h11 = "0.12.0"
|
||||
httpcore = "0.13.7"
|
||||
httptools = "0.2.0"
|
||||
httpx = "0.19.0"
|
||||
httpcore = "0.15.0"
|
||||
httptools = "0.4.0"
|
||||
httpx = "0.23.0"
|
||||
idna = "3.2"
|
||||
importlib-metadata = "4.8.1"
|
||||
jinja2 = "3.0.1"
|
||||
lnurl = "0.3.6"
|
||||
markupsafe = "2.0.1"
|
||||
marshmallow = "3.13.0"
|
||||
marshmallow = "3.17.0"
|
||||
outcome = "1.1.0"
|
||||
psycopg2-binary = "2.9.1"
|
||||
pycryptodomex = "3.14.1"
|
||||
@ -49,7 +49,7 @@ shortuuid = "1.0.1"
|
||||
six = "1.16.0"
|
||||
sniffio = "1.2.0"
|
||||
sqlalchemy = "1.3.23"
|
||||
sqlalchemy-aio = "0.16.0"
|
||||
sqlalchemy-aio = "0.17.0"
|
||||
sse-starlette = "0.6.2"
|
||||
typing-extensions = "3.10.0.2"
|
||||
uvicorn = "0.18.1"
|
||||
@ -62,6 +62,14 @@ cffi = "1.15.0"
|
||||
websocket-client = "1.3.3"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
isort = "^5.10.1"
|
||||
pytest = "^7.1.2"
|
||||
mock = "^4.0.3"
|
||||
black = "^22.6.0"
|
||||
pytest-asyncio = "^0.19.0"
|
||||
pytest-cov = "^3.0.0"
|
||||
mypy = "^0.971"
|
||||
types-protobuf = "^3.19.22"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
@ -69,3 +77,20 @@ build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
lnbits = "lnbits.server:main"
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
|
||||
[tool.mypy]
|
||||
ignore_missing_imports = "True"
|
||||
files = "lnbits"
|
||||
exclude = """(?x)(
|
||||
^lnbits/extensions.
|
||||
| ^lnbits/wallets/lnd_grpc_files.
|
||||
)"""
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--durations=1 -s --cov=lnbits --cov-report=xml"
|
||||
testpaths = [
|
||||
"tests"
|
||||
]
|
||||
|
@ -1,3 +0,0 @@
|
||||
[pytest]
|
||||
filterwarnings =
|
||||
ignore::pytest.PytestCacheWarning
|
@ -1,19 +1,17 @@
|
||||
import asyncio
|
||||
import pytest_asyncio
|
||||
from typing import Tuple
|
||||
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient
|
||||
|
||||
from lnbits.app import create_app
|
||||
from lnbits.commands import migrate_databases
|
||||
from lnbits.settings import HOST, PORT
|
||||
|
||||
from lnbits.core.views.api import api_payments_create_invoice, CreateInvoiceData
|
||||
|
||||
from lnbits.core.crud import create_account, create_wallet, get_wallet
|
||||
from tests.helpers import credit_wallet, get_random_invoice_data
|
||||
|
||||
from lnbits.core.models import BalanceCheck, Payment, User, Wallet
|
||||
from lnbits.core.views.api import CreateInvoiceData, api_payments_create_invoice
|
||||
from lnbits.db import Database
|
||||
from lnbits.core.models import User, Wallet, Payment, BalanceCheck
|
||||
from typing import Tuple
|
||||
from lnbits.settings import HOST, PORT
|
||||
from tests.helpers import credit_wallet, get_random_invoice_data
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session")
|
||||
|
@ -1,17 +1,21 @@
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
import hashlib
|
||||
from binascii import hexlify
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.core.views.api import api_payment
|
||||
|
||||
from lnbits.core.views.api import api_payments_create_invoice, CreateInvoiceData
|
||||
from lnbits.core.views.api import (
|
||||
CreateInvoiceData,
|
||||
api_payment,
|
||||
api_payments_create_invoice,
|
||||
)
|
||||
from lnbits.settings import wallet_class
|
||||
|
||||
from ...helpers import get_random_invoice_data
|
||||
|
||||
|
||||
# check if the client is working
|
||||
@pytest.mark.asyncio
|
||||
async def test_core_views_generic(client):
|
||||
@ -41,6 +45,20 @@ async def test_get_wallet_adminkey(client, adminkey_headers_to):
|
||||
assert "id" in result
|
||||
|
||||
|
||||
# check PUT /api/v1/wallet/newwallet: empty request where admin key is needed
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_empty_request_expected_admin_keys(client):
|
||||
response = await client.put("/api/v1/wallet/newwallet")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# check POST /api/v1/payments: empty request where invoice key is needed
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_empty_request_expected_invoice_keys(client):
|
||||
response = await client.post("/api/v1/payments")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# check POST /api/v1/payments: invoice creation
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_invoice(client, inkey_headers_to):
|
||||
@ -189,11 +207,32 @@ async def test_api_payment_with_key(invoice, inkey_headers_from):
|
||||
|
||||
|
||||
# check POST /api/v1/payments: invoice creation with a description hash
|
||||
@pytest.mark.skipif(
|
||||
wallet_class.__name__ in ["CoreLightningWallet"],
|
||||
reason="wallet does not support description_hash",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_invoice_with_description_hash(client, inkey_headers_to):
|
||||
data = await get_random_invoice_data()
|
||||
descr_hash = hashlib.sha256("asdasdasd".encode("utf-8")).hexdigest()
|
||||
data["description_hash"] = "asdasdasd".encode("utf-8").hex()
|
||||
data["description_hash"] = descr_hash
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/payments", json=data, headers=inkey_headers_to
|
||||
)
|
||||
invoice = response.json()
|
||||
|
||||
invoice_bolt11 = bolt11.decode(invoice["payment_request"])
|
||||
assert invoice_bolt11.description_hash == descr_hash
|
||||
assert invoice_bolt11.description is None
|
||||
return invoice
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_invoice_with_unhashed_description(client, inkey_headers_to):
|
||||
data = await get_random_invoice_data()
|
||||
descr_hash = hashlib.sha256("asdasdasd".encode("utf-8")).hexdigest()
|
||||
data["unhashed_description"] = "asdasdasd".encode("utf-8").hex()
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/payments", json=data, headers=inkey_headers_to
|
||||
|
@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from tests.conftest import client
|
||||
|
||||
|
||||
|
@ -1,7 +1,9 @@
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from lnbits.core.crud import get_wallet
|
||||
|
||||
|
||||
# check if the client is working
|
||||
@pytest.mark.asyncio
|
||||
async def test_core_views_generic(client):
|
||||
|
Binary file not shown.
@ -1,17 +1,19 @@
|
||||
import json
|
||||
import secrets
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import secrets
|
||||
|
||||
from lnbits.core.crud import create_account, create_wallet
|
||||
from lnbits.extensions.bleskomat.crud import create_bleskomat, create_bleskomat_lnurl
|
||||
from lnbits.extensions.bleskomat.models import CreateBleskomat
|
||||
from lnbits.extensions.bleskomat.exchange_rates import exchange_rate_providers
|
||||
from lnbits.extensions.bleskomat.helpers import (
|
||||
generate_bleskomat_lnurl_secret,
|
||||
generate_bleskomat_lnurl_signature,
|
||||
prepare_lnurl_params,
|
||||
query_to_signing_payload,
|
||||
)
|
||||
from lnbits.extensions.bleskomat.exchange_rates import exchange_rate_providers
|
||||
from lnbits.extensions.bleskomat.models import CreateBleskomat
|
||||
|
||||
exchange_rate_providers["dummy"] = {
|
||||
"name": "dummy",
|
||||
|
@ -1,16 +1,18 @@
|
||||
import secrets
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import secrets
|
||||
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.settings import HOST, PORT
|
||||
from lnbits.extensions.bleskomat.crud import get_bleskomat_lnurl
|
||||
from lnbits.extensions.bleskomat.helpers import (
|
||||
generate_bleskomat_lnurl_signature,
|
||||
query_to_signing_payload,
|
||||
)
|
||||
from lnbits.settings import HOST, PORT
|
||||
from tests.conftest import client
|
||||
from tests.helpers import credit_wallet
|
||||
from tests.extensions.bleskomat.conftest import bleskomat, lnurl
|
||||
from tests.helpers import credit_wallet
|
||||
from tests.mocks import WALLET
|
||||
|
||||
|
||||
|
0
tests/extensions/invoices/__init__.py
Normal file
0
tests/extensions/invoices/__init__.py
Normal file
37
tests/extensions/invoices/conftest.py
Normal file
37
tests/extensions/invoices/conftest.py
Normal file
@ -0,0 +1,37 @@
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from lnbits.core.crud import create_account, create_wallet
|
||||
from lnbits.extensions.invoices.crud import (
|
||||
create_invoice_internal,
|
||||
create_invoice_items,
|
||||
)
|
||||
from lnbits.extensions.invoices.models import CreateInvoiceData
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def invoices_wallet():
|
||||
user = await create_account()
|
||||
wallet = await create_wallet(user_id=user.id, wallet_name="invoices_test")
|
||||
|
||||
return wallet
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def accounting_invoice(invoices_wallet):
|
||||
invoice_data = CreateInvoiceData(
|
||||
status="open",
|
||||
currency="USD",
|
||||
company_name="LNBits, Inc",
|
||||
first_name="Ben",
|
||||
last_name="Arc",
|
||||
items=[{"amount": 10.20, "description": "Item costs 10.20"}],
|
||||
)
|
||||
invoice = await create_invoice_internal(
|
||||
wallet_id=invoices_wallet.id, data=invoice_data
|
||||
)
|
||||
items = await create_invoice_items(invoice_id=invoice.id, data=invoice_data.items)
|
||||
|
||||
invoice_dict = invoice.dict()
|
||||
invoice_dict["items"] = items
|
||||
return invoice_dict
|
135
tests/extensions/invoices/test_invoices_api.py
Normal file
135
tests/extensions/invoices/test_invoices_api.py
Normal file
@ -0,0 +1,135 @@
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import get_wallet
|
||||
from tests.conftest import adminkey_headers_from, client, invoice
|
||||
from tests.extensions.invoices.conftest import accounting_invoice, invoices_wallet
|
||||
from tests.helpers import credit_wallet
|
||||
from tests.mocks import WALLET
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoices_unknown_invoice(client):
|
||||
response = await client.get("/invoices/pay/u")
|
||||
assert response.json() == {"detail": "Invoice does not exist."}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoices_api_create_invoice_valid(client, invoices_wallet):
|
||||
query = {
|
||||
"status": "open",
|
||||
"currency": "EUR",
|
||||
"company_name": "LNBits, Inc.",
|
||||
"first_name": "Ben",
|
||||
"last_name": "Arc",
|
||||
"email": "ben@legend.arc",
|
||||
"items": [
|
||||
{"amount": 2.34, "description": "Item 1"},
|
||||
{"amount": 0.98, "description": "Item 2"},
|
||||
],
|
||||
}
|
||||
|
||||
status = query["status"]
|
||||
currency = query["currency"]
|
||||
fname = query["first_name"]
|
||||
total = sum(d["amount"] for d in query["items"])
|
||||
|
||||
response = await client.post(
|
||||
"/invoices/api/v1/invoice",
|
||||
json=query,
|
||||
headers={"X-Api-Key": invoices_wallet.inkey},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == status
|
||||
assert data["wallet"] == invoices_wallet.id
|
||||
assert data["currency"] == currency
|
||||
assert data["first_name"] == fname
|
||||
assert sum(d["amount"] / 100 for d in data["items"]) == total
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoices_api_partial_pay_invoice(
|
||||
client, accounting_invoice, adminkey_headers_from
|
||||
):
|
||||
invoice_id = accounting_invoice["id"]
|
||||
amount_to_pay = int(5.05 * 100) # mock invoice total amount is 10 USD
|
||||
|
||||
# ask for an invoice
|
||||
response = await client.post(
|
||||
f"/invoices/api/v1/invoice/{invoice_id}/payments?famount={amount_to_pay}"
|
||||
)
|
||||
assert response.status_code < 300
|
||||
data = response.json()
|
||||
payment_hash = data["payment_hash"]
|
||||
|
||||
# pay the invoice
|
||||
data = {"out": True, "bolt11": data["payment_request"]}
|
||||
response = await client.post(
|
||||
"/api/v1/payments", json=data, headers=adminkey_headers_from
|
||||
)
|
||||
assert response.status_code < 300
|
||||
assert len(response.json()["payment_hash"]) == 64
|
||||
assert len(response.json()["checking_id"]) > 0
|
||||
|
||||
# check invoice is paid
|
||||
response = await client.get(
|
||||
f"/invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["paid"] == True
|
||||
|
||||
# check invoice status
|
||||
response = await client.get(f"/invoices/api/v1/invoice/{invoice_id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "open"
|
||||
|
||||
|
||||
####
|
||||
#
|
||||
# TEST FAILS FOR NOW, AS LISTENERS ARE NOT WORKING ON TESTING
|
||||
#
|
||||
###
|
||||
|
||||
# @pytest.mark.asyncio
|
||||
# async def test_invoices_api_full_pay_invoice(client, accounting_invoice, adminkey_headers_to):
|
||||
# invoice_id = accounting_invoice["id"]
|
||||
# print(accounting_invoice["id"])
|
||||
# amount_to_pay = int(10.20 * 100)
|
||||
|
||||
# # ask for an invoice
|
||||
# response = await client.post(
|
||||
# f"/invoices/api/v1/invoice/{invoice_id}/payments?famount={amount_to_pay}"
|
||||
# )
|
||||
# assert response.status_code == 201
|
||||
# data = response.json()
|
||||
# payment_hash = data["payment_hash"]
|
||||
|
||||
# # pay the invoice
|
||||
# data = {"out": True, "bolt11": data["payment_request"]}
|
||||
# response = await client.post(
|
||||
# "/api/v1/payments", json=data, headers=adminkey_headers_to
|
||||
# )
|
||||
# assert response.status_code < 300
|
||||
# assert len(response.json()["payment_hash"]) == 64
|
||||
# assert len(response.json()["checking_id"]) > 0
|
||||
|
||||
# # check invoice is paid
|
||||
# response = await client.get(
|
||||
# f"/invoices/api/v1/invoice/{invoice_id}/payments/{payment_hash}"
|
||||
# )
|
||||
# assert response.status_code == 200
|
||||
# assert response.json()["paid"] == True
|
||||
|
||||
# # check invoice status
|
||||
# response = await client.get(f"/invoices/api/v1/invoice/{invoice_id}")
|
||||
# assert response.status_code == 200
|
||||
# data = response.json()
|
||||
|
||||
# print(data)
|
||||
# assert data["status"] == "paid"
|
@ -1,7 +1,8 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
import random
|
||||
import secrets
|
||||
import string
|
||||
|
||||
from lnbits.core.crud import create_payment
|
||||
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user