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.
This commit is contained in:
parent
6dae57f3f4
commit
638f36e945
2 changed files with 51 additions and 12 deletions
|
|
@ -37,14 +37,22 @@ States and their meaning:
|
||||||
2. Re-prices every line item against the live menu (modifier ids
|
2. Re-prices every line item against the live menu (modifier ids
|
||||||
are matched server-side; the customer's claimed `price_delta`
|
are matched server-side; the customer's claimed `price_delta`
|
||||||
values are ignored).
|
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`.
|
`total_msat`.
|
||||||
4. For Lightning / internal: calls
|
5. For Lightning / internal: calls
|
||||||
`lnbits.core.services.create_invoice` with
|
`lnbits.core.services.create_invoice` with
|
||||||
`extra={"tag": "restaurant", "restaurant_id": ...}`.
|
`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.
|
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.
|
marks the order `accepted` directly.
|
||||||
|
|
||||||
Returns `(Order, OrderInvoice | None)`. The webapp pays the bolt11.
|
Returns `(Order, OrderInvoice | None)`. The webapp pays the bolt11.
|
||||||
|
|
|
||||||
47
services.py
47
services.py
|
|
@ -25,6 +25,7 @@ from loguru import logger
|
||||||
|
|
||||||
from lnbits.core.services import create_invoice
|
from lnbits.core.services import create_invoice
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||||
|
|
||||||
from .crud import (
|
from .crud import (
|
||||||
create_order,
|
create_order,
|
||||||
|
|
@ -55,9 +56,33 @@ from .models import (
|
||||||
# --------------------------------------------------------------------- #
|
# --------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
def _to_msat(amount: float) -> int:
|
_SAT_ALIASES = {"sat", "sats", "satoshi", "satoshis", "msat", "msats"}
|
||||||
"""Convert a sat amount (possibly fractional) to integer msat."""
|
|
||||||
return int(round(amount * 1000))
|
|
||||||
|
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]:
|
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:
|
if item.stock is not None and item.stock < line.quantity:
|
||||||
raise ValueError(f"Menu item {item.name!r} is out of stock")
|
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] = []
|
resolved: list[SelectedModifier] = []
|
||||||
delta_msat_each = 0
|
delta_msat_each = 0
|
||||||
requested_ids = {m.modifier_id for m in line.selected_modifiers if m.modifier_id}
|
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,
|
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
|
line_total_msat = unit_price_msat * line.quantity
|
||||||
|
|
||||||
row = OrderItemRow(
|
row = OrderItemRow(
|
||||||
|
|
@ -334,8 +365,8 @@ async def quote_balance_required(items: list[CreateOrderItem]) -> int:
|
||||||
item = await get_menu_item(line.menu_item_id)
|
item = await get_menu_item(line.menu_item_id)
|
||||||
if not item:
|
if not item:
|
||||||
continue
|
continue
|
||||||
unit = _to_msat(item.price)
|
unit = await _price_to_msat(item.price, item.currency)
|
||||||
for sm in line.selected_modifiers:
|
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
|
total += unit * line.quantity
|
||||||
return total
|
return total
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue