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:
Lee Salminen 2022-07-02 21:16:27 -06:00
parent e4f16f172c
commit 82d7bfbba8
9 changed files with 259 additions and 39 deletions

View File

@ -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))

View File

@ -2,5 +2,5 @@
"name": "TPoS",
"short_description": "A shareable PoS terminal!",
"icon": "dialpad",
"contributors": ["talvasconcelos", "arcbtc"]
"contributors": ["talvasconcelos", "arcbtc", "leesalminen"]
}

View File

@ -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)

View File

@ -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;
"""
)

View File

@ -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":

View 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

View File

@ -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

View File

@ -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) {

View File

@ -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}