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

4.5 KiB
Raw Permalink Blame History

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_msattotal_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:

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, 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