Merge pull request #1424 from lnbits/tpospull

Pulls tpos for install worflow
This commit is contained in:
Arc 2023-01-27 14:26:57 +00:00 committed by GitHub
commit f11044ae75
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 0 additions and 1760 deletions

View File

@ -1,15 +0,0 @@
# TPoS
## A Shareable PoS (Point of Sale) that doesn't need to be installed and can run in the browser!
An easy, fast and secure way to accept Bitcoin, over Lightning Network, at your business. The PoS is isolated from the wallet, so it's safe for any employee to use. You can create as many TPOS's as you need, for example one for each employee, or one for each branch of your business.
### Usage
1. Enable extension
2. Create a TPOS\
![create](https://imgur.com/8jNj8Zq.jpg)
3. Open TPOS on the browser\
![open](https://imgur.com/LZuoWzb.jpg)
4. Present invoice QR to customer\
![pay](https://imgur.com/tOwxn77.jpg)

View File

@ -1,34 +0,0 @@
import asyncio
from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_tpos")
tpos_ext: APIRouter = APIRouter(prefix="/tpos", tags=["TPoS"])
tpos_static_files = [
{
"path": "/tpos/static",
"app": StaticFiles(directory="lnbits/extensions/tpos/static"),
"name": "tpos_static",
}
]
def tpos_renderer():
return template_renderer(["lnbits/extensions/tpos/templates"])
from .tasks import wait_for_paid_invoices
from .views import * # noqa
from .views_api import * # noqa
def tpos_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))

View File

@ -1,6 +0,0 @@
{
"name": "TPoS",
"short_description": "A shareable PoS terminal!",
"tile": "/tpos/static/image/tpos.png",
"contributors": ["talvasconcelos", "arcbtc", "leesalminen"]
}

View File

@ -1,49 +0,0 @@
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import CreateTposData, TPoS
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, tip_options, tip_wallet)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
tpos_id,
wallet_id,
data.name,
data.currency,
data.tip_options,
data.tip_wallet,
),
)
tpos = await get_tpos(tpos_id)
assert tpos, "Newly created tpos couldn't be retrieved"
return tpos
async def get_tpos(tpos_id: str) -> Optional[TPoS]:
row = await db.fetchone("SELECT * FROM tpos.tposs WHERE id = ?", (tpos_id,))
return TPoS(**row) if row else None
async def get_tposs(wallet_ids: Union[str, List[str]]) -> List[TPoS]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM tpos.tposs WHERE wallet IN ({q})", (*wallet_ids,)
)
return [TPoS(**row) for row in rows]
async def delete_tpos(tpos_id: str) -> None:
await db.execute("DELETE FROM tpos.tposs WHERE id = ?", (tpos_id,))

View File

@ -1,36 +0,0 @@
async def m001_initial(db):
"""
Initial tposs table.
"""
await db.execute(
"""
CREATE TABLE tpos.tposs (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
name TEXT NOT NULL,
currency TEXT NOT NULL
);
"""
)
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

@ -1,29 +0,0 @@
from sqlite3 import Row
from typing import Optional
from fastapi import Query
from pydantic import BaseModel
class CreateTposData(BaseModel):
name: str
currency: str
tip_options: str = Query(None)
tip_wallet: str = Query(None)
class TPoS(BaseModel):
id: str
wallet: str
name: str
currency: str
tip_options: Optional[str]
tip_wallet: Optional[str]
@classmethod
def from_row(cls, row: Row) -> "TPoS":
return cls(**dict(row))
class PayLnurlWData(BaseModel):
lnurl: str

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,64 +0,0 @@
import asyncio
from loguru import logger
from lnbits.core.models import Payment
from lnbits.core.services import create_invoice, pay_invoice, websocketUpdater
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener
from .crud import get_tpos
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue, get_current_extension_name())
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
if payment.extra.get("tag") != "tpos":
return
tipAmount = payment.extra.get("tipAmount")
strippedPayment = {
"amount": payment.amount,
"fee": payment.fee,
"checking_id": payment.checking_id,
"payment_hash": payment.payment_hash,
"bolt11": payment.bolt11,
}
tpos_id = payment.extra.get("tposId")
assert tpos_id
tpos = await get_tpos(tpos_id)
assert tpos
await websocketUpdater(tpos_id, str(strippedPayment))
if not tipAmount:
# no tip amount
return
wallet_id = tpos.tip_wallet
assert wallet_id
payment_hash, payment_request = await create_invoice(
wallet_id=wallet_id,
amount=int(tipAmount),
internal=True,
memo=f"tpos tip",
)
logger.debug(f"tpos: tip invoice created: {payment_hash}")
checking_id = await pay_invoice(
payment_request=payment_request,
wallet_id=payment.wallet_id,
extra={**payment.extra, "tipSplitted": True},
)
logger.debug(f"tpos: tip invoice paid: {checking_id}")

View File

@ -1,79 +0,0 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-btn flat label="Swagger API" type="a" href="../docs#/tpos"></q-btn>
<q-expansion-item group="api" dense expand-separator label="List TPoS">
<q-card>
<q-card-section>
<code><span class="text-blue">GET</span> /tpos/api/v1/tposs</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;tpos_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}tpos/api/v1/tposs -H "X-Api-Key:
&lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Create a TPoS">
<q-card>
<q-card-section>
<code><span class="text-green">POST</span> /tpos/api/v1/tposs</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>
<code
>{"name": &lt;string&gt;, "currency": &lt;string*ie USD*&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"currency": &lt;string&gt;, "id": &lt;string&gt;, "name":
&lt;string&gt;, "wallet": &lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}tpos/api/v1/tposs -d '{"name":
&lt;string&gt;, "currency": &lt;string&gt;}' -H "Content-type:
application/json" -H "X-Api-Key: &lt;admin_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Delete a TPoS"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/tpos/api/v1/tposs/&lt;tpos_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.base_url
}}tpos/api/v1/tposs/&lt;tpos_id&gt; -H "X-Api-Key: &lt;admin_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View File

@ -1,21 +0,0 @@
<q-expansion-item group="extras" icon="info" label="About TPoS">
<q-card>
<q-card-section>
<p>
Thiago's Point of Sale is a secure, mobile-ready, instant and shareable
point of sale terminal (PoS) for merchants. The PoS is linked to your
LNbits wallet but completely air-gapped so users can ONLY create
invoices. To share the TPoS hit the hash on the terminal.
</p>
<small
>Created by
<a
class="text-secondary"
href="https://github.com/talvasconcelos"
target="_blank"
>Tiago Vasconcelos</a
>.</small
>
</q-card-section>
</q-card>
</q-expansion-item>

View File

@ -1,471 +0,0 @@
{% 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-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true"
>New TPoS</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">TPoS</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="tposs"
row-key="id"
:columns="tpossTable.columns"
:pagination.sync="tpossTable.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></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="launch"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="props.row.tpos"
target="_blank"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ (col.name == 'tip_options' && col.value ?
JSON.parse(col.value).join(", ") : col.value) }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteTPoS(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</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}} TPoS extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "tpos/_api_docs.html" %}
<q-separator></q-separator>
{% include "tpos/_tpos.html" %}
</q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="createTPoS" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialog.data.name"
label="Name"
placeholder="Tiago's PoS"
></q-input>
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
></q-select>
<q-select
filled
dense
emit-value
v-model="formDialog.data.currency"
: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 (hit enter to add values)"
><q-tooltip>Hit enter to add values</q-tooltip>
<template v-slot:hint>
You can leave this blank. A default rounding option is available
(round amount to a value)
</template>
</q-select>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.currency == null || formDialog.data.name == null"
type="submit"
>Create TPoS</q-btn
>
<q-btn v-close-popup 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>
var mapTPoS = function (obj) {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.tpos = ['/tpos/', obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
tposs: [],
currencyOptions: [
'USD',
'EUR',
'GBP',
'AED',
'AFN',
'ALL',
'AMD',
'ANG',
'AOA',
'ARS',
'AUD',
'AWG',
'AZN',
'BAM',
'BBD',
'BDT',
'BGN',
'BHD',
'BIF',
'BMD',
'BND',
'BOB',
'BRL',
'BSD',
'BTN',
'BWP',
'BYN',
'BZD',
'CAD',
'CDF',
'CHF',
'CLF',
'CLP',
'CNH',
'CNY',
'COP',
'CRC',
'CUC',
'CUP',
'CVE',
'CZK',
'DJF',
'DKK',
'DOP',
'DZD',
'EGP',
'ERN',
'ETB',
'EUR',
'FJD',
'FKP',
'GBP',
'GEL',
'GGP',
'GHS',
'GIP',
'GMD',
'GNF',
'GTQ',
'GYD',
'HKD',
'HNL',
'HRK',
'HTG',
'HUF',
'IDR',
'ILS',
'IMP',
'INR',
'IQD',
'IRR',
'IRT',
'ISK',
'JEP',
'JMD',
'JOD',
'JPY',
'KES',
'KGS',
'KHR',
'KMF',
'KPW',
'KRW',
'KWD',
'KYD',
'KZT',
'LAK',
'LBP',
'LKR',
'LRD',
'LSL',
'LYD',
'MAD',
'MDL',
'MGA',
'MKD',
'MMK',
'MNT',
'MOP',
'MRO',
'MUR',
'MVR',
'MWK',
'MXN',
'MYR',
'MZN',
'NAD',
'NGN',
'NIO',
'NOK',
'NPR',
'NZD',
'OMR',
'PAB',
'PEN',
'PGK',
'PHP',
'PKR',
'PLN',
'PYG',
'QAR',
'RON',
'RSD',
'RUB',
'RWF',
'SAR',
'SBD',
'SCR',
'SDG',
'SEK',
'SGD',
'SHP',
'SLL',
'SOS',
'SRD',
'SSP',
'STD',
'SVC',
'SYP',
'SZL',
'THB',
'TJS',
'TMT',
'TND',
'TOP',
'TRY',
'TTD',
'TWD',
'TZS',
'UAH',
'UGX',
'USD',
'UYU',
'UZS',
'VEF',
'VES',
'VND',
'VUV',
'WST',
'XAF',
'XAG',
'XAU',
'XCD',
'XDR',
'XOF',
'XPD',
'XPF',
'XPT',
'YER',
'ZAR',
'ZMW',
'ZWL'
],
tpossTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'name', align: 'left', label: 'Name', field: 'name'},
{
name: 'currency',
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
}
},
formDialog: {
show: false,
data: {}
}
}
},
methods: {
closeFormDialog: function () {
this.formDialog.data = {}
},
getTPoSs: function () {
var self = this
LNbits.api
.request(
'GET',
'/tpos/api/v1/tposs?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.tposs = response.data.map(function (obj) {
return mapTPoS(obj)
})
})
},
createTPoS: function () {
var data = {
name: this.formDialog.data.name,
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
LNbits.api
.request(
'POST',
'/tpos/api/v1/tposs',
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
.inkey,
data
)
.then(function (response) {
self.tposs.push(mapTPoS(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteTPoS: function (tposId) {
var self = this
var tpos = _.findWhere(this.tposs, {id: tposId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this TPoS?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/tpos/api/v1/tposs/' + tposId,
_.findWhere(self.g.user.wallets, {id: tpos.wallet}).adminkey
)
.then(function (response) {
self.tposs = _.reject(self.tposs, function (obj) {
return obj.id == tposId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportCSV: function () {
LNbits.utils.exportCSV(this.tpossTable.columns, this.tposs)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getTPoSs()
}
}
})
</script>
{% endblock %}

View File

@ -1,686 +0,0 @@
{% 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">
<div class="row justify-center full-width">
<div class="col-12 col-sm-8 col-md-6 col-lg-4 text-center">
<h3 class="q-mb-md">{% raw %}{{ amountFormatted }}{% endraw %}</h3>
<h5 class="q-mt-none q-mb-sm">
{% raw %}{{ fsat }}{% endraw %} <small>sat</small>
</h5>
</div>
</div>
</q-page-sticky>
<q-page-sticky expand position="bottom">
<div class="row justify-center full-width">
<div class="col-12 col-sm-8 col-md-6 col-lg-4">
<div class="keypad q-pa-sm">
<q-btn
unelevated
@click="stack.push(1)"
size="xl"
:outline="!($q.dark.isActive)"
rounded
color="primary"
>1</q-btn
>
<q-btn
unelevated
@click="stack.push(2)"
size="xl"
:outline="!($q.dark.isActive)"
rounded
color="primary"
>2</q-btn
>
<q-btn
unelevated
@click="stack.push(3)"
size="xl"
:outline="!($q.dark.isActive)"
rounded
color="primary"
>3</q-btn
>
<q-btn
unelevated
@click="stack.push(4)"
size="xl"
:outline="!($q.dark.isActive)"
rounded
color="primary"
>4</q-btn
>
<q-btn
unelevated
@click="stack.push(5)"
size="xl"
:outline="!($q.dark.isActive)"
rounded
color="primary"
>5</q-btn
>
<q-btn
unelevated
@click="stack.push(6)"
size="xl"
:outline="!($q.dark.isActive)"
rounded
color="primary"
>6</q-btn
>
<q-btn
unelevated
@click="stack.push(7)"
size="xl"
:outline="!($q.dark.isActive)"
rounded
color="primary"
>7</q-btn
>
<q-btn
unelevated
@click="stack.push(8)"
size="xl"
:outline="!($q.dark.isActive)"
rounded
color="primary"
>8</q-btn
>
<q-btn
unelevated
@click="stack.push(9)"
size="xl"
:outline="!($q.dark.isActive)"
rounded
color="primary"
>9</q-btn
>
<q-btn
unelevated
@click="stack.splice(-1, 1)"
size="xl"
:outline="!($q.dark.isActive)"
rounded
color="primary"
>DEL</q-btn
>
<q-btn
unelevated
@click="stack.push(0)"
size="xl"
:outline="!($q.dark.isActive)"
rounded
color="primary"
>0</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
:disabled="amount == 0"
@click="submitForm()"
size="xl"
:outline="!($q.dark.isActive)"
rounded
color="primary"
class="btn-confirm"
>OK</q-btn
>
</div>
</div>
</div>
</q-page-sticky>
<q-page-sticky position="top-right" :offset="[18, 18]">
<q-btn
@click="showLastPayments"
fab
icon="receipt_long"
color="primary"
/>
</q-page-sticky>
<q-dialog
v-model="invoiceDialog.show"
position="top"
@hide="closeInvoiceDialog"
>
<q-card
v-if="invoiceDialog.data"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="'lightning:' + invoiceDialog.data.payment_request.toUpperCase()"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
<div class="text-center">
<h3 class="q-my-md">
{% raw %}{{ amountWithTipFormatted }}{% endraw %}
</h3>
<h5 class="q-mt-none">
{% raw %}{{ fsat }}
<small>sat</small>
<span v-show="tip_options" style="font-size: 0.75rem"
>( + {{ tipAmountFormatted }} tip)</span
>
{% endraw %}
</h5>
<q-btn
outline
color="grey"
icon="nfc"
@click="readNfcTag()"
:disable="nfcTagReading"
></q-btn>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(invoiceDialog.data.payment_request)"
>Copy invoice</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</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="lg"
:outline="!($q.dark.isActive)"
rounded
color="primary"
v-for="tip in tip_options.filter(f => f != 'Round')"
:key="tip"
>{% raw %}{{ tip }}{% endraw %}%</q-btn
>
<q-btn
style="padding: 10px; margin: 3px"
unelevated
@click="setRounding"
size="lg"
:outline="!($q.dark.isActive)"
rounded
color="primary"
label="Round to"
></q-btn>
<div class="row q-my-lg" v-if="rounding">
<q-input
class="col"
ref="inputRounding"
v-model.number="tipRounding"
:placeholder="roundToSugestion"
type="number"
hint="Total amount including tip"
:prefix="currency"
>
</q-input>
<q-btn
class="q-ml-sm"
style="margin-bottom: 20px"
color="primary"
@click="calculatePercent"
>Ok</q-btn
>
</div>
</div>
<div class="row q-mt-lg">
<q-btn flat color="primary" @click="processTipSelection(0)"
>No, thanks</q-btn
>
<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">
<qrcode
value="{{ request.url }}"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
<div class="text-center q-mb-xl">
<p style="word-break: break-all">
<strong>{{ tpos.name }}</strong><br />{{ request.url }}
</p>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText('{{ request.url }}', 'TPoS URL copied to clipboard!')"
>Copy URL</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="complete.show" position="top">
<q-icon
name="check"
transition-show="fade"
class="text-light-green"
style="font-size: min(90vw, 40em)"
></q-icon>
</q-dialog>
<q-dialog v-model="lastPaymentsDialog.show" position="bottom">
<q-card class="lnbits__dialog-card">
<q-card-section class="row items-center q-pb-none">
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-list separator class="q-mb-lg">
<q-item v-if="!lastPaymentsDialog.data.length">
<q-item-section>
<q-item-label class="text-bold">No paid invoices</q-item-label>
</q-item-section>
</q-item>
<q-item v-for="(payment, idx) in lastPaymentsDialog.data" :key="idx">
{%raw%}
<q-item-section>
<q-item-label class="text-bold"
>{{payment.amount / 1000}} sats</q-item-label
>
<q-item-label caption lines="2"
>Hash: {{payment.checking_id.slice(0, 30)}}...</q-item-label
>
</q-item-section>
<q-item-section side top>
<q-item-label caption>{{payment.dateFrom}}</q-item-label>
<q-icon name="check" color="green" />
</q-item-section>
{%endraw%}
</q-item>
</q-list>
</q-card>
</q-dialog>
</q-page>
</q-page-container>
{% endblock %} {% block styles %}
<style>
* {
touch-action: manipulation;
}
.keypad {
display: grid;
grid-gap: 8px;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(4, 1fr);
}
.keypad .btn {
height: 100%;
}
.keypad .btn-confirm {
grid-area: 1 / 4 / 5 / 4;
}
</style>
{% endblock %} {% block scripts %}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
tposId: '{{ tpos.id }}',
currency: '{{ tpos.currency }}',
tip_options: null,
exchangeRate: null,
stack: [],
tipAmount: 0.0,
tipRounding: null,
hasNFC: false,
nfcTagReading: false,
lastPaymentsDialog: {
show: false,
data: []
},
invoiceDialog: {
show: false,
data: null,
dismissMsg: null,
paymentChecker: null
},
tipDialog: {
show: false
},
urlDialog: {
show: false
},
complete: {
show: false
},
rounding: false
}
},
computed: {
amount: function () {
if (!this.stack.length) return 0.0
return Number(this.stack.join('') / 100)
},
amountFormatted: function () {
return LNbits.utils.formatCurrency(
this.amount.toFixed(2),
this.currency
)
},
amountWithTipFormatted: function () {
return LNbits.utils.formatCurrency(
(this.amount + this.tipAmount).toFixed(2),
this.currency
)
},
sat: function () {
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)
},
tipAmountFormatted: function () {
return LNbits.utils.formatSat(this.tipAmountSat)
},
fsat: function () {
return LNbits.utils.formatSat(this.sat)
},
isRoundValid() {
return this.tipRounding > this.amount
},
roundToSugestion() {
switch (true) {
case this.amount > 50:
toNext = 10
break
case this.amount > 6:
toNext = 5
break
case this.amount > 2.5:
toNext = 1
break
default:
toNext = 0.5
break
}
return Math.ceil(this.amount / toNext) * toNext
}
},
methods: {
setRounding() {
this.rounding = true
this.tipRounding = this.roundToSugestion
this.$nextTick(() => this.$refs.inputRounding.focus())
},
calculatePercent() {
let change = ((this.tipRounding - this.amount) / this.amount) * 100
if (change < 0) {
this.$q.notify({
type: 'warning',
message: 'Amount with tip must be greater than initial amount.'
})
this.tipRounding = this.roundToSugestion
return
}
this.processTipSelection(change)
},
closeInvoiceDialog: function () {
this.stack = []
this.tipAmount = 0.0
var dialog = this.invoiceDialog
setTimeout(function () {
clearInterval(dialog.paymentChecker)
dialog.dismissMsg()
}, 3000)
},
processTipSelection: function (selectedTipOption) {
this.tipDialog.show = false
if (!selectedTipOption) {
this.tipAmount = 0.0
return this.showInvoice()
}
this.tipAmount = (selectedTipOption / 100) * this.amount
this.showInvoice()
},
submitForm: function () {
if (this.tip_options && this.tip_options.length) {
this.rounding = false
this.tipRounding = null
this.showTipModal()
} else {
this.showInvoice()
}
},
showTipModal: function () {
this.tipDialog.show = true
},
showInvoice: function () {
var self = this
var dialog = this.invoiceDialog
axios
.post('/tpos/api/v1/tposs/' + this.tposId + '/invoices', null, {
params: {
amount: this.sat,
memo: this.amountFormatted,
tipAmount: this.tipAmountSat
}
})
.then(function (response) {
dialog.data = response.data
dialog.show = true
dialog.dismissMsg = self.$q.notify({
timeout: 0,
message: 'Waiting for payment...'
})
dialog.paymentChecker = setInterval(function () {
axios
.get(
'/tpos/api/v1/tposs/' +
self.tposId +
'/invoices/' +
response.data.payment_hash
)
.then(function (res) {
if (res.data.paid) {
clearInterval(dialog.paymentChecker)
dialog.dismissMsg()
dialog.show = false
self.complete.show = true
}
})
}, 3000)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
readNfcTag: function () {
try {
const self = this
if (typeof NDEFReader == 'undefined') {
throw {
toString: function () {
return 'NFC not supported on this device or browser.'
}
}
}
const ndef = new NDEFReader()
const readerAbortController = new AbortController()
readerAbortController.signal.onabort = event => {
console.log('All NFC Read operations have been aborted.')
}
this.nfcTagReading = true
this.$q.notify({
message: 'Tap your NFC tag to pay this invoice with LNURLw.'
})
return ndef.scan({signal: readerAbortController.signal}).then(() => {
ndef.onreadingerror = () => {
self.nfcTagReading = false
this.$q.notify({
type: 'negative',
message: 'There was an error reading this NFC tag.'
})
readerAbortController.abort()
}
ndef.onreading = ({message}) => {
//Decode NDEF data from tag
const textDecoder = new TextDecoder('utf-8')
const record = message.records.find(el => {
const payload = textDecoder.decode(el.data)
return payload.toUpperCase().indexOf('LNURL') !== -1
})
const lnurl = textDecoder.decode(record.data)
//User feedback, show loader icon
self.nfcTagReading = false
self.payInvoice(lnurl, readerAbortController)
this.$q.notify({
type: 'positive',
message: 'NFC tag read successfully.'
})
}
})
} catch (error) {
this.nfcTagReading = false
this.$q.notify({
type: 'negative',
message: error
? error.toString()
: 'An unexpected error has occurred.'
})
}
},
payInvoice: function (lnurl, readerAbortController) {
const self = this
return axios
.post(
'/tpos/api/v1/tposs/' +
self.tposId +
'/invoices/' +
self.invoiceDialog.data.payment_request +
'/pay',
{
lnurl: lnurl
}
)
.then(response => {
if (!response.data.success) {
this.$q.notify({
type: 'negative',
message: response.data.detail
})
}
readerAbortController.abort()
})
},
getRates: function () {
var self = this
axios.get('https://api.opennode.co/v1/rates').then(function (response) {
self.exchangeRate =
response.data.data['BTC' + self.currency][self.currency]
})
},
getLastPayments() {
return axios
.get(`/tpos/api/v1/tposs/${this.tposId}/invoices`)
.then(res => {
if (res.data && res.data.length) {
let last = [...res.data]
this.lastPaymentsDialog.data = last.map(obj => {
obj.dateFrom = moment(obj.time * 1000).fromNow()
return obj
})
}
})
.catch(e => console.error(e))
},
showLastPayments() {
this.getLastPayments()
this.lastPaymentsDialog.show = true
}
},
created: function () {
var getRates = this.getRates
getRates()
this.tip_options =
'{{ tpos.tip_options | tojson }}' == 'null'
? null
: JSON.parse('{{ tpos.tip_options }}')
if ('{{ tpos.tip_wallet }}') {
this.tip_options.push('Round')
}
setInterval(function () {
getRates()
}, 120000)
}
})
</script>
<style scoped>
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type='number'] {
-moz-appearance: textfield;
}
</style>
{% endblock %}

View File

@ -1,77 +0,0 @@
from http import HTTPStatus
from fastapi import Depends, Request
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.settings import settings
from . import tpos_ext, tpos_renderer
from .crud import get_tpos
templates = Jinja2Templates(directory="templates")
@tpos_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return tpos_renderer().TemplateResponse(
"tpos/index.html", {"request": request, "user": user.dict()}
)
@tpos_ext.get("/{tpos_id}")
async def tpos(request: Request, tpos_id):
tpos = await get_tpos(tpos_id)
if not tpos:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
)
return tpos_renderer().TemplateResponse(
"tpos/tpos.html",
{
"request": request,
"tpos": tpos,
"web_manifest": f"/tpos/manifest/{tpos_id}.webmanifest",
},
)
@tpos_ext.get("/manifest/{tpos_id}.webmanifest")
async def manifest(tpos_id: str):
tpos = await get_tpos(tpos_id)
if not tpos:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
)
return {
"short_name": settings.lnbits_site_title,
"name": tpos.name + " - " + settings.lnbits_site_title,
"icons": [
{
"src": settings.lnbits_custom_logo
if settings.lnbits_custom_logo
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png",
"type": "image/png",
"sizes": "900x900",
}
],
"start_url": "/tpos/" + tpos_id,
"background_color": "#1F2234",
"description": "Bitcoin Lightning tPOS",
"display": "standalone",
"scope": "/tpos/" + tpos_id,
"theme_color": "#1F2234",
"shortcuts": [
{
"name": tpos.name + " - " + settings.lnbits_site_title,
"short_name": tpos.name,
"description": tpos.name + " - " + settings.lnbits_site_title,
"url": "/tpos/" + tpos_id,
}
],
}

View File

@ -1,193 +0,0 @@
from http import HTTPStatus
import httpx
from fastapi import Depends, Query
from lnurl import decode as decode_lnurl
from loguru import logger
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_latest_payments_by_extension, get_user
from lnbits.core.models import Payment
from lnbits.core.services import create_invoice
from lnbits.core.views.api import api_payment
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from lnbits.settings import settings
from . import tpos_ext
from .crud import create_tpos, delete_tpos, get_tpos, get_tposs
from .models import CreateTposData, PayLnurlWData
@tpos_ext.get("/api/v1/tposs", status_code=HTTPStatus.OK)
async def api_tposs(
all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type)
):
wallet_ids = [wallet.wallet.id]
if all_wallets:
user = await get_user(wallet.wallet.user)
wallet_ids = user.wallet_ids if user else []
return [tpos.dict() for tpos in await get_tposs(wallet_ids)]
@tpos_ext.post("/api/v1/tposs", status_code=HTTPStatus.CREATED)
async def api_tpos_create(
data: CreateTposData, wallet: WalletTypeInfo = Depends(get_key_type)
):
tpos = await create_tpos(wallet_id=wallet.wallet.id, data=data)
return tpos.dict()
@tpos_ext.delete("/api/v1/tposs/{tpos_id}")
async def api_tpos_delete(
tpos_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
):
tpos = await get_tpos(tpos_id)
if not tpos:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
)
if tpos.wallet != wallet.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your TPoS.")
await delete_tpos(tpos_id)
return "", HTTPStatus.NO_CONTENT
@tpos_ext.post("/api/v1/tposs/{tpos_id}/invoices", status_code=HTTPStatus.CREATED)
async def api_tpos_create_invoice(
tpos_id: str, amount: int = Query(..., ge=1), memo: str = "", tipAmount: int = 0
) -> dict:
tpos = await get_tpos(tpos_id)
if not tpos:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
)
if tipAmount > 0:
amount += tipAmount
try:
payment_hash, payment_request = await create_invoice(
wallet_id=tpos.wallet,
amount=amount,
memo=f"{memo} to {tpos.name}" if memo else f"{tpos.name}",
extra={
"tag": "tpos",
"tipAmount": tipAmount,
"tposId": tpos_id,
"amount": amount - tipAmount if tipAmount else False,
},
)
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
return {"payment_hash": payment_hash, "payment_request": payment_request}
@tpos_ext.get("/api/v1/tposs/{tpos_id}/invoices")
async def api_tpos_get_latest_invoices(tpos_id: str):
try:
payments = [
Payment.from_row(row)
for row in await get_latest_payments_by_extension(
ext_name="tpos", ext_id=tpos_id
)
]
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
return [
{
"checking_id": payment.checking_id,
"amount": payment.amount,
"time": payment.time,
"pending": payment.pending,
}
for payment in payments
]
@tpos_ext.post(
"/api/v1/tposs/{tpos_id}/invoices/{payment_request}/pay", status_code=HTTPStatus.OK
)
async def api_tpos_pay_invoice(
lnurl_data: PayLnurlWData, payment_request: str, tpos_id: str
):
tpos = await get_tpos(tpos_id)
if not tpos:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
)
lnurl = (
lnurl_data.lnurl.replace("lnurlw://", "")
.replace("lightning://", "")
.replace("LIGHTNING://", "")
.replace("lightning:", "")
.replace("LIGHTNING:", "")
)
if lnurl.lower().startswith("lnurl"):
lnurl = decode_lnurl(lnurl)
else:
lnurl = "https://" + lnurl
async with httpx.AsyncClient() as client:
try:
headers = {"user-agent": f"lnbits/tpos commit {settings.lnbits_commit[:7]}"}
r = await client.get(lnurl, follow_redirects=True, headers=headers)
if r.is_error:
lnurl_response = {"success": False, "detail": "Error loading"}
else:
resp = r.json()
if resp["tag"] != "withdrawRequest":
lnurl_response = {"success": False, "detail": "Wrong tag type"}
else:
r2 = await client.get(
resp["callback"],
follow_redirects=True,
headers=headers,
params={
"k1": resp["k1"],
"pr": payment_request,
},
)
resp2 = r2.json()
if r2.is_error:
lnurl_response = {
"success": False,
"detail": "Error loading callback",
}
elif resp2["status"] == "ERROR":
lnurl_response = {"success": False, "detail": resp2["reason"]}
else:
lnurl_response = {"success": True, "detail": resp2}
except (httpx.ConnectError, httpx.RequestError):
lnurl_response = {"success": False, "detail": "Unexpected error occurred"}
return lnurl_response
@tpos_ext.get(
"/api/v1/tposs/{tpos_id}/invoices/{payment_hash}", status_code=HTTPStatus.OK
)
async def api_tpos_check_invoice(tpos_id: str, payment_hash: str):
tpos = await get_tpos(tpos_id)
if not tpos:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
)
try:
status = await api_payment(payment_hash)
except Exception as exc:
logger.error(exc)
return {"paid": False}
return status

Binary file not shown.