-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 2a88866
Showing
19 changed files
with
1,511 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
name: release github version | ||
on: | ||
push: | ||
tags: | ||
- "[0-9]+.[0-9]+" | ||
jobs: | ||
build: | ||
runs-on: ubuntu-latest | ||
steps: | ||
- name: Create GitHub Release | ||
id: create_release | ||
uses: actions/create-release@v1 | ||
env: | ||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
with: | ||
tag_name: ${{ github.ref }} | ||
release_name: ${{ github.ref }} | ||
draft: false | ||
prerelease: false |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
__pycache__ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
# Offline Shop | ||
|
||
## Create QR codes for each product and display them on your store for receiving payments Offline | ||
|
||
[![video tutorial offline shop](http://img.youtube.com/vi/_XAvM_LNsoo/0.jpg)](https://youtu.be/_XAvM_LNsoo 'video tutorial offline shop') | ||
|
||
LNbits Offline Shop allows for merchants to receive Bitcoin payments while offline and without any electronic device. | ||
|
||
Merchant will create items and associate a QR code ([a LNURLp](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) with a price. He can then print the QR codes and display them on their shop. When a customer chooses an item, scans the QR code, gets the description and price. After payment, the customer gets a confirmation code that the merchant can validate to be sure the payment was successful. | ||
|
||
Customers must use an LNURL pay capable wallet. | ||
|
||
[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets) | ||
|
||
## Usage | ||
|
||
1. Entering the Offline shop extension you'll see an Items list, the Shop wallet and a Wordslist\ | ||
![offline shop back office](https://i.imgur.com/Ei7cxj9.png) | ||
2. Begin by creating an item, click "ADD NEW ITEM" | ||
- set the item name and a small description | ||
- you can set an optional, preferably square image, that will show up on the customer wallet - _depending on wallet_ | ||
- set the item price, if you choose a fiat currency the bitcoin conversion will happen at the time customer scans to pay\ | ||
![add new item](https://i.imgur.com/pkZqRgj.png) | ||
3. After creating some products, click on "PRINT QR CODES"\ | ||
![print qr codes](https://i.imgur.com/2GAiSTe.png) | ||
4. You'll see a QR code for each product in your LNbits Offline Shop with a title and price ready for printing\ | ||
![qr codes sheet](https://i.imgur.com/faEqOcd.png) | ||
5. Place the printed QR codes on your shop, or at the fair stall, or have them as a menu style laminated sheet | ||
6. Choose what type of confirmation do you want customers to report to merchant after a successful payment\ | ||
![wordlist](https://i.imgur.com/9aM6NUL.png) | ||
|
||
- Wordlist is the default option: after a successful payment the customer will receive a word from this list, **sequentially**. Starting in _albatross_ as customers pay for the items they will get the next word in the list until _zebra_, then it starts at the top again. The list can be changed, for example if you think A-Z is a big list to track, you can use _apple_, _banana_, _coconut_\ | ||
![totp authenticator](https://i.imgur.com/MrJXFxz.png) | ||
- TOTP (time-based one time password) can be used instead. If you use Google Authenticator just scan the presented QR with the app and after a successful payment the user will get the password that you can check with GA\ | ||
![disable confirmations](https://i.imgur.com/2OFs4yi.png) | ||
- Nothing, disables the need for confirmation of payment, click the "DISABLE CONFIRMATION CODES" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
from fastapi import APIRouter | ||
from fastapi.staticfiles import StaticFiles | ||
|
||
from lnbits.db import Database | ||
from lnbits.helpers import template_renderer | ||
|
||
db = Database("ext_offlineshop") | ||
|
||
offlineshop_static_files = [ | ||
{ | ||
"path": "/offlineshop/static", | ||
"app": StaticFiles(packages=[("lnbits", "extensions/offlineshop/static")]), | ||
"name": "offlineshop_static", | ||
} | ||
] | ||
|
||
offlineshop_ext: APIRouter = APIRouter(prefix="/offlineshop", tags=["Offlineshop"]) | ||
|
||
|
||
def offlineshop_renderer(): | ||
return template_renderer(["lnbits/extensions/offlineshop/templates"]) | ||
|
||
|
||
from .lnurl import * # noqa: F401,F403 | ||
from .views import * # noqa: F401,F403 | ||
from .views_api import * # noqa: F401,F403 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
{ | ||
"name": "OfflineShop", | ||
"short_description": "Receive payments for products offline!", | ||
"tile": "/offlineshop/static/image/offlineshop.png", | ||
"contributors": [ | ||
"fiatjaf" | ||
] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
from typing import List, Optional | ||
|
||
from lnbits.db import SQLITE | ||
|
||
from . import db | ||
from .models import Item, Shop | ||
from .wordlists import animals | ||
|
||
|
||
async def create_shop(*, wallet_id: str) -> int: | ||
returning = "" if db.type == SQLITE else "RETURNING ID" | ||
method = db.execute if db.type == SQLITE else db.fetchone | ||
|
||
result = await (method)( | ||
f""" | ||
INSERT INTO offlineshop.shops (wallet, wordlist, method) | ||
VALUES (?, ?, 'wordlist') | ||
{returning} | ||
""", | ||
(wallet_id, "\n".join(animals)), | ||
) | ||
if db.type == SQLITE: | ||
return result._result_proxy.lastrowid | ||
else: | ||
return result[0] # type: ignore | ||
|
||
|
||
async def get_shop(id: int) -> Optional[Shop]: | ||
row = await db.fetchone("SELECT * FROM offlineshop.shops WHERE id = ?", (id,)) | ||
return Shop(**row) if row else None | ||
|
||
|
||
async def get_or_create_shop_by_wallet(wallet: str) -> Optional[Shop]: | ||
row = await db.fetchone( | ||
"SELECT * FROM offlineshop.shops WHERE wallet = ?", (wallet,) | ||
) | ||
|
||
if not row: | ||
# create on the fly | ||
ls_id = await create_shop(wallet_id=wallet) | ||
return await get_shop(ls_id) | ||
|
||
return Shop(**row) if row else None | ||
|
||
|
||
async def set_method(shop: int, method: str, wordlist: str = "") -> Optional[Shop]: | ||
await db.execute( | ||
"UPDATE offlineshop.shops SET method = ?, wordlist = ? WHERE id = ?", | ||
(method, wordlist, shop), | ||
) | ||
return await get_shop(shop) | ||
|
||
|
||
async def add_item( | ||
shop: int, | ||
name: str, | ||
description: str, | ||
image: Optional[str], | ||
price: int, | ||
unit: str, | ||
fiat_base_multiplier: int, | ||
) -> int: | ||
result = await db.execute( | ||
""" | ||
INSERT INTO offlineshop.items (shop, name, description, image, price, unit, fiat_base_multiplier) | ||
VALUES (?, ?, ?, ?, ?, ?, ?) | ||
""", | ||
(shop, name, description, image, price, unit, fiat_base_multiplier), | ||
) | ||
return result._result_proxy.lastrowid | ||
|
||
|
||
async def update_item( | ||
shop: int, | ||
item_id: int, | ||
name: str, | ||
description: str, | ||
image: Optional[str], | ||
price: int, | ||
unit: str, | ||
fiat_base_multiplier: int, | ||
) -> int: | ||
await db.execute( | ||
""" | ||
UPDATE offlineshop.items SET | ||
name = ?, | ||
description = ?, | ||
image = ?, | ||
price = ?, | ||
unit = ?, | ||
fiat_base_multiplier = ? | ||
WHERE shop = ? AND id = ? | ||
""", | ||
(name, description, image, price, unit, fiat_base_multiplier, shop, item_id), | ||
) | ||
return item_id | ||
|
||
|
||
async def get_item(id: int) -> Optional[Item]: | ||
row = await db.fetchone( | ||
"SELECT * FROM offlineshop.items WHERE id = ? LIMIT 1", (id,) | ||
) | ||
return Item.from_row(row) if row else None | ||
|
||
|
||
async def get_items(shop: int) -> List[Item]: | ||
rows = await db.fetchall("SELECT * FROM offlineshop.items WHERE shop = ?", (shop,)) | ||
return [Item.from_row(row) for row in rows] | ||
|
||
|
||
async def delete_item_from_shop(shop: int, item_id: int): | ||
await db.execute( | ||
""" | ||
DELETE FROM offlineshop.items WHERE shop = ? AND id = ? | ||
""", | ||
(shop, item_id), | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
import base64 | ||
import hmac | ||
import struct | ||
import time | ||
|
||
|
||
def hotp(key, counter, digits=6, digest="sha1"): | ||
key = base64.b32decode(key.upper() + "=" * ((8 - len(key)) % 8)) | ||
counter = struct.pack(">Q", counter) | ||
mac = hmac.new(key, counter, digest).digest() | ||
offset = mac[-1] & 0x0F | ||
binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7FFFFFFF | ||
return str(binary)[-digits:].zfill(digits) | ||
|
||
|
||
def totp(key, time_step=30, digits=6, digest="sha1"): | ||
return hotp(key, int(time.time() / time_step), digits, digest) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
from fastapi import Query | ||
from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse | ||
from lnurl.models import ClearnetUrl, LightningInvoice, MilliSatoshi | ||
from starlette.requests import Request | ||
|
||
from lnbits.core.services import create_invoice | ||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis | ||
|
||
from . import offlineshop_ext | ||
from .crud import get_item, get_shop | ||
|
||
|
||
@offlineshop_ext.get("/lnurl/{item_id}", name="offlineshop.lnurl_response") | ||
async def lnurl_response(req: Request, item_id: int = Query(...)) -> dict: | ||
item = await get_item(item_id) | ||
if not item: | ||
return {"status": "ERROR", "reason": "Item not found."} | ||
|
||
if not item.enabled: | ||
return {"status": "ERROR", "reason": "Item disabled."} | ||
|
||
price_msat = ( | ||
await fiat_amount_as_satoshis(item.price, item.unit) | ||
if item.unit != "sat" | ||
else item.price | ||
) * 1000 | ||
|
||
resp = LnurlPayResponse( | ||
callback=ClearnetUrl( | ||
req.url_for("offlineshop.lnurl_callback", item_id=item.id), scheme="https" | ||
), | ||
minSendable=MilliSatoshi(price_msat), | ||
maxSendable=MilliSatoshi(price_msat), | ||
metadata=await item.lnurlpay_metadata(), | ||
) | ||
|
||
return resp.dict() | ||
|
||
|
||
@offlineshop_ext.get("/lnurl/cb/{item_id}", name="offlineshop.lnurl_callback") | ||
async def lnurl_callback(request: Request, item_id: int): | ||
item = await get_item(item_id) | ||
if not item: | ||
return {"status": "ERROR", "reason": "Couldn't find item."} | ||
|
||
if item.unit == "sat": | ||
min = item.price * 1000 | ||
max = item.price * 1000 | ||
else: | ||
price = await fiat_amount_as_satoshis(item.price, item.unit) | ||
# allow some fluctuation (the fiat price may have changed between the calls) | ||
min = price * 995 | ||
max = price * 1010 | ||
|
||
amount_received = int(request.query_params.get("amount") or 0) | ||
if amount_received < min: | ||
return LnurlErrorResponse( | ||
reason=f"Amount {amount_received} is smaller than minimum {min}." | ||
).dict() | ||
elif amount_received > max: | ||
return LnurlErrorResponse( | ||
reason=f"Amount {amount_received} is greater than maximum {max}." | ||
).dict() | ||
|
||
shop = await get_shop(item.shop) | ||
assert shop | ||
|
||
try: | ||
payment_hash, payment_request = await create_invoice( | ||
wallet_id=shop.wallet, | ||
amount=int(amount_received / 1000), | ||
memo=item.name, | ||
unhashed_description=(await item.lnurlpay_metadata()).encode(), | ||
extra={"tag": "offlineshop", "item": item.id}, | ||
) | ||
except Exception as exc: | ||
return LnurlErrorResponse(reason=str(exc)).dict() | ||
|
||
if shop.method: | ||
success_action = item.success_action(shop, payment_hash, request) | ||
assert success_action | ||
resp = LnurlPayActionResponse( | ||
pr=LightningInvoice(payment_request), | ||
successAction=success_action, | ||
routes=[], | ||
) | ||
|
||
return resp.dict() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
{ | ||
"repos": [ | ||
{ | ||
"id": "offlineshop", | ||
"organisation": "lnbits", | ||
"repository": "offlineshop" | ||
} | ||
] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
async def m001_initial(db): | ||
""" | ||
Initial offlineshop tables. | ||
""" | ||
await db.execute( | ||
f""" | ||
CREATE TABLE offlineshop.shops ( | ||
id {db.serial_primary_key}, | ||
wallet TEXT NOT NULL, | ||
method TEXT NOT NULL, | ||
wordlist TEXT | ||
); | ||
""" | ||
) | ||
|
||
await db.execute( | ||
f""" | ||
CREATE TABLE offlineshop.items ( | ||
shop INTEGER NOT NULL REFERENCES {db.references_schema}shops (id), | ||
id {db.serial_primary_key}, | ||
name TEXT NOT NULL, | ||
description TEXT NOT NULL, | ||
image TEXT, -- image/png;base64,... | ||
enabled BOOLEAN NOT NULL DEFAULT true, | ||
price {db.big_int} NOT NULL, | ||
unit TEXT NOT NULL DEFAULT 'sat' | ||
); | ||
""" | ||
) | ||
|
||
|
||
async def m002_fiat_base_multiplier(db): | ||
""" | ||
Store the multiplier for fiat prices. We store the price in cents and | ||
remember to multiply by 100 when we use it to convert to Dollars. | ||
""" | ||
await db.execute( | ||
"ALTER TABLE offlineshop.items ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;" | ||
) |
Oops, something went wrong.