restaurant/services.py
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

372 lines
13 KiB
Python
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.

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