satspay ext added

This commit is contained in:
Tiago vasconcelos 2021-10-14 10:02:02 +01:00
parent 4686f87626
commit 43f36afeb0
11 changed files with 1468 additions and 0 deletions

View File

@ -0,0 +1,27 @@
# SatsPay Server
## Create onchain and LN charges. Includes webhooks!
Easilly create invoices that support Lightning Network and on-chain BTC payment.
1. Create a "NEW CHARGE"\
![new charge](https://i.imgur.com/fUl6p74.png)
2. Fill out the invoice fields
- set a descprition for the payment
- the amount in sats
- the time, in minutes, the invoice is valid for, after this period the invoice can't be payed
- set a webhook that will get the transaction details after a successful payment
- set to where the user should redirect after payment
- set the text for the button that will show after payment (not setting this, will display "NONE" in the button)
- select if you want onchain payment, LN payment or both
- depending on what you select you'll have to choose the respective wallets where to receive your payment\
![charge form](https://i.imgur.com/F10yRiW.png)
3. The charge will appear on the _Charges_ section\
![charges](https://i.imgur.com/zqHpVxc.png)
4. Your costumer/payee will get the payment page
- they can choose to pay on LN\
![offchain payment](https://i.imgur.com/4191SMV.png)
- or pay on chain\
![onchain payment](https://i.imgur.com/wzLRR5N.png)
5. You can check the state of your charges in LNBits\
![invoice state](https://i.imgur.com/JnBd22p.png)

View File

@ -0,0 +1,13 @@
from quart import Blueprint
from lnbits.db import Database
db = Database("ext_satspay")
satspay_ext: Blueprint = Blueprint(
"satspay", __name__, static_folder="static", template_folder="templates"
)
from .views_api import * # noqa
from .views import * # noqa

View File

@ -0,0 +1,8 @@
{
"name": "SatsPay Server",
"short_description": "Create onchain and LN charges",
"icon": "payment",
"contributors": [
"arcbtc"
]
}

View File

@ -0,0 +1,130 @@
from typing import List, Optional, Union
# from lnbits.db import open_ext_db
from . import db
from .models import Charges
from lnbits.helpers import urlsafe_short_hash
from quart import jsonify
import httpx
from lnbits.core.services import create_invoice, check_invoice_status
from ..watchonly.crud import get_watch_wallet, get_fresh_address, get_mempool
###############CHARGES##########################
async def create_charge(
user: str,
description: str = None,
onchainwallet: Optional[str] = None,
lnbitswallet: Optional[str] = None,
webhook: Optional[str] = None,
completelink: Optional[str] = None,
completelinktext: Optional[str] = "Back to Merchant",
time: Optional[int] = None,
amount: Optional[int] = None,
) -> Charges:
charge_id = urlsafe_short_hash()
if onchainwallet:
wallet = await get_watch_wallet(onchainwallet)
onchain = await get_fresh_address(onchainwallet)
onchainaddress = onchain.address
else:
onchainaddress = None
if lnbitswallet:
payment_hash, payment_request = await create_invoice(
wallet_id=lnbitswallet, amount=amount, memo=charge_id
)
else:
payment_hash = None
payment_request = None
await db.execute(
"""
INSERT INTO satspay.charges (
id,
"user",
description,
onchainwallet,
onchainaddress,
lnbitswallet,
payment_request,
payment_hash,
webhook,
completelink,
completelinktext,
time,
amount,
balance
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
charge_id,
user,
description,
onchainwallet,
onchainaddress,
lnbitswallet,
payment_request,
payment_hash,
webhook,
completelink,
completelinktext,
time,
amount,
0,
),
)
return await get_charge(charge_id)
async def update_charge(charge_id: str, **kwargs) -> Optional[Charges]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE satspay.charges SET {q} WHERE id = ?", (*kwargs.values(), charge_id)
)
row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,))
return Charges.from_row(row) if row else None
async def get_charge(charge_id: str) -> Charges:
row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,))
return Charges.from_row(row) if row else None
async def get_charges(user: str) -> List[Charges]:
rows = await db.fetchall(
"""SELECT * FROM satspay.charges WHERE "user" = ?""", (user,)
)
return [Charges.from_row(row) for row in rows]
async def delete_charge(charge_id: str) -> None:
await db.execute("DELETE FROM satspay.charges WHERE id = ?", (charge_id,))
async def check_address_balance(charge_id: str) -> List[Charges]:
charge = await get_charge(charge_id)
if not charge.paid:
if charge.onchainaddress:
mempool = await get_mempool(charge.user)
try:
async with httpx.AsyncClient() as client:
r = await client.get(
mempool.endpoint + "/api/address/" + charge.onchainaddress
)
respAmount = r.json()["chain_stats"]["funded_txo_sum"]
if respAmount >= charge.balance:
await update_charge(charge_id=charge_id, balance=respAmount)
except Exception:
pass
if charge.lnbitswallet:
invoice_status = await check_invoice_status(
charge.lnbitswallet, charge.payment_hash
)
if invoice_status.paid:
return await update_charge(charge_id=charge_id, balance=charge.amount)
row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,))
return Charges.from_row(row) if row else None

View File

@ -0,0 +1,28 @@
async def m001_initial(db):
"""
Initial wallet table.
"""
await db.execute(
"""
CREATE TABLE satspay.charges (
id TEXT NOT NULL PRIMARY KEY,
"user" TEXT,
description TEXT,
onchainwallet TEXT,
onchainaddress TEXT,
lnbitswallet TEXT,
payment_request TEXT,
payment_hash TEXT,
webhook TEXT,
completelink TEXT,
completelinktext TEXT,
time INTEGER,
amount INTEGER,
balance INTEGER DEFAULT 0,
timestamp TIMESTAMP NOT NULL DEFAULT """
+ db.timestamp_now
+ """
);
"""
)

View File

@ -0,0 +1,39 @@
from sqlite3 import Row
from typing import NamedTuple
import time
class Charges(NamedTuple):
id: str
user: str
description: str
onchainwallet: str
onchainaddress: str
lnbitswallet: str
payment_request: str
payment_hash: str
webhook: str
completelink: str
completelinktext: str
time: int
amount: int
balance: int
timestamp: int
@classmethod
def from_row(cls, row: Row) -> "Charges":
return cls(**dict(row))
@property
def time_elapsed(self):
if (self.timestamp + (self.time * 60)) >= time.time():
return False
else:
return True
@property
def paid(self):
if self.balance >= self.amount:
return True
else:
return False

View File

@ -0,0 +1,171 @@
<q-card>
<q-card-section>
<p>
SatsPayServer, create Onchain/LN charges.<br />WARNING: If using with the
WatchOnly extension, we highly reccomend using a fresh extended public Key
specifically for SatsPayServer!<br />
<small>
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
>
</p>
</q-card-section>
<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="Create charge">
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span> /satspay/api/v1/charge</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>[&lt;charge_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}api/v1/charge -d
'{"onchainwallet": &lt;string, watchonly_wallet_id&gt;,
"description": &lt;string&gt;, "webhook":&lt;string&gt;, "time":
&lt;integer&gt;, "amount": &lt;integer&gt;, "lnbitswallet":
&lt;string, lnbits_wallet_id&gt;}' -H "Content-type:
application/json" -H "X-Api-Key: {{g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Update charge">
<q-card>
<q-card-section>
<code
><span class="text-blue">PUT</span>
/satspay/api/v1/charge/&lt;charge_id&gt;</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>[&lt;charge_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}api/v1/charge/&lt;charge_id&gt;
-d '{"onchainwallet": &lt;string, watchonly_wallet_id&gt;,
"description": &lt;string&gt;, "webhook":&lt;string&gt;, "time":
&lt;integer&gt;, "amount": &lt;integer&gt;, "lnbitswallet":
&lt;string, lnbits_wallet_id&gt;}' -H "Content-type:
application/json" -H "X-Api-Key: {{g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Get charge">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/satspay/api/v1/charge/&lt;charge_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_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>[&lt;charge_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/charge/&lt;charge_id&gt;
-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="Get charges">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span> /satspay/api/v1/charges</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_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>[&lt;charge_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/charges -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="Delete a pay link"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/satspay/api/v1/charge/&lt;charge_id&gt;</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">Returns 204 NO CONTENT</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.url_root
}}api/v1/charge/&lt;charge_id&gt; -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Get balances">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/satspay/api/v1/charges/balance/&lt;charge_id&gt;</code
>
<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>[&lt;charge_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root
}}api/v1/charges/balance/&lt;charge_id&gt; -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
</q-card>

View File

@ -0,0 +1,318 @@
{% extends "public.html" %} {% block page %}
<div class="q-pa-sm theCard">
<q-card class="my-card">
<div class="column">
<center>
<div class="col theHeading">{{ charge.description }}</div>
</center>
<div class="col">
<div
class="col"
color="white"
style="background-color: grey; height: 30px; padding: 5px"
v-if="timetoComplete < 1"
>
<center>Time elapsed</center>
</div>
<div
class="col"
color="white"
style="background-color: grey; height: 30px; padding: 5px"
v-else-if="charge_paid == 'True'"
>
<center>Charge paid</center>
</div>
<div v-else>
<q-linear-progress size="30px" :value="newProgress" color="grey">
<q-item-section>
<q-item style="padding: 3px">
<q-spinner color="white" size="0.8em"></q-spinner
><span style="font-size: 15px; color: white"
><span class="q-pr-xl q-pl-md"> Awaiting payment...</span>
<span class="q-pl-xl" style="color: white">
{% raw %} {{ newTimeLeft }} {% endraw %}</span
></span
>
</q-item>
</q-item-section>
</q-linear-progress>
</div>
</div>
<div class="col" style="margin: 2px 15px; max-height: 100px">
<center>
<q-btn flat dense outline @click="copyText('{{ charge.id }}')"
>Charge ID: {{ charge.id }}</q-btn
>
</center>
<span
><small
>{% raw %} Total to pay: {{ charge_amount }}sats<br />
Amount paid: {{ charge_balance }}</small
><br />
Amount due: {{ charge_amount - charge_balance }}sats {% endraw %}
</span>
</div>
<q-separator></q-separator>
<div class="col">
<div class="row">
<div class="col">
<q-btn
flat
disable
v-if="'{{ charge.lnbitswallet }}' == 'None' || charge_time_elapsed == 'True'"
style="color: primary; width: 100%"
label="lightning⚡"
>
<q-tooltip>
bitcoin onchain payment method not available
</q-tooltip>
</q-btn>
<q-btn
flat
v-else
@click="payLN"
style="color: primary; width: 100%"
label="lightning⚡"
>
<q-tooltip> pay with lightning </q-tooltip>
</q-btn>
</div>
<div class="col">
<q-btn
flat
disable
v-if="'{{ charge.onchainwallet }}' == 'None' || charge_time_elapsed == 'True'"
style="color: primary; width: 100%"
label="onchain⛓"
>
<q-tooltip>
bitcoin lightning payment method not available
</q-tooltip>
</q-btn>
<q-btn
flat
v-else
@click="payON"
style="color: primary; width: 100%"
label="onchain⛓"
>
<q-tooltip> pay onchain </q-tooltip>
</q-btn>
</div>
</div>
<q-separator></q-separator>
</div>
</div>
<q-card class="q-pa-lg" v-if="lnbtc">
<q-card-section class="q-pa-none">
<div class="text-center q-pt-md">
<div v-if="timetoComplete < 1 && charge_paid == 'False'">
<q-icon
name="block"
style="color: #ccc; font-size: 21.4em"
></q-icon>
</div>
<div v-else-if="charge_paid == 'True'">
<q-icon
name="check"
style="color: green; font-size: 21.4em"
></q-icon>
<q-btn
outline
v-if="'{{ charge.webhook }}' != 'None'"
type="a"
href="{{ charge.completelink }}"
label="{{ charge.completelinktext }}"
></q-btn>
</div>
<div v-else>
<center>
<span class="text-subtitle2"
>Pay this <br />
lightning-network invoice</span
>
</center>
<a href="lightning://{{ charge.payment_request }}">
<q-responsive :ratio="1" class="q-mx-md">
<qrcode
:value="'{{ charge.payment_request }}'"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText('{{ charge.payment_request }}')"
>Copy invoice</q-btn
>
</div>
</div>
</div>
</q-card-section>
</q-card>
<q-card class="q-pa-lg" v-if="onbtc">
<q-card-section class="q-pa-none">
<div class="text-center q-pt-md">
<div v-if="timetoComplete < 1 && charge_paid == 'False'">
<q-icon
name="block"
style="color: #ccc; font-size: 21.4em"
></q-icon>
</div>
<div v-else-if="charge_paid == 'True'">
<q-icon
name="check"
style="color: green; font-size: 21.4em"
></q-icon>
<q-btn
outline
v-if="'{{ charge.webhook }}' != None"
type="a"
href="{{ charge.completelink }}"
label="{{ charge.completelinktext }}"
></q-btn>
</div>
<div v-else>
<center>
<span class="text-subtitle2"
>Send {{ charge.amount }}sats<br />
to this onchain address</span
>
</center>
<a href="bitcoin://{{ charge.onchainaddress }}">
<q-responsive :ratio="1" class="q-mx-md">
<qrcode
:value="'{{ charge.onchainaddress }}'"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText('{{ charge.onchainaddress }}')"
>Copy address</q-btn
>
</div>
</div>
</div>
</q-card-section>
</q-card>
</q-card>
</div>
{% endblock %} {% block scripts %}
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
<style>
.theCard {
width: 360px;
margin: 10px auto;
}
.theHeading {
margin: 15px;
font-size: 25px;
}
</style>
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data() {
return {
newProgress: 0.4,
counter: 1,
newTimeLeft: '',
timetoComplete: 100,
lnbtc: true,
onbtc: false,
charge_time_elapsed: '{{charge.time_elapsed}}',
charge_amount: '{{charge.amount}}',
charge_balance: '{{charge.balance}}',
charge_paid: '{{charge.paid}}'
}
},
methods: {
checkBalance: function () {
var self = this
LNbits.api
.request(
'GET',
'/satspay/api/v1/charges/balance/{{ charge.id }}',
'filla'
)
.then(function (response) {
self.charge_time_elapsed = response.data.time_elapsed
self.charge_amount = response.data.amount
self.charge_balance = response.data.balance
if (self.charge_balance >= self.charge_amount) {
self.charge_paid = 'True'
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
payLN: function () {
this.lnbtc = true
this.onbtc = false
},
payON: function () {
this.lnbtc = false
this.onbtc = true
},
getTheTime: function () {
var timeToComplete =
parseInt('{{ charge.time }}') * 60 -
(Date.now() / 1000 - parseInt('{{ charge.timestamp }}'))
this.timetoComplete = timeToComplete
var timeLeft = Quasar.utils.date.formatDate(
new Date((timeToComplete - 3600) * 1000),
'HH:mm:ss'
)
this.newTimeLeft = timeLeft
},
getThePercentage: function () {
var timeToComplete =
parseInt('{{ charge.time }}') * 60 -
(Date.now() / 1000 - parseInt('{{ charge.timestamp }}'))
this.newProgress =
1 - timeToComplete / (parseInt('{{ charge.time }}') * 60)
},
timerCount: function () {
self = this
var refreshIntervalId = setInterval(function () {
if (self.charge_paid == 'True' || self.timetoComplete < 1) {
clearInterval(refreshIntervalId)
}
self.getTheTime()
self.getThePercentage()
self.counter++
if (self.counter % 10 === 0) {
self.checkBalance()
}
}, 1000)
}
},
created: function () {
if ('{{ charge.lnbitswallet }}' == 'None') {
this.lnbtc = false
this.onbtc = true
}
this.getTheTime()
this.getThePercentage()
var timerCount = this.timerCount
if ('{{ charge.paid }}' == 'False') {
timerCount()
}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,555 @@
{% 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>
<q-card-section>
{% raw %}
<q-btn unelevated color="primary" @click="formDialogCharge.show = true"
>New charge
</q-btn>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Charges</h5>
</div>
<div class="col-auto">
<q-input
borderless
dense
debounce="300"
v-model="filter"
placeholder="Search"
>
<template v-slot:append>
<q-icon name="search"></q-icon>
</template>
</q-input>
<q-btn flat color="grey" @click="exportchargeCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
flat
dense
:data="ChargeLinks"
row-key="id"
:columns="ChargesTable.columns"
:pagination.sync="ChargesTable.pagination"
:filter="filter"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width></q-th>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
<div v-if="col.name == 'id'"></div>
<div v-else>{{ col.label }}</div>
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="link"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="props.row.displayUrl"
target="_blank"
>
<q-tooltip> Payment link </q-tooltip>
</q-btn>
</q-td>
<q-td auto-width>
<q-btn
v-if="props.row.time_elapsed && props.row.balance < props.row.amount"
unelevated
flat
dense
size="xs"
icon="error"
:color="($q.dark.isActive) ? 'red' : 'red'"
>
<q-tooltip> Time elapsed </q-tooltip>
</q-btn>
<q-btn
v-else-if="props.row.balance >= props.row.amount"
unelevated
flat
dense
size="xs"
icon="check"
:color="($q.dark.isActive) ? 'green' : 'green'"
>
<q-tooltip> PAID! </q-tooltip>
</q-btn>
<q-btn
v-else
unelevated
dense
size="xs"
icon="cached"
flat
:color="($q.dark.isActive) ? 'blue' : 'blue'"
>
<q-tooltip> Processing </q-tooltip>
</q-btn>
<q-btn
flat
dense
size="xs"
@click="deleteChargeLink(props.row.id)"
icon="cancel"
color="pink"
>
<q-tooltip> Delete charge </q-tooltip>
</q-btn>
</q-td>
<q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
<div v-if="col.name == 'id'"></div>
<div v-else>{{ col.value }}</div>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</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}} satspay Extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "satspay/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog
v-model="formDialogCharge.show"
position="top"
@hide="closeFormDialog"
>
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormDataCharge" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialogCharge.data.description"
type="text"
label="*Description"
></q-input>
<q-input
filled
dense
v-model.trim="formDialogCharge.data.amount"
type="number"
label="*Amount (sats)"
></q-input>
<q-input
filled
dense
v-model.trim="formDialogCharge.data.time"
type="number"
max="1440"
label="*Mins valid for (max 1440)"
>
</q-input>
<q-input
filled
dense
v-model.trim="formDialogCharge.data.webhook"
type="url"
label="Webhook (URL to send transaction data to once paid)"
>
</q-input>
<q-input
filled
dense
v-model.trim="formDialogCharge.data.completelink"
type="url"
label="Completed button URL"
>
</q-input>
<q-input
filled
dense
v-model.trim="formDialogCharge.data.completelinktext"
type="text"
label="Completed button text (ie 'Back to merchant')"
>
</q-input>
<div class="row">
<div class="col">
<div v-if="walletLinks.length > 0">
<q-checkbox
v-model="formDialogCharge.data.onchain"
label="Onchain"
/>
</div>
<div v-else>
<q-checkbox :value="false" label="Onchain" disabled>
<q-tooltip>
Watch-Only extension MUST be activated and have a wallet
</q-tooltip>
</q-checkbox>
</div>
</div>
<div class="col">
<div>
<q-checkbox
v-model="formDialogCharge.data.lnbits"
label="LNbits wallet"
/>
</div>
</div>
</div>
<div v-if="formDialogCharge.data.onchain">
<q-select
filled
dense
emit-value
v-model="formDialogCharge.data.onchainwallet"
:options="walletLinks"
label="Onchain Wallet"
/>
</div>
<q-select
v-if="formDialogCharge.data.lnbits"
filled
dense
emit-value
v-model="formDialogCharge.data.lnbitswallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="
formDialogCharge.data.time == null ||
formDialogCharge.data.amount == null"
type="submit"
>Create Charge</q-btn
>
<q-btn @click="cancelCharge" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
<style></style>
<script>
Vue.component(VueQrcode.name, VueQrcode)
var mapCharge = obj => {
obj._data = _.clone(obj)
obj.theTime = obj.time * 60 - (Date.now() / 1000 - obj.timestamp)
obj.time = obj.time + 'mins'
if (obj.time_elapsed) {
obj.date = 'Time elapsed'
} else {
obj.date = Quasar.utils.date.formatDate(
new Date((obj.theTime - 3600) * 1000),
'HH:mm:ss'
)
}
obj.displayUrl = ['/satspay/', obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
filter: '',
watchonlyactive: false,
balance: null,
checker: null,
walletLinks: [],
ChargeLinks: [],
ChargeLinksObj: [],
onchainwallet: '',
currentaddress: '',
Addresses: {
show: false,
data: null
},
mempool: {
endpoint: ''
},
ChargesTable: {
columns: [
{
name: 'theId',
align: 'left',
label: 'ID',
field: 'id'
},
{
name: 'description',
align: 'left',
label: 'Title',
field: 'description'
},
{
name: 'timeleft',
align: 'left',
label: 'Time left',
field: 'date'
},
{
name: 'time to pay',
align: 'left',
label: 'Time to Pay',
field: 'time'
},
{
name: 'amount',
align: 'left',
label: 'Amount to pay',
field: 'amount'
},
{
name: 'balance',
align: 'left',
label: 'Balance',
field: 'balance'
},
{
name: 'onchain address',
align: 'left',
label: 'Onchain Address',
field: 'onchainaddress'
},
{
name: 'LNbits wallet',
align: 'left',
label: 'LNbits wallet',
field: 'lnbitswallet'
},
{
name: 'Webhook link',
align: 'left',
label: 'Webhook link',
field: 'webhook'
},
{
name: 'Paid link',
align: 'left',
label: 'Paid link',
field: 'completelink'
}
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
data: {}
},
formDialogCharge: {
show: false,
data: {
onchain: false,
lnbits: false,
description: '',
time: null,
amount: null
}
},
qrCodeDialog: {
show: false,
data: null
}
}
},
methods: {
cancelCharge: function (data) {
var self = this
self.formDialogCharge.data.description = ''
self.formDialogCharge.data.onchainwallet = ''
self.formDialogCharge.data.lnbitswallet = ''
self.formDialogCharge.data.time = null
self.formDialogCharge.data.amount = null
self.formDialogCharge.data.webhook = ''
self.formDialogCharge.data.completelink = ''
self.formDialogCharge.show = false
},
getWalletLinks: function () {
var self = this
LNbits.api
.request(
'GET',
'/watchonly/api/v1/wallet',
this.g.user.wallets[0].inkey
)
.then(function (response) {
for (i = 0; i < response.data.length; i++) {
self.walletLinks.push(response.data[i].id)
}
return
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
closeFormDialog: function () {
this.formDialog.data = {
is_unique: false
}
},
openQrCodeDialog: function (linkId) {
var self = this
var getAddresses = this.getAddresses
getAddresses(linkId)
self.current = linkId
self.Addresses.show = true
},
getCharges: function () {
var self = this
var getAddressBalance = this.getAddressBalance
LNbits.api
.request(
'GET',
'/satspay/api/v1/charges',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.ChargeLinks = response.data.map(mapCharge)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
sendFormDataCharge: function () {
var self = this
var wallet = this.g.user.wallets[0].adminkey
var data = this.formDialogCharge.data
data.amount = parseInt(data.amount)
data.time = parseInt(data.time)
this.createCharge(wallet, data)
},
timerCount: function () {
self = this
var refreshIntervalId = setInterval(function () {
for (i = 0; i < self.ChargeLinks.length - 1; i++) {
if (self.ChargeLinks[i]['paid'] == 'True') {
setTimeout(function () {
LNbits.api
.request(
'GET',
'/satspay/api/v1/charges/balance/' +
self.ChargeLinks[i]['id'],
'filla'
)
.then(function (response) {})
}, 2000)
}
}
self.getCharges()
}, 20000)
},
createCharge: function (wallet, data) {
var self = this
LNbits.api
.request('POST', '/satspay/api/v1/charge', wallet, data)
.then(function (response) {
self.ChargeLinks.push(mapCharge(response.data))
self.formDialogCharge.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteChargeLink: function (chargeId) {
var self = this
var link = _.findWhere(this.ChargeLinks, {id: chargeId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this pay link?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/satspay/api/v1/charge/' + chargeId,
self.g.user.wallets[0].adminkey
)
.then(function (response) {
self.ChargeLinks = _.reject(self.ChargeLinks, function (obj) {
return obj.id === chargeId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportchargeCSV: function () {
var self = this
LNbits.utils.exportCSV(self.ChargesTable.columns, this.ChargeLinks)
}
},
created: function () {
var self = this
var getCharges = this.getCharges
getCharges()
var getWalletLinks = this.getWalletLinks
getWalletLinks()
var timerCount = this.timerCount
timerCount()
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,22 @@
from quart import g, abort, render_template, jsonify
from http import HTTPStatus
from lnbits.decorators import check_user_exists, validate_uuids
from . import satspay_ext
from .crud import get_charge
@satspay_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index():
return await render_template("satspay/index.html", user=g.user)
@satspay_ext.route("/<charge_id>")
async def display(charge_id):
charge = await get_charge(charge_id) or abort(
HTTPStatus.NOT_FOUND, "Charge link does not exist."
)
return await render_template("satspay/display.html", charge=charge)

View File

@ -0,0 +1,157 @@
import hashlib
from quart import g, jsonify, url_for
from http import HTTPStatus
import httpx
from lnbits.core.crud import get_user
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.extensions.satspay import satspay_ext
from .crud import (
create_charge,
update_charge,
get_charge,
get_charges,
delete_charge,
check_address_balance,
)
#############################CHARGES##########################
@satspay_ext.route("/api/v1/charge", methods=["POST"])
@satspay_ext.route("/api/v1/charge/<charge_id>", methods=["PUT"])
@api_check_wallet_key("admin")
@api_validate_post_request(
schema={
"onchainwallet": {"type": "string"},
"lnbitswallet": {"type": "string"},
"description": {"type": "string", "empty": False, "required": True},
"webhook": {"type": "string"},
"completelink": {"type": "string"},
"completelinktext": {"type": "string"},
"time": {"type": "integer", "min": 1, "required": True},
"amount": {"type": "integer", "min": 1, "required": True},
}
)
async def api_charge_create_or_update(charge_id=None):
if not charge_id:
charge = await create_charge(user=g.wallet.user, **g.data)
return jsonify(charge._asdict()), HTTPStatus.CREATED
else:
charge = await update_charge(charge_id=charge_id, **g.data)
return jsonify(charge._asdict()), HTTPStatus.OK
@satspay_ext.route("/api/v1/charges", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_charges_retrieve():
try:
return (
jsonify(
[
{
**charge._asdict(),
**{"time_elapsed": charge.time_elapsed},
**{"paid": charge.paid},
}
for charge in await get_charges(g.wallet.user)
]
),
HTTPStatus.OK,
)
except:
return ""
@satspay_ext.route("/api/v1/charge/<charge_id>", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_charge_retrieve(charge_id):
charge = await get_charge(charge_id)
if not charge:
return jsonify({"message": "charge does not exist"}), HTTPStatus.NOT_FOUND
return (
jsonify(
{
**charge._asdict(),
**{"time_elapsed": charge.time_elapsed},
**{"paid": charge.paid},
}
),
HTTPStatus.OK,
)
@satspay_ext.route("/api/v1/charge/<charge_id>", methods=["DELETE"])
@api_check_wallet_key("invoice")
async def api_charge_delete(charge_id):
charge = await get_charge(charge_id)
if not charge:
return jsonify({"message": "Wallet link does not exist."}), HTTPStatus.NOT_FOUND
await delete_charge(charge_id)
return "", HTTPStatus.NO_CONTENT
#############################BALANCE##########################
@satspay_ext.route("/api/v1/charges/balance/<charge_id>", methods=["GET"])
async def api_charges_balance(charge_id):
charge = await check_address_balance(charge_id)
if not charge:
return jsonify({"message": "charge does not exist"}), HTTPStatus.NOT_FOUND
if charge.paid and charge.webhook:
async with httpx.AsyncClient() as client:
try:
r = await client.post(
charge.webhook,
json={
"id": charge.id,
"description": charge.description,
"onchainaddress": charge.onchainaddress,
"payment_request": charge.payment_request,
"payment_hash": charge.payment_hash,
"time": charge.time,
"amount": charge.amount,
"balance": charge.balance,
"paid": charge.paid,
"timestamp": charge.timestamp,
"completelink": charge.completelink,
},
timeout=40,
)
except AssertionError:
charge.webhook = None
return jsonify(charge._asdict()), HTTPStatus.OK
#############################MEMPOOL##########################
@satspay_ext.route("/api/v1/mempool", methods=["PUT"])
@api_check_wallet_key("invoice")
@api_validate_post_request(
schema={
"endpoint": {"type": "string", "empty": False, "required": True},
}
)
async def api_update_mempool():
mempool = await update_mempool(user=g.wallet.user, **g.data)
return jsonify(mempool._asdict()), HTTPStatus.OK
@satspay_ext.route("/api/v1/mempool", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_get_mempool():
mempool = await get_mempool(g.wallet.user)
if not mempool:
mempool = await create_mempool(user=g.wallet.user)
return jsonify(mempool._asdict()), HTTPStatus.OK