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.
|
||||||
9
description.md
Normal file
9
description.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
Restaurant CMS for LNbits. Build menus, manage modifiers and inventory,
|
||||||
|
and process Lightning orders. Menus are published to Nostr (NIP-99
|
||||||
|
classified listings) so customer-facing webapps and aggregators
|
||||||
|
(festivals, food courts, collective spaces) can subscribe live across
|
||||||
|
many restaurants. Each restaurant issues its own invoice — multi-
|
||||||
|
restaurant carts pay each restaurant directly, no central wallet, no
|
||||||
|
splitting. Includes a Kitchen Display screen, thermal printer queue,
|
||||||
|
and an order state machine (pending → paid → accepted → ready →
|
||||||
|
completed).
|
||||||
Loading…
Add table
Add a link
Reference in a new issue