Merge pull request #1424 from lnbits/tpospull
Pulls tpos for install worflow
This commit is contained in:
commit
f11044ae75
|
@ -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)
|
|
@ -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))
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"name": "TPoS",
|
||||
"short_description": "A shareable PoS terminal!",
|
||||
"tile": "/tpos/static/image/tpos.png",
|
||||
"contributors": ["talvasconcelos", "arcbtc", "leesalminen"]
|
||||
}
|
|
@ -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,))
|
|
@ -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;
|
||||
"""
|
||||
)
|
|
@ -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 |
|
@ -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}")
|
|
@ -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": <invoice_key>}</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>[<tpos_object>, ...]</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:
|
||||
<invoice_key>"
|
||||
</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": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"name": <string>, "currency": <string*ie USD*>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"currency": <string>, "id": <string>, "name":
|
||||
<string>, "wallet": <string>}</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":
|
||||
<string>, "currency": <string>}' -H "Content-type:
|
||||
application/json" -H "X-Api-Key: <admin_key>"
|
||||
</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/<tpos_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</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/<tpos_id> -H "X-Api-Key: <admin_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
|
@ -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>
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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,
|
||||
}
|
||||
],
|
||||
}
|
|
@ -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.
Loading…
Reference in New Issue
Block a user