From 13de28e2e1a4bc7eca3e2d089939db3dd0faa815 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 11 May 2026 09:03:10 +0200 Subject: [PATCH 1/3] feat(api): public GET /restaurants/by-slug/{slug} Prerequisite for the customer webapp module (aiolabs/webapp, branch feat/restaurant-bundle): the webapp's /r/:slug route needs to resolve a slug to a Restaurant payload without an admin key. crud.get_restaurant_by_slug already exists (used by the server- rendered CMS routes in views.py); just expose it as a public REST endpoint. Mirrors api_get_restaurant by id and is declared before the bare-id route so the static prefix wins FastAPI's path match. Verified live against seeded 'Big Jay's Bustaurant': GET /restaurant/api/v1/restaurants/by-slug/big-jays-bustaurant -> 200 with the Restaurant payload. --- views_api.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/views_api.py b/views_api.py index 74ca8e3..ad26520 100644 --- a/views_api.py +++ b/views_api.py @@ -59,6 +59,7 @@ from .crud import ( get_print_job, get_print_jobs, get_restaurant, + get_restaurant_by_slug, get_restaurants, get_settings, move_menu_node, @@ -248,6 +249,20 @@ async def api_list_restaurants( return await get_restaurants(wallet_ids) +@restaurant_api_router.get("/api/v1/restaurants/by-slug/{slug}") +async def api_get_restaurant_by_slug(slug: str) -> Restaurant: + """Public — used by the customer webapp to resolve a URL slug + (e.g. /r/big-jays-bustaurant) to a restaurant. Mirrors + api_get_restaurant; declared *before* the bare-id route so the + static prefix wins the path match in FastAPI's router.""" + restaurant = await get_restaurant_by_slug(slug) + if not restaurant: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Restaurant not found." + ) + return restaurant + + @restaurant_api_router.get("/api/v1/restaurants/{restaurant_id}") async def api_get_restaurant(restaurant_id: str) -> Restaurant: """Public — used by the webapp to fetch profile metadata.""" From 2294bcd0c056ac9b7ace177ac1359f181511bb97 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 11 May 2026 17:49:12 +0200 Subject: [PATCH 2/3] fix(services): convert fiat menu prices to sat via exchange rates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- services.py | 47 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 8 deletions(-) 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 From d61e48b3e68a95cf86764f40a793e7aad6e9a37b Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 11 May 2026 18:16:39 +0200 Subject: [PATCH 3/3] fix(cms): KDS card text legible on dark mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The age-escalation highlights (bg-amber-1 / bg-orange-1 / bg-red-1) are very pale Quasar shades. On LNbits dark mode the q-card inherits a near-white text color from the theme — paired with the pale background that's white-on-cream, which is what the user reported: the '1x Coffee' on a ready-card was barely visible. Pin an explicit text-grey-9 alongside each pale bg so dark text on light background renders in both themes. The 'no highlight' branch returns '' unchanged, so non-aged orders still use the q-card's theme-aware default text color. --- static/js/kds.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/static/js/kds.js b/static/js/kds.js index bc0980d..ab4f0cc 100644 --- a/static/js/kds.js +++ b/static/js/kds.js @@ -25,10 +25,16 @@ window.app = Vue.createApp({ }, cardClass(order) { // Visually escalate as orders age. >5min = highlight; >15min = alarm. + // + // Pair every pale `bg-{color}-1` with an explicit dark text color + // — otherwise on LNbits dark mode the q-card inherits light text + // and renders white-on-cream, which is unreadable. The non- + // highlighted branch (default theme) returns '' so the q-card + // keeps its theme-aware defaults. const ageSec = (Date.now() - new Date(order.time).getTime()) / 1000 - if (order.status === 'ready') return 'bg-amber-1' - if (ageSec > 900) return 'bg-red-1' - if (ageSec > 300) return 'bg-orange-1' + if (order.status === 'ready') return 'bg-amber-1 text-grey-9' + if (ageSec > 900) return 'bg-red-1 text-grey-9' + if (ageSec > 300) return 'bg-orange-1 text-grey-9' return '' }, async fetchActive() {