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:
Padreug 2026-04-29 23:52:29 +02:00
commit 027db9cad2
2 changed files with 273 additions and 0 deletions

264
README.md Normal file
View 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 58 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
View 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).