Skip to content

Commit

Permalink
init commit
Browse files Browse the repository at this point in the history
  • Loading branch information
dni committed Feb 15, 2023
0 parents commit 2a88866
Show file tree
Hide file tree
Showing 19 changed files with 1,511 additions and 0 deletions.
19 changes: 19 additions & 0 deletions .github/workflows/release.yml
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__pycache__
36 changes: 36 additions & 0 deletions README.md
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"
26 changes: 26 additions & 0 deletions __init__.py
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
8 changes: 8 additions & 0 deletions config.json
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"
]
}
117 changes: 117 additions & 0 deletions crud.py
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),
)
17 changes: 17 additions & 0 deletions helpers.py
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)
88 changes: 88 additions & 0 deletions lnurl.py
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()
9 changes: 9 additions & 0 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"repos": [
{
"id": "offlineshop",
"organisation": "lnbits",
"repository": "offlineshop"
}
]
}
39 changes: 39 additions & 0 deletions migrations.py
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;"
)
Loading

0 comments on commit 2a88866

Please sign in to comment.