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.
4.5 KiB
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):
- Resolves the restaurant; rejects if
is_open=False. - Re-prices every line item against the live menu (modifier ids
are matched server-side; the customer's claimed
price_deltavalues are ignored). - Converts each item's
pricefrom its declaredcurrencyto msat. For sat-denominated items (currency∈{sat, sats, satoshi}) this is a flat× 1000. For fiat (USD,GTQ, …) it callslnbits.utils.exchange_rates.fiat_amount_as_satoshisto look up the live rate, then× 1000. The conversion lives inservices._price_to_msatso the rate lookup is the same path the quote endpoint uses — a customer's preview and the recordedorder.total_msatcannot drift apart between request and place. - Sums
subtotal_msat, appliestax_rate, addstip_msat→total_msat. - For Lightning / internal: calls
lnbits.core.services.create_invoicewithextra={"tag": "restaurant", "restaurant_id": ...}. - Persists the order with
id = payment_hashso the listener can look it up cheaply, plus oneorder_itemsrow per line. - For cash:
payment_method = "cash"skips invoice creation and marks the orderaccepteddirectly.
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:
- Sets
order.status→"paid"(or"accepted"ifsettings.auto_accept_orders). - Decrements
menu_item.stockfor each line, clamped at 0. - Creates a
print_jobsrow.
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
- architecture
- data-model —
orders,order_items,print_jobs - webapp-integration — multi-restaurant aggregation
- nostr-layer — outbound status DMs (NIP-17, scaffolded)