docs: Obsidian-style vault under docs/
Add a navigable Obsidian vault as the project's first-class technical documentation. Notes cross-reference with [[wikilinks]]; docs/index.md is the Map of Content. New notes: index.md MOC, entry point architecture.md what the extension owns vs what lives outside data-model.md entity-by-entity schema reference menu-tree.md the arbitrary-depth tree concept order-flow.md state machine + invoice listener + print nostr-layer.md kinds 0/30402/5/1059, signing, t-tags api-reference.md endpoint catalog by audience cms.md Vue 3 + Quasar 2 UMD conventions, q-tree webapp-integration.md multi-restaurant cart pattern + atomicity glossary.md domain terms Existing notes (kept as-is): adr-0001-menu-tree.md the storage choice rationale design-conversation.md trimmed transcript README.md adds a Documentation section pointing at docs/index.md with the headline note list. Each note links to ~3-5 others; the vault forms a connected graph. A project-level memory rule (saved outside the repo) commits us to keeping these docs in sync as the code evolves: any commit that materially changes schema, API, order flow, Nostr surface, CMS conventions, or webapp integration must update the relevant note(s) in the same commit.
This commit is contained in:
parent
7f7915a041
commit
42a8b08a5b
11 changed files with 1015 additions and 0 deletions
14
README.md
14
README.md
|
|
@ -304,6 +304,20 @@ without the Nostr layer.
|
||||||
- **Image upload pipeline** (today images are URLs; a CDN integration
|
- **Image upload pipeline** (today images are URLs; a CDN integration
|
||||||
belongs in the AIO webapp, not here).
|
belongs in the AIO webapp, not here).
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Deeper docs live in [`docs/`](docs/) as an Obsidian-style vault. Start
|
||||||
|
at [`docs/index.md`](docs/index.md) (Map of Content) and follow the
|
||||||
|
`[[wikilinks]]`. Highlights:
|
||||||
|
|
||||||
|
- [`architecture`](docs/architecture.md) — layered overview
|
||||||
|
- [`data-model`](docs/data-model.md) — every table, every relationship
|
||||||
|
- [`menu-tree`](docs/menu-tree.md) — the tree as a concept
|
||||||
|
- [`order-flow`](docs/order-flow.md) — state machine + payment + print
|
||||||
|
- [`nostr-layer`](docs/nostr-layer.md) — kinds, tags, signing
|
||||||
|
- [`webapp-integration`](docs/webapp-integration.md) — multi-restaurant cart pattern
|
||||||
|
- [`adr-0001-menu-tree`](docs/adr-0001-menu-tree.md) — why adjacency + materialized path
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT.
|
MIT.
|
||||||
|
|
|
||||||
109
docs/api-reference.md
Normal file
109
docs/api-reference.md
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
# API reference
|
||||||
|
|
||||||
|
All routes live under `/restaurant/api/v1`. Three audiences:
|
||||||
|
|
||||||
|
- **Public** — no auth, used by customer webapps to read menus.
|
||||||
|
- **Customer** — no auth, places orders and queries them.
|
||||||
|
- **Owner** — wallet admin key in `X-Api-Key`, ownership-checked by
|
||||||
|
matching `restaurant.wallet`.
|
||||||
|
|
||||||
|
For exhaustive per-endpoint detail open `views_api.py`; this note is
|
||||||
|
the catalog.
|
||||||
|
|
||||||
|
## Public reads
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/restaurants/{id}` | Restaurant profile |
|
||||||
|
| `GET` | `/restaurants/{id}/menu` | `{restaurant, tree, items}` — the canonical [[menu-tree|menu tree]] (hydrated children + items per node) plus a flat enriched items list with modifier groups + availability windows pre-joined |
|
||||||
|
| `GET` | `/menu_items/{id}` | Single item |
|
||||||
|
| `GET` | `/menu_nodes/{id}` | Single node row |
|
||||||
|
| `GET` | `/restaurants/{id}/menu_nodes` | Flat list of all nodes — useful for parent pickers |
|
||||||
|
| `GET` | `/menu_items/{id}/modifier_groups` | Groups for an item |
|
||||||
|
| `GET` | `/modifier_groups/{id}/modifiers` | Modifiers for a group |
|
||||||
|
| `GET` | `/menu_items/{id}/availability_windows` | Availability rules |
|
||||||
|
|
||||||
|
## Customer order placement
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST` | `/orders/quote` | Pre-flight balance check; body is a list of `CreateOrderItem`. Returns `{required_msat}`. See [[order-flow]] |
|
||||||
|
| `POST` | `/orders` | Place an order on one restaurant; returns `{order, invoice}` where `invoice` is the bolt11 + payment_hash |
|
||||||
|
| `GET` | `/orders/{id}` | Order + items |
|
||||||
|
|
||||||
|
For multi-restaurant carts the webapp posts `/orders` once per
|
||||||
|
restaurant; see [[webapp-integration]].
|
||||||
|
|
||||||
|
## Owner CRUD (`X-Api-Key: <admin>`)
|
||||||
|
|
||||||
|
### Restaurants
|
||||||
|
|
||||||
|
| Method | Path |
|
||||||
|
|---|---|
|
||||||
|
| `GET` | `/restaurants?all_wallets=true` |
|
||||||
|
| `POST` | `/restaurants` |
|
||||||
|
| `PUT` | `/restaurants/{id}` |
|
||||||
|
| `DELETE` | `/restaurants/{id}` |
|
||||||
|
|
||||||
|
### Menu nodes (the tree)
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST` | `/menu_nodes` | depth + path derived from parent; HTTP 400 if creates would exceed cap |
|
||||||
|
| `PUT` | `/menu_nodes/{id}` | rename / desc / sort / image. **Rename re-publishes every item in the subtree** so [[nostr-layer\|ancestor `t` tags]] update |
|
||||||
|
| `PUT` | `/menu_nodes/{id}/move` | body `{new_parent_id}`; HTTP 400 on cycle / depth violation |
|
||||||
|
| `DELETE` | `/menu_nodes/{id}?cascade=true\|false` | default blocks (HTTP 409) if children/items exist; cascade=true detaches items and deletes the subtree of nodes |
|
||||||
|
|
||||||
|
### Menu items
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST` | `/menu_items` | Re-publishes to Nostr |
|
||||||
|
| `PUT` | `/menu_items/{id}` | Re-publishes |
|
||||||
|
| `DELETE` | `/menu_items/{id}` | Sends NIP-09 deletion |
|
||||||
|
|
||||||
|
### Modifier groups + modifiers
|
||||||
|
|
||||||
|
`POST` / `DELETE` for `/modifier_groups` and `/modifiers`.
|
||||||
|
|
||||||
|
### Availability windows
|
||||||
|
|
||||||
|
`POST` / `DELETE` for `/availability_windows`.
|
||||||
|
|
||||||
|
### Orders (operator)
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/restaurants/{id}/orders?statuses=...&limit=...` | invoice key acceptable — used by the [[cms\|order monitor + KDS]] |
|
||||||
|
| `PUT` | `/orders/{id}/status/{new_status}` | Manual transitions (`accepted`, `ready`, `completed`, `canceled`, `refunded`); admin key required |
|
||||||
|
|
||||||
|
### Print jobs
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/restaurants/{id}/print_jobs?status=...` | invoice key |
|
||||||
|
| `PUT` | `/print_jobs/{id}/ack` | Called by `printer-pi` after a successful print; admin key |
|
||||||
|
|
||||||
|
### Settings (LNbits admin only)
|
||||||
|
|
||||||
|
| Method | Path |
|
||||||
|
|---|---|
|
||||||
|
| `GET` | `/settings` |
|
||||||
|
| `PUT` | `/settings` |
|
||||||
|
|
||||||
|
## Error shapes
|
||||||
|
|
||||||
|
Standard FastAPI `{"detail": "<msg>"}`. Status codes:
|
||||||
|
|
||||||
|
- `400` — validation, depth / cycle on menu node ops, balance precheck failures
|
||||||
|
- `403` — owner check failed
|
||||||
|
- `404` — entity missing
|
||||||
|
- `409` — node delete blocked by children / items (pass `?cascade=true`)
|
||||||
|
- `500` — server-side, with the exception captured in logs
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [[architecture]]
|
||||||
|
- [[order-flow]]
|
||||||
|
- [[menu-tree]]
|
||||||
|
- [[webapp-integration]]
|
||||||
89
docs/architecture.md
Normal file
89
docs/architecture.md
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
# Architecture
|
||||||
|
|
||||||
|
The restaurant extension is the **operator's CMS** for one or many
|
||||||
|
restaurants on a single LNbits account. Customer-facing UIs (kiosks,
|
||||||
|
mobile apps, the AIO webapp) live outside the extension and consume it
|
||||||
|
over REST + Nostr.
|
||||||
|
|
||||||
|
## What this extension owns
|
||||||
|
|
||||||
|
- Restaurant profile rows and per-restaurant Nostr identity.
|
||||||
|
- The [[menu-tree]] (`menu_nodes` + `menu_items` + `modifier_groups` +
|
||||||
|
`modifiers` + `availability_windows`).
|
||||||
|
- The [[order-flow|order pipeline]] (`orders`, `order_items`,
|
||||||
|
`print_jobs`).
|
||||||
|
- Publishing the [[nostr-layer|menu to Nostr]] as NIP-99 listings.
|
||||||
|
- The [[cms|operator console]] under `/restaurant/...` (Jinja +
|
||||||
|
Quasar 2 UMD).
|
||||||
|
- A REST [[api-reference|API]] under `/restaurant/api/v1/...`.
|
||||||
|
|
||||||
|
## What lives outside the extension
|
||||||
|
|
||||||
|
| Concern | Where |
|
||||||
|
|---|---|
|
||||||
|
| Customer kiosk / mobile / web | `~/dev/webapp` ([[webapp-integration]]) |
|
||||||
|
| Multi-restaurant aggregation (festivals, food courts, collective spaces) | NIP-51 lists, curated externally |
|
||||||
|
| Lightning wallet, payment routing, user auth | LNbits core |
|
||||||
|
| Nostr relay connection | `nostrclient` extension |
|
||||||
|
| Thermal printer | `printer-pi` (subscribes to a webhook or Nostr event) |
|
||||||
|
|
||||||
|
## High-level topology
|
||||||
|
|
||||||
|
```
|
||||||
|
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, multi- │
|
||||||
|
└──────────────┘ │ restaurant cart) │
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
`__init__.py` registers three permanent tasks on extension start
|
||||||
|
(`create_permanent_unique_task`):
|
||||||
|
|
||||||
|
1. **Invoice listener** — `tasks.wait_for_paid_invoices` consumes
|
||||||
|
LNbits' global payment queue, filters on
|
||||||
|
`payment.extra.tag == "restaurant"`, and dispatches to
|
||||||
|
[[order-flow|services.mark_order_paid]].
|
||||||
|
2. **NostrClient bootstrap** — `nostr.nostr_client.NostrClient`
|
||||||
|
connects to the `nostrclient` extension's internal WebSocket
|
||||||
|
after a 10s grace.
|
||||||
|
3. **Nostr sync** — `nostr_sync.wait_for_nostr_events` subscribes
|
||||||
|
to the relevant filters once the client is up. NIP-17 unwrap is
|
||||||
|
stubbed.
|
||||||
|
|
||||||
|
`restaurant_stop` cancels the three tasks and closes the WebSocket.
|
||||||
|
|
||||||
|
If `nostrclient` isn't enabled, the publisher / sync no-op gracefully
|
||||||
|
and the extension still works — it just operates as REST-only without
|
||||||
|
the [[nostr-layer]].
|
||||||
|
|
||||||
|
## Boundaries we keep
|
||||||
|
|
||||||
|
The extension only ever knows about **its own restaurant's** data.
|
||||||
|
There is no global "festival" or "marketplace" entity stored anywhere
|
||||||
|
in `restaurant.*` tables. Cross-restaurant grouping is the customer
|
||||||
|
webapp's concern; see [[webapp-integration]].
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [[data-model]] — the entity catalog
|
||||||
|
- [[order-flow]] — payment lands → print job
|
||||||
|
- [[nostr-layer]] — what propagates outward
|
||||||
|
- [[adr-0001-menu-tree]] — why the menu storage choice
|
||||||
88
docs/cms.md
Normal file
88
docs/cms.md
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
# CMS
|
||||||
|
|
||||||
|
The operator-facing console — what the restaurant owner sees when
|
||||||
|
they enable the extension. Lives under `/restaurant/...` and uses
|
||||||
|
LNbits' built-in **Vue 3.5 + Quasar 2.18 UMD** runtime: no build
|
||||||
|
step, no bundler, just Jinja templates including a per-page JS file.
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
All pages extend `base.html` (provided by LNbits core) and require a
|
||||||
|
logged-in user (`check_user_exists`).
|
||||||
|
|
||||||
|
| Path | Template | Script | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/restaurant/` | `restaurant/index.html` | `static/js/index.js` | Restaurant list / dashboard |
|
||||||
|
| `/restaurant/{slug}` | `restaurant/menu.html` | `static/js/menu.js` | [[menu-tree\|menu]] builder (q-tree) |
|
||||||
|
| `/restaurant/{slug}/orders` | `restaurant/orders.html` | `static/js/orders.js` | Order monitor |
|
||||||
|
| `/restaurant/{slug}/kds` | `restaurant/kds.html` | `static/js/kds.js` | Kitchen Display |
|
||||||
|
| `/restaurant/{slug}/settings` | `restaurant/settings.html` | `static/js/settings.js` | Restaurant + extension settings |
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- `{% extends "base.html" %}` and `{% from "macros.jinja" import window_vars with context %}`.
|
||||||
|
- Page content goes in `{% block page %}`. Scripts in `{% block scripts %}`, after `{{ window_vars(user) }}`.
|
||||||
|
- The page JS sets `window.app = Vue.createApp({mixins: [windowMixin], data, methods, created})`. LNbits' `init-app.js` runs after the extension scripts and finishes the mount with `app.use(Quasar)` + `app.mount('#vue')` — **don't call `.mount()` yourself**.
|
||||||
|
- Bootstrap data is injected via `<script>window.X = {{ thing | tojson | safe }}</script>` between the macro and the per-page script.
|
||||||
|
- The shared REST client is `static/js/api.js`, exposing `window.RestaurantAPI` (one method per resource).
|
||||||
|
|
||||||
|
## Menu builder (q-tree)
|
||||||
|
|
||||||
|
The menu page uses Quasar's `q-tree` directly off the hydrated tree
|
||||||
|
returned by `GET /api/v1/restaurants/{id}/menu`. Three-pane layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
+--------------------+----------------+----------------------------+
|
||||||
|
| sidebar nav | q-tree | Items panel |
|
||||||
|
| (orders / KDS / | with inline | (filtered by selected |
|
||||||
|
| settings links) | edit buttons | tree node) |
|
||||||
|
+--------------------+----------------+----------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
Custom `default-header` slot renders:
|
||||||
|
|
||||||
|
- node name + item-count badge + child-count hint
|
||||||
|
- inline buttons: `add` (disabled at depth 3), `edit`,
|
||||||
|
`drive_file_move`, `delete` (with cascade prompt)
|
||||||
|
|
||||||
|
Add-root button sits above the tree (`+ New top-level`).
|
||||||
|
|
||||||
|
The Move dialog uses a flat-indented `q-select` of all nodes,
|
||||||
|
filtered to exclude the moved node + its descendants and any
|
||||||
|
depth-3 candidate. (The server enforces both checks too — see
|
||||||
|
[[menu-tree]].)
|
||||||
|
|
||||||
|
Drag-drop reorder is **v2**; v1 uses the explicit Move dialog.
|
||||||
|
|
||||||
|
## Item dialog
|
||||||
|
|
||||||
|
The item dialog includes a flat-indented `q-select` for `node_id`,
|
||||||
|
populated by walking the tree with em-space indentation per depth
|
||||||
|
level. An item can land on any node, not just leaves.
|
||||||
|
|
||||||
|
Modifier groups + modifiers live in a separate dialog (a child of
|
||||||
|
the item dialog) with the `chooseOne / chooseMany / required /
|
||||||
|
optional` semantics from [[data-model|the data model]].
|
||||||
|
|
||||||
|
## Order monitor + KDS
|
||||||
|
|
||||||
|
Both use the same data source (`GET /restaurants/{id}/orders`)
|
||||||
|
filtered by status. The KDS view escalates color by age (`>5min`
|
||||||
|
orange, `>15min` red) and offers one-tap state transitions.
|
||||||
|
|
||||||
|
Today the monitor + KDS poll every 5–8 s. SSE / Nostr push is on
|
||||||
|
the roadmap.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
`settings.html` saves restaurant fields via
|
||||||
|
`PUT /restaurants/{id}` and (for LNbits admins) extension-wide
|
||||||
|
toggles via `PUT /settings`. NIP-17 orders toggle is currently
|
||||||
|
disabled because the unwrap step is stubbed — see [[nostr-layer]].
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [[architecture]]
|
||||||
|
- [[menu-tree]]
|
||||||
|
- [[order-flow]]
|
||||||
|
- [[api-reference]]
|
||||||
149
docs/data-model.md
Normal file
149
docs/data-model.md
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
# Data model
|
||||||
|
|
||||||
|
Schema-by-schema reference. The migration that creates each table is
|
||||||
|
in `migrations.py`; the pydantic shapes are in `models.py`; CRUD is
|
||||||
|
in `crud.py`.
|
||||||
|
|
||||||
|
All tables live under the Postgres schema `restaurant.` (or the
|
||||||
|
SQLite equivalent), keyed off the LNbits `Database("ext_restaurant")`
|
||||||
|
binding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `restaurants`
|
||||||
|
|
||||||
|
One row per restaurant. **One LNbits wallet can own many.**
|
||||||
|
|
||||||
|
| Column | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `id` | `urlsafe_short_hash()` |
|
||||||
|
| `wallet` | LNbits wallet id — payment receiver, signing-key fallback |
|
||||||
|
| `name`, `slug` | `slug` is the URL segment in `/restaurant/<slug>` |
|
||||||
|
| `description`, `currency`, `timezone`, `location`, `geohash` | metadata |
|
||||||
|
| `logo_url`, `banner_url`, `social_links` (JSON) | profile dressing |
|
||||||
|
| `open_hours` (JSON) | weekly schedule, see [[order-flow]] |
|
||||||
|
| `is_open`, `accepts_cash`, `accepts_lightning` | runtime toggles |
|
||||||
|
| `tip_presets` (JSON int[]), `tax_rate` | money / UX hints |
|
||||||
|
| `printer_endpoint` | URL or `nostr:<pubkey>` for [[order-flow|print jobs]] |
|
||||||
|
| `nostr_pubkey`, `nostr_relays` (JSON str[]) | per-restaurant Nostr identity (optional override; defaults to the LNbits Account keypair) |
|
||||||
|
| `nostr_event_id`, `nostr_event_created_at` | last published kind-0 metadata event |
|
||||||
|
| `extra` (JSON) | free-form |
|
||||||
|
|
||||||
|
Published as a [[nostr-layer|kind-0 metadata event]] on create / update.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `menu_nodes`
|
||||||
|
|
||||||
|
The [[menu-tree]] — adjacency list with materialized path. Capped
|
||||||
|
at 4 levels (`MAX_MENU_DEPTH = 3`, zero-indexed).
|
||||||
|
|
||||||
|
| Column | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `id` | `urlsafe_short_hash()` |
|
||||||
|
| `restaurant_id` | FK |
|
||||||
|
| `parent_id` | self-FK, NULL = root |
|
||||||
|
| `name`, `description`, `image_url`, `sort_order` | display |
|
||||||
|
| `depth` | denormalized 0..3 — O(1) max-depth checks |
|
||||||
|
| `path` | denormalized `'rootid'` or `'rootid/childid'` — cheap subtree queries |
|
||||||
|
| `time` | created_at |
|
||||||
|
|
||||||
|
Indexes: `(restaurant_id)`, `(parent_id)`, `(path)`. See
|
||||||
|
[[adr-0001-menu-tree]] for why this shape and not a closure table.
|
||||||
|
|
||||||
|
Not published as a Nostr event itself — internal organizational
|
||||||
|
structure only. Renames trigger re-publish of every item in the
|
||||||
|
subtree (so the [[nostr-layer|ancestor `t` tag]] stays current).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `menu_items`
|
||||||
|
|
||||||
|
| Column | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `id`, `restaurant_id` | |
|
||||||
|
| `node_id` | nullable — orphans allowed when a node is deleted with `cascade=False` |
|
||||||
|
| `name`, `description`, `price`, `currency`, `sku` | core |
|
||||||
|
| `images`, `dietary`, `allergens`, `ingredients` (JSON arrays) | structured tags |
|
||||||
|
| `calories`, `sort_order`, `is_available`, `is_featured` | display |
|
||||||
|
| `stock`, `low_stock_threshold` | inventory; nullable = unlimited |
|
||||||
|
| `nostr_event_id`, `nostr_event_created_at` | last published kind-30402 |
|
||||||
|
| `extra` (JSON) | free-form |
|
||||||
|
|
||||||
|
Published as [[nostr-layer|NIP-99 kind 30402]] on create / update;
|
||||||
|
deleted via NIP-09 kind 5.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `modifier_groups` + `modifiers`
|
||||||
|
|
||||||
|
A menu item can have multiple modifier groups. Each group has a
|
||||||
|
`kind` (`required` | `optional`) and `selection` (`one` | `many`).
|
||||||
|
Each modifier carries a `price_delta`.
|
||||||
|
|
||||||
|
This unifies "required choices" (e.g. *Choose your protein: Chicken /
|
||||||
|
Tofu*) with "optional addons" (e.g. *Extra cheese +5*) under one
|
||||||
|
schema.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `availability_windows`
|
||||||
|
|
||||||
|
Per-item time-of-day availability.
|
||||||
|
|
||||||
|
| Column | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `menu_item_id` | FK |
|
||||||
|
| `weekday` | 0=Mon..6=Sun, NULL = every day |
|
||||||
|
| `start_time`, `end_time` | `'HH:MM'` 24h, restaurant timezone |
|
||||||
|
|
||||||
|
Used by clients to gray out items outside their window. The extension
|
||||||
|
itself doesn't auto-disable items — it surfaces the windows in the
|
||||||
|
[[api-reference|menu response]] so the consumer decides.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `orders` + `order_items`
|
||||||
|
|
||||||
|
See [[order-flow]] for the state machine and amounts (msat).
|
||||||
|
|
||||||
|
`orders.id` is set to `payment_hash` for Lightning orders, giving the
|
||||||
|
invoice listener a zero-metadata lookup path. `order_items` snapshot
|
||||||
|
price + selected modifiers at order time, so subsequent menu edits
|
||||||
|
don't rewrite history.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `print_jobs`
|
||||||
|
|
||||||
|
Created when an order transitions to `paid`. `printer-pi` polls or
|
||||||
|
subscribes, prints, and acknowledges via `PUT /print_jobs/{id}/ack`.
|
||||||
|
|
||||||
|
| Column | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `restaurant_id`, `order_id` | FKs |
|
||||||
|
| `status` | `queued` → `sent` → `acknowledged`, or `failed` |
|
||||||
|
| `attempts`, `last_error` | retry bookkeeping |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `settings`
|
||||||
|
|
||||||
|
Single-row table (id=1) for per-instance toggles.
|
||||||
|
|
||||||
|
| Toggle | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `nostr_publish_enabled` | gate for kind-0 / kind-30402 publishing |
|
||||||
|
| `nostr_orders_enabled` | enable subscription to kind-1059 DMs (NIP-17 unwrap is currently stubbed) |
|
||||||
|
| `invoice_expiry_seconds` | LNbits invoice lifetime |
|
||||||
|
| `auto_accept_orders` | `paid` → `accepted` automatically (see [[order-flow]]) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [[architecture]]
|
||||||
|
- [[menu-tree]]
|
||||||
|
- [[order-flow]]
|
||||||
|
- [[nostr-layer]]
|
||||||
|
- [[adr-0001-menu-tree]]
|
||||||
80
docs/glossary.md
Normal file
80
docs/glossary.md
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
# Glossary
|
||||||
|
|
||||||
|
Domain terms used throughout the docs. Linked from many notes.
|
||||||
|
|
||||||
|
**Aggregator** — a webapp / client that pulls menus from multiple
|
||||||
|
restaurants and presents them as a single experience (festival, food
|
||||||
|
court, collective space). Aggregators live outside this extension.
|
||||||
|
|
||||||
|
**Ancestor chain** — the ordered list of node names from the root of
|
||||||
|
the [[menu-tree]] down to (and including) a given node. Slugified
|
||||||
|
versions of these names ride along on every Nostr menu listing as
|
||||||
|
`t` tags so [[nostr-layer|clients can filter]] without parsing
|
||||||
|
markdown.
|
||||||
|
|
||||||
|
**Cascade detach** — the default behavior when deleting a
|
||||||
|
[[menu-tree|menu node]] that has items: the items are
|
||||||
|
**detached** (their `node_id` is set to NULL) rather than
|
||||||
|
hard-deleted. They survive as orphans for the operator to re-home
|
||||||
|
through the [[cms]]. Hard delete is opt-in.
|
||||||
|
|
||||||
|
**CMS** — the operator console. Server-rendered Jinja templates +
|
||||||
|
inline Vue 3 / Quasar 2 UMD. See [[cms]].
|
||||||
|
|
||||||
|
**Customer pubkey** — the Nostr pubkey of an ordering customer.
|
||||||
|
Optional metadata on `orders.customer_pubkey`. Used for sending
|
||||||
|
status updates back via [[nostr-layer|NIP-17 DMs]] (scaffolded).
|
||||||
|
|
||||||
|
**Festival** — common shorthand for a curated multi-restaurant
|
||||||
|
context. Not an entity stored in this extension; see
|
||||||
|
[[webapp-integration]].
|
||||||
|
|
||||||
|
**Internal payment** — an LNbits invoice paid from another wallet
|
||||||
|
on the same instance, never touching the Lightning Network. The
|
||||||
|
extension supports this as `payment_method = "internal"` for testing
|
||||||
|
and same-instance flows.
|
||||||
|
|
||||||
|
**MAX_MENU_DEPTH** — `3` (zero-indexed); 4 levels of nesting total.
|
||||||
|
Soft-enforced by the API via HTTP 400 on creates / moves.
|
||||||
|
|
||||||
|
**msat** — millisatoshi. Money on `orders` and `order_items` is
|
||||||
|
stored as integer msat for precision; UI / Nostr surfaces convert
|
||||||
|
back to sat (or fiat) at display time.
|
||||||
|
|
||||||
|
**Node** — a row in `menu_nodes`. The unit of organization in the
|
||||||
|
[[menu-tree]]. Has zero or more children, zero or more items, and
|
||||||
|
zero or one parent.
|
||||||
|
|
||||||
|
**NIP-XX** — a Nostr Implementation Possibility. Reference repo at
|
||||||
|
`~/dev/nostr-protocol/nips`. Specific NIPs we use:
|
||||||
|
|
||||||
|
- **NIP-01** — base event structure; kind 0 metadata.
|
||||||
|
- **NIP-09** — deletion request (kind 5).
|
||||||
|
- **NIP-17** — gift-wrapped DMs (kind 1059); planned order intake.
|
||||||
|
- **NIP-44** — encryption used inside NIP-17.
|
||||||
|
- **NIP-51** — generic lists; festival aggregator vehicle.
|
||||||
|
- **NIP-99** — classified listings (kind 30402); how we publish menu items.
|
||||||
|
|
||||||
|
**Operator** — the LNbits user who has enabled this extension on
|
||||||
|
their account. Owns one or more restaurants.
|
||||||
|
|
||||||
|
**Parent order ref** — an opaque string on `orders.parent_order_ref`
|
||||||
|
the webapp can use to correlate its own umbrella-cart id with the
|
||||||
|
per-restaurant orders. The extension stores it and echoes it back;
|
||||||
|
never reads it.
|
||||||
|
|
||||||
|
**Path** — denormalized materialized path on `menu_nodes`. Either
|
||||||
|
`'rootid'` (for a root node) or `'rootid/childid/...'` (for deeper
|
||||||
|
nodes). Underpins [[menu-tree|cheap subtree queries]].
|
||||||
|
|
||||||
|
**Restaurant Nostr identity** — each restaurant has an effective
|
||||||
|
keypair for signing kind-0 / kind-30402 events. If
|
||||||
|
`restaurant.nostr_pubkey` is set it overrides; otherwise the LNbits
|
||||||
|
Account keypair of the wallet owner is used. See [[nostr-layer]].
|
||||||
|
|
||||||
|
**Slug** — the URL segment under which a restaurant's [[cms|CMS pages]]
|
||||||
|
live (e.g. `/restaurant/emporium`). Lowercase, dashes, no spaces.
|
||||||
|
|
||||||
|
**Webapp** — the customer-facing UI at `~/dev/webapp`. Subscribes
|
||||||
|
to restaurants over Nostr, posts orders over REST. See
|
||||||
|
[[webapp-integration]].
|
||||||
50
docs/index.md
Normal file
50
docs/index.md
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# Restaurant extension — docs
|
||||||
|
|
||||||
|
Map of Content for the restaurant LNbits extension.
|
||||||
|
|
||||||
|
> Treat this folder as an Obsidian vault: notes use `[[wikilinks]]` to
|
||||||
|
> cross-reference. Open the folder in Obsidian (or any markdown editor
|
||||||
|
> that resolves bare-name links) for the full graph.
|
||||||
|
|
||||||
|
## Start here
|
||||||
|
|
||||||
|
- [[architecture]] — what the extension is and how it sits inside
|
||||||
|
LNbits, alongside the customer webapp and Nostr.
|
||||||
|
- [[glossary]] — domain terms used throughout the docs.
|
||||||
|
|
||||||
|
## Concepts
|
||||||
|
|
||||||
|
- [[data-model]] — every table, every relationship.
|
||||||
|
- [[menu-tree]] — the arbitrary-depth menu structure (capped at 4
|
||||||
|
levels), where items can attach to any node.
|
||||||
|
- [[order-flow]] — the order state machine, the invoice listener,
|
||||||
|
and the kitchen pipeline.
|
||||||
|
- [[nostr-layer]] — what we publish to Nostr (kind 0, kind 30402,
|
||||||
|
kind 5) and what we listen for (kind 1059, scaffolded).
|
||||||
|
|
||||||
|
## Surface
|
||||||
|
|
||||||
|
- [[api-reference]] — REST endpoints organized by audience
|
||||||
|
(public read, customer order placement, owner CRUD).
|
||||||
|
- [[cms]] — the operator UI: Vue 3 + Quasar 2 UMD conventions,
|
||||||
|
template structure, q-tree menu builder.
|
||||||
|
- [[webapp-integration]] — how the AIO webapp aggregates multiple
|
||||||
|
restaurants into a single cart and pays each one's invoice.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- [[adr-0001-menu-tree]] — why an adjacency list with materialized
|
||||||
|
path, not a closure table or `ltree`.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
- [[design-conversation]] — trimmed transcript of the design
|
||||||
|
discussion that produced the initial scaffold. Keep for
|
||||||
|
rationale that didn't make it into commit messages.
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
This vault is the project's first-class technical documentation.
|
||||||
|
Every commit that changes the surface listed in [[architecture]] or
|
||||||
|
the rules above must update the matching note(s) in the same commit.
|
||||||
|
Stale docs are worse than no docs.
|
||||||
91
docs/menu-tree.md
Normal file
91
docs/menu-tree.md
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
# Menu tree
|
||||||
|
|
||||||
|
Real menus are nested:
|
||||||
|
*Drinks → Hot Beverages → Coffee-based → Espressos*. The extension
|
||||||
|
models this as a single self-referential `menu_nodes` table.
|
||||||
|
|
||||||
|
## Storage shape
|
||||||
|
|
||||||
|
Adjacency list (`parent_id` self-FK) plus two denormalized columns:
|
||||||
|
|
||||||
|
- `path` — `'rootid'` or `'rootid/childid/...'` (slash-separated ids).
|
||||||
|
- `depth` — integer 0..3 (zero-indexed; cap is 4 levels).
|
||||||
|
|
||||||
|
See [[data-model]] for the column list and [[adr-0001-menu-tree]] for
|
||||||
|
why this shape was chosen over a closure table or `ltree`.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Max depth is 4** (`MAX_MENU_DEPTH = 3`). Creates that would
|
||||||
|
exceed the cap return HTTP 400. Moves that would push descendants
|
||||||
|
past the cap also return 400.
|
||||||
|
- **Items can attach to any node** — the leaf-only constraint of
|
||||||
|
the legacy two-level shape is gone. A "Drinks" node can hold its
|
||||||
|
own drinks AND nest sub-categories below it.
|
||||||
|
- **Cycle prevention** on move: the new parent's path must not
|
||||||
|
contain the moved node's id.
|
||||||
|
- **Cascade-delete detaches items** rather than wiping them. Items
|
||||||
|
carry `nostr_event_id` and revenue history, so the operator
|
||||||
|
re-homes orphans through the [[cms]] rather than losing them.
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
| Op | Cost | How |
|
||||||
|
|---|---|---|
|
||||||
|
| Children of node | O(log n) | `WHERE parent_id = :id` (indexed) |
|
||||||
|
| Subtree of node | O(log n) | `WHERE path LIKE :p \|\| '%'` (indexed) |
|
||||||
|
| Ancestors of node | O(depth) | split `path`, fetch by id |
|
||||||
|
| Cycle check on move | O(depth) | id in new parent's `path.split('/')` |
|
||||||
|
| Max-depth check | O(1) | integer compare |
|
||||||
|
| Move subtree | one statement | `path = new_prefix \|\| SUBSTR(path, len(old)+1)` |
|
||||||
|
| Build full tree for restaurant | O(n+m) Python | one `SELECT *` → assemble in memory |
|
||||||
|
|
||||||
|
## Move
|
||||||
|
|
||||||
|
The load-bearing operation. Single-statement subtree rewrite:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE restaurant.menu_nodes
|
||||||
|
SET path = :new_prefix || SUBSTR(path, :old_len + 1),
|
||||||
|
depth = depth + :delta
|
||||||
|
WHERE path = :old_path
|
||||||
|
OR path LIKE :old_path || '/%'
|
||||||
|
```
|
||||||
|
|
||||||
|
`SUBSTR` is 1-indexed on both SQLite and Postgres, so `len(old_path)
|
||||||
|
+ 1` slices the old prefix off correctly. Followed by a separate
|
||||||
|
`UPDATE menu_nodes SET parent_id = :new_pid WHERE id = :node_id` for
|
||||||
|
the moved root (descendants keep their parent_id; only paths +
|
||||||
|
depths shift).
|
||||||
|
|
||||||
|
Implementation lives in `crud.move_menu_node`.
|
||||||
|
|
||||||
|
## Tree assembly
|
||||||
|
|
||||||
|
Customers and the [[cms]] both want the whole tree in one call.
|
||||||
|
`crud.get_menu_tree(restaurant_id)`:
|
||||||
|
|
||||||
|
1. `SELECT * FROM menu_nodes WHERE restaurant_id = :rid ORDER BY depth, sort_order, time`
|
||||||
|
2. `SELECT * FROM menu_items WHERE restaurant_id = :rid`
|
||||||
|
3. Build `by_id: dict[id, MenuNode]` in Python.
|
||||||
|
4. Walk rows, attaching each non-root to `by_id[parent_id].children`.
|
||||||
|
5. Walk items, attaching each to `by_id[node_id].items`.
|
||||||
|
|
||||||
|
For typical restaurant sizes (5–50 nodes, 10–200 items) this is
|
||||||
|
microseconds. Identical on SQLite + Postgres, no recursive CTE
|
||||||
|
needed.
|
||||||
|
|
||||||
|
## Items at non-leaf levels
|
||||||
|
|
||||||
|
A node can have BOTH children AND items attached. The [[cms]]
|
||||||
|
renders both at each level. The [[nostr-layer]] tags items with
|
||||||
|
their full ancestor chain (root-first, slugified) so a customer
|
||||||
|
filtering for `#t=hot-beverages` finds everything under that branch
|
||||||
|
regardless of how deeply it nests.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [[data-model]] — columns + indexes
|
||||||
|
- [[cms]] — the q-tree builder UI
|
||||||
|
- [[nostr-layer]] — ancestor `t` tags
|
||||||
|
- [[adr-0001-menu-tree]] — adjacency vs. closure trade-off
|
||||||
110
docs/nostr-layer.md
Normal file
110
docs/nostr-layer.md
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
# Nostr layer
|
||||||
|
|
||||||
|
Why Nostr at all? Two reasons:
|
||||||
|
|
||||||
|
1. **Live menu propagation.** Customer apps subscribe to a
|
||||||
|
restaurant's pubkey and pick up menu changes (new items, price
|
||||||
|
updates, sold-out states) without polling.
|
||||||
|
2. **Cross-instance discoverability.** A festival or food court
|
||||||
|
curator publishes a [[webapp-integration|NIP-51 list]] of
|
||||||
|
restaurant pubkeys; any client can resolve it into a unified
|
||||||
|
menu without needing to know which LNbits instance hosts each
|
||||||
|
restaurant.
|
||||||
|
|
||||||
|
## What gets published
|
||||||
|
|
||||||
|
| Kind | Source | When |
|
||||||
|
|---|---|---|
|
||||||
|
| `0` (NIP-01 metadata) | restaurant profile | restaurant create / update |
|
||||||
|
| `30402` (NIP-99 classified listing, parameterized replaceable, `d`-tag = item id) | menu items | item create / update; node rename re-publishes the whole subtree's items |
|
||||||
|
| `5` (NIP-09 deletion request) | menu items | item delete |
|
||||||
|
|
||||||
|
Menu listings carry structured tags so subscribers can filter
|
||||||
|
without parsing markdown:
|
||||||
|
|
||||||
|
| Tag | Format | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `d` | item.id | addressable identifier |
|
||||||
|
| `title` | item.name | listing title |
|
||||||
|
| `summary` | first 140 chars of description | preview |
|
||||||
|
| `price` | `["price", n, currency]` | structured price (NIP-99) |
|
||||||
|
| `image` | url | one per image, repeatable |
|
||||||
|
| `t` | `"menu"` | universal anchor |
|
||||||
|
| `t` | `<slug>` per ancestor | root-first, slugified to lowercase ASCII (e.g. `hot-beverages`); lets clients filter by category |
|
||||||
|
| `t` | dietary tag | `vegan`, `gluten_free`, etc. |
|
||||||
|
| `t` | `allergen:<x>` | structured allergens |
|
||||||
|
| `t` | `ingr:<x>` | structured ingredients |
|
||||||
|
| `l` | `"restaurant:<id>"` | back-link to the operator |
|
||||||
|
| `location` | restaurant location | physical reference |
|
||||||
|
| `g` | restaurant geohash | geo-filterable |
|
||||||
|
| `status` | `"active"` or `"sold"` | NIP-99 sold-out state |
|
||||||
|
|
||||||
|
Builders live in `nostr_publisher.py`:
|
||||||
|
|
||||||
|
- `build_restaurant_metadata_event`
|
||||||
|
- `build_menu_item_event(..., ancestor_names=...)`
|
||||||
|
- `build_delete_event`
|
||||||
|
|
||||||
|
`_slugify` produces the ancestor `t` tag values. Renaming a
|
||||||
|
[[menu-tree|menu node]] re-publishes every item in the subtree so
|
||||||
|
the new tag set lands.
|
||||||
|
|
||||||
|
## Signing
|
||||||
|
|
||||||
|
Each restaurant has an effective Nostr identity:
|
||||||
|
|
||||||
|
- If `restaurant.nostr_pubkey` is set, that's a per-restaurant
|
||||||
|
identity (storage of the matching secret key is **out of scope**
|
||||||
|
in v1; the column is informational until a vault is wired up).
|
||||||
|
- Otherwise, the LNbits Account keypair of the wallet owner is
|
||||||
|
used (`account.pubkey` / `account.prvkey`).
|
||||||
|
|
||||||
|
`nostr_publisher.publish_event(client, event, prvkey)` signs in
|
||||||
|
place with `coincurve.PrivateKey.sign_schnorr` (BIP-340) and ships
|
||||||
|
to the relay via the [[architecture|nostrclient extension's]]
|
||||||
|
internal WebSocket.
|
||||||
|
|
||||||
|
## What gets listened for
|
||||||
|
|
||||||
|
`nostr_sync.wait_for_nostr_events` subscribes to:
|
||||||
|
|
||||||
|
- `kind 30402` with `#t=menu`, `limit 200` for backfill, then live.
|
||||||
|
Currently used only as an echo confirmation of our own publishes;
|
||||||
|
federated foreign-menu indexing is on the roadmap.
|
||||||
|
- `kind 1059` (NIP-17 gift-wrapped DMs), only when
|
||||||
|
`settings.nostr_orders_enabled`. The unwrap step (NIP-44 v2) is
|
||||||
|
**stubbed** — the dispatcher (`_place_order_from_dm`) is complete
|
||||||
|
and ready for the decryption hook.
|
||||||
|
|
||||||
|
## NIP-17 order intake (planned)
|
||||||
|
|
||||||
|
The intended flow once unwrap lands:
|
||||||
|
|
||||||
|
1. Customer's webapp encrypts an order payload with NIP-44 v2 to
|
||||||
|
the restaurant's pubkey, gift-wraps it (kind 13 → kind 1059),
|
||||||
|
and publishes.
|
||||||
|
2. The restaurant's `nostr_sync` receives the wrap, decrypts
|
||||||
|
layers, and produces a `CreateOrder`.
|
||||||
|
3. Order placement goes through the same `services.place_order`
|
||||||
|
path as REST — including invoice creation. The bolt11 is sent
|
||||||
|
back to the customer pubkey via another NIP-17 DM.
|
||||||
|
4. Status updates (`paid → preparing → ready`) flow back the same
|
||||||
|
way.
|
||||||
|
|
||||||
|
REST stays the supported transport until that lands, since LNbits
|
||||||
|
already has tested invoice plumbing.
|
||||||
|
|
||||||
|
## What does NOT get published
|
||||||
|
|
||||||
|
[[menu-tree|Menu nodes]] themselves. They're internal organizational
|
||||||
|
structure; only items and the restaurant profile carry public Nostr
|
||||||
|
identity. If we ever want categories to be discoverable as
|
||||||
|
standalone entities, NIP-51 lists are the right vehicle, not a new
|
||||||
|
kind.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [[architecture]] — extension lifecycle starts the NostrClient
|
||||||
|
- [[menu-tree]] — ancestor names come from here
|
||||||
|
- [[order-flow]] — what NIP-17 will eventually deliver
|
||||||
|
- [[webapp-integration]] — clients of this layer
|
||||||
108
docs/order-flow.md
Normal file
108
docs/order-flow.md
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
# Order flow
|
||||||
|
|
||||||
|
From "customer adds to cart" to "ticket prints in the kitchen", in
|
||||||
|
one restaurant's slice. The customer-side aggregation across
|
||||||
|
multiple restaurants is in [[webapp-integration]].
|
||||||
|
|
||||||
|
## State machine
|
||||||
|
|
||||||
|
```
|
||||||
|
pending ──pay──▶ paid ──accept──▶ accepted ──ready──▶ ready ──serve──▶ completed
|
||||||
|
│ │ │
|
||||||
|
└─cancel────────────┴──────────────────┴─▶ canceled
|
||||||
|
└─refund────────────────────────────────▶ refunded
|
||||||
|
```
|
||||||
|
|
||||||
|
`pending → paid` is the **only** transition driven by money. All
|
||||||
|
others are explicit calls to `PUT /api/v1/orders/{id}/status/{new}`
|
||||||
|
from the [[cms]].
|
||||||
|
|
||||||
|
States and their meaning:
|
||||||
|
|
||||||
|
| State | Set by | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `pending` | `services.place_order` | Invoice issued, not yet paid |
|
||||||
|
| `paid` | invoice listener | LNbits payment settled (or cash recorded) |
|
||||||
|
| `accepted` | operator (or auto-accept) | Restaurant has acknowledged, prep in progress |
|
||||||
|
| `ready` | operator | Pickup-ready / served |
|
||||||
|
| `completed` | operator | Finished |
|
||||||
|
| `canceled` | operator | Pre- or post-payment cancel |
|
||||||
|
| `refunded` | operator | Paid → refunded |
|
||||||
|
|
||||||
|
## Place order
|
||||||
|
|
||||||
|
`services.place_order(CreateOrder)`:
|
||||||
|
|
||||||
|
1. Resolves the restaurant; rejects if `is_open=False`.
|
||||||
|
2. Re-prices every line item against the live menu (modifier ids
|
||||||
|
are matched server-side; the customer's claimed `price_delta`
|
||||||
|
values are ignored).
|
||||||
|
3. Sums `subtotal_msat`, applies `tax_rate`, adds `tip_msat` →
|
||||||
|
`total_msat`.
|
||||||
|
4. For Lightning / internal: calls
|
||||||
|
`lnbits.core.services.create_invoice` with
|
||||||
|
`extra={"tag": "restaurant", "restaurant_id": ...}`.
|
||||||
|
5. Persists the order with `id = payment_hash` so the listener can
|
||||||
|
look it up cheaply, plus one `order_items` row per line.
|
||||||
|
6. For cash: `payment_method = "cash"` skips invoice creation and
|
||||||
|
marks the order `accepted` directly.
|
||||||
|
|
||||||
|
Returns `(Order, OrderInvoice | None)`. The webapp pays the bolt11.
|
||||||
|
|
||||||
|
## Pre-flight quote
|
||||||
|
|
||||||
|
`POST /api/v1/orders/quote` returns `{"required_msat": <int>}` for a
|
||||||
|
hypothetical cart. The webapp sums the quotes from each restaurant
|
||||||
|
**before** opening any per-restaurant invoice — so a customer with
|
||||||
|
insufficient balance sees one error rather than partially-paid carts
|
||||||
|
across N restaurants.
|
||||||
|
|
||||||
|
## Settlement
|
||||||
|
|
||||||
|
`tasks.wait_for_paid_invoices` registers an `asyncio.Queue` listener
|
||||||
|
on LNbits' global payment stream. On each payment:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if payment.extra.get("tag") != "restaurant":
|
||||||
|
return
|
||||||
|
order = await get_order_by_payment_hash(payment.payment_hash)
|
||||||
|
if order:
|
||||||
|
await mark_order_paid(order.id)
|
||||||
|
```
|
||||||
|
|
||||||
|
`services.mark_order_paid` is idempotent. It:
|
||||||
|
|
||||||
|
1. Sets `order.status` → `"paid"` (or `"accepted"` if
|
||||||
|
`settings.auto_accept_orders`).
|
||||||
|
2. Decrements `menu_item.stock` for each line, clamped at 0.
|
||||||
|
3. Creates a `print_jobs` row.
|
||||||
|
|
||||||
|
Stock decrements happen at settlement, not at order placement —
|
||||||
|
unpaid orders don't lock inventory.
|
||||||
|
|
||||||
|
## Print pipeline
|
||||||
|
|
||||||
|
`print_jobs` is a queue. `printer-pi` (a separate process / device
|
||||||
|
running outside this extension) picks up jobs via `GET /restaurants/
|
||||||
|
{id}/print_jobs?status=queued`, prints, and acknowledges via
|
||||||
|
`PUT /print_jobs/{id}/ack`. Status flow:
|
||||||
|
`queued → sent → acknowledged`, or `failed` with a `last_error`.
|
||||||
|
|
||||||
|
The roadmap calls for a Nostr-based push: `printer-pi` subscribes to
|
||||||
|
the restaurant's pubkey for an `order.confirmed` event, removing the
|
||||||
|
poll loop.
|
||||||
|
|
||||||
|
## Multi-restaurant carts
|
||||||
|
|
||||||
|
A single customer cart spanning multiple restaurants is handled by
|
||||||
|
the [[webapp-integration|webapp]], not here. Each restaurant's
|
||||||
|
extension instance only ever sees its own slice — its own order, its
|
||||||
|
own invoice, its own print job. There is no umbrella order on the
|
||||||
|
server side.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [[architecture]]
|
||||||
|
- [[data-model]] — `orders`, `order_items`, `print_jobs`
|
||||||
|
- [[webapp-integration]] — multi-restaurant aggregation
|
||||||
|
- [[nostr-layer]] — outbound status DMs (NIP-17, scaffolded)
|
||||||
127
docs/webapp-integration.md
Normal file
127
docs/webapp-integration.md
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
# Webapp integration
|
||||||
|
|
||||||
|
The customer-facing UI lives in `~/dev/webapp` (the AIO webapp).
|
||||||
|
This note describes the contract between the webapp and any number
|
||||||
|
of restaurants, especially the multi-restaurant cart pattern.
|
||||||
|
|
||||||
|
## Discovery
|
||||||
|
|
||||||
|
A webapp can either talk to one restaurant directly or aggregate
|
||||||
|
many. There's no central directory inside this extension — grouping
|
||||||
|
("festival", "collective space", "food court") is **emergent** via
|
||||||
|
NIP-51 list events curated by whoever runs the venue:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"kind": 30000, // NIP-51 follow set / generic list
|
||||||
|
"tags": [
|
||||||
|
["d", "festival-2026"],
|
||||||
|
["title", "Atitlan Bitcoin Festival 2026"],
|
||||||
|
["p", "<restaurant1-pubkey>"],
|
||||||
|
["p", "<restaurant2-pubkey>"],
|
||||||
|
["p", "<restaurant3-pubkey>"]
|
||||||
|
],
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The webapp:
|
||||||
|
|
||||||
|
1. Resolves the list event for the festival / venue.
|
||||||
|
2. Fans out to fetch each restaurant's profile (kind 0) and menu
|
||||||
|
(kind 30402) over Nostr, **or** falls back to
|
||||||
|
`GET /restaurant/api/v1/restaurants/{id}/menu` over REST.
|
||||||
|
3. Subscribes to each restaurant pubkey for live updates.
|
||||||
|
|
||||||
|
No central wallet, no central database, no per-restaurant
|
||||||
|
onboarding flow specific to "joining a festival". Restaurants
|
||||||
|
exist; festivals are curated views.
|
||||||
|
|
||||||
|
## Multi-restaurant cart
|
||||||
|
|
||||||
|
A single customer can put items from multiple restaurants into one
|
||||||
|
cart. On checkout, the webapp issues **one order per restaurant**;
|
||||||
|
each restaurant returns its own bolt11; the customer pays N
|
||||||
|
invoices. There is no payment splitter and no umbrella order on
|
||||||
|
the server side.
|
||||||
|
|
||||||
|
The pattern, in pseudocode (this lives in the webapp, not the
|
||||||
|
extension):
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 1. Group cart by restaurant.
|
||||||
|
const cartByRestaurant = groupBy(cart.lines, line => line.restaurant_id)
|
||||||
|
|
||||||
|
// 2. Pre-flight quote per restaurant.
|
||||||
|
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}))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. Fail fast on insufficient balance.
|
||||||
|
const totalMsat = quotes.reduce((s, q) => s + q.msat, 0)
|
||||||
|
if (walletBalanceMsat < totalMsat) {
|
||||||
|
return showInsufficientBalanceError()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Open one order per restaurant.
|
||||||
|
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(asCreateOrderItem),
|
||||||
|
customer_pubkey: window.user.nostrPubkey,
|
||||||
|
parent_order_ref: cart.id,
|
||||||
|
tip_msat: q.tipMsat,
|
||||||
|
payment_method: 'lightning'
|
||||||
|
})
|
||||||
|
}).then(r => r.json())
|
||||||
|
orders.push(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Pay each bolt11 in sequence.
|
||||||
|
for (const o of orders) {
|
||||||
|
await payInvoice(o.invoice.bolt11)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Atomicity
|
||||||
|
|
||||||
|
Sequential per-restaurant payments are best-effort, not atomic. In
|
||||||
|
practice — for internal LNbits transfers — payment failures are
|
||||||
|
rare and usually transient. If one of N invoices does fail mid-flow:
|
||||||
|
|
||||||
|
- The successful orders are already paid; their restaurants will
|
||||||
|
print and prepare them.
|
||||||
|
- The failed order has issued an invoice that simply expires.
|
||||||
|
- The customer settles the gap in person at the failing restaurant
|
||||||
|
(cash, retry, or refund of the others).
|
||||||
|
|
||||||
|
The pre-flight `/orders/quote` step plus a balance check before
|
||||||
|
opening any invoice eliminates the most common cause (insufficient
|
||||||
|
funds). HODL invoices for true atomicity are on the roadmap and
|
||||||
|
would replace step 4 once they ship.
|
||||||
|
|
||||||
|
## `parent_order_ref`
|
||||||
|
|
||||||
|
The webapp may pass an opaque `parent_order_ref` (e.g. its own
|
||||||
|
cart id) when posting `/orders`. The extension stores it on the
|
||||||
|
`orders` row but never reads it — useful only for the webapp to
|
||||||
|
correlate its umbrella cart with the per-restaurant orders later.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [[architecture]]
|
||||||
|
- [[order-flow]]
|
||||||
|
- [[api-reference]]
|
||||||
|
- [[nostr-layer]]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue