From 0cd828d43fb6a32d5bd9da3165d1f03efd4ca7f1 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Fri, 20 Aug 2021 10:34:52 +0100 Subject: [PATCH] added tpos back --- lnbits/extensions/tpos/README.md | 15 + lnbits/extensions/tpos/__init__.py | 12 + lnbits/extensions/tpos/config.json | 6 + lnbits/extensions/tpos/crud.py | 42 ++ lnbits/extensions/tpos/migrations.py | 14 + lnbits/extensions/tpos/models.py | 13 + .../tpos/templates/tpos/_api_docs.html | 78 ++++ .../extensions/tpos/templates/tpos/_tpos.html | 18 + .../extensions/tpos/templates/tpos/index.html | 423 ++++++++++++++++++ .../extensions/tpos/templates/tpos/tpos.html | 264 +++++++++++ lnbits/extensions/tpos/views.py | 23 + lnbits/extensions/tpos/views_api.py | 101 +++++ 12 files changed, 1009 insertions(+) create mode 100644 lnbits/extensions/tpos/README.md create mode 100644 lnbits/extensions/tpos/__init__.py create mode 100644 lnbits/extensions/tpos/config.json create mode 100644 lnbits/extensions/tpos/crud.py create mode 100644 lnbits/extensions/tpos/migrations.py create mode 100644 lnbits/extensions/tpos/models.py create mode 100644 lnbits/extensions/tpos/templates/tpos/_api_docs.html create mode 100644 lnbits/extensions/tpos/templates/tpos/_tpos.html create mode 100644 lnbits/extensions/tpos/templates/tpos/index.html create mode 100644 lnbits/extensions/tpos/templates/tpos/tpos.html create mode 100644 lnbits/extensions/tpos/views.py create mode 100644 lnbits/extensions/tpos/views_api.py diff --git a/lnbits/extensions/tpos/README.md b/lnbits/extensions/tpos/README.md new file mode 100644 index 000000000..04e049e37 --- /dev/null +++ b/lnbits/extensions/tpos/README.md @@ -0,0 +1,15 @@ +# 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 costumer\ + ![pay](https://imgur.com/tOwxn77.jpg) diff --git a/lnbits/extensions/tpos/__init__.py b/lnbits/extensions/tpos/__init__.py new file mode 100644 index 000000000..daa3022e5 --- /dev/null +++ b/lnbits/extensions/tpos/__init__.py @@ -0,0 +1,12 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_tpos") + +tpos_ext: Blueprint = Blueprint( + "tpos", __name__, static_folder="static", template_folder="templates" +) + + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/tpos/config.json b/lnbits/extensions/tpos/config.json new file mode 100644 index 000000000..c5789afb7 --- /dev/null +++ b/lnbits/extensions/tpos/config.json @@ -0,0 +1,6 @@ +{ + "name": "TPoS", + "short_description": "A shareable PoS terminal!", + "icon": "dialpad", + "contributors": ["talvasconcelos", "arcbtc"] +} diff --git a/lnbits/extensions/tpos/crud.py b/lnbits/extensions/tpos/crud.py new file mode 100644 index 000000000..99dab6627 --- /dev/null +++ b/lnbits/extensions/tpos/crud.py @@ -0,0 +1,42 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import TPoS + + +async def create_tpos(*, wallet_id: str, name: str, currency: str) -> TPoS: + tpos_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO tpos.tposs (id, wallet, name, currency) + VALUES (?, ?, ?, ?) + """, + (tpos_id, wallet_id, name, currency), + ) + + 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.from_row(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.from_row(row) for row in rows] + + +async def delete_tpos(tpos_id: str) -> None: + await db.execute("DELETE FROM tpos.tposs WHERE id = ?", (tpos_id,)) diff --git a/lnbits/extensions/tpos/migrations.py b/lnbits/extensions/tpos/migrations.py new file mode 100644 index 000000000..7a7fff0d5 --- /dev/null +++ b/lnbits/extensions/tpos/migrations.py @@ -0,0 +1,14 @@ +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 + ); + """ + ) diff --git a/lnbits/extensions/tpos/models.py b/lnbits/extensions/tpos/models.py new file mode 100644 index 000000000..e10615672 --- /dev/null +++ b/lnbits/extensions/tpos/models.py @@ -0,0 +1,13 @@ +from sqlite3 import Row +from typing import NamedTuple + + +class TPoS(NamedTuple): + id: str + wallet: str + name: str + currency: str + + @classmethod + def from_row(cls, row: Row) -> "TPoS": + return cls(**dict(row)) diff --git a/lnbits/extensions/tpos/templates/tpos/_api_docs.html b/lnbits/extensions/tpos/templates/tpos/_api_docs.html new file mode 100644 index 000000000..6ceab7284 --- /dev/null +++ b/lnbits/extensions/tpos/templates/tpos/_api_docs.html @@ -0,0 +1,78 @@ + + + + + GET /tpos/api/v1/tposs +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<tpos_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/tposs -H "X-Api-Key: + <invoice_key>" + +
+
+
+ + + + POST /tpos/api/v1/tposs +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+ {"name": <string>, "currency": <string*ie USD*>} +
+ Returns 201 CREATED (application/json) +
+ {"currency": <string>, "id": <string>, "name": + <string>, "wallet": <string>} +
Curl example
+ curl -X POST {{ request.url_root }}api/v1/tposs -d '{"name": + <string>, "currency": <string>}' -H "Content-type: + application/json" -H "X-Api-Key: <admin_key>" + +
+
+
+ + + + + DELETE + /tpos/api/v1/tposs/<tpos_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root }}api/v1/tposs/<tpos_id> -H + "X-Api-Key: <admin_key>" + +
+
+
+
diff --git a/lnbits/extensions/tpos/templates/tpos/_tpos.html b/lnbits/extensions/tpos/templates/tpos/_tpos.html new file mode 100644 index 000000000..54ddcd0f9 --- /dev/null +++ b/lnbits/extensions/tpos/templates/tpos/_tpos.html @@ -0,0 +1,18 @@ + + + +

+ 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. +

+ Created by + Tiago Vasconcelos. +
+
+
diff --git a/lnbits/extensions/tpos/templates/tpos/index.html b/lnbits/extensions/tpos/templates/tpos/index.html new file mode 100644 index 000000000..f3b55b37d --- /dev/null +++ b/lnbits/extensions/tpos/templates/tpos/index.html @@ -0,0 +1,423 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New TPoS + + + + + +
+
+
TPoS
+
+
+ Export to CSV +
+
+ + {% raw %} + + + + {% endraw %} + +
+
+
+ +
+ + +
{{SITE_TITLE}} TPoS extension
+
+ + + + {% include "tpos/_api_docs.html" %} + + {% include "tpos/_tpos.html" %} + + +
+
+ + + + + + + +
+ Create TPoS + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/tpos/templates/tpos/tpos.html b/lnbits/extensions/tpos/templates/tpos/tpos.html new file mode 100644 index 000000000..1727e6e99 --- /dev/null +++ b/lnbits/extensions/tpos/templates/tpos/tpos.html @@ -0,0 +1,264 @@ +{% extends "public.html" %} {% block toolbar_title %}{{ tpos.name }}{% endblock +%} {% block footer %}{% endblock %} {% block page_container %} + + + +
+
+

{% raw %}{{ famount }}{% endraw %}

+
+ {% raw %}{{ fsat }}{% endraw %} sat +
+
+
+
+ +
+
+
+ 1 + 2 + 3 + C + 4 + 5 + 6 + 7 + 8 + 9 + OK + DEL + 0 + # +
+
+
+
+ + + + + +
+

{% raw %}{{ famount }}{% endraw %}

+
+ {% raw %}{{ fsat }}{% endraw %} sat +
+
+
+ Close +
+
+
+ + + + + +
+

+ {{ tpos.name }}
{{ request.url }} +

+
+
+ Copy URL + Close +
+
+
+
+
+{% endblock %} {% block styles %} + +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/tpos/views.py b/lnbits/extensions/tpos/views.py new file mode 100644 index 000000000..ce8422956 --- /dev/null +++ b/lnbits/extensions/tpos/views.py @@ -0,0 +1,23 @@ +from quart import g, abort, render_template +from http import HTTPStatus + +from lnbits.decorators import check_user_exists, validate_uuids + +from . import tpos_ext +from .crud import get_tpos + + +@tpos_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("tpos/index.html", user=g.user) + + +@tpos_ext.route("/") +async def tpos(tpos_id): + tpos = await get_tpos(tpos_id) + if not tpos: + abort(HTTPStatus.NOT_FOUND, "TPoS does not exist.") + + return await render_template("tpos/tpos.html", tpos=tpos) diff --git a/lnbits/extensions/tpos/views_api.py b/lnbits/extensions/tpos/views_api.py new file mode 100644 index 000000000..1f0802c77 --- /dev/null +++ b/lnbits/extensions/tpos/views_api.py @@ -0,0 +1,101 @@ +from quart import g, jsonify, request +from http import HTTPStatus + +from lnbits.core.crud import get_user, get_wallet +from lnbits.core.services import create_invoice, check_invoice_status +from lnbits.decorators import api_check_wallet_key, api_validate_post_request + +from . import tpos_ext +from .crud import create_tpos, get_tpos, get_tposs, delete_tpos + + +@tpos_ext.route("/api/v1/tposs", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_tposs(): + wallet_ids = [g.wallet.id] + if "all_wallets" in request.args: + wallet_ids = (await get_user(g.wallet.user)).wallet_ids + + return ( + jsonify([tpos._asdict() for tpos in await get_tposs(wallet_ids)]), + HTTPStatus.OK, + ) + + +@tpos_ext.route("/api/v1/tposs", methods=["POST"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={ + "name": {"type": "string", "empty": False, "required": True}, + "currency": {"type": "string", "empty": False, "required": True}, + } +) +async def api_tpos_create(): + tpos = await create_tpos(wallet_id=g.wallet.id, **g.data) + return jsonify(tpos._asdict()), HTTPStatus.CREATED + + +@tpos_ext.route("/api/v1/tposs/", methods=["DELETE"]) +@api_check_wallet_key("admin") +async def api_tpos_delete(tpos_id): + tpos = await get_tpos(tpos_id) + + if not tpos: + return jsonify({"message": "TPoS does not exist."}), HTTPStatus.NOT_FOUND + + if tpos.wallet != g.wallet.id: + return jsonify({"message": "Not your TPoS."}), HTTPStatus.FORBIDDEN + + await delete_tpos(tpos_id) + + return "", HTTPStatus.NO_CONTENT + + +@tpos_ext.route("/api/v1/tposs//invoices/", methods=["POST"]) +@api_validate_post_request( + schema={"amount": {"type": "integer", "min": 1, "required": True}} +) +async def api_tpos_create_invoice(tpos_id): + tpos = await get_tpos(tpos_id) + + if not tpos: + return jsonify({"message": "TPoS does not exist."}), HTTPStatus.NOT_FOUND + + try: + payment_hash, payment_request = await create_invoice( + wallet_id=tpos.wallet, + amount=g.data["amount"], + memo=f"{tpos.name}", + extra={"tag": "tpos"}, + ) + except Exception as e: + return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR + + return ( + jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), + HTTPStatus.CREATED, + ) + + +@tpos_ext.route("/api/v1/tposs//invoices/", methods=["GET"]) +async def api_tpos_check_invoice(tpos_id, payment_hash): + tpos = await get_tpos(tpos_id) + + if not tpos: + return jsonify({"message": "TPoS does not exist."}), HTTPStatus.NOT_FOUND + + try: + status = await check_invoice_status(tpos.wallet, payment_hash) + is_paid = not status.pending + except Exception as exc: + print(exc) + return jsonify({"paid": False}), HTTPStatus.OK + + if is_paid: + wallet = await get_wallet(tpos.wallet) + payment = await wallet.get_payment(payment_hash) + await payment.set_pending(False) + + return jsonify({"paid": True}), HTTPStatus.OK + + return jsonify({"paid": False}), HTTPStatus.OK