mirror of
https://github.com/lnbits/lnbits.git
synced 2025-09-20 21:29:51 +02:00
lnurlp: accept comments, USD prices, min/max ranges.
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import json
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.db import open_ext_db
|
||||
from lnbits.core.models import Payment
|
||||
from quart import g
|
||||
|
||||
from .models import PayLink
|
||||
|
||||
@@ -10,7 +12,10 @@ def create_pay_link(
|
||||
*,
|
||||
wallet_id: str,
|
||||
description: str,
|
||||
amount: int,
|
||||
min: int,
|
||||
max: int,
|
||||
comment_chars: int = 0,
|
||||
currency: Optional[str] = None,
|
||||
webhook_url: Optional[str] = None,
|
||||
success_text: Optional[str] = None,
|
||||
success_url: Optional[str] = None,
|
||||
@@ -21,16 +26,29 @@ def create_pay_link(
|
||||
INSERT INTO pay_links (
|
||||
wallet,
|
||||
description,
|
||||
amount,
|
||||
min,
|
||||
max,
|
||||
served_meta,
|
||||
served_pr,
|
||||
webhook_url,
|
||||
success_text,
|
||||
success_url
|
||||
success_url,
|
||||
comment_chars,
|
||||
currency
|
||||
)
|
||||
VALUES (?, ?, ?, 0, 0, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(wallet_id, description, amount, webhook_url, success_text, success_url),
|
||||
(
|
||||
wallet_id,
|
||||
description,
|
||||
min,
|
||||
max,
|
||||
webhook_url,
|
||||
success_text,
|
||||
success_url,
|
||||
comment_chars,
|
||||
currency,
|
||||
),
|
||||
)
|
||||
link_id = db.cursor.lastrowid
|
||||
return get_pay_link(link_id)
|
||||
@@ -43,22 +61,6 @@ def get_pay_link(link_id: int) -> Optional[PayLink]:
|
||||
return PayLink.from_row(row) if row else None
|
||||
|
||||
|
||||
def get_pay_link_by_invoice(payment_hash: str) -> Optional[PayLink]:
|
||||
# this excludes invoices with webhooks that have been sent already
|
||||
|
||||
with open_ext_db("lnurlp") as db:
|
||||
row = db.fetchone(
|
||||
"""
|
||||
SELECT pay_links.* FROM pay_links
|
||||
INNER JOIN invoices ON invoices.pay_link = pay_links.id
|
||||
WHERE payment_hash = ? AND webhook_sent IS NULL
|
||||
""",
|
||||
(payment_hash,),
|
||||
)
|
||||
|
||||
return PayLink.from_row(row) if row else None
|
||||
|
||||
|
||||
def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
@@ -101,25 +103,12 @@ def delete_pay_link(link_id: int) -> None:
|
||||
db.execute("DELETE FROM pay_links WHERE id = ?", (link_id,))
|
||||
|
||||
|
||||
def save_link_invoice(link_id: int, payment_request: str) -> None:
|
||||
inv = bolt11.decode(payment_request)
|
||||
|
||||
with open_ext_db("lnurlp") as db:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO invoices (pay_link, payment_hash, expiry)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(link_id, inv.payment_hash, inv.expiry),
|
||||
)
|
||||
|
||||
|
||||
def mark_webhook_sent(payment_hash: str, status: int) -> None:
|
||||
with open_ext_db("lnurlp") as db:
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE invoices SET webhook_sent = ?
|
||||
WHERE payment_hash = ?
|
||||
""",
|
||||
(status, payment_hash),
|
||||
)
|
||||
def mark_webhook_sent(payment: Payment, status: int) -> None:
|
||||
payment.extra["wh_status"] = status
|
||||
g.db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET extra = ?
|
||||
WHERE hash = ?
|
||||
""",
|
||||
(json.dumps(payment.extra), payment.payment_hash),
|
||||
)
|
||||
|
48
lnbits/extensions/lnurlp/helpers.py
Normal file
48
lnbits/extensions/lnurlp/helpers.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import trio # type: ignore
|
||||
import httpx
|
||||
|
||||
|
||||
async def get_fiat_rate(currency: str):
|
||||
assert currency == "USD", "Only USD is supported as fiat currency."
|
||||
return await get_usd_rate()
|
||||
|
||||
|
||||
async def get_usd_rate():
|
||||
"""
|
||||
Returns an average satoshi price from multiple sources.
|
||||
"""
|
||||
|
||||
satoshi_prices = [None, None, None]
|
||||
|
||||
async def fetch_price(index, url, getter):
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
satoshi_price = int(100_000_000 / float(getter(r.json())))
|
||||
satoshi_prices[index] = satoshi_price
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async with trio.open_nursery() as nursery:
|
||||
nursery.start_soon(
|
||||
fetch_price,
|
||||
0,
|
||||
"https://api.kraken.com/0/public/Ticker?pair=XXBTZUSD",
|
||||
lambda d: d["result"]["XXBTCZUSD"]["c"][0],
|
||||
)
|
||||
nursery.start_soon(
|
||||
fetch_price,
|
||||
1,
|
||||
"https://www.bitstamp.net/api/v2/ticker/btcusd",
|
||||
lambda d: d["last"],
|
||||
)
|
||||
nursery.start_soon(
|
||||
fetch_price,
|
||||
2,
|
||||
"https://api.coincap.io/v2/rates/bitcoin",
|
||||
lambda d: d["data"]["rateUsd"],
|
||||
)
|
||||
|
||||
satoshi_prices = [x for x in satoshi_prices if x]
|
||||
return sum(satoshi_prices) / len(satoshi_prices)
|
@@ -1,12 +1,14 @@
|
||||
import hashlib
|
||||
import math
|
||||
from http import HTTPStatus
|
||||
from quart import jsonify, url_for
|
||||
from lnurl import LnurlPayResponse, LnurlPayActionResponse
|
||||
from quart import jsonify, url_for, request
|
||||
from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
|
||||
|
||||
from lnbits.core.services import create_invoice
|
||||
|
||||
from . import lnurlp_ext
|
||||
from .crud import increment_pay_link, save_link_invoice
|
||||
from .crud import increment_pay_link
|
||||
from .helpers import get_fiat_rate
|
||||
|
||||
|
||||
@lnurlp_ext.route("/api/v1/lnurl/<link_id>", methods=["GET"])
|
||||
@@ -15,16 +17,19 @@ async def api_lnurl_response(link_id):
|
||||
if not link:
|
||||
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
|
||||
|
||||
url = url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True)
|
||||
|
||||
rate = await get_fiat_rate(link.currency) if link.currency else 1
|
||||
resp = LnurlPayResponse(
|
||||
callback=url,
|
||||
min_sendable=link.amount * 1000,
|
||||
max_sendable=link.amount * 1000,
|
||||
callback=url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True),
|
||||
min_sendable=math.ceil(link.min * rate) * 1000,
|
||||
max_sendable=round(link.max * rate) * 1000,
|
||||
metadata=link.lnurlpay_metadata,
|
||||
)
|
||||
params = resp.dict()
|
||||
|
||||
return jsonify(resp.dict()), HTTPStatus.OK
|
||||
if link.comment_chars > 0:
|
||||
params["commentAllowed"] = link.comment_chars
|
||||
|
||||
return jsonify(params), HTTPStatus.OK
|
||||
|
||||
|
||||
@lnurlp_ext.route("/api/v1/lnurl/cb/<link_id>", methods=["GET"])
|
||||
@@ -33,16 +38,44 @@ async def api_lnurl_callback(link_id):
|
||||
if not link:
|
||||
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
|
||||
|
||||
min, max = link.min, link.max
|
||||
rate = await get_fiat_rate(link.currency) if link.currency else 1
|
||||
if link.currency:
|
||||
# allow some fluctuation (as the fiat price may have changed between the calls)
|
||||
min = rate * 995 * link.min
|
||||
max = rate * 1010 * link.max
|
||||
|
||||
amount_received = int(request.args.get("amount"))
|
||||
if amount_received < min:
|
||||
return (
|
||||
jsonify(LnurlErrorResponse(reason=f"Amount {amount_received} is smaller than minimum {min}.").dict()),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
elif amount_received > max:
|
||||
return (
|
||||
jsonify(LnurlErrorResponse(reason=f"Amount {amount_received} is greater than maximum {max}.").dict()),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
comment = request.args.get("comment")
|
||||
if len(comment or "") > link.comment_chars:
|
||||
return (
|
||||
jsonify(
|
||||
LnurlErrorResponse(
|
||||
reason=f"Got a comment with {len(comment)} characters, but can only accept {link.comment_chars}"
|
||||
).dict()
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
payment_hash, payment_request = create_invoice(
|
||||
wallet_id=link.wallet,
|
||||
amount=link.amount,
|
||||
amount=int(amount_received / 1000),
|
||||
memo=link.description,
|
||||
description_hash=hashlib.sha256(link.lnurlpay_metadata.encode("utf-8")).digest(),
|
||||
extra={"tag": "lnurlp"},
|
||||
extra={"tag": "lnurlp", "link": link.id, "comment": comment},
|
||||
)
|
||||
|
||||
save_link_invoice(link_id, payment_request)
|
||||
|
||||
resp = LnurlPayActionResponse(
|
||||
pr=payment_request,
|
||||
success_action=link.success_action(payment_hash),
|
||||
|
@@ -33,3 +33,16 @@ def m002_webhooks_and_success_actions(db):
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def m003_min_max_comment_fiat(db):
|
||||
"""
|
||||
Support for min/max amounts, comments and fiat prices that get
|
||||
converted automatically to satoshis based on some API.
|
||||
"""
|
||||
db.execute("ALTER TABLE pay_links ADD COLUMN currency TEXT;") # null = satoshis
|
||||
db.execute("ALTER TABLE pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;")
|
||||
db.execute("ALTER TABLE pay_links RENAME COLUMN amount TO min;")
|
||||
db.execute("ALTER TABLE pay_links ADD COLUMN max INTEGER;")
|
||||
db.execute("UPDATE pay_links SET max = min;")
|
||||
db.execute("DROP TABLE invoices")
|
||||
|
@@ -12,12 +12,15 @@ class PayLink(NamedTuple):
|
||||
id: int
|
||||
wallet: str
|
||||
description: str
|
||||
amount: int
|
||||
min: int
|
||||
served_meta: int
|
||||
served_pr: int
|
||||
webhook_url: str
|
||||
success_text: str
|
||||
success_url: str
|
||||
currency: str
|
||||
comment_chars: int
|
||||
max: int
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "PayLink":
|
||||
|
207
lnbits/extensions/lnurlp/static/js/index.js
Normal file
207
lnbits/extensions/lnurlp/static/js/index.js
Normal file
@@ -0,0 +1,207 @@
|
||||
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
var locationPath = [
|
||||
window.location.protocol,
|
||||
'//',
|
||||
window.location.host,
|
||||
window.location.pathname
|
||||
].join('')
|
||||
|
||||
var mapPayLink = obj => {
|
||||
obj._data = _.clone(obj)
|
||||
obj.date = Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
obj.amount = new Intl.NumberFormat(LOCALE).format(obj.amount)
|
||||
obj.print_url = [locationPath, 'print/', obj.id].join('')
|
||||
obj.pay_url = [locationPath, obj.id].join('')
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
fiatRates: {},
|
||||
checker: null,
|
||||
payLinks: [],
|
||||
payLinksTable: {
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
fixedAmount: true,
|
||||
data: {}
|
||||
},
|
||||
qrCodeDialog: {
|
||||
show: false,
|
||||
data: null
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getPayLinks() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/lnurlp/api/v1/links?all_wallets',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(response => {
|
||||
this.payLinks = response.data.map(mapPayLink)
|
||||
})
|
||||
.catch(err => {
|
||||
clearInterval(this.checker)
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
closeFormDialog() {},
|
||||
openQrCodeDialog(linkId) {
|
||||
var link = _.findWhere(this.payLinks, {id: linkId})
|
||||
if (link.currency) this.updateFiatRate(link.currency)
|
||||
|
||||
this.qrCodeDialog.data = {
|
||||
id: link.id,
|
||||
amount:
|
||||
(link.min === link.max ? link.min : `${link.min} - ${link.max}`) +
|
||||
' ' +
|
||||
(link.currency || 'sat'),
|
||||
currency: link.currency,
|
||||
comments: link.comment_chars
|
||||
? `${link.comment_chars} characters`
|
||||
: 'no',
|
||||
webhook: link.webhook_url || 'nowhere',
|
||||
success:
|
||||
link.success_text || link.success_url
|
||||
? 'Display message "' +
|
||||
link.success_text +
|
||||
'"' +
|
||||
(link.success_url ? ' and URL "' + link.success_url + '"' : '')
|
||||
: 'do nothing',
|
||||
lnurl: link.lnurl,
|
||||
pay_url: link.pay_url,
|
||||
print_url: link.print_url
|
||||
}
|
||||
this.qrCodeDialog.show = true
|
||||
},
|
||||
openUpdateDialog(linkId) {
|
||||
const link = _.findWhere(this.payLinks, {id: linkId})
|
||||
if (link.currency) this.updateFiatRate(link.currency)
|
||||
|
||||
this.formDialog.data = _.clone(link._data)
|
||||
this.formDialog.show = true
|
||||
this.formDialog.fixedAmount =
|
||||
this.formDialog.data.min === this.formDialog.data.max
|
||||
},
|
||||
sendFormData() {
|
||||
const wallet = _.findWhere(this.g.user.wallets, {
|
||||
id: this.formDialog.data.wallet
|
||||
})
|
||||
var data = _.omit(this.formDialog.data, 'wallet')
|
||||
|
||||
if (this.formDialog.fixedAmount) data.max = data.min
|
||||
if (data.currency === 'satoshis') data.currency = null
|
||||
if (isNaN(parseInt(data.comment_chars))) data.comment_chars = 0
|
||||
|
||||
if (data.id) {
|
||||
this.updatePayLink(wallet, data)
|
||||
} else {
|
||||
this.createPayLink(wallet, data)
|
||||
}
|
||||
},
|
||||
updatePayLink(wallet, data) {
|
||||
let values = _.omit(
|
||||
_.pick(
|
||||
data,
|
||||
'description',
|
||||
'min',
|
||||
'max',
|
||||
'webhook_url',
|
||||
'success_text',
|
||||
'success_url',
|
||||
'comment_chars',
|
||||
'currency'
|
||||
),
|
||||
(value, key) =>
|
||||
(key === 'webhook_url' ||
|
||||
key === 'success_text' ||
|
||||
key === 'success_url') &&
|
||||
(value === null || value === '')
|
||||
)
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/lnurlp/api/v1/links/' + data.id,
|
||||
wallet.adminkey,
|
||||
values
|
||||
)
|
||||
.then(response => {
|
||||
this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id)
|
||||
this.payLinks.push(mapPayLink(response.data))
|
||||
this.formDialog.show = false
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
createPayLink(wallet, data) {
|
||||
LNbits.api
|
||||
.request('POST', '/lnurlp/api/v1/links', wallet.adminkey, data)
|
||||
.then(response => {
|
||||
this.payLinks.push(mapPayLink(response.data))
|
||||
this.formDialog.show = false
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
deletePayLink: linkId => {
|
||||
var link = _.findWhere(this.payLinks, {id: linkId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this pay link?')
|
||||
.onOk(() => {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/lnurlp/api/v1/links/' + linkId,
|
||||
_.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey
|
||||
)
|
||||
.then(response => {
|
||||
this.payLinks = _.reject(this.payLinks, obj => obj.id === linkId)
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
updateFiatRate(currency) {
|
||||
LNbits.api
|
||||
.request('GET', '/lnurlp/api/v1/rate/' + currency, null)
|
||||
.then(response => {
|
||||
let rates = _.clone(this.fiatRates)
|
||||
rates[currency] = response.data.rate
|
||||
this.fiatRates = rates
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
}
|
||||
},
|
||||
created() {
|
||||
if (this.g.user.wallets.length) {
|
||||
var getPayLinks = this.getPayLinks
|
||||
getPayLinks()
|
||||
this.checker = setInterval(() => {
|
||||
getPayLinks()
|
||||
}, 20000)
|
||||
}
|
||||
}
|
||||
})
|
@@ -4,7 +4,7 @@ import httpx
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.tasks import run_on_pseudo_request, register_invoice_listener
|
||||
|
||||
from .crud import get_pay_link_by_invoice, mark_webhook_sent
|
||||
from .crud import mark_webhook_sent, get_pay_link
|
||||
|
||||
|
||||
async def register_listeners():
|
||||
@@ -19,25 +19,29 @@ async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
|
||||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
islnurlp = "lnurlp" == payment.extra.get("tag")
|
||||
if islnurlp:
|
||||
pay_link = get_pay_link_by_invoice(payment.payment_hash)
|
||||
if not pay_link:
|
||||
# no pay_link or this webhook has already been sent
|
||||
return
|
||||
if pay_link.webhook_url:
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.post(
|
||||
pay_link.webhook_url,
|
||||
json={
|
||||
"payment_hash": payment.payment_hash,
|
||||
"payment_request": payment.bolt11,
|
||||
"amount": payment.amount,
|
||||
"lnurlp": pay_link.id,
|
||||
},
|
||||
timeout=40,
|
||||
)
|
||||
mark_webhook_sent(payment.payment_hash, r.status_code)
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
mark_webhook_sent(payment.payment_hash, -1)
|
||||
if "lnurlp" != payment.extra.get("tag"):
|
||||
# not an lnurlp invoice
|
||||
return
|
||||
|
||||
if payment.extra.get("wh_status"):
|
||||
# this webhook has already been sent
|
||||
return
|
||||
|
||||
pay_link = get_pay_link(payment.extra.get("link", -1))
|
||||
if pay_link and pay_link.webhook_url:
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.post(
|
||||
pay_link.webhook_url,
|
||||
json={
|
||||
"payment_hash": payment.payment_hash,
|
||||
"payment_request": payment.bolt11,
|
||||
"amount": payment.amount,
|
||||
"comment": payment.extra.get("comment"),
|
||||
"lnurlp": pay_link.id,
|
||||
},
|
||||
timeout=40,
|
||||
)
|
||||
mark_webhook_sent(payment, r.status_code)
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
mark_webhook_sent(payment, -1)
|
||||
|
@@ -16,25 +16,22 @@
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Pay links</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="payLinks"
|
||||
row-key="id"
|
||||
:columns="payLinksTable.columns"
|
||||
:pagination.sync="payLinksTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width>Description</q-th>
|
||||
<q-th auto-width>Amount</q-th>
|
||||
<q-th auto-width>Currency</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
@@ -60,8 +57,39 @@
|
||||
@click="openQrCodeDialog(props.row.id)"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
<q-td auto-width>{{ props.row.description }}</q-td>
|
||||
<q-td auto-width>
|
||||
<span v-if="props.row.min == props.row.max">
|
||||
{{ props.row.min }}
|
||||
</span>
|
||||
<span v-else>{{ props.row.min }} - {{ props.row.max }}</span>
|
||||
</q-td>
|
||||
<q-td>{{ props.row.currency || 'sat' }}</q-td>
|
||||
<q-td>
|
||||
<q-icon v-if="props.row.webhook_url" size="14px" name="http">
|
||||
<q-tooltip>Webhook to {{ props.row.webhook_url}}</q-tooltip>
|
||||
</q-icon>
|
||||
<q-icon
|
||||
v-if="props.row.success_text || props.row.success_url"
|
||||
size="14px"
|
||||
name="call_to_action"
|
||||
>
|
||||
<q-tooltip>
|
||||
On success, show message '{{ props.row.success_text }}'
|
||||
<span v-if="props.row.success_url"
|
||||
>and URL '{{ props.row.success_url }}'</span
|
||||
>
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
<q-icon
|
||||
v-if="props.row.comment_chars > 0"
|
||||
size="14px"
|
||||
name="insert_comment"
|
||||
>
|
||||
<q-tooltip>
|
||||
{{ props.row.comment_chars }}-char comment allowed
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
@@ -124,12 +152,52 @@
|
||||
type="text"
|
||||
label="Item description *"
|
||||
></q-input>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.min"
|
||||
type="number"
|
||||
:step="formDialog.data.currency && formDialog.data.currency !== 'satoshis' ? '0.01' : '1'"
|
||||
:label="formDialog.fixedAmount ? 'Amount *' : 'Min *'"
|
||||
></q-input>
|
||||
<q-input
|
||||
v-if="!formDialog.fixedAmount"
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.max"
|
||||
type="number"
|
||||
:step="formDialog.data.currency && formDialog.data.currency !== 'satoshis' ? '0.01' : '1'"
|
||||
label="Max *"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col">
|
||||
<q-checkbox
|
||||
dense
|
||||
v-model="formDialog.fixedAmount"
|
||||
label="Fixed amount"
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-select
|
||||
dense
|
||||
:options='["satoshis", "USD"]'
|
||||
v-model="formDialog.data.currency"
|
||||
:display-value="formDialog.data.currency || 'satoshis'"
|
||||
label="Currency"
|
||||
:hint="'Amounts will be converted at use-time to satoshis. ' + (formDialog.data.currency && fiatRates[formDialog.data.currency] ? `Currently 1 ${formDialog.data.currency} = ${fiatRates[formDialog.data.currency]} sat` : '')"
|
||||
@input="updateFiatRate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.amount"
|
||||
v-model.number="formDialog.data.comment_chars"
|
||||
type="number"
|
||||
label="Amount (sat) *"
|
||||
label="Comment maximum characters"
|
||||
hint="Tell wallets to prompt users for a comment that will be sent along with the payment. LNURLp will store the comment and send it in the webhook."
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
@@ -171,8 +239,8 @@
|
||||
formDialog.data.wallet == null ||
|
||||
formDialog.data.description == null ||
|
||||
(
|
||||
formDialog.data.amount == null ||
|
||||
formDialog.data.amount < 1
|
||||
formDialog.data.min == null ||
|
||||
formDialog.data.min <= 0
|
||||
)
|
||||
"
|
||||
type="submit"
|
||||
@@ -198,11 +266,16 @@
|
||||
</q-responsive>
|
||||
<p style="word-break: break-all">
|
||||
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
|
||||
<strong>Amount:</strong> {{ qrCodeDialog.data.amount }} sat<br />
|
||||
<strong>Webhook:</strong> {{ qrCodeDialog.data.webhook_url }}<br />
|
||||
<strong>Success Message:</strong> {{ qrCodeDialog.data.success_text
|
||||
<strong>Amount:</strong> {{ qrCodeDialog.data.amount }}<br />
|
||||
<span v-if="qrCodeDialog.data.currency"
|
||||
><strong>{{ qrCodeDialog.data.currency }} price:</strong> {{
|
||||
fiatRates[qrCodeDialog.data.currency] ?
|
||||
fiatRates[qrCodeDialog.data.currency] + ' sat' : 'Loading...' }}<br
|
||||
/></span>
|
||||
<strong>Accepts comments:</strong> {{ qrCodeDialog.data.comments }}<br />
|
||||
<strong>Dispatches webhook to:</strong> {{ qrCodeDialog.data.webhook
|
||||
}}<br />
|
||||
<strong>Success URL:</strong> {{ qrCodeDialog.data.success_url }}<br />
|
||||
<strong>On success:</strong> {{ qrCodeDialog.data.success }}<br />
|
||||
</p>
|
||||
{% endraw %}
|
||||
<div class="row q-mt-lg q-gutter-sm">
|
||||
@@ -220,7 +293,6 @@
|
||||
>Shareable link</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-if="!qrCodeDialog.data.is_unique"
|
||||
outline
|
||||
color="grey"
|
||||
icon="print"
|
||||
@@ -234,223 +306,5 @@
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
var locationPath = [
|
||||
window.location.protocol,
|
||||
'//',
|
||||
window.location.host,
|
||||
window.location.pathname
|
||||
].join('')
|
||||
|
||||
var mapPayLink = function (obj) {
|
||||
obj._data = _.clone(obj)
|
||||
obj.date = Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
obj.amount = new Intl.NumberFormat(LOCALE).format(obj.amount)
|
||||
obj.print_url = [locationPath, 'print/', obj.id].join('')
|
||||
obj.pay_url = [locationPath, obj.id].join('')
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
checker: null,
|
||||
payLinks: [],
|
||||
payLinksTable: {
|
||||
columns: [
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
{
|
||||
name: 'description',
|
||||
align: 'left',
|
||||
label: 'Description',
|
||||
field: 'description'
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
align: 'right',
|
||||
label: 'Amount (sat)',
|
||||
field: 'amount'
|
||||
},
|
||||
{
|
||||
name: 'webhook_url',
|
||||
align: 'left',
|
||||
label: 'Webhook URL',
|
||||
field: 'webhook_url'
|
||||
},
|
||||
{
|
||||
name: 'success_action',
|
||||
align: 'center',
|
||||
label: '',
|
||||
format: (_, row) =>
|
||||
row.success_text || row.success_url ? '💬' : ''
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
secondMultiplier: 'seconds',
|
||||
secondMultiplierOptions: ['seconds', 'minutes', 'hours'],
|
||||
data: {
|
||||
is_unique: false
|
||||
}
|
||||
},
|
||||
qrCodeDialog: {
|
||||
show: false,
|
||||
data: null
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getPayLinks: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/lnurlp/api/v1/links?all_wallets',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.payLinks = response.data.map(function (obj) {
|
||||
return mapPayLink(obj)
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
clearInterval(self.checker)
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
closeFormDialog: function () {
|
||||
this.formDialog.data = {
|
||||
is_unique: false
|
||||
}
|
||||
},
|
||||
openQrCodeDialog: function (linkId) {
|
||||
var link = _.findWhere(this.payLinks, {id: linkId})
|
||||
this.qrCodeDialog.data = _.clone(link)
|
||||
this.qrCodeDialog.show = true
|
||||
},
|
||||
openUpdateDialog: function (linkId) {
|
||||
var link = _.findWhere(this.payLinks, {id: linkId})
|
||||
this.formDialog.data = _.clone(link._data)
|
||||
this.formDialog.show = true
|
||||
},
|
||||
sendFormData: function () {
|
||||
var wallet = _.findWhere(this.g.user.wallets, {
|
||||
id: this.formDialog.data.wallet
|
||||
})
|
||||
var data = _.omit(this.formDialog.data, 'wallet')
|
||||
|
||||
data.wait_time =
|
||||
data.wait_time *
|
||||
{
|
||||
seconds: 1,
|
||||
minutes: 60,
|
||||
hours: 3600
|
||||
}[this.formDialog.secondMultiplier]
|
||||
|
||||
if (data.id) {
|
||||
this.updatePayLink(wallet, data)
|
||||
} else {
|
||||
this.createPayLink(wallet, data)
|
||||
}
|
||||
},
|
||||
updatePayLink: function (wallet, data) {
|
||||
var self = this
|
||||
|
||||
let values = _.omit(
|
||||
_.pick(
|
||||
data,
|
||||
'description',
|
||||
'amount',
|
||||
'webhook_url',
|
||||
'success_text',
|
||||
'success_url'
|
||||
),
|
||||
(value, key) =>
|
||||
(key === 'webhook_url' ||
|
||||
key === 'success_text' ||
|
||||
key === 'success_url') &&
|
||||
(value === null || value === '')
|
||||
)
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/lnurlp/api/v1/links/' + data.id,
|
||||
wallet.adminkey,
|
||||
values
|
||||
)
|
||||
.then(function (response) {
|
||||
self.payLinks = _.reject(self.payLinks, function (obj) {
|
||||
return obj.id === data.id
|
||||
})
|
||||
self.payLinks.push(mapPayLink(response.data))
|
||||
self.formDialog.show = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
createPayLink: function (wallet, data) {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request('POST', '/lnurlp/api/v1/links', wallet.adminkey, data)
|
||||
.then(function (response) {
|
||||
self.payLinks.push(mapPayLink(response.data))
|
||||
self.formDialog.show = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deletePayLink: function (linkId) {
|
||||
var self = this
|
||||
var link = _.findWhere(this.payLinks, {id: linkId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this pay link?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/lnurlp/api/v1/links/' + linkId,
|
||||
_.findWhere(self.g.user.wallets, {id: link.wallet}).adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.payLinks = _.reject(self.payLinks, function (obj) {
|
||||
return obj.id === linkId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
exportCSV: function () {
|
||||
LNbits.utils.exportCSV(this.paywallsTable.columns, this.paywalls)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
var getPayLinks = this.getPayLinks
|
||||
getPayLinks()
|
||||
this.checker = setInterval(function () {
|
||||
getPayLinks()
|
||||
}, 20000)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<script src="/lnurlp/static/js/index.js"></script>
|
||||
{% endblock %}
|
||||
|
@@ -1,11 +1,11 @@
|
||||
from quart import g, jsonify, request
|
||||
from http import HTTPStatus
|
||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
|
||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
|
||||
from lnbits.extensions.lnurlp import lnurlp_ext
|
||||
from lnbits.extensions.lnurlp import lnurlp_ext # type: ignore
|
||||
from .crud import (
|
||||
create_pay_link,
|
||||
get_pay_link,
|
||||
@@ -13,6 +13,7 @@ from .crud import (
|
||||
update_pay_link,
|
||||
delete_pay_link,
|
||||
)
|
||||
from .helpers import get_fiat_rate
|
||||
|
||||
|
||||
@lnurlp_ext.route("/api/v1/links", methods=["GET"])
|
||||
@@ -55,13 +56,24 @@ async def api_link_retrieve(link_id):
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"description": {"type": "string", "empty": False, "required": True},
|
||||
"amount": {"type": "integer", "min": 1, "required": True},
|
||||
"min": {"type": "number", "min": 0.01, "required": True},
|
||||
"max": {"type": "number", "min": 0.01, "required": True},
|
||||
"currency": {"type": "string", "allowed": ["USD"], "nullable": True, "required": False},
|
||||
"comment_chars": {"type": "integer", "required": True, "min": 0, "max": 800},
|
||||
"webhook_url": {"type": "string", "required": False},
|
||||
"success_text": {"type": "string", "required": False},
|
||||
"success_url": {"type": "string", "required": False},
|
||||
}
|
||||
)
|
||||
async def api_link_create_or_update(link_id=None):
|
||||
if g.data["min"] > g.data["max"]:
|
||||
return jsonify({"message": "Min is greater than max."}), HTTPStatus.BAD_REQUEST
|
||||
|
||||
if g.data.get("currency") == None and (
|
||||
round(g.data["min"]) != g.data["min"] or round(g.data["max"]) != g.data["max"]
|
||||
):
|
||||
return jsonify({"message": "Must use full satoshis."}), HTTPStatus.BAD_REQUEST
|
||||
|
||||
if link_id:
|
||||
link = get_pay_link(link_id)
|
||||
|
||||
@@ -92,3 +104,13 @@ async def api_link_delete(link_id):
|
||||
delete_pay_link(link_id)
|
||||
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@lnurlp_ext.route("/api/v1/rate/<currency>", methods=["GET"])
|
||||
async def api_check_fiat_rate(currency):
|
||||
try:
|
||||
rate = await get_fiat_rate(currency)
|
||||
except AssertionError:
|
||||
rate = None
|
||||
|
||||
return jsonify({"rate": rate}), HTTPStatus.OK
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block scripts %} {{ window_vars(user) }}
|
||||
<script type="text/javascript" src="/withdraw/static/js/index.js"></script>
|
||||
<script src="/withdraw/static/js/index.js"></script>
|
||||
{% endblock %} {% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||
|
Reference in New Issue
Block a user