feat: expose Chatelet over the LNbits nostr transport (#1) #8

Merged
padreug merged 3 commits from feat/nostr-transport-rpcs into main 2026-07-19 14:50:39 +00:00
2 changed files with 171 additions and 120 deletions
Showing only changes of commit 5cfc9b893d - Show all commits

refactor: extract booking flow into services.py

Move availability quoting + the check-then-hold + invoice orchestration out
of views_api.py into a transport-agnostic services.py, with typed errors
(NotFound/Unavailable/ValueError/BookingError). views_api becomes a thin
HTTP door that maps those to status codes. No behavior change — this is so
the incoming nostr-transport door can share one booking flow instead of
duplicating it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
Padreug 2026-07-19 01:30:13 +02:00

147
services.py Normal file
View file

@ -0,0 +1,147 @@
"""Booking orchestration shared by both entry points.
`views_api.py` (HTTP) and `transport_rpcs.py` (Nostr kind-21000 RPC) are two
doors into the same flow; the availability arbiter + quoting + hold + invoice
logic lives here so neither door duplicates it and they can't drift. `crud.py`
is persistence; this module is the flow on top of it.
Exceptions are typed so each door can map them to its own error surface (HTTP
status / RPC error). All the client-facing ones subclass `ValueError` so the
Nostr dispatcher which turns `ValueError`/`PermissionError` into a returned
error message relays them to the caller verbatim; `BookingError` is a
backend failure and surfaces as a generic error over RPC (logged server-side).
"""
from datetime import datetime, timedelta, timezone
from lnbits.core.services import create_invoice
from lnbits.exceptions import InvoiceError
from lnbits.helpers import urlsafe_short_hash
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from . import crud
from .models import (
AvailabilityResult,
Booking,
BookingQuote,
BookingRequestData,
BookingStatus,
RoomStatus,
)
class NotFound(ValueError):
"""Referenced entity does not exist (-> HTTP 404)."""
class Unavailable(ValueError):
"""Room inactive or dates already taken (-> HTTP 409)."""
class BookingError(Exception):
"""Backend failure mid-booking, e.g. invoice creation (-> HTTP 502)."""
async def to_sats(amount: float, currency: str) -> int:
"""Fiat/currency -> sats. The single FX point; its result is the canonical
amount_sat and is never recomputed downstream (source-of-truth rule)."""
if currency.lower() in ("sat", "sats"):
return int(amount)
return await fiat_amount_as_satoshis(amount, currency)
async def get_availability(
room_id: str, check_in: str, check_out: str
) -> AvailabilityResult:
room = await crud.get_room(room_id)
if not room:
raise NotFound("Room not found")
nights = crud.nights_between(check_in, check_out)
if nights < 1:
raise ValueError("check_out must be after check_in")
available = await crud.is_available(room_id, check_in, check_out)
quote_sat = quote_fiat = None
if available:
quote_fiat = round(room.price_amount * nights, 2)
quote_sat = await to_sats(quote_fiat, room.price_currency)
return AvailabilityResult(
room_id=room_id,
check_in=check_in,
check_out=check_out,
available=available,
nights=nights,
quote_sat=quote_sat,
quote_fiat=quote_fiat,
currency=room.price_currency,
)
async def request_booking(data: BookingRequestData) -> BookingQuote:
"""Check-then-hold, then invoice. The `is_available` read + the `held`
write are the lock; TODO(#4) makes that pair atomic against a concurrent
request. Returns the held booking + the bolt11 that will confirm it."""
room = await crud.get_room(data.room_id)
if not room or room.status != RoomStatus.active:
raise NotFound("Room not available")
nights = crud.nights_between(data.check_in, data.check_out)
if nights < room.min_nights:
raise ValueError(f"Minimum stay is {room.min_nights} night(s)")
if data.num_guests > room.max_guests:
raise ValueError(f"Max {room.max_guests} guests")
# TODO(#4): wrap is_available + create_booking in a per-room lock / txn.
if not await crud.is_available(data.room_id, data.check_in, data.check_out):
raise Unavailable("Those dates are no longer available")
settings = await crud.get_or_create_settings()
price_fiat = round(room.price_amount * nights, 2)
amount_sat = await to_sats(price_fiat, room.price_currency) # canonical
deposit_sat = amount_sat * settings.deposit_percent // 100
booking = Booking(
id=urlsafe_short_hash()[:10],
room_id=room.id,
guest_pubkey=data.guest_pubkey,
guest_contact=data.guest_contact,
check_in=data.check_in,
check_out=data.check_out,
nights=nights,
num_guests=data.num_guests,
currency=room.price_currency,
price_fiat=price_fiat,
amount_sat=amount_sat,
deposit_sat=deposit_sat,
status=BookingStatus.held,
expires_at=datetime.now(timezone.utc)
+ timedelta(minutes=settings.default_hold_minutes),
)
await crud.create_booking(booking)
# Sats-denominated (deposit_sat locked at quote time) so FX drift before
# payment can't change what's owed. tag+booking_id let
# tasks.on_invoice_paid match the settlement back to this booking.
try:
payment = await create_invoice(
wallet_id=room.wallet,
amount=booking.deposit_sat,
memo=(
f"Chatelet · {room.title} · "
f"{booking.check_in}{booking.check_out} ({nights}n)"
),
extra={"tag": "chatelet", "booking_id": booking.id},
)
except InvoiceError as exc:
booking.status = BookingStatus.declined # dead hold -> free the dates
await crud.update_booking(booking)
raise BookingError(f"Could not create invoice: {exc.message}") from exc
booking.payment_hash = payment.payment_hash
booking.status = BookingStatus.awaiting_payment
await crud.update_booking(booking)
return BookingQuote(
booking=booking,
payment_request=payment.bolt11,
payment_hash=payment.payment_hash,
)

View file

@ -1,33 +1,23 @@
"""Chatelet REST API. """Chatelet REST API.
The REST surface and the Nostr surface (nostr/service.py) are two doors The REST surface and the Nostr-transport surface (transport_rpcs.py) are two
into the SAME booking flow both funnel through crud + the helpers here so doors into the SAME booking flow both delegate to services.py so
availability arbitration and quoting live in exactly one place. Per the availability arbitration + quoting live in one place. Per the aiolabs
aiolabs long-term direction (webapp<->lnbits over Nostr), REST is the long-term direction (webapp<->lnbits over Nostr), REST is the transitional
transitional door; don't grow booking logic that only the HTTP path knows. door; keep new booking logic in services.py, not here.
SKETCH: handlers show the intended flow and auth scopes; the pricing/FX and
invoice-creation calls are marked TODO where they'd touch core LNbits.
""" """
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from lnbits.core.models import WalletTypeInfo from lnbits.core.models import WalletTypeInfo
from lnbits.core.services import create_invoice
from lnbits.decorators import require_admin_key, require_invoice_key from lnbits.decorators import require_admin_key, require_invoice_key
from lnbits.exceptions import InvoiceError
from lnbits.helpers import urlsafe_short_hash
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from . import crud from . import crud, services
from .models import ( from .models import (
AvailabilityQuery, AvailabilityQuery,
AvailabilityResult, AvailabilityResult,
Booking, Booking,
BookingQuote, BookingQuote,
BookingRequestData, BookingRequestData,
BookingStatus,
CreateBlockData, CreateBlockData,
CreateRoomData, CreateRoomData,
Room, Room,
@ -37,6 +27,15 @@ from .nostr import service as nostr
chatelet_api_router = APIRouter() chatelet_api_router = APIRouter()
def _to_http(exc: ValueError) -> HTTPException:
"""Map a services-layer ValueError subclass to an HTTP status."""
if isinstance(exc, services.NotFound):
return HTTPException(404, str(exc))
if isinstance(exc, services.Unavailable):
return HTTPException(409, str(exc))
return HTTPException(400, str(exc))
# --- rooms (operator; admin-key scoped to own wallet) ---------------------- # --- rooms (operator; admin-key scoped to own wallet) ----------------------
@ -70,27 +69,10 @@ async def api_publish_room(
@chatelet_api_router.post("/api/v1/availability") @chatelet_api_router.post("/api/v1/availability")
async def api_check_availability(q: AvailabilityQuery) -> AvailabilityResult: async def api_check_availability(q: AvailabilityQuery) -> AvailabilityResult:
room = await crud.get_room(q.room_id) try:
if not room: return await services.get_availability(q.room_id, q.check_in, q.check_out)
raise HTTPException(404, "Room not found") except ValueError as exc:
nights = crud.nights_between(q.check_in, q.check_out) raise _to_http(exc) from exc
if nights < 1:
raise HTTPException(400, "check_out must be after check_in")
available = await crud.is_available(q.room_id, q.check_in, q.check_out)
quote_sat = quote_fiat = None
if available:
quote_fiat = round(room.price_amount * nights, 2)
quote_sat = await _to_sats(quote_fiat, room.price_currency)
return AvailabilityResult(
room_id=q.room_id,
check_in=q.check_in,
check_out=q.check_out,
available=available,
nights=nights,
quote_sat=quote_sat,
quote_fiat=quote_fiat,
currency=room.price_currency,
)
# --- booking (public write; guest-initiated) ------------------------------- # --- booking (public write; guest-initiated) -------------------------------
@ -98,78 +80,12 @@ async def api_check_availability(q: AvailabilityQuery) -> AvailabilityResult:
@chatelet_api_router.post("/api/v1/bookings", status_code=201) @chatelet_api_router.post("/api/v1/bookings", status_code=201)
async def api_request_booking(data: BookingRequestData) -> BookingQuote: async def api_request_booking(data: BookingRequestData) -> BookingQuote:
"""Guest requests a stay. Check-then-hold is the lock: if available we
write a `held` booking with the canonical amount_sat, create the deposit
invoice, and return the bolt11. The Nostr path calls the same steps."""
room = await crud.get_room(data.room_id)
if not room or room.status != room.status.active:
raise HTTPException(404, "Room not available")
nights = crud.nights_between(data.check_in, data.check_out)
if nights < room.min_nights:
raise HTTPException(400, f"Minimum stay is {room.min_nights} night(s)")
if data.num_guests > room.max_guests:
raise HTTPException(400, f"Max {room.max_guests} guests")
# TODO(concurrency): wrap is_available + create_booking in a per-room
# lock / DB transaction so two requests can't both pass the read.
if not await crud.is_available(data.room_id, data.check_in, data.check_out):
raise HTTPException(409, "Those dates are no longer available")
settings = await crud.get_or_create_settings()
price_fiat = round(room.price_amount * nights, 2)
amount_sat = await _to_sats(price_fiat, room.price_currency) # canonical
deposit_sat = amount_sat * settings.deposit_percent // 100
booking = Booking(
id=urlsafe_short_hash()[:10],
room_id=room.id,
guest_pubkey=data.guest_pubkey,
guest_contact=data.guest_contact,
check_in=data.check_in,
check_out=data.check_out,
nights=nights,
num_guests=data.num_guests,
currency=room.price_currency,
price_fiat=price_fiat,
amount_sat=amount_sat,
deposit_sat=deposit_sat,
status=BookingStatus.held,
expires_at=datetime.now(timezone.utc)
+ timedelta(minutes=settings.default_hold_minutes),
)
await crud.create_booking(booking)
# Invoice is denominated in sats (deposit_sat is already the canonical
# amount locked at quote time) so FX drift between now and payment can't
# change what the guest owes. tag+booking_id let tasks.on_invoice_paid
# match the settlement back to this booking.
try: try:
payment = await create_invoice( return await services.request_booking(data)
wallet_id=room.wallet, except services.BookingError as exc:
amount=booking.deposit_sat, raise HTTPException(502, str(exc)) from exc
memo=( except ValueError as exc:
f"Chatelet · {room.title} · " raise _to_http(exc) from exc
f"{booking.check_in}{booking.check_out} ({nights}n)"
),
extra={"tag": "chatelet", "booking_id": booking.id},
)
except InvoiceError as exc:
# Free the dates immediately — a hold with no payable invoice is dead.
booking.status = BookingStatus.declined
await crud.update_booking(booking)
raise HTTPException(502, f"Could not create invoice: {exc.message}") from exc
booking.payment_hash = payment.payment_hash
booking.status = BookingStatus.awaiting_payment
await crud.update_booking(booking)
# TODO(#5): DM the bolt11 to the guest over NIP-17 for the Nostr path.
return BookingQuote(
booking=booking,
payment_request=payment.bolt11,
payment_hash=payment.payment_hash,
)
@chatelet_api_router.get("/api/v1/bookings/{booking_id}") @chatelet_api_router.get("/api/v1/bookings/{booking_id}")
@ -196,15 +112,3 @@ async def api_create_block(
room, block.start_date, block.end_date, block.id room, block.start_date, block.end_date, block.id
) )
return block return block
# --- helpers ---------------------------------------------------------------
async def _to_sats(amount: float, currency: str) -> int:
"""Fiat/currency -> sats. Canonical conversion happens HERE, once, at
quote/hold time; the result is stored as amount_sat and never recomputed
downstream."""
if currency.lower() in ("sat", "sats"):
return int(amount)
return await fiat_amount_as_satoshis(amount, currency)