Enhancements to TPOS Extension
- Add new tip % option to TPOS extension. - When adding a new TPOS, a user can choose 1 or more tip % options to be displayed to the customer. - When adding a new TPOS, a user can choose a wallet to send all collected tips to. - UI Refresh on TPOS extension. - Moved the share button to the top navigation, next to the TPOS name, and changed the icon to a more recognizable one. - Re-arranged the buttons on the keypad to be more ergonomic.
This commit is contained in:
parent
e4f16f172c
commit
82d7bfbba8
|
@ -1,7 +1,10 @@
|
|||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.tasks import catch_everything_and_restart
|
||||
|
||||
db = Database("ext_tpos")
|
||||
|
||||
|
@ -11,6 +14,10 @@ tpos_ext: APIRouter = APIRouter(prefix="/tpos", tags=["TPoS"])
|
|||
def tpos_renderer():
|
||||
return template_renderer(["lnbits/extensions/tpos/templates"])
|
||||
|
||||
|
||||
from .tasks import wait_for_paid_invoices
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
||||
|
||||
def tpos_start():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
||||
|
|
|
@ -2,5 +2,5 @@
|
|||
"name": "TPoS",
|
||||
"short_description": "A shareable PoS terminal!",
|
||||
"icon": "dialpad",
|
||||
"contributors": ["talvasconcelos", "arcbtc"]
|
||||
"contributors": ["talvasconcelos", "arcbtc", "leesalminen"]
|
||||
}
|
||||
|
|
|
@ -10,10 +10,10 @@ async def create_tpos(wallet_id: str, data: CreateTposData) -> TPoS:
|
|||
tpos_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO tpos.tposs (id, wallet, name, currency)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO tpos.tposs (id, wallet, name, currency, tip_options, tip_wallet)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(tpos_id, wallet_id, data.name, data.currency),
|
||||
(tpos_id, wallet_id, data.name, data.currency, data.tip_options, data.tip_wallet),
|
||||
)
|
||||
|
||||
tpos = await get_tpos(tpos_id)
|
||||
|
|
|
@ -12,3 +12,23 @@ async def m001_initial(db):
|
|||
);
|
||||
"""
|
||||
)
|
||||
|
||||
async def m002_addtip_wallet(db):
|
||||
"""
|
||||
Add tips to tposs table
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
ALTER TABLE tpos.tposs ADD tip_wallet TEXT NULL;
|
||||
"""
|
||||
)
|
||||
|
||||
async def m003_addtip_options(db):
|
||||
"""
|
||||
Add tips to tposs table
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
ALTER TABLE tpos.tposs ADD tip_options TEXT NULL;
|
||||
"""
|
||||
)
|
|
@ -6,6 +6,8 @@ from pydantic import BaseModel
|
|||
class CreateTposData(BaseModel):
|
||||
name: str
|
||||
currency: str
|
||||
tip_options: str
|
||||
tip_wallet: str
|
||||
|
||||
|
||||
class TPoS(BaseModel):
|
||||
|
@ -13,6 +15,8 @@ class TPoS(BaseModel):
|
|||
wallet: str
|
||||
name: str
|
||||
currency: str
|
||||
tip_options: str
|
||||
tip_wallet: str
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "TPoS":
|
||||
|
|
70
lnbits/extensions/tpos/tasks.py
Normal file
70
lnbits/extensions/tpos/tasks.py
Normal file
|
@ -0,0 +1,70 @@
|
|||
import asyncio
|
||||
import json
|
||||
|
||||
from lnbits.core import db as core_db
|
||||
from lnbits.core.crud import create_payment
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
from lnbits.tasks import internal_invoice_queue, register_invoice_listener
|
||||
|
||||
from .crud import get_tpos
|
||||
|
||||
|
||||
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 "tpos" == payment.extra.get("tag") and payment.extra.get("tipSplitted"):
|
||||
# already splitted, ignore
|
||||
return
|
||||
|
||||
# now we make some special internal transfers (from no one to the receiver)
|
||||
tpos = await get_tpos(payment.extra.get("tposId"))
|
||||
|
||||
tipAmount = payment.extra.get("tipAmount")
|
||||
|
||||
if tipAmount is None:
|
||||
#no tip amount
|
||||
return
|
||||
|
||||
tipAmount = tipAmount * 1000
|
||||
|
||||
# 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 = amount - ?
|
||||
WHERE hash = ?
|
||||
AND checking_id NOT LIKE 'internal_%'
|
||||
""",
|
||||
(
|
||||
json.dumps(dict(**payment.extra, tipSplitted=True)),
|
||||
tipAmount,
|
||||
payment.payment_hash,
|
||||
),
|
||||
)
|
||||
|
||||
# perform the internal transfer using the same payment_hash
|
||||
internal_checking_id = f"internal_{urlsafe_short_hash()}"
|
||||
await create_payment(
|
||||
wallet_id=tpos.tip_wallet,
|
||||
checking_id=internal_checking_id,
|
||||
payment_request="",
|
||||
payment_hash=payment.payment_hash,
|
||||
amount=tipAmount,
|
||||
memo=payment.memo,
|
||||
pending=False,
|
||||
extra={"tipSplitted": True},
|
||||
)
|
||||
|
||||
# manually send this for now
|
||||
await internal_invoice_queue.put(internal_checking_id)
|
||||
return
|
|
@ -54,7 +54,7 @@
|
|||
></q-btn>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
{{ (col.name == 'tip_options' ? JSON.parse(col.value).join(", ") : col.value) }}
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
|
@ -116,6 +116,29 @@
|
|||
:options="currencyOptions"
|
||||
label="Currency *"
|
||||
></q-select>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.tip_wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Tip Wallet"
|
||||
></q-select>
|
||||
<q-select
|
||||
filled
|
||||
multiple
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.tip_options"
|
||||
v-if="formDialog.data.tip_wallet"
|
||||
use-input
|
||||
use-chips
|
||||
multiple
|
||||
hide-dropdown-icon
|
||||
input-debounce="0"
|
||||
new-value-mode="add-unique"
|
||||
label="Tip % Options"
|
||||
></q-select>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
|
@ -333,7 +356,19 @@
|
|||
align: 'left',
|
||||
label: 'Currency',
|
||||
field: 'currency'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'tip_wallet',
|
||||
align: 'left',
|
||||
label: "Tip Wallet",
|
||||
field: "tip_wallet",
|
||||
},
|
||||
{
|
||||
name: 'tip_options',
|
||||
align: 'left',
|
||||
label: "Tip Options %",
|
||||
field: "tip_options",
|
||||
},
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
|
@ -367,7 +402,9 @@
|
|||
createTPoS: function () {
|
||||
var data = {
|
||||
name: this.formDialog.data.name,
|
||||
currency: this.formDialog.data.currency
|
||||
currency: this.formDialog.data.currency,
|
||||
tip_options: (this.formDialog.data.tip_options ? JSON.stringify(this.formDialog.data.tip_options.map(str => parseInt(str))) : JSON.stringify([])),
|
||||
tip_wallet: this.formDialog.data.tip_wallet || "",
|
||||
}
|
||||
var self = this
|
||||
|
||||
|
|
|
@ -1,5 +1,16 @@
|
|||
{% extends "public.html" %} {% block toolbar_title %}{{ tpos.name }}{% endblock
|
||||
%} {% block footer %}{% endblock %} {% block page_container %}
|
||||
{% extends "public.html" %}
|
||||
{% block toolbar_title %}
|
||||
{{ tpos.name }}
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="md"
|
||||
@click.prevent="urlDialog.show = true"
|
||||
icon="share"
|
||||
color="white"
|
||||
></q-btn>
|
||||
{% endblock %}
|
||||
{% block footer %}{% endblock %} {% block page_container %}
|
||||
<q-page-container>
|
||||
<q-page>
|
||||
<q-page-sticky v-if="exchangeRate" expand position="top">
|
||||
|
@ -43,16 +54,6 @@
|
|||
color="primary"
|
||||
>3</q-btn
|
||||
>
|
||||
<q-btn
|
||||
unelevated
|
||||
@click="stack = []"
|
||||
size="xl"
|
||||
:outline="!($q.dark.isActive)"
|
||||
rounded
|
||||
color="primary"
|
||||
class="btn-cancel"
|
||||
>C</q-btn
|
||||
>
|
||||
<q-btn
|
||||
unelevated
|
||||
@click="stack.push(4)"
|
||||
|
@ -107,17 +108,6 @@
|
|||
color="primary"
|
||||
>9</q-btn
|
||||
>
|
||||
<q-btn
|
||||
unelevated
|
||||
:disabled="amount == 0"
|
||||
@click="showInvoice()"
|
||||
size="xl"
|
||||
:outline="!($q.dark.isActive)"
|
||||
rounded
|
||||
color="primary"
|
||||
class="btn-confirm"
|
||||
>OK</q-btn
|
||||
>
|
||||
<q-btn
|
||||
unelevated
|
||||
@click="stack.splice(-1, 1)"
|
||||
|
@ -138,12 +128,24 @@
|
|||
>
|
||||
<q-btn
|
||||
unelevated
|
||||
@click="urlDialog.show = true"
|
||||
@click="stack = []"
|
||||
size="xl"
|
||||
:outline="!($q.dark.isActive)"
|
||||
rounded
|
||||
color="primary"
|
||||
>#</q-btn
|
||||
class="btn-cancel"
|
||||
>C</q-btn
|
||||
>
|
||||
<q-btn
|
||||
unelevated
|
||||
:disabled="amount == 0"
|
||||
@click="submitForm()"
|
||||
size="xl"
|
||||
:outline="!($q.dark.isActive)"
|
||||
rounded
|
||||
color="primary"
|
||||
class="btn-confirm"
|
||||
>OK</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -176,6 +178,38 @@
|
|||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog
|
||||
v-model="tipDialog.show"
|
||||
position="top"
|
||||
>
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div class="text-center q-mb-xl">
|
||||
<b style="font-size: 24px;">Would you like to leave a tip?</b>
|
||||
</div>
|
||||
<div class="text-center q-mb-xl">
|
||||
<q-btn
|
||||
style="padding: 10px; margin: 3px;"
|
||||
unelevated
|
||||
@click="processTipSelection(tip)"
|
||||
size="xl"
|
||||
:outline="!($q.dark.isActive)"
|
||||
rounded
|
||||
color="primary"
|
||||
v-for="tip in this.tip_options"
|
||||
:key="tip"
|
||||
>{% raw %}{{ tip }}{% endraw %}%</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="text-center q-mb-xl">
|
||||
<p><a href="#" @click="processTipSelection(0)" style="color: white;"> No, thanks</a></p>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="urlDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
||||
|
@ -214,6 +248,10 @@
|
|||
</q-page-container>
|
||||
{% endblock %} {% block styles %}
|
||||
<style>
|
||||
* {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.keypad {
|
||||
display: grid;
|
||||
grid-gap: 8px;
|
||||
|
@ -225,9 +263,8 @@
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
.btn-cancel,
|
||||
.btn-confirm {
|
||||
grid-row: auto/span 2;
|
||||
.keypad .btn-confirm {
|
||||
grid-area: 1 / 4 / 5 / 4;
|
||||
}
|
||||
</style>
|
||||
{% endblock %} {% block scripts %}
|
||||
|
@ -241,14 +278,19 @@
|
|||
return {
|
||||
tposId: '{{ tpos.id }}',
|
||||
currency: '{{ tpos.currency }}',
|
||||
tip_options: JSON.parse('{{ tpos.tip_options }}'),
|
||||
exchangeRate: null,
|
||||
stack: [],
|
||||
tipAmount: 0.00,
|
||||
invoiceDialog: {
|
||||
show: false,
|
||||
data: null,
|
||||
dismissMsg: null,
|
||||
paymentChecker: null
|
||||
},
|
||||
tipDialog: {
|
||||
show: false,
|
||||
},
|
||||
urlDialog: {
|
||||
show: false
|
||||
},
|
||||
|
@ -269,6 +311,10 @@
|
|||
if (!this.exchangeRate) return 0
|
||||
return Math.ceil((this.amount / this.exchangeRate) * 100000000)
|
||||
},
|
||||
tipAmountSat: function () {
|
||||
if (!this.exchangeRate) return 0
|
||||
return Math.ceil((this.tipAmount / this.exchangeRate) * 100000000)
|
||||
},
|
||||
fsat: function () {
|
||||
console.log('sat', this.sat, LNbits.utils.formatSat(this.sat))
|
||||
return LNbits.utils.formatSat(this.sat)
|
||||
|
@ -277,12 +323,46 @@
|
|||
methods: {
|
||||
closeInvoiceDialog: function () {
|
||||
this.stack = []
|
||||
this.tipAmount = 0.00
|
||||
var dialog = this.invoiceDialog
|
||||
setTimeout(function () {
|
||||
clearInterval(dialog.paymentChecker)
|
||||
dialog.dismissMsg()
|
||||
}, 3000)
|
||||
},
|
||||
processTipSelection: function (selectedTipOption) {
|
||||
this.tipDialog.show = false
|
||||
|
||||
if(selectedTipOption) {
|
||||
const tipAmount = parseFloat(parseFloat((selectedTipOption / 100) * this.amount))
|
||||
const subtotal = parseFloat(this.amount)
|
||||
const grandTotal = parseFloat((tipAmount + subtotal).toFixed(2))
|
||||
const totalString = grandTotal.toFixed(2).toString()
|
||||
|
||||
this.stack = []
|
||||
for (var i = 0; i < totalString.length; i++) {
|
||||
const char = totalString[i]
|
||||
|
||||
if(char !== ".") {
|
||||
this.stack.push(char)
|
||||
}
|
||||
}
|
||||
|
||||
this.tipAmount = tipAmount
|
||||
}
|
||||
|
||||
this.showInvoice()
|
||||
},
|
||||
submitForm: function() {
|
||||
if(this.tip_options.length) {
|
||||
this.showTipModal()
|
||||
} else {
|
||||
this.showInvoice()
|
||||
}
|
||||
},
|
||||
showTipModal: function() {
|
||||
this.tipDialog.show = true
|
||||
},
|
||||
showInvoice: function () {
|
||||
var self = this
|
||||
var dialog = this.invoiceDialog
|
||||
|
@ -290,7 +370,8 @@
|
|||
axios
|
||||
.post('/tpos/api/v1/tposs/' + this.tposId + '/invoices', null, {
|
||||
params: {
|
||||
amount: this.sat
|
||||
amount: this.sat,
|
||||
tipAmount: this.tipAmountSat,
|
||||
}
|
||||
})
|
||||
.then(function (response) {
|
||||
|
|
|
@ -52,7 +52,7 @@ async def api_tpos_delete(
|
|||
|
||||
|
||||
@tpos_ext.post("/api/v1/tposs/{tpos_id}/invoices", status_code=HTTPStatus.CREATED)
|
||||
async def api_tpos_create_invoice(amount: int = Query(..., ge=1), tpos_id: str = None):
|
||||
async def api_tpos_create_invoice(amount: int = Query(..., ge=1), tipAmount: int = None, tpos_id: str = None):
|
||||
tpos = await get_tpos(tpos_id)
|
||||
|
||||
if not tpos:
|
||||
|
@ -65,7 +65,7 @@ async def api_tpos_create_invoice(amount: int = Query(..., ge=1), tpos_id: str =
|
|||
wallet_id=tpos.wallet,
|
||||
amount=amount,
|
||||
memo=f"{tpos.name}",
|
||||
extra={"tag": "tpos"},
|
||||
extra={"tag": "tpos", "tipAmount": tipAmount, "tposId": tpos_id},
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
@ -84,6 +84,7 @@ async def api_tpos_check_invoice(tpos_id: str, payment_hash: str):
|
|||
)
|
||||
try:
|
||||
status = await api_payment(payment_hash)
|
||||
|
||||
except Exception as exc:
|
||||
print(exc)
|
||||
return {"paid": False}
|
||||
|
|
Loading…
Reference in New Issue
Block a user