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.
This commit is contained in:
Padreug 2026-04-29 23:38:39 +02:00
commit 201c387722
2 changed files with 387 additions and 0 deletions

341
services.py Normal file
View file

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

46
tasks.py Normal file
View file

@ -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)