Compare commits

...

18 commits

Author SHA1 Message Date
PatMulligan
138350dab4
Merge branch 'lnbits:main' into main
Some checks failed
lint.yml / Merge branch 'lnbits:main' into main (push) Failing after 0s
2026-05-14 09:27:26 +02:00
arbadacarba
766d317ce8
Fix typos (#38)
Removed duplicate lines regarding LNURLp and LNaddress handling in the README.
2026-05-05 22:57:44 +01:00
Arc
81184a0a53
feat: adds a wallet select to picking wallet + uv (#37)
* Adds a wallet select to picking wallet

* added uv

* uv uv uv

---------

Co-authored-by: dni  <office@dnilabs.com>
2026-05-05 22:53:59 +01:00
dni ⚡
d963af4042
chore: update to v1.1.0 (#36)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-08-18 12:12:36 +02:00
dni ⚡
b457fecc90
refactor: use get_pr_from_lnurl instead of api_lnurlscan (#35)
* refactor: use `get_pr_from_lnurl` instead of `api_lnurlscan`
2025-08-18 12:11:30 +02:00
Tiago Vasconcelos
d1c14ac199
Update pyproject.toml 2025-07-11 09:50:23 +01:00
Patrick Mulligan
3f87a5e0ec change org
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-06-17 17:34:30 +02:00
dni ⚡
4e2ebb7944
feat: pass previous memo through (#31)
now the memo of the payment has this form
`{memo;memo_parent_payment;payment_hash_parent_payment}
2025-02-26 12:56:31 +01:00
dni ⚡
0bf1557ce7
fix: linting (#30) 2025-02-26 12:36:41 +01:00
Sat
c524bcee69
Handle hodl invoices asynchronously tasks.py (#25)
- Modified the on_invoice_paid function to call pay_invoice asynchronously using asyncio.create_task.
- Added a new function pay_invoice_in_background to handle the asynchronous payment processing and exception handling.
- Ensured that the main event loop remains responsive even when encountering hodl invoices by running pay_invoice in the background.
2025-02-26 12:22:52 +01:00
Arc
abe8250839
Merge pull request #29 from lnbits/fix_key_based_sending
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
Fix: Keys were not working for splits
2025-02-12 13:49:21 +00:00
arcbtc
c36b5131c0 Fix: Keys were not working for splits 2025-02-12 13:48:34 +00:00
dni ⚡
980b9eaaef
fix: migration m002
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2024-11-28 12:34:02 +01:00
dni ⚡
5042d40af6
feat: update to lnbits 1.0.0 (#27)
* feat: update to lnbits 1.0.0
* fix select wallet
* fix splits
* fix: types, postgres errors with cache
---------

Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
2024-11-28 12:28:00 +01:00
dni ⚡
32bf4ae1d6
feat: code quality (#26)
* feat: code quality

* fixes
2024-08-02 11:05:26 +03:00
Arc
eb18fda87b
Merge pull request #20 from lnbits/AddExtDescr
Added extra description
2024-06-19 17:31:33 +01:00
Arc
d840f402da
Merge branch 'main' into AddExtDescr 2024-06-19 17:31:22 +01:00
benarc
979d09d244 Added extra description 2024-05-09 12:09:47 +01:00
28 changed files with 2845 additions and 187 deletions

10
.github/workflows/lint.yml vendored Normal file
View file

@ -0,0 +1,10 @@
name: lint
on:
push:
branches:
- main
pull_request:
jobs:
lint:
uses: lnbits/lnbits/.github/workflows/lint.yml@dev

View file

@ -1,10 +1,9 @@
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
- 'v[0-9]+.[0-9]+.[0-9]+'
jobs:
release:
runs-on: ubuntu-latest
steps:
@ -34,12 +33,12 @@ jobs:
- name: Create pull request in extensions repo
env:
GH_TOKEN: ${{ secrets.EXT_GITHUB }}
repo_name: "${{ github.event.repository.name }}"
tag: "${{ github.ref_name }}"
branch: "update-${{ github.event.repository.name }}-${{ github.ref_name }}"
title: "[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}"
body: "https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}"
archive: "https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip"
repo_name: '${{ github.event.repository.name }}'
tag: '${{ github.ref_name }}'
branch: 'update-${{ github.event.repository.name }}-${{ github.ref_name }}'
title: '[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}'
body: 'https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}'
archive: 'https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip'
run: |
cd lnbits-extensions
git checkout -b $branch

3
.gitignore vendored
View file

@ -1 +1,4 @@
__pycache__
node_modules
.mypy_cache
.venv

12
.prettierrc Normal file
View file

@ -0,0 +1,12 @@
{
"semi": false,
"arrowParens": "avoid",
"insertPragma": false,
"printWidth": 80,
"proseWrap": "preserve",
"singleQuote": true,
"trailingComma": "none",
"useTabs": false,
"bracketSameLine": false,
"bracketSpacing": false
}

47
Makefile Normal file
View file

@ -0,0 +1,47 @@
all: format check
format: prettier black ruff
check: mypy pyright checkblack checkruff checkprettier
prettier:
uv run ./node_modules/.bin/prettier --write .
pyright:
uv run ./node_modules/.bin/pyright
mypy:
uv run mypy .
black:
uv run black .
ruff:
uv run ruff check . --fix
checkruff:
uv run ruff check .
checkprettier:
uv run ./node_modules/.bin/prettier --check .
checkblack:
uv run black --check .
checkeditorconfig:
editorconfig-checker
test:
PYTHONUNBUFFERED=1 \
DEBUG=true \
uv run pytest
install-pre-commit-hook:
@echo "Installing pre-commit hook to git"
@echo "Uninstall the hook with uv run pre-commit uninstall"
uv run pre-commit install
pre-commit:
uv run pre-commit run --all-files
checkbundle:
@echo "skipping checkbundle"

View file

@ -33,9 +33,9 @@ LNbits Split Payments extension allows for distributing payments across multiple
IMPORTANT:
- 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.
- 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
- 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
- If you split to a LNURLp or LNaddress through the LNURLp extension make sure your recipients allow comments ! Split&Scrub add a comment in your transaction - and if it is not allowed, the split/scrub will not take place.
- Make sure the LNURLp / LNaddress of the recipient 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
- 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 recipient
<img width="1148" alt="Bildschirm­foto 2023-05-01 um 22 14 36" src="https://user-images.githubusercontent.com/63317640/235534056-49296aeb-7295-4b4e-9f57-914a677f5ad4.png">
<img width="1402" alt="Bildschirm­foto 2023-05-01 um 22 17 52" src="https://user-images.githubusercontent.com/63317640/235534063-b2734654-7c1a-48a3-b48e-32798c232b49.png">

View file

@ -1,13 +1,12 @@
import asyncio
from loguru import logger
from fastapi import APIRouter
from loguru import logger
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import create_permanent_unique_task
db = Database("ext_splitpayments")
from .crud import db
from .tasks import wait_for_paid_invoices
from .views import splitpayments_generic_router
from .views_api import splitpayments_api_router
splitpayments_static_files = [
{
@ -18,19 +17,12 @@ splitpayments_static_files = [
splitpayments_ext: APIRouter = APIRouter(
prefix="/splitpayments", tags=["splitpayments"]
)
def splitpayments_renderer():
return template_renderer(["splitpayments/templates"])
from .tasks import wait_for_paid_invoices
from .views import * # noqa: F401,F403
from .views_api import * # noqa: F401,F403
splitpayments_ext.include_router(splitpayments_generic_router)
splitpayments_ext.include_router(splitpayments_api_router)
scheduled_tasks: list[asyncio.Task] = []
def splitpayments_stop():
for task in scheduled_tasks:
try:
@ -38,6 +30,18 @@ def splitpayments_stop():
except Exception as ex:
logger.warning(ex)
def splitpayments_start():
from lnbits.tasks import create_permanent_unique_task
task = create_permanent_unique_task("ext_splitpayments", wait_for_paid_invoices)
scheduled_tasks.append(task)
__all__ = [
"db",
"splitpayments_ext",
"splitpayments_start",
"splitpayments_static_files",
"splitpayments_stop",
]

View file

@ -2,6 +2,57 @@
"name": "Split Payments",
"short_description": "Split incoming payments across wallets",
"tile": "/splitpayments/static/image/split-payments.png",
"contributors": ["fiatjaf", "cryptograffiti"],
"min_lnbits_version": "0.12.5"
"version": "1.1.0",
"min_lnbits_version": "1.3.0",
"contributors": [
{
"name": "cryptograffiti",
"uri": "https://github.com/cryptograffiti",
"role": "Idea/Sponsor"
},
{
"name": "fiatjaf",
"uri": "https://github.com/fiatjaf",
"role": "Developer"
},
{
"name": "dni",
"uri": "https://github.com/dni",
"role": "Developer"
},
{
"name": "talvasconcelos",
"uri": "https://github.com/talvasconcelos",
"role": "Developer"
},
{
"name": "prusnak",
"uri": "https://github.com/prusnak",
"role": "Developer"
},
{
"name": "arbadacarbaYK",
"uri": "https://github.com/arbadacarbaYK",
"role": "Developer"
},
{
"name": "arcbtc",
"uri": "https://github.com/arcbtc",
"role": "Developer"
}
],
"images": [
{
"uri": "https://raw.githubusercontent.com/lnbits/splitpayments/main/static/image/1.png"
},
{
"uri": "https://raw.githubusercontent.com/lnbits/splitpayments/main/static/image/2.png"
},
{
"uri": "https://raw.githubusercontent.com/lnbits/splitpayments/main/static/image/3.png"
}
],
"description_md": "https://raw.githubusercontent.com/lnbits/splitpayments/main/description.md",
"terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/splitpayments/main/toc.md",
"license": "MIT"
}

36
crud.py
View file

@ -1,35 +1,23 @@
from typing import List
from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import Target
db = Database("ext_splitpayments")
async def get_targets(source_wallet: str) -> List[Target]:
rows = await db.fetchall(
"SELECT * FROM splitpayments.targets WHERE source = ?", (source_wallet,)
async def get_targets(source_wallet: str) -> list[Target]:
return await db.fetchall(
"SELECT * FROM splitpayments.targets WHERE source = :source_wallet",
{"source_wallet": source_wallet},
Target,
)
return [Target(**row) for row in rows]
async def set_targets(source_wallet: str, targets: List[Target]):
async def set_targets(source_wallet: str, targets: list[Target]):
async with db.connect() as conn:
await conn.execute(
"DELETE FROM splitpayments.targets WHERE source = ?", (source_wallet,)
"DELETE FROM splitpayments.targets WHERE source = :source_wallet",
{"source_wallet": source_wallet},
)
for target in targets:
await conn.execute(
"""
INSERT INTO splitpayments.targets
(id, source, wallet, percent, alias)
VALUES (?, ?, ?, ?, ?)
""",
(
urlsafe_short_hash(),
source_wallet,
target.wallet,
target.percent,
target.alias,
),
)
await conn.insert("splitpayments.targets", target)

16
description.md Normal file
View file

@ -0,0 +1,16 @@
Split Payments across multiple wallets/lnaddresses/lnurlps seamlessly!
Once configured, it continuously splits your payments across different wallets.
Usage:
- Enable the Extension: Start by enabling the Split Payments extension.
- Select the Source Wallet: Identify and select the wallet that will receive and subsequently distribute the payments.
- 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.
- Set Distribution Percentages: Specify the percentage of each payment that each wallet should receive. Ensure the total distribution does not exceed 100%.
- Save Your Settings: After adding or deleting wallet information, click “SAVE TARGETS” to activate the payment splits.
Note:
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.
Automatic Payment Splitting:
When the source wallet receives a payment, the extension automatically allocates the specified percentages to each designated wallet.

View file

@ -2,7 +2,7 @@
"repos": [
{
"id": "splitpayments",
"organisation": "lnbits",
"organisation": "PatMulligan",
"repository": "splitpayments"
}
]

View file

@ -1,7 +1,8 @@
from lnbits.db import Connection
from lnbits.helpers import urlsafe_short_hash
async def m001_initial(db):
async def m001_initial(db: Connection):
"""
Initial split payment table.
"""
@ -19,11 +20,12 @@ async def m001_initial(db):
)
async def m002_float_percent(db):
async def m002_float_percent(db: Connection):
"""
Add float percent and migrates the existing data.
alter percent to be float.
"""
await db.execute("ALTER TABLE splitpayments.targets RENAME TO splitpayments_old")
await db.execute("ALTER TABLE splitpayments.targets RENAME TO splitpayments_m001")
await db.execute(
"""
CREATE TABLE splitpayments.targets (
@ -36,11 +38,9 @@ async def m002_float_percent(db):
);
"""
)
for row in [
list(row)
for row in await db.fetchall("SELECT * FROM splitpayments.splitpayments_old")
]:
result = await db.execute("SELECT * FROM splitpayments.splitpayments_m001")
rows = result.mappings().all()
for row in rows:
await db.execute(
"""
INSERT INTO splitpayments.targets (
@ -49,19 +49,25 @@ async def m002_float_percent(db):
percent,
alias
)
VALUES (?, ?, ?, ?)
VALUES (:wallet, :source, :percent, :alias)
""",
(row[0], row[1], row[2], row[3]),
{
"wallet": row["wallet"],
"source": row["source"],
"percent": row["percent"],
"alias": row["alias"],
},
)
await db.execute("DROP TABLE splitpayments.splitpayments_old")
await db.execute("DROP TABLE splitpayments.splitpayments_m001")
async def m003_add_id_and_tag(db):
async def m003_add_id_and_tag(db: Connection):
"""
Add float percent and migrates the existing data.
Add id, tag and migrates the existing data.
"""
await db.execute("ALTER TABLE splitpayments.targets RENAME TO splitpayments_old")
await db.execute("ALTER TABLE splitpayments.targets RENAME TO splitpayments_m002")
await db.execute(
"""
CREATE TABLE splitpayments.targets (
@ -76,11 +82,9 @@ async def m003_add_id_and_tag(db):
);
"""
)
for row in [
list(row)
for row in await db.fetchall("SELECT * FROM splitpayments.splitpayments_old")
]:
result = await db.execute("SELECT * FROM splitpayments.splitpayments_m002")
rows = result.mappings().all()
for row in rows:
await db.execute(
"""
INSERT INTO splitpayments.targets (
@ -91,23 +95,31 @@ async def m003_add_id_and_tag(db):
tag,
alias
)
VALUES (?, ?, ?, ?, ?, ?)
VALUES (:id, :wallet, :source, :percent, :tag, :alias)
""",
(urlsafe_short_hash(), row[0], row[1], row[2], "", row[3]),
{
"id": urlsafe_short_hash(),
"wallet": row["wallet"],
"source": row["source"],
"percent": row["percent"],
"tag": row["tag"],
"alias": row["alias"],
},
)
await db.execute("DROP TABLE splitpayments.splitpayments_old")
await db.execute("DROP TABLE splitpayments.splitpayments_m002")
async def m004_remove_tag(db):
async def m004_remove_tag(db: Connection):
"""
This removes tag
"""
keys = "id,wallet,source,percent,alias"
new_db = "splitpayments.targets"
old_db = "splitpayments.targets_old"
old_db = "splitpayments.targets_m003"
await db.execute(f"ALTER TABLE {new_db} RENAME TO targets_m003")
await db.execute(f"ALTER TABLE {new_db} RENAME TO targets_old")
await db.execute(
f"""
CREATE TABLE {new_db} (

View file

@ -1,19 +1,13 @@
from sqlite3 import Row
from typing import List, Optional
from fastapi import Query
from pydantic import BaseModel
class Target(BaseModel):
id: str
wallet: str
source: str
percent: float
alias: Optional[str]
@classmethod
def from_row(cls, row: Row):
return cls(**dict(row))
alias: str | None = None
class TargetPut(BaseModel):
@ -23,4 +17,4 @@ class TargetPut(BaseModel):
class TargetPutList(BaseModel):
targets: List[TargetPut]
targets: list[TargetPut]

59
package-lock.json generated Normal file
View file

@ -0,0 +1,59 @@
{
"name": "splitpayments",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "splitpayments",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"prettier": "^3.2.5",
"pyright": "^1.1.358"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/prettier": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pyright": {
"version": "1.1.374",
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.374.tgz",
"integrity": "sha512-ISbC1YnYDYrEatoKKjfaA5uFIp0ddC/xw9aSlN/EkmwupXUMVn41Jl+G6wHEjRhC+n4abHZeGpEvxCUus/K9dA==",
"bin": {
"pyright": "index.js",
"pyright-langserver": "langserver.index.js"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
}
}
}

15
package.json Normal file
View file

@ -0,0 +1,15 @@
{
"name": "splitpayments",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"prettier": "^3.2.5",
"pyright": "^1.1.358"
}
}

92
pyproject.toml Normal file
View file

@ -0,0 +1,92 @@
[project]
name = "splitpayments"
version = "1.1.1"
requires-python = ">=3.10,<3.13"
description = "Send incoming payments to different targets"
authors = [{name = "benarc"}, {name = "dni"}, {name = "alan"}]
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/splitpayments" }
dependencies = [ "lnbits>1" ]
[tool.poetry]
package-mode = false
[tool.uv]
dev-dependencies = [
"black",
"pytest-asyncio",
"pytest",
"mypy==1.17.1",
"pre-commit",
"ruff",
"pytest-md",
]
[tool.mypy]
exclude = "(nostr/*)"
plugins = ["pydantic.mypy"]
[tool.pydantic-mypy]
init_forbid_extra = true
init_typed = true
warn_required_dynamic_aliases = true
warn_untyped_fields = true
[tool.pytest.ini_options]
log_cli = false
testpaths = [
"tests"
]
[tool.black]
line-length = 88
[tool.ruff]
# Same as Black. + 10% rule of black
line-length = 88
exclude = [
"nostr",
]
[tool.ruff.lint]
# Enable:
# F - pyflakes
# E - pycodestyle errors
# W - pycodestyle warnings
# I - isort
# A - flake8-builtins
# C - mccabe
# N - naming
# UP - pyupgrade
# RUF - ruff
# B - bugbear
select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"]
ignore = ["C901"]
# Allow autofix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
unfixable = []
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
# needed for pydantic
[tool.ruff.lint.pep8-naming]
classmethod-decorators = [
"validator",
"root_validator",
]
# Ignore unused imports in __init__.py files.
# [tool.ruff.lint.extend-per-file-ignores]
# "__init__.py" = ["F401", "F403"]
# [tool.ruff.lint.mccabe]
# max-complexity = 10
[tool.ruff.lint.flake8-bugbear]
# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.
extend-immutable-calls = [
"fastapi.Depends",
"fastapi.Query",
]

BIN
static/image/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
static/image/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
static/image/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View file

@ -1,7 +1,3 @@
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
Vue.component(VueQrcode.name, VueQrcode)
function hashTargets(targets) {
return targets
.filter(isTargetComplete)
@ -17,9 +13,14 @@ function isTargetComplete(target) {
)
}
new Vue({
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
watch: {
selectedWallet() {
this.getTargets()
}
},
data() {
return {
selectedWallet: null,
@ -34,11 +35,11 @@ new Vue({
},
methods: {
clearTarget(index) {
if(this.targets.length == 1) {
if (this.targets.length == 1) {
return this.deleteTargets()
}
this.targets.splice(index, 1)
this.$q.notify({
Quasar.Notify.create({
message: 'Removed item. You must click to save manually.',
timeout: 500
})
@ -50,8 +51,11 @@ new Vue({
'/splitpayments/api/v1/targets',
this.selectedWallet.adminkey
)
.then(response => {
this.targets = response.data
.then(res => {
this.targets = res.data.map(t => ({
...t,
targetChoice: t.targetChoice || 'wallet'
}))
})
.catch(err => {
LNbits.utils.notifyApiError(err)
@ -62,20 +66,30 @@ new Vue({
this.getTargets()
},
addTarget() {
this.targets.push({source: this.selectedWallet})
this.targets.push({
source: this.selectedWallet,
targetChoice: 'wallet'
})
},
saveTargets() {
const payload = this.targets
.filter(t => t.wallet && String(t.wallet).trim() !== '')
.map(({alias, percent, wallet}) => ({
alias,
percent: Number(percent) || 0,
wallet
}))
LNbits.api
.request(
'PUT',
'/splitpayments/api/v1/targets',
this.selectedWallet.adminkey,
{
targets: this.targets
targets: payload
}
)
.then(response => {
this.$q.notify({
Quasar.Notify.create({
message: 'Split payments targets set.',
timeout: 700
})
@ -96,7 +110,7 @@ new Vue({
this.selectedWallet.adminkey
)
.then(response => {
this.$q.notify({
Quasar.Notify.create({
message: 'Split payments targets deleted.',
timeout: 700
})
@ -109,6 +123,5 @@ new Vue({
},
created() {
this.selectedWallet = this.g.user.wallets[0]
this.getTargets()
}
})

107
tasks.py
View file

@ -1,25 +1,25 @@
import asyncio
import json
from math import floor
from typing import Optional
import httpx
from loguru import logger
from lnbits import bolt11
import bolt11
from lnbits.core.crud import get_standalone_payment
from lnbits.core.crud.wallets import get_wallet_for_key
from lnbits.core.models import Payment
from lnbits.core.services import create_invoice, fee_reserve, pay_invoice
from lnbits.helpers import get_current_extension_name
from lnbits.core.services import (
create_invoice,
fee_reserve,
get_pr_from_lnurl,
pay_invoice,
)
from lnbits.tasks import register_invoice_listener
from loguru import logger
from .crud import get_targets
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue, get_current_extension_name())
register_invoice_listener(invoice_queue, "ext_splitpayments_invoice_listener")
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
@ -45,83 +45,76 @@ async def on_invoice_paid(payment: Payment) -> None:
logger.trace(f"splitpayments: performing split payments to {len(targets)} targets")
for target in targets:
if target.percent > 0:
amount_msat = int(payment.amount * target.percent / 100)
memo = (
f"Split payment: {target.percent}% for {target.alias or target.wallet}"
f"Split payment: {target.percent}% "
f"for {target.alias or target.wallet}"
f";{payment.memo};{payment.payment_hash}"
)
if target.wallet.find("@") >= 0 or target.wallet.find("LNURL") >= 0:
if "@" in target.wallet or "LNURL" in target.wallet:
safe_amount_msat = amount_msat - fee_reserve(amount_msat)
payment_request = await get_lnurl_invoice(
target.wallet, payment.wallet_id, safe_amount_msat, memo
)
else:
_, payment_request = await create_invoice(
wallet = await get_wallet_for_key(target.wallet)
if wallet is not None:
target.wallet = wallet.id
new_payment = await create_invoice(
wallet_id=target.wallet,
amount=int(amount_msat / 1000),
internal=True,
memo=memo,
)
payment_request = new_payment.bolt11
extra = {**payment.extra, "tag": "splitpayments", "splitted": True}
extra = {**payment.extra, "splitted": True}
if payment_request:
await pay_invoice(
payment_request=payment_request,
wallet_id=payment.wallet_id,
description=memo,
extra=extra,
task = asyncio.create_task(
pay_invoice_in_background(
payment_request=payment_request,
wallet_id=payment.wallet_id,
description=memo,
extra=extra,
)
)
task.add_done_callback(lambda fut: logger.success(fut.result()))
async def pay_invoice_in_background(payment_request, wallet_id, description, extra):
try:
await pay_invoice(
payment_request=payment_request,
wallet_id=wallet_id,
description=description,
extra=extra,
)
return f"Splitpayments: paid invoice for {description}"
except Exception as e:
logger.error(f"Failed to pay invoice: {e}")
async def get_lnurl_invoice(
payoraddress, wallet_id, amount_msat, memo
) -> Optional[str]:
payoraddress: str, wallet_id: str, amount_msat: int, memo: str
) -> str | None:
from lnbits.core.views.api import api_lnurlscan
data = await api_lnurlscan(payoraddress)
rounded_amount = floor(amount_msat / 1000) * 1000
async with httpx.AsyncClient() as client:
try:
r = await client.get(
data["callback"],
params={"amount": rounded_amount, "comment": memo},
timeout=40,
)
if r.is_error:
raise httpx.ConnectError("issue with scrub callback")
r.raise_for_status()
except (httpx.ConnectError, httpx.RequestError):
logger.error(
f"splitting LNURL failed: Failed to connect to {data['callback']}."
)
return None
except Exception as exc:
logger.error(f"splitting LNURL failed: {str(exc)}.")
return None
params = json.loads(r.text)
if params.get("status") == "ERROR":
logger.error(f"{data['callback']} said: '{params.get('reason', '')}'")
try:
payment_request = await get_pr_from_lnurl(payoraddress, rounded_amount, memo)
except Exception as e:
logger.error(f"Error getting LNURL invoice: {e!s}")
return None
invoice = bolt11.decode(params["pr"])
invoice = bolt11.decode(payment_request)
lnurlp_payment = await get_standalone_payment(invoice.payment_hash)
if lnurlp_payment and lnurlp_payment.wallet_id == wallet_id:
logger.error(f"split failed. cannot split payments to yourself via LNURL.")
logger.error("split failed. cannot split payments to yourself via LNURL.")
return None
if invoice.amount_msat != rounded_amount:
logger.error(
f"{data['callback']} returned an invalid invoice. Expected {amount_msat} msat, got {invoice.amount_msat}."
)
return None
return params["pr"]
return payment_request

View file

@ -9,10 +9,9 @@
filled
dense
:options="g.user.wallets"
:value="selectedWallet"
v-model="selectedWallet"
label="Source Wallet:"
option-label="name"
@input="changedWallet"
>
</q-select>
</q-form>
@ -39,12 +38,30 @@
:hint="t === targets.length - 1 ? 'A name to identify this target wallet locally.' : undefined"
style="width: 150px"
></q-input>
<div class="column q-mt-none">
<div class="col">
<q-radio
class="float-left"
v-model="target.targetChoice"
val="wallet"
label="wallet"
></q-radio>
</div>
<div class="col">
<q-radio
class="float-left"
v-model="target.targetChoice"
val="lnurl"
label="lnurl"
></q-radio>
</div>
</div>
<q-input
v-if="target.targetChoice === 'lnurl'"
dense
v-model.trim="target.wallet"
label="Target"
hint="A wallet ID, invoice key, LNURLp or Lightning Address."
hint="LNURLp or Lightning Address."
option-label="name"
style="width: 500px"
new-value-mode="add-unique"
@ -52,6 +69,19 @@
input-debounce="0"
emit-value
></q-input>
<q-select
v-if="target.targetChoice === 'wallet'"
class="q-pr-md q-pt-sm"
filled
dense
style="width: 500px"
v-model="target.wallet"
:options="g.user.walletOptions"
emit-value
map-options
label="Receive wallet *"
>
</q-select>
<q-input
style="width: 150px"

0
tests/__init__.py Normal file
View file

11
tests/test_init.py Normal file
View file

@ -0,0 +1,11 @@
import pytest
from fastapi import APIRouter
from .. import splitpayments_ext
# just import router and add it to a test router
@pytest.mark.asyncio
async def test_router():
router = APIRouter()
router.include_router(splitpayments_ext)

29
toc.md Normal file
View file

@ -0,0 +1,29 @@
# Terms and Conditions for LNbits Extension
## 1. Acceptance of Terms
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.
## 2. License
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.
## 3. No Warranty
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.
## 4. Limitation of Liability
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.
## 5. Modification of Terms
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.
## 6. General Provisions
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.
## 7. Contact Information
If you have any questions about these Terms, please contact the developer at [developer's contact information].

2277
uv.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,17 +1,18 @@
from fastapi import Depends, Request
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer
from . import splitpayments_ext, splitpayments_renderer
templates = Jinja2Templates(directory="templates")
splitpayments_generic_router = APIRouter()
@splitpayments_ext.get("/", response_class=HTMLResponse)
def splitpayments_renderer():
return template_renderer(["splitpayments/templates"])
@splitpayments_generic_router.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return splitpayments_renderer().TemplateResponse(
"splitpayments/index.html", {"request": request, "user": user.dict()}
"splitpayments/index.html", {"request": request, "user": user.json()}
)

View file

@ -1,39 +1,39 @@
from http import HTTPStatus
from typing import List
from fastapi import Depends
from loguru import logger
from starlette.exceptions import HTTPException
from fastapi import APIRouter, Depends, HTTPException
from lnbits.core.crud import get_wallet, get_wallet_for_key
from lnbits.decorators import WalletTypeInfo, require_admin_key
from lnbits.core.models import WalletTypeInfo
from lnbits.decorators import require_admin_key
from lnbits.helpers import urlsafe_short_hash
from loguru import logger
from . import splitpayments_ext
from .crud import get_targets, set_targets
from .models import Target, TargetPutList
splitpayments_api_router = APIRouter()
@splitpayments_ext.get("/api/v1/targets")
@splitpayments_api_router.get("/api/v1/targets")
async def api_targets_get(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> List[Target]:
) -> list[Target]:
targets = await get_targets(wallet.wallet.id)
return targets or []
@splitpayments_ext.put("/api/v1/targets", status_code=HTTPStatus.OK)
@splitpayments_api_router.put("/api/v1/targets", status_code=HTTPStatus.OK)
async def api_targets_set(
target_put: TargetPutList,
source_wallet: WalletTypeInfo = Depends(require_admin_key),
) -> None:
try:
targets: List[Target] = []
targets: list[Target] = []
for entry in target_put.targets:
if entry.wallet.find("@") < 0 and entry.wallet.find("LNURL") < 0:
wallet = await get_wallet(entry.wallet)
if not wallet:
wallet = await get_wallet_for_key(entry.wallet, "invoice")
wallet = await get_wallet_for_key(entry.wallet)
if not wallet:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
@ -42,7 +42,8 @@ async def api_targets_set(
if wallet.id == source_wallet.wallet.id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Can't split to itself."
status_code=HTTPStatus.BAD_REQUEST,
detail="Can't split to itself.",
)
if entry.percent <= 0:
@ -53,6 +54,7 @@ async def api_targets_set(
targets.append(
Target(
id=urlsafe_short_hash(),
wallet=entry.wallet,
source=source_wallet.wallet.id,
percent=entry.percent,
@ -73,10 +75,10 @@ async def api_targets_set(
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot set targets.",
)
) from ex
@splitpayments_ext.delete("/api/v1/targets", status_code=HTTPStatus.OK)
@splitpayments_api_router.delete("/api/v1/targets", status_code=HTTPStatus.OK)
async def api_targets_delete(
source_wallet: WalletTypeInfo = Depends(require_admin_key),
) -> None: