Before this fix `_to_msat(item.price)` blindly did `price * 1000`,
treating any menu price as sat-denominated regardless of the item's
`currency` field. Quote and bolt11 were internally consistent but
charged ~0.1% of the real price for fiat-priced menus.
Big Jay's seeded with GTQ:
2× Tacos (Maíz, +Brisket, +Chicken)
pre-fix: 170 GTQ → 170000 msat → 170 sat invoice (~$0.14)
post-fix: 170 GTQ → 26968000 msat → 26968 sat invoice (~$22)
services.py:
- Drop `_to_msat` in favor of a `_price_to_msat(amount, currency)`
helper. Sat-aliased currencies ("sat", "sats", "satoshi",
"msat", …) take the flat ×1000 path; everything else round-
trips through lnbits.utils.exchange_rates.fiat_amount_as_satoshis
(same pool the events extension uses).
- Update _price_line_item: item.price AND each modifier.price_delta
are converted using item.currency. Modifier deltas inherit the
parent item's currency since we don't carry a per-modifier
currency field.
- Update quote_balance_required: same conversion via the item's
currency.
Verified live against the seeded "Big Jay's Bustaurant":
GTQ → sat conversion matches LNbits's bitcoin-price aggregate
(Binance / Blockchain / Bitfinex / Bitstamp / Coinbase / yadio).
Quote returns 26968 sats for 170 GTQ — within ~2% of expected
rate from external sources.
116 lines
4.5 KiB
Markdown
116 lines
4.5 KiB
Markdown
# 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. Converts each item's `price` from its declared `currency` to
|
||
msat. For sat-denominated items (`currency` ∈ `{sat, sats,
|
||
satoshi}`) this is a flat `× 1000`. For fiat (`USD`, `GTQ`, …)
|
||
it calls `lnbits.utils.exchange_rates.fiat_amount_as_satoshis`
|
||
to look up the live rate, then `× 1000`. The conversion lives
|
||
in `services._price_to_msat` so the rate lookup is the same path
|
||
the quote endpoint uses — a customer's preview and the recorded
|
||
`order.total_msat` cannot drift apart between request and place.
|
||
4. Sums `subtotal_msat`, applies `tax_rate`, adds `tip_msat` →
|
||
`total_msat`.
|
||
5. For Lightning / internal: calls
|
||
`lnbits.core.services.create_invoice` with
|
||
`extra={"tag": "restaurant", "restaurant_id": ...}`.
|
||
6. Persists the order with `id = payment_hash` so the listener can
|
||
look it up cheaply, plus one `order_items` row per line.
|
||
7. 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)
|