docs: README + description.md
README covers: - What the extension is / isn't (CMS only; customer UI in webapp; no festival entity; no central splitter) - Architecture diagram - Data model summary - Order state machine - Nostr (kind 0 / 30402 / 5; NIP-17 stub) - Public vs owner-write API surface - A worked-out webapp integration snippet showing the multi- restaurant cart flow (group by restaurant -> per-restaurant quote -> sufficient-balance check -> N place_order calls -> pay each bolt11) - Install instructions for development - Roadmap of explicitly-deferred items (NIP-44 unwrap, per- restaurant secret storage, SSE/push, HODL atomicity, foreign menu cache, image upload pipeline)
This commit is contained in:
parent
3382462af4
commit
027db9cad2
2 changed files with 273 additions and 0 deletions
264
README.md
Normal file
264
README.md
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
# Restaurant — LNbits extension
|
||||
|
||||
A Nostr-native restaurant CMS for LNbits. Restaurant owners enable this
|
||||
extension on their LNbits account to build menus, manage modifiers and
|
||||
inventory, and watch orders in real time. Customer-facing UIs (kiosks,
|
||||
mobile, the AIO webapp) live elsewhere and connect via REST + Nostr.
|
||||
|
||||
## What this extension is
|
||||
|
||||
- **A CMS** for one operator (one or many restaurants per LNbits wallet).
|
||||
- **A REST API** for menu read + order placement.
|
||||
- **A Nostr publisher** for menus (NIP-99 classified listings) and a
|
||||
Nostr inbound sync skeleton for orders (NIP-17 DMs).
|
||||
- **An order state machine** with print-job queueing and a Kitchen
|
||||
Display screen.
|
||||
|
||||
## What this extension is not
|
||||
|
||||
- **Not a customer kiosk.** Customer-facing UI is the AIO webapp at
|
||||
`~/dev/webapp`.
|
||||
- **Not a festival platform.** "Festival" / "collective space" /
|
||||
"food court" are emergent — a curator publishes a NIP-51 list of
|
||||
restaurant pubkeys, the webapp aggregates from that list. The
|
||||
extension itself only ever knows about its own restaurant.
|
||||
- **Not a payment splitter.** Per the design discussion: each menu
|
||||
item belongs to one restaurant, each restaurant issues its own
|
||||
invoice, and the customer pays N invoices to complete a multi-
|
||||
restaurant cart. The webapp pre-flights the total via
|
||||
`POST /api/v1/orders/quote` to confirm sufficient balance before
|
||||
opening any per-restaurant invoice. If a payment ever fails after
|
||||
another succeeded (rare on internal LNbits transfers), the
|
||||
customer settles the remainder in person.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
LNbits instance
|
||||
┌───────────────────────────┐
|
||||
│ Restaurant ext │
|
||||
│ ├── REST /restaurant/api/v1
|
||||
│ ├── CMS /restaurant/...
|
||||
│ ├── Nostr publisher │──────┐
|
||||
│ └── Invoice listener │ │
|
||||
│ (settle, decrement, │ │
|
||||
│ queue print) │ │
|
||||
└─────────┬─────────────────┘ │
|
||||
│ ▼
|
||||
│ ┌──────────────────┐
|
||||
│ │ nostrclient ext │──→ relays
|
||||
│ └──────────────────┘
|
||||
▼
|
||||
┌──────────────┐ ┌────────────────┐
|
||||
│ printer-pi │ ◀──────│ webapp / AIO │
|
||||
│ (subscribes) │ │ (customer UI, │
|
||||
└──────────────┘ │ multi-rest │
|
||||
│ cart) │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
A customer's webapp:
|
||||
|
||||
1. Discovers a restaurant (directly, or via a NIP-51 list curated for a
|
||||
festival/collective space).
|
||||
2. Loads the menu via `GET /api/v1/restaurants/{id}/menu` (one-shot
|
||||
tree fetch) and subscribes to the restaurant's Nostr pubkey for
|
||||
live updates.
|
||||
3. Builds a cart that may span multiple restaurants.
|
||||
4. Calls `POST /api/v1/orders/quote` (per restaurant) to get the total
|
||||
msat needed; sums them and verifies the wallet has enough.
|
||||
5. Calls `POST /api/v1/orders` once per restaurant; gets back N
|
||||
`OrderInvoice` payloads (`{order_id, payment_hash, bolt11,
|
||||
amount_msat, expires_at}`).
|
||||
6. Pays each bolt11 from the customer's LNbits wallet.
|
||||
|
||||
Each restaurant's LNbits instance:
|
||||
|
||||
7. Receives the payment via its own invoice listener
|
||||
(`tag == "restaurant"`), looks up the order by `payment_hash`,
|
||||
transitions the order to `paid` (or `accepted` if auto-accept is
|
||||
set), decrements stock, and queues a print job.
|
||||
8. Optionally, when wired up, sends NIP-17 status DMs back to the
|
||||
customer's pubkey: `paid → preparing → ready`.
|
||||
|
||||
## Data model
|
||||
|
||||
| Table | Purpose |
|
||||
| --------------------- | ------------------------------------------------------ |
|
||||
| `restaurants` | One row per restaurant. Owns a wallet + Nostr pubkey. |
|
||||
| `categories` | Top-level menu sections. |
|
||||
| `subcategories` | Optional second level under a category. |
|
||||
| `menu_items` | Items, with structured dietary/allergens/ingredients, |
|
||||
| | images, stock, availability, Nostr event id. |
|
||||
| `modifier_groups` | Choice groups (`required`/`optional`, `one`/`many`). |
|
||||
| `modifiers` | Individual options with `price_delta`. |
|
||||
| `availability_windows`| Per-item time-of-day + weekday availability. |
|
||||
| `orders` | Per-restaurant order with state machine. |
|
||||
| `order_items` | Snapshot of price + selected modifiers at order time. |
|
||||
| `print_jobs` | Thermal printer queue with retry tracking. |
|
||||
| `settings` | Per-instance toggles (Nostr publish, auto-accept, …). |
|
||||
|
||||
Money amounts on `orders`/`order_items` are stored as integer **msat**
|
||||
for precision. Item prices are floats in their declared currency
|
||||
(`sat`, `USD`, `GTQ`, etc.); the order pipeline multiplies by 1000 to
|
||||
go to msat at order time and snapshots that into `unit_price_msat`.
|
||||
|
||||
## Order state machine
|
||||
|
||||
```
|
||||
pending ──pay──▶ paid ──accept──▶ accepted ──ready──▶ ready ──serve──▶ completed
|
||||
│ │ │
|
||||
└─cancel────────────┴──────────────────┴─▶ canceled
|
||||
└─refund────────────────────────────────▶ refunded
|
||||
```
|
||||
|
||||
`pending → paid` is the *only* transition driven by money movement
|
||||
(the invoice listener). All others are explicit calls to
|
||||
`PUT /api/v1/orders/{id}/status/{new_status}` from the CMS.
|
||||
|
||||
## Nostr
|
||||
|
||||
- **Restaurant profile** is published as **kind 0** (NIP-01 metadata)
|
||||
whenever the restaurant is created or updated.
|
||||
- **Menu items** are published as **kind 30402** (NIP-99 classified
|
||||
listings, parameterized replaceable by `item.id`). Tags: `d`,
|
||||
`title`, `summary`, `price` (as `[price, n, currency]`), `image*`,
|
||||
`t` (category, dietary, `allergen:<x>`, `ingr:<x>`), `l`
|
||||
(`restaurant:<id>`), `location`, `g` (geohash), `status`
|
||||
(`active`/`sold`).
|
||||
- **Deletions** use **kind 5** (NIP-09) referencing the addressable
|
||||
event via `["a", "30402:<pubkey>:<d-tag>"]`.
|
||||
- **Inbound order DMs** are scaffolded as **NIP-17 gift-wrapped DMs**
|
||||
(kind 1059). The unwrap step (NIP-44 v2) is a stub; until it lands
|
||||
REST is the supported transport. The dispatcher
|
||||
(`_place_order_from_dm`) is complete and ready to wire in.
|
||||
|
||||
A restaurant signs with `restaurant.nostr_pubkey` if set (per-restaurant
|
||||
identity), else with the LNbits Account keypair of the wallet owner.
|
||||
|
||||
## API surface
|
||||
|
||||
Reading menus is **public** (no auth):
|
||||
|
||||
- `GET /restaurant/api/v1/restaurants/{id}` — profile
|
||||
- `GET /restaurant/api/v1/restaurants/{id}/menu` — full menu tree
|
||||
- `GET /restaurant/api/v1/menu_items/{id}` — single item
|
||||
|
||||
Customers placing orders need no auth (the customer pubkey is
|
||||
optional metadata):
|
||||
|
||||
- `POST /restaurant/api/v1/orders/quote` — pre-flight balance check
|
||||
- `POST /restaurant/api/v1/orders` — place an order, get bolt11
|
||||
- `GET /restaurant/api/v1/orders/{id}` — order + items
|
||||
|
||||
Owners write with their wallet's admin key:
|
||||
|
||||
- `POST /restaurant/api/v1/restaurants` — create
|
||||
- `PUT /restaurant/api/v1/restaurants/{id}` — update
|
||||
- `POST /restaurant/api/v1/menu_items` — create
|
||||
- `PUT /restaurant/api/v1/menu_items/{id}` — update (re-publishes
|
||||
to Nostr; kind 30402 is replaceable)
|
||||
- `DELETE /restaurant/api/v1/menu_items/{id}` — delete (sends NIP-09
|
||||
deletion to Nostr)
|
||||
- `PUT /restaurant/api/v1/orders/{id}/status/{new_status}` — manual
|
||||
state transitions
|
||||
- `PUT /restaurant/api/v1/print_jobs/{id}/ack` — printer-pi
|
||||
acknowledgement
|
||||
|
||||
Plus full CRUD for categories, subcategories, modifier groups,
|
||||
modifiers, and availability windows.
|
||||
|
||||
## Customer-facing webapp integration
|
||||
|
||||
The webapp's multi-restaurant cart flow:
|
||||
|
||||
```js
|
||||
// 1. Resolve restaurant pubkeys (e.g. from a NIP-51 festival list).
|
||||
const restaurants = await fetchRestaurantsForFestival(festivalId)
|
||||
|
||||
// 2. Pull each menu in parallel; subscribe to Nostr for live updates.
|
||||
await Promise.all(restaurants.map(r =>
|
||||
fetch(`/restaurant/api/v1/restaurants/${r.id}/menu`).then(r => r.json())
|
||||
))
|
||||
|
||||
// 3. User builds a cart spanning N restaurants.
|
||||
// Group lines by restaurant_id.
|
||||
const cartByRestaurant = groupBy(cart.lines, line => line.restaurant_id)
|
||||
|
||||
// 4. Pre-flight: ask each restaurant for the msat total.
|
||||
const quotes = await Promise.all(
|
||||
Object.entries(cartByRestaurant).map(([rid, lines]) =>
|
||||
fetch(`/restaurant/api/v1/orders/quote`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(lines.map(l => ({
|
||||
menu_item_id: l.id,
|
||||
quantity: l.qty,
|
||||
selected_modifiers: l.modifiers
|
||||
})))
|
||||
}).then(r => r.json()).then(j => ({rid, lines, msat: j.required_msat}))
|
||||
)
|
||||
)
|
||||
|
||||
const totalMsat = quotes.reduce((s, q) => s + q.msat, 0)
|
||||
if (walletBalanceMsat < totalMsat) {
|
||||
alert('Insufficient balance — top up first')
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Open one order per restaurant. Each returns its own bolt11.
|
||||
const orders = []
|
||||
for (const q of quotes) {
|
||||
const res = await fetch(`/restaurant/api/v1/orders`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
restaurant_id: q.rid,
|
||||
items: q.lines.map(...),
|
||||
customer_pubkey: window.user.nostrPubkey,
|
||||
parent_order_ref: cart.id,
|
||||
tip_msat: q.tipMsat,
|
||||
payment_method: 'lightning'
|
||||
})
|
||||
}).then(r => r.json())
|
||||
orders.push(res)
|
||||
}
|
||||
|
||||
// 6. Pay each bolt11 in sequence from the user's wallet.
|
||||
// The restaurant's invoice listener marks each as paid + queues
|
||||
// its print job independently.
|
||||
for (const o of orders) {
|
||||
await payInvoice(o.invoice.bolt11)
|
||||
}
|
||||
```
|
||||
|
||||
## Install (dev)
|
||||
|
||||
```sh
|
||||
cd ~/dev/lnbits/main
|
||||
# Drop a clone of this repo into the extensions dir LNbits expects:
|
||||
ln -s ~/dev/shared/extensions/restaurant lnbits/extensions/restaurant
|
||||
poetry run lnbits # or whatever your dev runner is
|
||||
```
|
||||
|
||||
Then enable the extension from the LNbits admin UI. The
|
||||
`nostrclient` extension must also be enabled for the publisher and
|
||||
sync to function — without it, the extension still works, just
|
||||
without the Nostr layer.
|
||||
|
||||
## Roadmap (not implemented yet)
|
||||
|
||||
- **NIP-44 v2 unwrap** for NIP-17 gift-wrapped order DMs.
|
||||
- **Per-restaurant Nostr keypair** secret storage (currently the
|
||||
fallback to the LNbits Account keypair is the only working path).
|
||||
- **SSE / push** for orders + KDS (today the CMS polls every 5–8 s).
|
||||
- **HODL invoices** for atomic multi-restaurant cart settlement (the
|
||||
scaffold accepts that best-effort sequential payment is enough for
|
||||
internal LNbits transfers; HODL would harden the rare external case).
|
||||
- **Foreign menu cache** so a single LNbits instance can serve a
|
||||
webapp that aggregates restaurants from many other instances. The
|
||||
`nostr_sync` skeleton currently only echoes our own published items.
|
||||
- **Image upload pipeline** (today images are URLs; a CDN integration
|
||||
belongs in the AIO webapp, not here).
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
Loading…
Add table
Add a link
Reference in a new issue