restaurant/docs/order-flow.md
Padreug 638f36e945 fix(services): convert fiat menu prices to sat via exchange rates
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.
2026-05-11 19:18:16 +02:00

116 lines
4.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)