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
|
||||||
46
tasks.py
Normal file
46
tasks.py
Normal 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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue