diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index f9321056c..2c0c5c3d5 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -136,7 +136,7 @@ async def get_standalone_payment(checking_id_or_hash: str) -> Optional[Payment]: """ SELECT * FROM apipayments - WHERE checking_id = ? OR payment_hash = ? + WHERE checking_id = ? OR hash = ? LIMIT 1 """, (checking_id_or_hash, checking_id_or_hash), diff --git a/lnbits/extensions/offlineshop/config.json b/lnbits/extensions/offlineshop/config.json index 3748c9f45..507b1d146 100644 --- a/lnbits/extensions/offlineshop/config.json +++ b/lnbits/extensions/offlineshop/config.json @@ -1,5 +1,5 @@ { - "name": "Offline Shop", + "name": "OfflineShop", "short_description": "Sell stuff with Lightning and lnurlpay on a shop without internet or any electronic device.", "icon": "nature_people", "contributors": [ diff --git a/lnbits/extensions/offlineshop/lnurl.py b/lnbits/extensions/offlineshop/lnurl.py index 171ff970b..e53a145cd 100644 --- a/lnbits/extensions/offlineshop/lnurl.py +++ b/lnbits/extensions/offlineshop/lnurl.py @@ -22,7 +22,7 @@ async def lnurl_response(item_id): price_msat = item.price * 1000 * rate resp = LnurlPayResponse( - callback=url_for("shop.lnurl_callback", item_id=item.id, _external=True), + callback=url_for("offlineshop.lnurl_callback", item_id=item.id, _external=True), min_sendable=price_msat, max_sendable=price_msat, metadata=await item.lnurlpay_metadata(), @@ -56,11 +56,11 @@ async def lnurl_callback(item_id): payment_hash, payment_request = await create_invoice( wallet_id=shop.wallet, amount=int(amount_received / 1000), - memo=await item.name, + memo=item.name, description_hash=hashlib.sha256((await item.lnurlpay_metadata()).encode("utf-8")).digest(), extra={"tag": "offlineshop", "item": item.id}, ) - resp = LnurlPayActionResponse(pr=payment_request, success_action=item.success_action(payment_hash, shop), routes=[]) + resp = LnurlPayActionResponse(pr=payment_request, success_action=item.success_action(shop, payment_hash), routes=[]) return jsonify(resp.dict()) diff --git a/lnbits/extensions/offlineshop/models.py b/lnbits/extensions/offlineshop/models.py index 5a0dcee11..490b8a6a9 100644 --- a/lnbits/extensions/offlineshop/models.py +++ b/lnbits/extensions/offlineshop/models.py @@ -1,11 +1,54 @@ import json from collections import OrderedDict from quart import url_for -from typing import NamedTuple, Optional +from typing import NamedTuple, Optional, List from lnurl import encode as lnurl_encode # type: ignore from lnurl.types import LnurlPayMetadata # type: ignore from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore +shop_counters = {} + + +class ShopCounter(object): + fulfilled_payments: OrderedDict + counter: int + + @classmethod + def invoke(cls, shop: "Shop"): + shop_counter = shop_counters.get(shop.id) + if not shop_counter: + shop_counter = cls(wordlist=shop.wordlist.split("\n")) + shop_counters[shop.id] = shop_counter + return shop_counter + + @classmethod + def reset(cls, shop: "Shop"): + shop_counter = cls.invoke(shop) + shop_counter.counter = -1 + shop_counter.wordlist = shop.wordlist.split("\n") + + def __init__(self, wordlist: List[str]): + self.wordlist = wordlist + self.fulfilled_payments = OrderedDict() + self.counter = -1 + + def get_word(self, payment_hash): + if payment_hash in self.fulfilled_payments: + return self.fulfilled_payments[payment_hash] + + # get a new word + self.counter += 1 + word = self.wordlist[self.counter % len(self.wordlist)] + self.fulfilled_payments[payment_hash] = word + + # cleanup confirmation words cache + to_remove = len(self.fulfilled_payments) - 23 + if to_remove > 0: + for i in range(to_remove): + self.fulfilled_payments.popitem(False) + + return word + class Shop(NamedTuple): id: int @@ -13,24 +56,8 @@ class Shop(NamedTuple): wordlist: str def get_word(self, payment_hash): - # initialize confirmation words cache - self.fulfilled_payments = self.words or OrderedDict() - - if payment_hash in self.fulfilled_payments: - return self.fulfilled_payments[payment_hash] - - # get a new word - self.counter = (self.counter or -1) + 1 - wordlist = self.wordlist.split("\n") - word = [self.counter % len(wordlist)] - - # cleanup confirmation words cache - to_remove = self.fulfilled_payments - 23 - if to_remove > 0: - for i in range(to_remove): - self.fulfilled_payments.popitem(False) - - return word + sc = ShopCounter.invoke(self) + return sc.get_word(payment_hash) class Item(NamedTuple): @@ -45,18 +72,23 @@ class Item(NamedTuple): @property def lnurl(self) -> str: - return lnurl_encode(url_for("offlineshop.lnurl_response", item_id=self.id)) + return lnurl_encode(url_for("offlineshop.lnurl_response", item_id=self.id, _external=True)) + + def values(self): + values = self._asdict() + values["lnurl"] = self.lnurl + return values async def lnurlpay_metadata(self) -> LnurlPayMetadata: metadata = [["text/plain", self.description]] if self.image: - metadata.append(self.image.split(",")) + metadata.append(self.image.split(":")[1].split(",")) return LnurlPayMetadata(json.dumps(metadata)) def success_action(self, shop: Shop, payment_hash: str) -> Optional[LnurlPaySuccessAction]: - if not self.wordlist: + if not shop.wordlist: return None return UrlAction( diff --git a/lnbits/extensions/offlineshop/static/js/index.js b/lnbits/extensions/offlineshop/static/js/index.js index f0c1445bc..a55cf4368 100644 --- a/lnbits/extensions/offlineshop/static/js/index.js +++ b/lnbits/extensions/offlineshop/static/js/index.js @@ -25,6 +25,11 @@ new Vue({ } } }, + computed: { + printItems() { + return this.offlineshop.items.filter(({enabled}) => enabled) + } + }, methods: { openNewDialog() { this.itemDialog.show = true diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/index.html b/lnbits/extensions/offlineshop/templates/offlineshop/index.html index 8522dcff4..6ede54544 100644 --- a/lnbits/extensions/offlineshop/templates/offlineshop/index.html +++ b/lnbits/extensions/offlineshop/templates/offlineshop/index.html @@ -18,6 +18,7 @@ + +
+ Print QR Codes +
@@ -120,6 +131,30 @@ +
+
Adding a new item
+ + + + + +
+ Copy LNURL +
+
+
{{ item.name }}
+ +
{{ item.price }}
+
+ +{% endraw %} {% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/offlineshop/views.py b/lnbits/extensions/offlineshop/views.py index 140c1a766..ace818b00 100644 --- a/lnbits/extensions/offlineshop/views.py +++ b/lnbits/extensions/offlineshop/views.py @@ -17,18 +17,35 @@ async def index(): return await render_template("offlineshop/index.html", user=g.user) +@offlineshop_ext.route("/print") +async def print_qr_codes(): + items = [] + for item_id in request.args.get("items").split(","): + item = await get_item(item_id) + if item: + items.append({"lnurl": item.lnurl, "name": item.name, "price": f"{item.price} {item.unit}"}) + + return await render_template("offlineshop/print.html", items=items) + + @offlineshop_ext.route("/confirmation") async def confirmation_code(): + style = "" + payment_hash = request.args.get("p") payment: Payment = await get_standalone_payment(payment_hash) if not payment: - return f"Couldn't find the payment {payment_hash}.", HTTPStatus.NOT_FOUND + return f"Couldn't find the payment {payment_hash}." + style, HTTPStatus.NOT_FOUND if payment.pending: - return f"Payment {payment_hash} wasn't received yet. Please try again in a minute.", HTTPStatus.PAYMENT_REQUIRED + return ( + f"Payment {payment_hash} wasn't received yet. Please try again in a minute." + style, + HTTPStatus.PAYMENT_REQUIRED, + ) if payment.time + 60 * 15 < time.time(): - return "too much time has passed." + return "too much time has passed." + style item = await get_item(payment.extra.get("item")) shop = await get_shop(item.shop) - return shop.next_word(payment_hash) + + return shop.get_word(payment_hash) + style diff --git a/lnbits/extensions/offlineshop/views_api.py b/lnbits/extensions/offlineshop/views_api.py index 749eec427..430b23f0e 100644 --- a/lnbits/extensions/offlineshop/views_api.py +++ b/lnbits/extensions/offlineshop/views_api.py @@ -22,7 +22,7 @@ async def api_shop_from_wallet(): try: return ( - jsonify({**shop._asdict(), **{"items": [item._asdict() for item in items],},}), + jsonify({**shop._asdict(), **{"items": [item.values() for item in items],},}), HTTPStatus.OK, ) except LnurlInvalidUrl: @@ -39,7 +39,7 @@ async def api_shop_from_wallet(): schema={ "name": {"type": "string", "empty": False, "required": True}, "description": {"type": "string", "empty": False, "required": True}, - "image": {"type": "string", "required": False}, + "image": {"type": "string", "required": False, "nullable": True}, "price": {"type": "number", "required": True}, "unit": {"type": "string", "allowed": ["sat", "USD"], "required": True}, }