diff --git a/docs/order-flow.md b/docs/order-flow.md index ffa2888..fd29bc7 100644 --- a/docs/order-flow.md +++ b/docs/order-flow.md @@ -37,14 +37,22 @@ States and their meaning: 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. Sums `subtotal_msat`, applies `tax_rate`, adds `tip_msat` → +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`. -4. For Lightning / internal: calls +5. For Lightning / internal: calls `lnbits.core.services.create_invoice` with `extra={"tag": "restaurant", "restaurant_id": ...}`. -5. Persists the order with `id = payment_hash` so the listener can +6. Persists the order with `id = payment_hash` so the listener can look it up cheaply, plus one `order_items` row per line. -6. For cash: `payment_method = "cash"` skips invoice creation and +7. For cash: `payment_method = "cash"` skips invoice creation and marks the order `accepted` directly. Returns `(Order, OrderInvoice | None)`. The webapp pays the bolt11. diff --git a/services.py b/services.py index f187e2a..793e86e 100644 --- a/services.py +++ b/services.py @@ -25,6 +25,7 @@ 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, @@ -55,9 +56,33 @@ from .models import ( # --------------------------------------------------------------------- # -def _to_msat(amount: float) -> int: - """Convert a sat amount (possibly fractional) to integer msat.""" - return int(round(amount * 1000)) +_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]: @@ -81,7 +106,9 @@ async def _price_line_item(line: CreateOrderItem) -> tuple[OrderItemRow, int]: 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. + # 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} @@ -101,9 +128,13 @@ async def _price_line_item(line: CreateOrderItem) -> tuple[OrderItemRow, int]: price_delta=mod.price_delta, ) ) - delta_msat_each += _to_msat(mod.price_delta) + delta_msat_each += await _price_to_msat( + mod.price_delta, item.currency + ) - unit_price_msat = _to_msat(item.price) + delta_msat_each + unit_price_msat = ( + await _price_to_msat(item.price, item.currency) + ) + delta_msat_each line_total_msat = unit_price_msat * line.quantity row = OrderItemRow( @@ -334,8 +365,8 @@ async def quote_balance_required(items: list[CreateOrderItem]) -> int: item = await get_menu_item(line.menu_item_id) if not item: continue - unit = _to_msat(item.price) + unit = await _price_to_msat(item.price, item.currency) for sm in line.selected_modifiers: - unit += _to_msat(sm.price_delta or 0) + unit += await _price_to_msat(sm.price_delta or 0, item.currency) total += unit * line.quantity return total