splitpayment init

This commit is contained in:
benarc 2021-10-18 12:34:45 +01:00
parent 167a32fed9
commit a574743080
12 changed files with 644 additions and 0 deletions

View File

@ -0,0 +1,34 @@
# Split Payments
## Have payments split between multiple wallets
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.
## Usage
1. After enabling the extension, choose the source wallet that will receive and distribute the Payments
![choose wallet](https://i.imgur.com/nPQudqL.png)
2. Add the wallet or wallets info to split payments to
![split wallets](https://i.imgur.com/5hCNWpg.png) - get the wallet id, or an invoice key from a different wallet. It can be a completely different user as long as it's under the same LNbits instance/domain. You can get the wallet information on the API Info section on every wallet page\
![wallet info](https://i.imgur.com/betqflC.png) - set a wallet _Alias_ for your own identification\
- set how much, in percentage, this wallet will receive from every payment sent to the source wallets
3. When done, click "SAVE TARGETS" to make the splits effective
4. You can have several wallets to split to, as long as the sum of the percentages is under or equal to 100%
5. When the source wallet receives a payment, the extension will automatically split the corresponding values to every wallet\
- on receiving a 20 sats payment\
![get 20 sats payment](https://i.imgur.com/BKp0xvy.png)
- source wallet gets 18 sats\
![source wallet](https://i.imgur.com/GCxDZ5s.png)
- Ben's wallet (the wallet from the example) instantly, and feeless, gets the corresponding 10%, or 2 sats\
![ben wallet](https://i.imgur.com/MfsccNa.png)
## Sponsored by
[![](https://cdn.shopify.com/s/files/1/0826/9235/files/cryptograffiti_logo_clear_background.png?v=1504730421)](https://cryptograffiti.com/)

View File

@ -0,0 +1,38 @@
import asyncio
from fastapi import APIRouter, FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.routing import Mount
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_splitpayments")
copilot_static_files = [
{
"path": "/copilot/static",
"app": StaticFiles(directory="lnbits/extensions/splitpayments/static"),
"name": "copilot_static",
}
]
splitpayments_ext: APIRouter = APIRouter(
prefix="/splitpayments", tags=["splitpayments"]
)
def splitpayments_renderer():
return template_renderer(["lnbits/extensions/splitpayments/templates"])
# from lnbits.tasks import record_async
# splitpayments_ext.record(record_async(register_listeners))
from .views_api import * # noqa
from .views import * # noqa
from .tasks import wait_for_paid_invoices
def splitpayments_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))

View File

@ -0,0 +1,6 @@
{
"name": "Split Payments",
"short_description": "Split incoming payments across wallets",
"icon": "call_split",
"contributors": ["fiatjaf", "cryptograffiti"]
}

View File

@ -0,0 +1,27 @@
from typing import List
from . import db
from .models import Target
async def get_targets(source_wallet: str) -> List[Target]:
rows = await db.fetchall(
"SELECT * FROM splitpayments.targets WHERE source = ?", (source_wallet,)
)
return [Target(**dict(row)) for row in rows]
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,)
)
for target in targets:
await conn.execute(
"""
INSERT INTO splitpayments.targets
(source, wallet, percent, alias)
VALUES (?, ?, ?, ?)
""",
(source_wallet, target.wallet, target.percent, target.alias),
)

View File

@ -0,0 +1,16 @@
async def m001_initial(db):
"""
Initial split payment table.
"""
await db.execute(
"""
CREATE TABLE splitpayments.targets (
wallet TEXT NOT NULL,
source TEXT NOT NULL,
percent INTEGER NOT NULL CHECK (percent >= 0 AND percent <= 100),
alias TEXT,
UNIQUE (source, wallet)
);
"""
)

View File

@ -0,0 +1,21 @@
from pydantic.main import BaseModel
from pydantic import BaseModel
from fastapi import FastAPI, Request
from typing import List
class Target(BaseModel):
wallet: str
source: str
percent: int
alias: str
class TargetPutList(BaseModel):
wallet: str
aliat: str
percent: int
class TargetPut(BaseModel):
targets: List[TargetPutList]

View File

@ -0,0 +1,143 @@
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
Vue.component(VueQrcode.name, VueQrcode)
function hashTargets(targets) {
return targets
.filter(isTargetComplete)
.map(({wallet, percent, alias}) => `${wallet}${percent}${alias}`)
.join('')
}
function isTargetComplete(target) {
return target.wallet && target.wallet.trim() !== '' && target.percent > 0
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data() {
return {
selectedWallet: null,
currentHash: '', // a string that must match if the edit data is unchanged
targets: []
}
},
computed: {
isDirty() {
return hashTargets(this.targets) !== this.currentHash
}
},
methods: {
clearTargets() {
this.targets = [{}]
this.$q.notify({
message:
'Cleared the form, but not saved. You must click to save manually.',
timeout: 500
})
},
getTargets() {
LNbits.api
.request(
'GET',
'/splitpayments/api/v1/targets',
this.selectedWallet.adminkey
)
.catch(err => {
LNbits.utils.notifyApiError(err)
})
.then(response => {
this.currentHash = hashTargets(response.data)
this.targets = response.data.concat({})
})
},
changedWallet(wallet) {
this.selectedWallet = wallet
this.getTargets()
},
targetChanged(isPercent, index) {
// fix percent min and max range
if (isPercent) {
if (this.targets[index].percent > 100) this.targets[index].percent = 100
if (this.targets[index].percent < 0) this.targets[index].percent = 0
}
// remove empty lines (except last)
if (this.targets.length >= 2) {
for (let i = this.targets.length - 2; i >= 0; i--) {
let target = this.targets[i]
if (
(!target.wallet || target.wallet.trim() === '') &&
(!target.alias || target.alias.trim() === '') &&
!target.percent
) {
this.targets.splice(i, 1)
}
}
}
// add a line at the end if the last one is filled
let last = this.targets[this.targets.length - 1]
if (last.wallet && last.wallet.trim() !== '' && last.percent > 0) {
this.targets.push({})
}
// sum of all percents
let currentTotal = this.targets.reduce(
(acc, target) => acc + (target.percent || 0),
0
)
// remove last (unfilled) line if the percent is already 100
if (currentTotal >= 100) {
let last = this.targets[this.targets.length - 1]
if (
(!last.wallet || last.wallet.trim() === '') &&
(!last.alias || last.alias.trim() === '') &&
!last.percent
) {
this.targets = this.targets.slice(0, -1)
}
}
// adjust percents of other lines (not this one)
if (currentTotal > 100 && isPercent) {
let diff = (currentTotal - 100) / (100 - this.targets[index].percent)
this.targets.forEach((target, t) => {
if (t !== index) target.percent -= Math.round(diff * target.percent)
})
}
// overwrite so changes appear
this.targets = this.targets
},
saveTargets() {
LNbits.api
.request(
'PUT',
'/splitpayments/api/v1/targets',
this.selectedWallet.adminkey,
{
targets: this.targets
.filter(isTargetComplete)
.map(({wallet, percent, alias}) => ({wallet, percent, alias}))
}
)
.then(response => {
this.$q.notify({
message: 'Split payments targets set.',
timeout: 700
})
this.getTargets()
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
}
},
created() {
this.selectedWallet = this.g.user.wallets[0]
this.getTargets()
}
})

View File

@ -0,0 +1,81 @@
import json
from lnbits.core.models import Payment
from lnbits.core.crud import create_payment
from lnbits.core import db as core_db
from lnbits.tasks import register_invoice_listener, internal_invoice_paid
from lnbits.helpers import urlsafe_short_hash
from .crud import get_targets
import asyncio
import httpx
from lnbits.core import db as core_db
from lnbits.core.models import Payment
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue)
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
if "splitpayments" == payment.extra.get("tag") or payment.extra.get("splitted"):
# already splitted, ignore
return
# now we make some special internal transfers (from no one to the receiver)
targets = await get_targets(payment.wallet_id)
transfers = [
(target.wallet, int(target.percent * payment.amount / 100))
for target in targets
]
transfers = [(wallet, amount) for wallet, amount in transfers if amount > 0]
amount_left = payment.amount - sum([amount for _, amount in transfers])
if amount_left < 0:
print("splitpayments failure: amount_left is negative.", payment.payment_hash)
return
if not targets:
return
# mark the original payment with one extra key, "splitted"
# (this prevents us from doing this process again and it's informative)
# and reduce it by the amount we're going to send to the producer
await core_db.execute(
"""
UPDATE apipayments
SET extra = ?, amount = ?
WHERE hash = ?
AND checking_id NOT LIKE 'internal_%'
""",
(
json.dumps(dict(**payment.extra, splitted=True)),
amount_left,
payment.payment_hash,
),
)
# perform the internal transfer using the same payment_hash
for wallet, amount in transfers:
internal_checking_id = f"internal_{urlsafe_short_hash()}"
await create_payment(
wallet_id=wallet,
checking_id=internal_checking_id,
payment_request="",
payment_hash=payment.payment_hash,
amount=amount,
memo=payment.memo,
pending=False,
extra={"tag": "splitpayments"},
)
# manually send this for now
await internal_invoice_paid.send(internal_checking_id)

View File

@ -0,0 +1,90 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="How to use"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<p>
Add some wallets to the list of "Target Wallets", each with an
associated <em>percent</em>. After saving, every time any payment
arrives at the "Source Wallet" that payment will be split with the
target wallets according to their percent.
</p>
<p>This is valid for every payment, doesn't matter how it was created.</p>
<p>Target wallets can be any wallet from this same LNbits instance.</p>
<p>
To remove a wallet from the targets list, just erase its fields and
save. To remove all, click "Clear" then save.
</p>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item
group="api"
dense
expand-separator
label="List Target Wallets"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/splitpayments/api/v1/targets</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code
>[{"wallet": &lt;wallet id&gt;, "alias": &lt;chosen name for this
wallet&gt;, "percent": &lt;number between 1 and 100&gt;}, ...]</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/livestream -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Set Target Wallets"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">PUT</span>
/splitpayments/api/v1/targets</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X PUT {{ request.url_root }}api/v1/splitpayments/targets -H
"X-Api-Key: {{ g.user.wallets[0].adminkey }}" -H 'Content-Type:
application/json' -d '{"targets": [{"wallet": &lt;wallet id or invoice
key&gt;, "alias": &lt;name to identify this&gt;, "percent": &lt;number
between 1 and 100&gt;}, ...]}'
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View File

@ -0,0 +1,100 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card class="q-pa-sm col-5">
<q-card-section class="q-pa-none text-center">
<q-form class="q-gutter-md">
<q-select
filled
dense
:options="g.user.wallets"
:value="selectedWallet"
label="Source Wallet:"
option-label="name"
@input="changedWallet"
>
</q-select>
</q-form>
</q-card-section>
</q-card>
<q-card class="q-pa-sm col-5">
<q-card-section class="q-pa-none text-center">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Target Wallets</h5>
</div>
<q-form class="q-gutter-md" @submit="saveTargets">
<div
class="q-gutter-md row items-start"
style="flex-wrap: nowrap"
v-for="(target, t) in targets"
>
<q-input
dense
outlined
v-model="target.wallet"
label="Wallet"
:hint="t === targets.length - 1 ? 'A wallet ID or invoice key.' : undefined"
@input="targetChanged(false)"
></q-input>
<q-input
dense
outlined
v-model="target.alias"
label="Alias"
:hint="t === targets.length - 1 ? 'A name to identify this target wallet locally.' : undefined"
@input="targetChanged(false)"
></q-input>
<q-input
dense
outlined
v-model.number="target.percent"
label="Split Share"
:hint="t === targets.length - 1 ? 'How much of the incoming payments will go to the target wallet.' : undefined"
suffix="%"
@input="targetChanged(true, t)"
></q-input>
</div>
<q-row class="row justify-evenly q-pa-lg">
<q-col>
<q-btn unelevated outline color="secondary" @click="clearTargets">
Clear
</q-btn>
</q-col>
<q-col>
<q-btn
unelevated
color="primary"
type="submit"
:disabled="!isDirty"
>
Save Targets
</q-btn>
</q-col>
</q-row>
</q-form>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} SplitPayments extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "splitpayments/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="/splitpayments/static/js/index.js"></script>
{% endblock %}

View File

@ -0,0 +1,18 @@
from http import HTTPStatus
from lnbits.decorators import check_user_exists, WalletTypeInfo, get_key_type
from . import splitpayments_ext, splitpayments_renderer
from fastapi import FastAPI, Request
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from lnbits.core.models import User, Payment
templates = Jinja2Templates(directory="templates")
@splitpayments_ext.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()}
)

View File

@ -0,0 +1,70 @@
import json
import httpx
import base64
from .crud import get_targets, set_targets
from .models import Target, TargetPut
from fastapi import Request
from http import HTTPStatus
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse, JSONResponse # type: ignore
from typing import Optional
from fastapi.params import Depends
from fastapi.param_functions import Query
from . import splitpayments_ext
from lnbits.decorators import (
check_user_exists,
WalletTypeInfo,
get_key_type,
api_validate_post_request,
WalletAdminKeyChecker,
WalletInvoiceKeyChecker,
)
from lnbits.core.crud import get_wallet, get_wallet_for_key
@splitpayments_ext.get("/api/v1/targets")
async def api_targets_get(wallet: WalletTypeInfo = Depends(WalletAdminKeyChecker())):
targets = await get_targets(wallet.wallet.id)
return [target.dict() for target in targets] or []
@splitpayments_ext.put("/api/v1/targets")
async def api_targets_set(
data: TargetPut, wallet: WalletTypeInfo = Depends(WalletAdminKeyChecker())
):
targets = []
for entry in data["targets"]:
wallet = await get_wallet(entry["wallet"])
if not wallet:
wallet = await get_wallet_for_key(entry["wallet"], "invoice")
if not wallet:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Invalid wallet '{entry['wallet']}'.",
)
if wallet.id == wallet.wallet.id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Can't split to itself.",
)
if entry["percent"] < 0:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Invalid percent '{entry['percent']}'.",
)
targets.append(
Target(wallet.id, wallet.wallet.id, entry["percent"], entry["alias"] or "")
)
percent_sum = sum([target.percent for target in targets])
if percent_sum > 100:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Splitting over 100%.",
)
await set_targets(wallet.wallet.id, targets)
return ""