From 027db9cad2c306d0eaf555b1b92313bac55bc248 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 29 Apr 2026 23:52:29 +0200 Subject: [PATCH] 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) --- README.md | 264 +++++++++++++++++++++++++++++++++++++++++++++++++ description.md | 9 ++ 2 files changed, 273 insertions(+) create mode 100644 README.md create mode 100644 description.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..bacbc41 --- /dev/null +++ b/README.md @@ -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:`, `ingr:`), `l` + (`restaurant:`), `location`, `g` (geohash), `status` + (`active`/`sold`). +- **Deletions** use **kind 5** (NIP-09) referencing the addressable + event via `["a", "30402::"]`. +- **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. diff --git a/description.md b/description.md new file mode 100644 index 0000000..cc5272a --- /dev/null +++ b/description.md @@ -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).