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.
372 lines
13 KiB
Python
372 lines
13 KiB
Python
"""
|
||
Business logic for the restaurant extension.
|
||
|
||
The HTTP / Nostr handlers should stay thin and delegate to the
|
||
functions in this module so the same flows (place order, settle, print,
|
||
notify) work regardless of channel.
|
||
|
||
State machine
|
||
-------------
|
||
|
||
pending --pay--> paid --accept--> accepted --ready--> ready --serve--> completed
|
||
| |
|
||
+---cancel----------------+--> canceled
|
||
|
||
Once an order is *paid*, money has moved (Lightning settled, internal
|
||
transfer cleared, or cash recorded). Print jobs are queued at this point
|
||
so the kitchen sees the ticket as soon as the customer's payment
|
||
confirms.
|
||
"""
|
||
|
||
from datetime import datetime, timezone
|
||
from typing import Optional
|
||
|
||
from loguru import logger
|
||
|
||
from lnbits.core.services import create_invoice
|
||
from lnbits.helpers import urlsafe_short_hash
|
||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||
|
||
from .crud import (
|
||
create_order,
|
||
create_order_item,
|
||
create_print_job,
|
||
get_menu_item,
|
||
get_modifier_groups,
|
||
get_modifiers,
|
||
get_order,
|
||
get_order_items,
|
||
get_restaurant,
|
||
get_settings,
|
||
update_menu_item,
|
||
update_order,
|
||
)
|
||
from .models import (
|
||
CreateOrder,
|
||
CreateOrderItem,
|
||
Order,
|
||
OrderInvoice,
|
||
OrderItemRow,
|
||
SelectedModifier,
|
||
)
|
||
|
||
|
||
# --------------------------------------------------------------------- #
|
||
# Pricing #
|
||
# --------------------------------------------------------------------- #
|
||
|
||
|
||
_SAT_ALIASES = {"sat", "sats", "satoshi", "satoshis", "msat", "msats"}
|
||
|
||
|
||
def _is_sat_currency(currency: str | None) -> bool:
|
||
return (currency or "sat").strip().lower() in _SAT_ALIASES
|
||
|
||
|
||
async def _price_to_msat(amount: float, currency: str | None) -> int:
|
||
"""
|
||
Convert a menu-item-currency amount to integer **msat**.
|
||
|
||
- For native sat-denominated prices (currency in
|
||
`_SAT_ALIASES`) this is a flat ×1000.
|
||
- For any other ISO-ish currency code (GTQ, USD, EUR, BRL, …)
|
||
we round-trip through LNbits's `fiat_amount_as_satoshis`
|
||
which queries the same exchange-rate pool the rest of LNbits
|
||
uses, then multiply by 1000.
|
||
|
||
Side-effect-free helper; safe to call multiple times per
|
||
request (rates are LNbits-cached internally).
|
||
"""
|
||
if amount == 0:
|
||
return 0
|
||
if _is_sat_currency(currency):
|
||
return int(round(amount * 1000))
|
||
sats = await fiat_amount_as_satoshis(amount, (currency or "").upper())
|
||
return int(round(sats * 1000))
|
||
|
||
|
||
async def _price_line_item(line: CreateOrderItem) -> tuple[OrderItemRow, int]:
|
||
"""
|
||
Resolve a CreateOrderItem against the live menu, validate the
|
||
selected modifiers, and return (OrderItemRow, line_total_msat).
|
||
|
||
Validation is intentionally lenient: we trust the caller's
|
||
modifier names + price_deltas only as a hint. The authoritative
|
||
price comes from the menu_item + the matched modifier rows in DB.
|
||
Anything the customer sends that doesn't match a real modifier
|
||
is dropped silently.
|
||
"""
|
||
item = await get_menu_item(line.menu_item_id)
|
||
if not item:
|
||
raise ValueError(f"Menu item {line.menu_item_id} not found")
|
||
|
||
if not item.is_available:
|
||
raise ValueError(f"Menu item {item.name!r} is not available")
|
||
|
||
if item.stock is not None and item.stock < line.quantity:
|
||
raise ValueError(f"Menu item {item.name!r} is out of stock")
|
||
|
||
# Resolve & price modifiers against canonical DB rows. Modifier
|
||
# `price_delta` is denominated in the same currency as the parent
|
||
# item (we don't carry a per-modifier currency).
|
||
resolved: list[SelectedModifier] = []
|
||
delta_msat_each = 0
|
||
requested_ids = {m.modifier_id for m in line.selected_modifiers if m.modifier_id}
|
||
|
||
if requested_ids:
|
||
groups = await get_modifier_groups(item.id)
|
||
for grp in groups:
|
||
mods = await get_modifiers(grp.id)
|
||
for mod in mods:
|
||
if mod.id in requested_ids:
|
||
resolved.append(
|
||
SelectedModifier(
|
||
group_id=grp.id,
|
||
group_name=grp.name,
|
||
modifier_id=mod.id,
|
||
name=mod.name,
|
||
price_delta=mod.price_delta,
|
||
)
|
||
)
|
||
delta_msat_each += await _price_to_msat(
|
||
mod.price_delta, item.currency
|
||
)
|
||
|
||
unit_price_msat = (
|
||
await _price_to_msat(item.price, item.currency)
|
||
) + delta_msat_each
|
||
line_total_msat = unit_price_msat * line.quantity
|
||
|
||
row = OrderItemRow(
|
||
id=urlsafe_short_hash(),
|
||
order_id="", # caller fills in
|
||
menu_item_id=item.id,
|
||
name=item.name,
|
||
quantity=line.quantity,
|
||
unit_price_msat=unit_price_msat,
|
||
line_total_msat=line_total_msat,
|
||
selected_modifiers=resolved,
|
||
note=line.note,
|
||
time=datetime.now(timezone.utc),
|
||
)
|
||
return row, line_total_msat
|
||
|
||
|
||
# --------------------------------------------------------------------- #
|
||
# Order placement #
|
||
# --------------------------------------------------------------------- #
|
||
|
||
|
||
async def place_order(data: CreateOrder) -> tuple[Order, OrderInvoice | None]:
|
||
"""
|
||
Create an order + line items + invoice (if Lightning).
|
||
|
||
For `payment_method == 'lightning'`:
|
||
Returns (order, OrderInvoice) — caller pays the bolt11 to settle.
|
||
For `payment_method == 'cash'`:
|
||
Returns (order, None) — order is recorded, settlement is manual.
|
||
For `payment_method == 'internal'`:
|
||
Same shape as 'lightning' — caller is expected to pay the bolt11
|
||
from another LNbits wallet on the same instance.
|
||
"""
|
||
restaurant = await get_restaurant(data.restaurant_id)
|
||
if not restaurant:
|
||
raise ValueError(f"Restaurant {data.restaurant_id} not found")
|
||
|
||
if not restaurant.is_open:
|
||
raise ValueError(f"{restaurant.name!r} is currently closed")
|
||
|
||
if not data.items:
|
||
raise ValueError("Order must contain at least one item")
|
||
|
||
# Resolve all line items first, so we don't half-write an order
|
||
# that fails validation halfway through.
|
||
priced_lines: list[tuple[OrderItemRow, int]] = []
|
||
for line in data.items:
|
||
priced_lines.append(await _price_line_item(line))
|
||
|
||
subtotal_msat = sum(line_total for _, line_total in priced_lines)
|
||
tax_msat = int(round(subtotal_msat * (restaurant.tax_rate or 0) / 100))
|
||
tip_msat = max(0, data.tip_msat)
|
||
total_msat = subtotal_msat + tax_msat + tip_msat
|
||
|
||
if total_msat <= 0:
|
||
raise ValueError("Order total must be greater than zero")
|
||
|
||
settings = await get_settings()
|
||
expiry = settings.invoice_expiry_seconds
|
||
|
||
payment_hash: Optional[str] = None
|
||
bolt11: Optional[str] = None
|
||
|
||
if data.payment_method in ("lightning", "internal"):
|
||
payment = await create_invoice(
|
||
wallet_id=restaurant.wallet,
|
||
amount=int(total_msat / 1000), # LNbits expects sat
|
||
memo=f"Order at {restaurant.name}",
|
||
extra={
|
||
"tag": "restaurant",
|
||
"restaurant_id": restaurant.id,
|
||
},
|
||
expiry=expiry,
|
||
internal=(data.payment_method == "internal"),
|
||
)
|
||
payment_hash = payment.payment_hash
|
||
bolt11 = payment.bolt11
|
||
|
||
# Use payment_hash as the order id when available — gives the invoice
|
||
# listener a zero-metadata lookup path (`get_order(payment.payment_hash)`).
|
||
order_id = payment_hash or urlsafe_short_hash()
|
||
|
||
order = Order(
|
||
id=order_id,
|
||
restaurant_id=restaurant.id,
|
||
wallet=restaurant.wallet,
|
||
customer_pubkey=data.customer_pubkey,
|
||
customer_name=data.customer_name,
|
||
customer_contact=data.customer_contact,
|
||
status="pending" if data.payment_method != "cash" else "accepted",
|
||
channel=data.channel,
|
||
payment_method=data.payment_method,
|
||
payment_hash=payment_hash,
|
||
bolt11=bolt11,
|
||
subtotal_msat=subtotal_msat,
|
||
tip_msat=tip_msat,
|
||
tax_msat=tax_msat,
|
||
total_msat=total_msat,
|
||
currency_display=restaurant.currency,
|
||
fiat_amount=data.extra.fiat_rate and (total_msat / 1000) / data.extra.fiat_rate,
|
||
fiat_rate=data.extra.fiat_rate,
|
||
note=data.note,
|
||
parent_order_ref=data.parent_order_ref,
|
||
extra=data.extra,
|
||
time=datetime.now(timezone.utc),
|
||
)
|
||
await create_order(order)
|
||
|
||
for row, _ in priced_lines:
|
||
row.order_id = order.id
|
||
await create_order_item(row)
|
||
|
||
if data.payment_method == "cash":
|
||
await mark_order_paid(order.id)
|
||
return order, None
|
||
|
||
invoice = OrderInvoice(
|
||
order_id=order.id,
|
||
payment_hash=payment_hash or "",
|
||
bolt11=bolt11 or "",
|
||
amount_msat=total_msat,
|
||
expires_at=int(datetime.now(timezone.utc).timestamp()) + expiry,
|
||
)
|
||
return order, invoice
|
||
|
||
|
||
# --------------------------------------------------------------------- #
|
||
# Settlement + state transitions #
|
||
# --------------------------------------------------------------------- #
|
||
|
||
|
||
async def mark_order_paid(order_id: str) -> Optional[Order]:
|
||
"""Transition an order to `paid` (or `accepted` if auto_accept is on),
|
||
decrement stock, and queue a print job."""
|
||
order = await get_order(order_id)
|
||
if not order:
|
||
logger.warning(f"[RESTAURANT] mark_order_paid: order {order_id} not found")
|
||
return None
|
||
|
||
if order.status not in ("pending", "accepted"):
|
||
# Idempotent — already settled.
|
||
return order
|
||
|
||
settings = await get_settings()
|
||
now = datetime.now(timezone.utc)
|
||
|
||
order.paid_at = now
|
||
if settings.auto_accept_orders:
|
||
order.status = "accepted"
|
||
order.accepted_at = now
|
||
else:
|
||
order.status = "paid"
|
||
|
||
await update_order(order)
|
||
|
||
# Stock decrement happens after payment, not at order creation —
|
||
# we don't want pending-but-never-paid orders to lock inventory.
|
||
items = await get_order_items(order.id)
|
||
for it in items:
|
||
if it.menu_item_id:
|
||
menu_item = await get_menu_item(it.menu_item_id)
|
||
if menu_item and menu_item.stock is not None:
|
||
menu_item.stock = max(0, menu_item.stock - it.quantity)
|
||
await update_menu_item(menu_item)
|
||
|
||
# Queue a thermal print job for the kitchen.
|
||
await create_print_job(order.restaurant_id, order.id)
|
||
|
||
logger.info(
|
||
f"[RESTAURANT] Order {order.id[:12]}.. paid "
|
||
f"({order.total_msat / 1000:.0f} sat); print job queued"
|
||
)
|
||
return order
|
||
|
||
|
||
async def transition_order(order_id: str, new_status: str) -> Optional[Order]:
|
||
"""Apply a manual status transition (accept / ready / complete / cancel)."""
|
||
order = await get_order(order_id)
|
||
if not order:
|
||
return None
|
||
|
||
now = datetime.now(timezone.utc)
|
||
valid = {
|
||
"accepted": ("paid", "pending"),
|
||
"ready": ("accepted",),
|
||
"completed": ("ready", "accepted"),
|
||
"canceled": ("pending", "paid", "accepted", "ready"),
|
||
"refunded": ("paid", "accepted", "ready", "completed"),
|
||
}
|
||
if new_status not in valid:
|
||
raise ValueError(f"Unknown status {new_status!r}")
|
||
if order.status not in valid[new_status]:
|
||
raise ValueError(
|
||
f"Cannot transition {order.status!r} -> {new_status!r}"
|
||
)
|
||
|
||
order.status = new_status
|
||
if new_status == "accepted":
|
||
order.accepted_at = now
|
||
elif new_status == "ready":
|
||
order.ready_at = now
|
||
elif new_status == "completed":
|
||
order.completed_at = now
|
||
elif new_status == "canceled":
|
||
order.canceled_at = now
|
||
|
||
await update_order(order)
|
||
return order
|
||
|
||
|
||
# --------------------------------------------------------------------- #
|
||
# Customer-side helpers #
|
||
# --------------------------------------------------------------------- #
|
||
|
||
|
||
async def quote_balance_required(items: list[CreateOrderItem]) -> int:
|
||
"""
|
||
Pre-flight balance check: sum the msat the customer would need to
|
||
have available to pay every restaurant in a multi-restaurant cart.
|
||
|
||
The webapp calls this before opening any per-restaurant invoices,
|
||
so a customer with insufficient funds gets one clean error instead
|
||
of N partially-paid orders.
|
||
"""
|
||
total = 0
|
||
for line in items:
|
||
item = await get_menu_item(line.menu_item_id)
|
||
if not item:
|
||
continue
|
||
unit = await _price_to_msat(item.price, item.currency)
|
||
for sm in line.selected_modifiers:
|
||
unit += await _price_to_msat(sm.price_delta or 0, item.currency)
|
||
total += unit * line.quantity
|
||
return total
|