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:
parent
5f4b416f5f
commit
201c387722
2 changed files with 387 additions and 0 deletions
341
services.py
Normal file
341
services.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue