From 201c387722df9fded6bdab165e2b92e8d180dc24 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 29 Apr 2026 23:38:39 +0200 Subject: [PATCH] feat(services,tasks): order placement, settlement, invoice listener services.py - place_order: validates against live menu, prices line items authoritatively from DB (modifier ids resolved server-side, not trusted from input), creates LNbits invoice, persists order + items. Order id := payment_hash for zero-metadata listener lookups. - mark_order_paid: idempotent paid -> [accepted if auto-accept] + stock decrement + queues a print job. - transition_order: explicit state-machine guard for accept/ready/ complete/cancel/refund. - quote_balance_required: pre-flight total for the webapp's multi-restaurant balance check (per the user's requirement to verify funds before opening any per-restaurant invoice). tasks.py - Single invoice listener filtered on extra.tag == 'restaurant', looks up order by payment_hash, delegates to mark_order_paid. Wrapped in try/except so one bad payment doesn't kill the loop. --- services.py | 341 ++++++++++++++++++++++++++++++++++++++++++++++++++++ tasks.py | 46 +++++++ 2 files changed, 387 insertions(+) create mode 100644 services.py create mode 100644 tasks.py diff --git a/services.py b/services.py new file mode 100644 index 0000000..f187e2a --- /dev/null +++ b/services.py @@ -0,0 +1,341 @@ +""" +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 .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 # +# --------------------------------------------------------------------- # + + +def _to_msat(amount: float) -> int: + """Convert a sat amount (possibly fractional) to integer msat.""" + return int(round(amount * 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. + 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 += _to_msat(mod.price_delta) + + unit_price_msat = _to_msat(item.price) + 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 = _to_msat(item.price) + for sm in line.selected_modifiers: + unit += _to_msat(sm.price_delta or 0) + total += unit * line.quantity + return total diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..037e503 --- /dev/null +++ b/tasks.py @@ -0,0 +1,46 @@ +""" +Background tasks. + +The invoice listener is the *only* place where money-moves trigger +business logic. We keep it small and idempotent: filter by +extra.tag == 'restaurant', look up the order by payment_hash, and +hand off to services.mark_order_paid(). +""" + +import asyncio + +from loguru import logger + +from lnbits.core.models import Payment +from lnbits.tasks import register_invoice_listener + +from .crud import get_order_by_payment_hash +from .services import mark_order_paid + + +async def wait_for_paid_invoices() -> None: + invoice_queue: asyncio.Queue = asyncio.Queue() + register_invoice_listener(invoice_queue, "ext_restaurant") + + while True: + payment = await invoice_queue.get() + try: + await on_invoice_paid(payment) + except Exception as ex: + logger.exception(f"[RESTAURANT] invoice listener error: {ex}") + + +async def on_invoice_paid(payment: Payment) -> None: + if not payment.extra or payment.extra.get("tag") != "restaurant": + return + + order = await get_order_by_payment_hash(payment.payment_hash) + if not order: + # Could be an order created on a different LNbits instance, or + # a payment whose order row was already deleted. Nothing to do. + logger.debug( + f"[RESTAURANT] No order for payment {payment.payment_hash[:12]}.." + ) + return + + await mark_order_paid(order.id)