├── .github └── workflows │ ├── lint.yml │ └── release.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── Makefile ├── README.md ├── __init__.py ├── config.json ├── crud.py ├── description.md ├── manifest.json ├── migrations.py ├── models.py ├── package-lock.json ├── package.json ├── poetry.lock ├── pyproject.toml ├── static ├── image │ ├── 1.png │ ├── 2.png │ ├── 3.png │ └── split-payments.png └── js │ └── index.js ├── tasks.py ├── templates └── splitpayments │ └── index.html ├── tests ├── __init__.py └── test_init.py ├── toc.md ├── views.py └── views_api.py /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | jobs: 9 | lint: 10 | uses: lnbits/lnbits/.github/workflows/lint.yml@dev 11 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - 'v[0-9]+.[0-9]+.[0-9]+' 5 | 6 | jobs: 7 | release: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: Create github release 12 | env: 13 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | tag: ${{ github.ref_name }} 15 | run: | 16 | gh release create "$tag" --generate-notes 17 | 18 | pullrequest: 19 | needs: [release] 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v3 23 | with: 24 | token: ${{ secrets.EXT_GITHUB }} 25 | repository: lnbits/lnbits-extensions 26 | path: './lnbits-extensions' 27 | 28 | - name: setup git user 29 | run: | 30 | git config --global user.name "alan" 31 | git config --global user.email "alan@lnbits.com" 32 | 33 | - name: Create pull request in extensions repo 34 | env: 35 | GH_TOKEN: ${{ secrets.EXT_GITHUB }} 36 | repo_name: '${{ github.event.repository.name }}' 37 | tag: '${{ github.ref_name }}' 38 | branch: 'update-${{ github.event.repository.name }}-${{ github.ref_name }}' 39 | title: '[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}' 40 | body: 'https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}' 41 | archive: 'https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip' 42 | run: | 43 | cd lnbits-extensions 44 | git checkout -b $branch 45 | 46 | # if there is another open PR 47 | git pull origin $branch || echo "branch does not exist" 48 | 49 | sh util.sh update_extension $repo_name $tag 50 | 51 | git add -A 52 | git commit -am "$title" 53 | git push origin $branch 54 | 55 | # check if pr exists before creating it 56 | gh config set pager cat 57 | check=$(gh pr list -H $branch | wc -l) 58 | test $check -ne 0 || gh pr create --title "$title" --body "$body" --repo lnbits/lnbits-extensions 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | node_modules 3 | .mypy_cache 4 | .venv 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "arrowParens": "avoid", 4 | "insertPragma": false, 5 | "printWidth": 80, 6 | "proseWrap": "preserve", 7 | "singleQuote": true, 8 | "trailingComma": "none", 9 | "useTabs": false, 10 | "bracketSameLine": false, 11 | "bracketSpacing": false 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 LNbits 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: format check 2 | 3 | format: prettier black ruff 4 | 5 | check: mypy pyright checkblack checkruff checkprettier 6 | 7 | prettier: 8 | poetry run ./node_modules/.bin/prettier --write . 9 | pyright: 10 | poetry run ./node_modules/.bin/pyright 11 | 12 | mypy: 13 | poetry run mypy . 14 | 15 | black: 16 | poetry run black . 17 | 18 | ruff: 19 | poetry run ruff check . --fix 20 | 21 | checkruff: 22 | poetry run ruff check . 23 | 24 | checkprettier: 25 | poetry run ./node_modules/.bin/prettier --check . 26 | 27 | checkblack: 28 | poetry run black --check . 29 | 30 | checkeditorconfig: 31 | editorconfig-checker 32 | 33 | test: 34 | PYTHONUNBUFFERED=1 \ 35 | DEBUG=true \ 36 | poetry run pytest 37 | install-pre-commit-hook: 38 | @echo "Installing pre-commit hook to git" 39 | @echo "Uninstall the hook with poetry run pre-commit uninstall" 40 | poetry run pre-commit install 41 | 42 | pre-commit: 43 | poetry run pre-commit run --all-files 44 | 45 | 46 | checkbundle: 47 | @echo "skipping checkbundle" 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Split Payments - [LNbits](https://github.com/lnbits/lnbits) extension 2 | 3 | For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Documentation#use-cases-of-lnbits) 4 | 5 | ## Have payments split between multiple wallets 6 | 7 | LNbits Split Payments extension allows for distributing payments across multiple wallets. Set it and forget it. It will keep splitting your payments across wallets forever. 8 | 9 | ## Usage 10 | 11 | 1. After enabling the extension, choose the source wallet that will receive and distribute the Payments 12 | 13 | ![choose wallet](https://i.imgur.com/nPQudqL.png) 14 | 15 | 2. Add the wallet or wallets info to split payments to 16 | 17 | ![split wallets](https://i.imgur.com/5hCNWpg.png) - get the LNURLp, a LNaddress, wallet id, or an invoice key from a different wallet. It can be a completely different user on another instance/domain. You can get the wallet information on the API Info section on every wallet page\ 18 | ![wallet info](https://i.imgur.com/betqflC.png) - set a wallet _Alias_ for your own identification\ 19 | 20 | - set how much, in percentage, this wallet will receive from every payment sent to the source wallet 21 | 22 | 3. When done with adding or deleting a set of targets, click "SAVE TARGETS" to make the splits effective. 23 | 24 | 4. You can have several wallets to split to, as long as the sum of the percentages is under or equal to 100%. It can only reach 100% if the targets are all internal ones. 25 | 26 | 5. When the source wallet receives a payment, the extension will automatically split the corresponding values to every wallet. 27 | - on receiving a 20 sats payment\ 28 | ![get 20 sats payment](https://i.imgur.com/BKp0xvy.png) 29 | - source wallet gets 18 sats\ 30 | ![source wallet](https://i.imgur.com/GCxDZ5s.png) 31 | - Ben's wallet (the wallet from the example) instantly, and feeless, gets the corresponding 10%, or 2 sats\ 32 | ![ben wallet](https://i.imgur.com/MfsccNa.png) 33 | 34 | IMPORTANT: 35 | 36 | - If you split to a LNURLp or LNaddress through the LNURLp extension make sure your receipients allow comments ! Split&Scrub add a comment in your transaction - and if it is not allowed, the split/scrub will not take place. 37 | - Make sure the LNURLp / LNaddress of the receipient has its min-sats set very low (e.g. 1 sat). If the wallet does not belong to you you can [check with a Decoder](https://lightningdecoder.com/), if that is the case already 38 | - Yes, there is fees - internal and external! Updating your own wallets on your own instance will not cost any fees but sending to an external instance will. Please notice that you should therefore not split up to 100% if you send to a wallet that is external (leave 1-2% reserve for routing fees!). External fees are deducted from the individual payment percentage of the receipient 39 | 40 | Bildschirm­foto 2023-05-01 um 22 14 36 41 | Bildschirm­foto 2023-05-01 um 22 17 52 42 | 43 | ## Sponsored by 44 | 45 | [![](https://cdn.shopify.com/s/files/1/0826/9235/files/cryptograffiti_logo_clear_background.png?v=1504730421)](https://cryptograffiti.com/) 46 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from fastapi import APIRouter 4 | from loguru import logger 5 | 6 | from .crud import db 7 | from .tasks import wait_for_paid_invoices 8 | from .views import splitpayments_generic_router 9 | from .views_api import splitpayments_api_router 10 | 11 | splitpayments_static_files = [ 12 | { 13 | "path": "/splitpayments/static", 14 | "name": "splitpayments_static", 15 | } 16 | ] 17 | splitpayments_ext: APIRouter = APIRouter( 18 | prefix="/splitpayments", tags=["splitpayments"] 19 | ) 20 | splitpayments_ext.include_router(splitpayments_generic_router) 21 | splitpayments_ext.include_router(splitpayments_api_router) 22 | 23 | scheduled_tasks: list[asyncio.Task] = [] 24 | 25 | 26 | def splitpayments_stop(): 27 | for task in scheduled_tasks: 28 | try: 29 | task.cancel() 30 | except Exception as ex: 31 | logger.warning(ex) 32 | 33 | 34 | def splitpayments_start(): 35 | from lnbits.tasks import create_permanent_unique_task 36 | 37 | task = create_permanent_unique_task("ext_splitpayments", wait_for_paid_invoices) 38 | scheduled_tasks.append(task) 39 | 40 | 41 | __all__ = [ 42 | "db", 43 | "splitpayments_ext", 44 | "splitpayments_static_files", 45 | "splitpayments_start", 46 | "splitpayments_stop", 47 | ] 48 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Split Payments", 3 | "short_description": "Split incoming payments across wallets", 4 | "tile": "/splitpayments/static/image/split-payments.png", 5 | "min_lnbits_version": "1.0.0", 6 | "contributors": [ 7 | { 8 | "name": "cryptograffiti", 9 | "uri": "https://github.com/cryptograffiti", 10 | "role": "Idea/Sponsor" 11 | }, 12 | { 13 | "name": "fiatjaf", 14 | "uri": "https://github.com/fiatjaf", 15 | "role": "Developer" 16 | }, 17 | { 18 | "name": "dni", 19 | "uri": "https://github.com/dni", 20 | "role": "Developer" 21 | }, 22 | { 23 | "name": "talvasconcelos", 24 | "uri": "https://github.com/talvasconcelos", 25 | "role": "Developer" 26 | }, 27 | { 28 | "name": "prusnak", 29 | "uri": "https://github.com/prusnak", 30 | "role": "Developer" 31 | }, 32 | { 33 | "name": "arbadacarbaYK", 34 | "uri": "https://github.com/arbadacarbaYK", 35 | "role": "Developer" 36 | }, 37 | { 38 | "name": "arcbtc", 39 | "uri": "https://github.com/arcbtc", 40 | "role": "Developer" 41 | } 42 | ], 43 | "images": [ 44 | { 45 | "uri": "https://raw.githubusercontent.com/lnbits/splitpayments/main/static/image/1.png" 46 | }, 47 | { 48 | "uri": "https://raw.githubusercontent.com/lnbits/splitpayments/main/static/image/2.png" 49 | }, 50 | { 51 | "uri": "https://raw.githubusercontent.com/lnbits/splitpayments/main/static/image/3.png" 52 | } 53 | ], 54 | "description_md": "https://raw.githubusercontent.com/lnbits/splitpayments/main/description.md", 55 | "terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/splitpayments/main/toc.md", 56 | "license": "MIT" 57 | } 58 | -------------------------------------------------------------------------------- /crud.py: -------------------------------------------------------------------------------- 1 | from lnbits.db import Database 2 | 3 | from .models import Target 4 | 5 | db = Database("ext_splitpayments") 6 | 7 | 8 | async def get_targets(source_wallet: str) -> list[Target]: 9 | return await db.fetchall( 10 | "SELECT * FROM splitpayments.targets WHERE source = :source_wallet", 11 | {"source_wallet": source_wallet}, 12 | Target, 13 | ) 14 | 15 | 16 | async def set_targets(source_wallet: str, targets: list[Target]): 17 | async with db.connect() as conn: 18 | await conn.execute( 19 | "DELETE FROM splitpayments.targets WHERE source = :source_wallet", 20 | {"source_wallet": source_wallet}, 21 | ) 22 | for target in targets: 23 | await conn.insert("splitpayments.targets", target) 24 | -------------------------------------------------------------------------------- /description.md: -------------------------------------------------------------------------------- 1 | Split Payments across multiple wallets/lnaddresses/lnurlps seamlessly! 2 | Once configured, it continuously splits your payments across different wallets. 3 | 4 | Usage: 5 | 6 | - Enable the Extension: Start by enabling the Split Payments extension. 7 | - Select the Source Wallet: Identify and select the wallet that will receive and subsequently distribute the payments. 8 | - Add Wallet Information for Payment Splitting: Enter the details of the wallets where the payments will be split. This could include LNURLp, LNaddress, wallet ID, or an invoice key from a different wallet. Wallet details can be found under the API Info section on each wallet's page. Optionally, assign an alias to each wallet for easier identification. 9 | - Set Distribution Percentages: Specify the percentage of each payment that each wallet should receive. Ensure the total distribution does not exceed 100%. 10 | - Save Your Settings: After adding or deleting wallet information, click “SAVE TARGETS” to activate the payment splits. 11 | 12 | Note: 13 | You can distribute payments to multiple wallets as long as their combined percentage is at or below 100%. Distribution can only total exactly 100% if all target wallets are internal. 14 | 15 | Automatic Payment Splitting: 16 | When the source wallet receives a payment, the extension automatically allocates the specified percentages to each designated wallet. 17 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "repos": [ 3 | { 4 | "id": "splitpayments", 5 | "organisation": "lnbits", 6 | "repository": "splitpayments" 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /migrations.py: -------------------------------------------------------------------------------- 1 | from lnbits.db import Connection 2 | from lnbits.helpers import urlsafe_short_hash 3 | 4 | 5 | async def m001_initial(db: Connection): 6 | """ 7 | Initial split payment table. 8 | """ 9 | await db.execute( 10 | """ 11 | CREATE TABLE splitpayments.targets ( 12 | wallet TEXT NOT NULL, 13 | source TEXT NOT NULL, 14 | percent INTEGER NOT NULL CHECK (percent >= 0 AND percent <= 100), 15 | alias TEXT, 16 | 17 | UNIQUE (source, wallet) 18 | ); 19 | """ 20 | ) 21 | 22 | 23 | async def m002_float_percent(db: Connection): 24 | """ 25 | alter percent to be float. 26 | """ 27 | await db.execute("ALTER TABLE splitpayments.targets RENAME TO splitpayments_m001") 28 | 29 | await db.execute( 30 | """ 31 | CREATE TABLE splitpayments.targets ( 32 | wallet TEXT NOT NULL, 33 | source TEXT NOT NULL, 34 | percent REAL NOT NULL CHECK (percent >= 0 AND percent <= 100), 35 | alias TEXT, 36 | 37 | UNIQUE (source, wallet) 38 | ); 39 | """ 40 | ) 41 | result = await db.execute("SELECT * FROM splitpayments.splitpayments_m001") 42 | rows = result.mappings().all() 43 | for row in rows: 44 | await db.execute( 45 | """ 46 | INSERT INTO splitpayments.targets ( 47 | wallet, 48 | source, 49 | percent, 50 | alias 51 | ) 52 | VALUES (:wallet, :source, :percent, :alias) 53 | """, 54 | { 55 | "wallet": row["wallet"], 56 | "source": row["source"], 57 | "percent": row["percent"], 58 | "alias": row["alias"], 59 | }, 60 | ) 61 | 62 | await db.execute("DROP TABLE splitpayments.splitpayments_m001") 63 | 64 | 65 | async def m003_add_id_and_tag(db: Connection): 66 | """ 67 | Add id, tag and migrates the existing data. 68 | """ 69 | await db.execute("ALTER TABLE splitpayments.targets RENAME TO splitpayments_m002") 70 | 71 | await db.execute( 72 | """ 73 | CREATE TABLE splitpayments.targets ( 74 | id TEXT PRIMARY KEY, 75 | wallet TEXT NOT NULL, 76 | source TEXT NOT NULL, 77 | percent REAL NOT NULL CHECK (percent >= 0 AND percent <= 100), 78 | tag TEXT NOT NULL, 79 | alias TEXT, 80 | 81 | UNIQUE (source, wallet) 82 | ); 83 | """ 84 | ) 85 | result = await db.execute("SELECT * FROM splitpayments.splitpayments_m002") 86 | rows = result.mappings().all() 87 | for row in rows: 88 | await db.execute( 89 | """ 90 | INSERT INTO splitpayments.targets ( 91 | id, 92 | wallet, 93 | source, 94 | percent, 95 | tag, 96 | alias 97 | ) 98 | VALUES (:id, :wallet, :source, :percent, :tag, :alias) 99 | """, 100 | { 101 | "id": urlsafe_short_hash(), 102 | "wallet": row["wallet"], 103 | "source": row["source"], 104 | "percent": row["percent"], 105 | "tag": row["tag"], 106 | "alias": row["alias"], 107 | }, 108 | ) 109 | 110 | await db.execute("DROP TABLE splitpayments.splitpayments_m002") 111 | 112 | 113 | async def m004_remove_tag(db: Connection): 114 | """ 115 | This removes tag 116 | """ 117 | keys = "id,wallet,source,percent,alias" 118 | new_db = "splitpayments.targets" 119 | old_db = "splitpayments.targets_m003" 120 | 121 | await db.execute(f"ALTER TABLE {new_db} RENAME TO targets_m003") 122 | 123 | await db.execute( 124 | f""" 125 | CREATE TABLE {new_db} ( 126 | id TEXT PRIMARY KEY, 127 | wallet TEXT NOT NULL, 128 | source TEXT NOT NULL, 129 | percent REAL NOT NULL CHECK (percent >= 0 AND percent <= 100), 130 | alias TEXT, 131 | UNIQUE (source, wallet) 132 | ); 133 | """ 134 | ) 135 | await db.execute(f"INSERT INTO {new_db} ({keys}) SELECT {keys} FROM {old_db}") 136 | await db.execute(f"DROP TABLE {old_db}") 137 | -------------------------------------------------------------------------------- /models.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from fastapi import Query 4 | from pydantic import BaseModel 5 | 6 | 7 | class Target(BaseModel): 8 | id: str 9 | wallet: str 10 | source: str 11 | percent: float 12 | alias: Optional[str] = None 13 | 14 | 15 | class TargetPut(BaseModel): 16 | wallet: str = Query(...) 17 | alias: str = Query("") 18 | percent: float = Query(..., ge=0, le=100) 19 | 20 | 21 | class TargetPutList(BaseModel): 22 | targets: list[TargetPut] 23 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "splitpayments", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "splitpayments", 9 | "version": "1.0.0", 10 | "license": "ISC", 11 | "dependencies": { 12 | "prettier": "^3.2.5", 13 | "pyright": "^1.1.358" 14 | } 15 | }, 16 | "node_modules/fsevents": { 17 | "version": "2.3.3", 18 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 19 | "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 20 | "hasInstallScript": true, 21 | "optional": true, 22 | "os": [ 23 | "darwin" 24 | ], 25 | "engines": { 26 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 27 | } 28 | }, 29 | "node_modules/prettier": { 30 | "version": "3.3.3", 31 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", 32 | "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", 33 | "bin": { 34 | "prettier": "bin/prettier.cjs" 35 | }, 36 | "engines": { 37 | "node": ">=14" 38 | }, 39 | "funding": { 40 | "url": "https://github.com/prettier/prettier?sponsor=1" 41 | } 42 | }, 43 | "node_modules/pyright": { 44 | "version": "1.1.374", 45 | "resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.374.tgz", 46 | "integrity": "sha512-ISbC1YnYDYrEatoKKjfaA5uFIp0ddC/xw9aSlN/EkmwupXUMVn41Jl+G6wHEjRhC+n4abHZeGpEvxCUus/K9dA==", 47 | "bin": { 48 | "pyright": "index.js", 49 | "pyright-langserver": "langserver.index.js" 50 | }, 51 | "engines": { 52 | "node": ">=14.0.0" 53 | }, 54 | "optionalDependencies": { 55 | "fsevents": "~2.3.3" 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "splitpayments", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "prettier": "^3.2.5", 13 | "pyright": "^1.1.358" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "lnbits-splitpayments" 3 | version = "0.0.0" 4 | description = "LNbits, free and open-source Lightning wallet and accounts system." 5 | authors = ["Alan Bits "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.10 | ^3.9" 9 | lnbits = {version = "*", allow-prereleases = true} 10 | 11 | [tool.poetry.group.dev.dependencies] 12 | black = "^24.3.0" 13 | pytest-asyncio = "^0.21.0" 14 | pytest = "^7.3.2" 15 | mypy = "^1.5.1" 16 | pre-commit = "^3.2.2" 17 | ruff = "^0.3.2" 18 | 19 | [build-system] 20 | requires = ["poetry-core>=1.0.0"] 21 | build-backend = "poetry.core.masonry.api" 22 | 23 | [tool.mypy] 24 | exclude = "(nostr/*)" 25 | [[tool.mypy.overrides]] 26 | module = [ 27 | "lnbits.*", 28 | "lnurl.*", 29 | "loguru.*", 30 | "fastapi.*", 31 | "pydantic.*", 32 | "pyqrcode.*", 33 | "shortuuid.*", 34 | "httpx.*", 35 | ] 36 | ignore_missing_imports = "True" 37 | 38 | [tool.pytest.ini_options] 39 | log_cli = false 40 | testpaths = [ 41 | "tests" 42 | ] 43 | 44 | [tool.black] 45 | line-length = 88 46 | 47 | [tool.ruff] 48 | # Same as Black. + 10% rule of black 49 | line-length = 88 50 | exclude = [ 51 | "nostr", 52 | ] 53 | 54 | [tool.ruff.lint] 55 | # Enable: 56 | # F - pyflakes 57 | # E - pycodestyle errors 58 | # W - pycodestyle warnings 59 | # I - isort 60 | # A - flake8-builtins 61 | # C - mccabe 62 | # N - naming 63 | # UP - pyupgrade 64 | # RUF - ruff 65 | # B - bugbear 66 | select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"] 67 | ignore = ["C901"] 68 | 69 | # Allow autofix for all enabled rules (when `--fix`) is provided. 70 | fixable = ["ALL"] 71 | unfixable = [] 72 | 73 | # Allow unused variables when underscore-prefixed. 74 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 75 | 76 | # needed for pydantic 77 | [tool.ruff.lint.pep8-naming] 78 | classmethod-decorators = [ 79 | "root_validator", 80 | ] 81 | 82 | # Ignore unused imports in __init__.py files. 83 | # [tool.ruff.lint.extend-per-file-ignores] 84 | # "__init__.py" = ["F401", "F403"] 85 | 86 | # [tool.ruff.lint.mccabe] 87 | # max-complexity = 10 88 | 89 | [tool.ruff.lint.flake8-bugbear] 90 | # Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`. 91 | extend-immutable-calls = [ 92 | "fastapi.Depends", 93 | "fastapi.Query", 94 | ] 95 | -------------------------------------------------------------------------------- /static/image/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnbits/splitpayments/4e2ebb7944935129db1a988102482e35738642f8/static/image/1.png -------------------------------------------------------------------------------- /static/image/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnbits/splitpayments/4e2ebb7944935129db1a988102482e35738642f8/static/image/2.png -------------------------------------------------------------------------------- /static/image/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnbits/splitpayments/4e2ebb7944935129db1a988102482e35738642f8/static/image/3.png -------------------------------------------------------------------------------- /static/image/split-payments.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnbits/splitpayments/4e2ebb7944935129db1a988102482e35738642f8/static/image/split-payments.png -------------------------------------------------------------------------------- /static/js/index.js: -------------------------------------------------------------------------------- 1 | function hashTargets(targets) { 2 | return targets 3 | .filter(isTargetComplete) 4 | .map(({wallet, percent, alias}) => `${wallet}${percent}${alias}`) 5 | .join('') 6 | } 7 | 8 | function isTargetComplete(target) { 9 | return ( 10 | target.wallet && 11 | target.wallet.trim() !== '' && 12 | (target.percent > 0 || target.tag != '') 13 | ) 14 | } 15 | 16 | window.app = Vue.createApp({ 17 | el: '#vue', 18 | mixins: [windowMixin], 19 | watch: { 20 | selectedWallet() { 21 | this.getTargets() 22 | } 23 | }, 24 | data() { 25 | return { 26 | selectedWallet: null, 27 | currentHash: '', // a string that must match if the edit data is unchanged 28 | targets: [] 29 | } 30 | }, 31 | computed: { 32 | isDirty() { 33 | return hashTargets(this.targets) !== this.currentHash 34 | } 35 | }, 36 | methods: { 37 | clearTarget(index) { 38 | if (this.targets.length == 1) { 39 | return this.deleteTargets() 40 | } 41 | this.targets.splice(index, 1) 42 | Quasar.Notify.create({ 43 | message: 'Removed item. You must click to save manually.', 44 | timeout: 500 45 | }) 46 | }, 47 | getTargets() { 48 | LNbits.api 49 | .request( 50 | 'GET', 51 | '/splitpayments/api/v1/targets', 52 | this.selectedWallet.adminkey 53 | ) 54 | .then(response => { 55 | this.targets = response.data 56 | }) 57 | .catch(err => { 58 | LNbits.utils.notifyApiError(err) 59 | }) 60 | }, 61 | changedWallet(wallet) { 62 | this.selectedWallet = wallet 63 | this.getTargets() 64 | }, 65 | addTarget() { 66 | this.targets.push({source: this.selectedWallet}) 67 | }, 68 | saveTargets() { 69 | LNbits.api 70 | .request( 71 | 'PUT', 72 | '/splitpayments/api/v1/targets', 73 | this.selectedWallet.adminkey, 74 | { 75 | targets: this.targets 76 | } 77 | ) 78 | .then(response => { 79 | Quasar.Notify.create({ 80 | message: 'Split payments targets set.', 81 | timeout: 700 82 | }) 83 | }) 84 | .catch(err => { 85 | LNbits.utils.notifyApiError(err) 86 | }) 87 | }, 88 | deleteTargets() { 89 | LNbits.utils 90 | .confirmDialog('Are you sure you want to delete all targets?') 91 | .onOk(() => { 92 | this.targets = [] 93 | LNbits.api 94 | .request( 95 | 'DELETE', 96 | '/splitpayments/api/v1/targets', 97 | this.selectedWallet.adminkey 98 | ) 99 | .then(response => { 100 | Quasar.Notify.create({ 101 | message: 'Split payments targets deleted.', 102 | timeout: 700 103 | }) 104 | }) 105 | .catch(err => { 106 | LNbits.utils.notifyApiError(err) 107 | }) 108 | }) 109 | } 110 | }, 111 | created() { 112 | this.selectedWallet = this.g.user.wallets[0] 113 | } 114 | }) 115 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from math import floor 4 | from typing import Optional 5 | 6 | import bolt11 7 | import httpx 8 | from lnbits.core.crud import get_standalone_payment 9 | from lnbits.core.crud.wallets import get_wallet_for_key 10 | from lnbits.core.models import Payment 11 | from lnbits.core.services import create_invoice, fee_reserve, pay_invoice 12 | from lnbits.tasks import register_invoice_listener 13 | from loguru import logger 14 | 15 | from .crud import get_targets 16 | 17 | 18 | async def wait_for_paid_invoices(): 19 | invoice_queue = asyncio.Queue() 20 | register_invoice_listener(invoice_queue, "ext_splitpayments_invoice_listener") 21 | while True: 22 | payment = await invoice_queue.get() 23 | await on_invoice_paid(payment) 24 | 25 | 26 | async def on_invoice_paid(payment: Payment) -> None: 27 | 28 | if payment.extra.get("tag") == "splitpayments" or payment.extra.get("splitted"): 29 | # already a splitted payment, ignore 30 | return 31 | 32 | targets = await get_targets(payment.wallet_id) 33 | 34 | if not targets: 35 | return 36 | 37 | total_percent = sum([target.percent for target in targets]) 38 | 39 | if total_percent > 100: 40 | logger.error("splitpayment: total percent adds up to more than 100%") 41 | return 42 | 43 | logger.trace(f"splitpayments: performing split payments to {len(targets)} targets") 44 | 45 | for target in targets: 46 | if target.percent > 0: 47 | amount_msat = int(payment.amount * target.percent / 100) 48 | memo = ( 49 | f"Split payment: {target.percent}% " 50 | f"for {target.alias or target.wallet}" 51 | f";{payment.memo};{payment.payment_hash}" 52 | ) 53 | 54 | if "@" in target.wallet or "LNURL" in target.wallet: 55 | safe_amount_msat = amount_msat - fee_reserve(amount_msat) 56 | payment_request = await get_lnurl_invoice( 57 | target.wallet, payment.wallet_id, safe_amount_msat, memo 58 | ) 59 | else: 60 | wallet = await get_wallet_for_key(target.wallet) 61 | if wallet is not None: 62 | target.wallet = wallet.id 63 | new_payment = await create_invoice( 64 | wallet_id=target.wallet, 65 | amount=int(amount_msat / 1000), 66 | internal=True, 67 | memo=memo, 68 | ) 69 | payment_request = new_payment.bolt11 70 | 71 | extra = {**payment.extra, "splitted": True} 72 | 73 | if payment_request: 74 | task = asyncio.create_task( 75 | pay_invoice_in_background( 76 | payment_request=payment_request, 77 | wallet_id=payment.wallet_id, 78 | description=memo, 79 | extra=extra, 80 | ) 81 | ) 82 | task.add_done_callback(lambda fut: logger.success(fut.result())) 83 | 84 | 85 | async def pay_invoice_in_background(payment_request, wallet_id, description, extra): 86 | try: 87 | await pay_invoice( 88 | payment_request=payment_request, 89 | wallet_id=wallet_id, 90 | description=description, 91 | extra=extra, 92 | ) 93 | return f"Splitpayments: paid invoice for {description}" 94 | except Exception as e: 95 | logger.error(f"Failed to pay invoice: {e}") 96 | 97 | 98 | async def get_lnurl_invoice( 99 | payoraddress, wallet_id, amount_msat, memo 100 | ) -> Optional[str]: 101 | 102 | from lnbits.core.views.api import api_lnurlscan 103 | 104 | data = await api_lnurlscan(payoraddress) 105 | rounded_amount = floor(amount_msat / 1000) * 1000 106 | 107 | async with httpx.AsyncClient() as client: 108 | try: 109 | r = await client.get( 110 | data["callback"], 111 | params={"amount": rounded_amount, "comment": memo}, 112 | timeout=5, 113 | ) 114 | if r.is_error: 115 | raise httpx.ConnectError("issue with scrub callback") 116 | r.raise_for_status() 117 | except (httpx.ConnectError, httpx.RequestError): 118 | logger.error( 119 | f"splitting LNURL failed: Failed to connect to {data['callback']}." 120 | ) 121 | return None 122 | except Exception as exc: 123 | logger.error(f"splitting LNURL failed: {exc!s}.") 124 | return None 125 | 126 | params = json.loads(r.text) 127 | if params.get("status") == "ERROR": 128 | logger.error(f"{data['callback']} said: '{params.get('reason', '')}'") 129 | return None 130 | 131 | invoice = bolt11.decode(params["pr"]) 132 | 133 | lnurlp_payment = await get_standalone_payment(invoice.payment_hash) 134 | 135 | if lnurlp_payment and lnurlp_payment.wallet_id == wallet_id: 136 | logger.error("split failed. cannot split payments to yourself via LNURL.") 137 | return None 138 | 139 | if invoice.amount_msat != rounded_amount: 140 | logger.error( 141 | f""" 142 | {data['callback']} returned an invalid invoice. 143 | Expected {amount_msat} msat, got {invoice.amount_msat}. 144 | """ 145 | ) 146 | return None 147 | 148 | return params["pr"] 149 | -------------------------------------------------------------------------------- /templates/splitpayments/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} {% from "macros.jinja" import window_vars with context 2 | %} {% block page %} 3 |
4 |
5 | 6 | 7 | 8 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 |
Target Wallets
25 |
26 | 27 | 28 |
33 | 41 | 42 | 54 | 55 | 63 | 64 | 71 |
72 |
73 |
74 | 75 | Add Target 76 | 77 |
78 |
79 | 80 | Delete all Targets 81 | 82 |
83 | 84 |
85 | 91 | Save Targets 92 | 93 |
94 |
95 |
96 |
97 |
98 |
99 | 100 |
101 | 102 | 103 |
104 | {{SITE_TITLE}} SplitPayments extension 105 |
106 |
107 | 108 | 109 | 110 |

111 | Add some targets to the list of "Target Wallets", each with an 112 | associated percentage. After saving, every time any payment 113 | arrives at the "Source Wallet" that payment will be split with the 114 | target wallets according to their percentage. 115 |

116 |

117 | This is valid for every payment, doesn't matter how it was created. 118 |

119 |

120 | Targets can be LNBits wallets from this LNBits instance or any valid 121 | LNURL or LN Address. 122 |

123 |

124 | LNURLp and LN Addresses must allow comments > 100 chars and also 125 | have a flexible amount. 126 |

127 |

128 | To remove a wallet from the targets list just press the X and save. 129 | To remove all, click "Delete all Targets". 130 |

131 |

132 | For each split via LNURLp or Lightning addresses a fee_reserve is 133 | substracted, because of potential routing fees. 134 |

135 |
136 |
137 |
138 |
139 |
140 | {% endblock %} {% block scripts %} {{ window_vars(user) }} 141 | 142 | {% endblock %} 143 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lnbits/splitpayments/4e2ebb7944935129db1a988102482e35738642f8/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import APIRouter 3 | 4 | from .. import splitpayments_ext 5 | 6 | 7 | # just import router and add it to a test router 8 | @pytest.mark.asyncio 9 | async def test_router(): 10 | router = APIRouter() 11 | router.include_router(splitpayments_ext) 12 | -------------------------------------------------------------------------------- /toc.md: -------------------------------------------------------------------------------- 1 | # Terms and Conditions for LNbits Extension 2 | 3 | ## 1. Acceptance of Terms 4 | 5 | By installing and using the LNbits extension ("Extension"), you agree to be bound by these terms and conditions ("Terms"). If you do not agree to these Terms, do not use the Extension. 6 | 7 | ## 2. License 8 | 9 | The Extension is free and open-source software, released under [specify the FOSS license here, e.g., GPL-3.0, MIT, etc.]. You are permitted to use, copy, modify, and distribute the Extension under the terms of that license. 10 | 11 | ## 3. No Warranty 12 | 13 | The Extension is provided "as is" and with all faults, and the developer expressly disclaims all warranties of any kind, whether express, implied, statutory, or otherwise, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, and any warranties arising out of course of dealing or usage of trade. No advice or information, whether oral or written, obtained from the developer or elsewhere will create any warranty not expressly stated in this Terms. 14 | 15 | ## 4. Limitation of Liability 16 | 17 | In no event will the developer be liable to you or any third party for any direct, indirect, incidental, special, consequential, or punitive damages, including lost profit, lost revenue, loss of data, or other damages arising out of or in connection with your use of the Extension, even if the developer has been advised of the possibility of such damages. The foregoing limitation of liability shall apply to the fullest extent permitted by law in the applicable jurisdiction. 18 | 19 | ## 5. Modification of Terms 20 | 21 | The developer reserves the right to modify these Terms at any time. You are advised to review these Terms periodically for any changes. Changes to these Terms are effective when they are posted on the appropriate location within or associated with the Extension. 22 | 23 | ## 6. General Provisions 24 | 25 | If any provision of these Terms is held to be invalid or unenforceable, that provision will be enforced to the maximum extent permissible, and the other provisions of these Terms will remain in full force and effect. These Terms constitute the entire agreement between you and the developer regarding the use of the Extension. 26 | 27 | ## 7. Contact Information 28 | 29 | If you have any questions about these Terms, please contact the developer at [developer's contact information]. 30 | -------------------------------------------------------------------------------- /views.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, Request 2 | from fastapi.responses import HTMLResponse 3 | from lnbits.core.models import User 4 | from lnbits.decorators import check_user_exists 5 | from lnbits.helpers import template_renderer 6 | 7 | splitpayments_generic_router = APIRouter() 8 | 9 | 10 | def splitpayments_renderer(): 11 | return template_renderer(["splitpayments/templates"]) 12 | 13 | 14 | @splitpayments_generic_router.get("/", response_class=HTMLResponse) 15 | async def index(request: Request, user: User = Depends(check_user_exists)): 16 | return splitpayments_renderer().TemplateResponse( 17 | "splitpayments/index.html", {"request": request, "user": user.json()} 18 | ) 19 | -------------------------------------------------------------------------------- /views_api.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from fastapi import APIRouter, Depends, HTTPException 4 | from lnbits.core.crud import get_wallet, get_wallet_for_key 5 | from lnbits.core.models import WalletTypeInfo 6 | from lnbits.decorators import require_admin_key 7 | from lnbits.helpers import urlsafe_short_hash 8 | from loguru import logger 9 | 10 | from .crud import get_targets, set_targets 11 | from .models import Target, TargetPutList 12 | 13 | splitpayments_api_router = APIRouter() 14 | 15 | 16 | @splitpayments_api_router.get("/api/v1/targets") 17 | async def api_targets_get( 18 | wallet: WalletTypeInfo = Depends(require_admin_key), 19 | ) -> list[Target]: 20 | targets = await get_targets(wallet.wallet.id) 21 | return targets or [] 22 | 23 | 24 | @splitpayments_api_router.put("/api/v1/targets", status_code=HTTPStatus.OK) 25 | async def api_targets_set( 26 | target_put: TargetPutList, 27 | source_wallet: WalletTypeInfo = Depends(require_admin_key), 28 | ) -> None: 29 | try: 30 | targets: list[Target] = [] 31 | for entry in target_put.targets: 32 | 33 | if entry.wallet.find("@") < 0 and entry.wallet.find("LNURL") < 0: 34 | wallet = await get_wallet(entry.wallet) 35 | if not wallet: 36 | wallet = await get_wallet_for_key(entry.wallet) 37 | if not wallet: 38 | raise HTTPException( 39 | status_code=HTTPStatus.BAD_REQUEST, 40 | detail=f"Invalid wallet '{entry.wallet}'.", 41 | ) 42 | 43 | if wallet.id == source_wallet.wallet.id: 44 | raise HTTPException( 45 | status_code=HTTPStatus.BAD_REQUEST, 46 | detail="Can't split to itself.", 47 | ) 48 | 49 | if entry.percent <= 0: 50 | raise HTTPException( 51 | status_code=HTTPStatus.BAD_REQUEST, 52 | detail=f"Invalid percent '{entry.percent}'.", 53 | ) 54 | 55 | targets.append( 56 | Target( 57 | id=urlsafe_short_hash(), 58 | wallet=entry.wallet, 59 | source=source_wallet.wallet.id, 60 | percent=entry.percent, 61 | alias=entry.alias, 62 | ) 63 | ) 64 | 65 | percent_sum = sum([target.percent for target in targets]) 66 | if percent_sum > 100: 67 | raise HTTPException( 68 | status_code=HTTPStatus.BAD_REQUEST, detail="Splitting over 100%" 69 | ) 70 | 71 | await set_targets(source_wallet.wallet.id, targets) 72 | 73 | except Exception as ex: 74 | logger.warning(ex) 75 | raise HTTPException( 76 | status_code=HTTPStatus.INTERNAL_SERVER_ERROR, 77 | detail="Cannot set targets.", 78 | ) from ex 79 | 80 | 81 | @splitpayments_api_router.delete("/api/v1/targets", status_code=HTTPStatus.OK) 82 | async def api_targets_delete( 83 | source_wallet: WalletTypeInfo = Depends(require_admin_key), 84 | ) -> None: 85 | await set_targets(source_wallet.wallet.id, []) 86 | --------------------------------------------------------------------------------