diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..4e02f97 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,10 @@ +name: lint +on: + push: + branches: + - main + pull_request: + +jobs: + lint: + uses: lnbits/lnbits/.github/workflows/lint.yml@dev diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ec9b48..27c8a60 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore index bee8a64..0152b6e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ __pycache__ +node_modules +.mypy_cache +.venv diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..725c398 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,12 @@ +{ + "semi": false, + "arrowParens": "avoid", + "insertPragma": false, + "printWidth": 80, + "proseWrap": "preserve", + "singleQuote": true, + "trailingComma": "none", + "useTabs": false, + "bracketSameLine": false, + "bracketSpacing": false +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0fac253 --- /dev/null +++ b/Makefile @@ -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" diff --git a/README.md b/README.md index 86d7656..c93e897 100644 --- a/README.md +++ b/README.md @@ -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 Bildschirm­foto 2023-05-01 um 22 14 36 Bildschirm­foto 2023-05-01 um 22 17 52 diff --git a/__init__.py b/__init__.py index aeca6cd..5132323 100644 --- a/__init__.py +++ b/__init__.py @@ -1,15 +1,12 @@ import asyncio -from typing import List from fastapi import APIRouter +from loguru import logger -from lnbits.db import Database -from lnbits.helpers import template_renderer -from lnbits.tasks import catch_everything_and_restart - -db = Database("ext_splitpayments") - -scheduled_tasks: List[asyncio.Task] = [] +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 = [ { @@ -20,18 +17,31 @@ splitpayments_static_files = [ splitpayments_ext: APIRouter = APIRouter( prefix="/splitpayments", tags=["splitpayments"] ) +splitpayments_ext.include_router(splitpayments_generic_router) +splitpayments_ext.include_router(splitpayments_api_router) + +scheduled_tasks: list[asyncio.Task] = [] -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 +def splitpayments_stop(): + for task in scheduled_tasks: + try: + task.cancel() + except Exception as ex: + logger.warning(ex) def splitpayments_start(): - loop = asyncio.get_event_loop() - task = loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) + 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", +] diff --git a/config.json b/config.json index a95c729..2ec4bbf 100644 --- a/config.json +++ b/config.json @@ -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.11.0" + "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" } diff --git a/crud.py b/crud.py index f9a7e04..13a764d 100644 --- a/crud.py +++ b/crud.py @@ -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) diff --git a/description.md b/description.md new file mode 100644 index 0000000..91359cb --- /dev/null +++ b/description.md @@ -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. diff --git a/manifest.json b/manifest.json index 3ee75e1..37c187c 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "repos": [ { "id": "splitpayments", - "organisation": "lnbits", + "organisation": "PatMulligan", "repository": "splitpayments" } ] diff --git a/migrations.py b/migrations.py index 69eb81e..3c0ccab 100644 --- a/migrations.py +++ b/migrations.py @@ -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} ( diff --git a/models.py b/models.py index 854a4a2..a514c32 100644 --- a/models.py +++ b/models.py @@ -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] diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..bacebcf --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8af073b --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6c057b3 --- /dev/null +++ b/pyproject.toml @@ -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", +] diff --git a/static/image/1.png b/static/image/1.png new file mode 100644 index 0000000..4a6d516 Binary files /dev/null and b/static/image/1.png differ diff --git a/static/image/2.png b/static/image/2.png new file mode 100644 index 0000000..18ee47b Binary files /dev/null and b/static/image/2.png differ diff --git a/static/image/3.png b/static/image/3.png new file mode 100644 index 0000000..0de5289 Binary files /dev/null and b/static/image/3.png differ diff --git a/static/js/index.js b/static/js/index.js index 1ea7a8c..76b6fab 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -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() } }) diff --git a/tasks.py b/tasks.py index 13b1659..9951662 100644 --- a/tasks.py +++ b/tasks.py @@ -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 diff --git a/templates/splitpayments/index.html b/templates/splitpayments/index.html index d14dcfc..76103f2 100644 --- a/templates/splitpayments/index.html +++ b/templates/splitpayments/index.html @@ -9,10 +9,9 @@ filled dense :options="g.user.wallets" - :value="selectedWallet" + v-model="selectedWallet" label="Source Wallet:" option-label="name" - @input="changedWallet" > @@ -34,17 +33,35 @@ - +
+
+ +
+
+ +
+
+ + 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,24 +75,11 @@ 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: await set_targets(source_wallet.wallet.id, []) - - -# deinit extension invoice listener -@splitpayments_ext.delete( - "/api/v1", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)] -) -async def api_stop(): - for t in scheduled_tasks: - try: - t.cancel() - except Exception as ex: - logger.warning(ex) - return {"success": True}