diff --git a/__init__.py b/__init__.py index 910eb39..c18179a 100644 --- a/__init__.py +++ b/__init__.py @@ -35,57 +35,12 @@ def chatelet_start(): scheduled_tasks.append( create_permanent_unique_task("chatelet_hold_expiry", expire_holds_loop) ) - - # Expose the booking flow over the core LNbits nostr transport (kind-21000 - # RPC) so an HTTP-allergic client can drive Chatelet over relays. Also wire - # the booking-owner resolver so the operator can stream booking - # settlements via subscribe_payments({tag:"chatelet", link_id:}). - # No-op if the core transport module isn't present in this LNbits build. - try: - from lnbits.core.services.nostr_transport.dispatcher import ( - AUTH_ACCOUNT, - AUTH_NONE, - AUTH_WALLET, - register_rpc, - ) - from lnbits.core.services.nostr_transport.subscriptions import ( - register_link_owner_resolver, - ) - except ImportError: - return - - from .transport_rpcs import ( - handle_availability, - handle_block_create, - handle_booking_get, - handle_booking_request, - handle_room_create, - handle_room_get, - handle_room_list, - handle_room_list_mine, - handle_room_publish, - handle_room_update, - resolve_chatelet_owner, - ) - - # operator (wallet-scoped) - register_rpc("chatelet_room_create", handle_room_create, AUTH_WALLET) - register_rpc("chatelet_room_update", handle_room_update, AUTH_WALLET) - register_rpc("chatelet_room_publish", handle_room_publish, AUTH_WALLET) - register_rpc("chatelet_block_create", handle_block_create, AUTH_WALLET) - register_rpc("chatelet_room_list_mine", handle_room_list_mine, AUTH_ACCOUNT) - # public (discovery + guest booking) - register_rpc("chatelet_room_list", handle_room_list, AUTH_NONE) - register_rpc("chatelet_room_get", handle_room_get, AUTH_NONE) - register_rpc("chatelet_availability", handle_availability, AUTH_NONE) - register_rpc("chatelet_booking_request", handle_booking_request, AUTH_NONE) - register_rpc("chatelet_booking_get", handle_booking_get, AUTH_NONE) - - # tasks.py stamps extra["booking_id"] on settlement (see on_invoice_paid), - # so override the default link_extra_key ("link") to match. - register_link_owner_resolver( - "chatelet", resolve_chatelet_owner, link_extra_key="booking_id" - ) + # TODO(relay): once relay plumbing lands, also start the inbound Nostr + # subscription so guests can query availability + book over Nostr: + # from .nostr.service import subscribe_inbound + # scheduled_tasks.append( + # create_permanent_unique_task("chatelet_nostr_in", subscribe_inbound) + # ) __all__ = [ diff --git a/docs/event-flow.md b/docs/event-flow.md index b8fc7cc..3355acb 100644 --- a/docs/event-flow.md +++ b/docs/event-flow.md @@ -7,49 +7,9 @@ How rooms, guests, LNbits, and relays interact. The guiding rule: > Lightning payment is the confirmation. Two doors lead into the *same* booking flow — the REST API -([`../views_api.py`](../views_api.py)) and the Nostr-transport RPC layer -([`../transport_rpcs.py`](../transport_rpcs.py)). Both delegate to -[`../services.py`](../services.py) (orchestration) over -[`../crud.py`](../crud.py) (persistence) so arbitration + quoting live in one -place and can't drift between doors. - -## Door 2: RPC over the core nostr transport - -The core LNbits nostr transport (`lnbits.core.services.nostr_transport`) is a -**kind-21000 encrypted RPC bus** (NIP-44), not a general event publisher. -Chatelet registers handlers on it in `chatelet_start()` so the whole booking -flow runs over relays with no HTTP: - -| RPC | Auth | Purpose | -|---|---|---| -| `chatelet_room_create` / `_update` / `_publish` | wallet | operator room CRUD (ownership-checked) | -| `chatelet_block_create` | wallet | operator blocks a range | -| `chatelet_room_list_mine` | account | operator's rooms across their wallets | -| `chatelet_room_list` / `_get` | none | public discovery (active rooms, wallet id stripped) | -| `chatelet_availability` | none | is a range free + a quote | -| `chatelet_booking_request` | none | guest requests a stay (guest id = signed `sender_pubkey`) | -| `chatelet_booking_get` | none | guest reads back their booking (ownership by `sender_pubkey`) | - -Guest identity is the `sender_pubkey` the dispatcher lifts off the signed -kind-21000 event — unspoofable, and it means no separate `guest_pubkey` is -trusted from the body. - -**Payment confirmation over RPC:** `subscribe_payments` is wallet-owner -scoped, so the *operator* can stream booking settlements -(`subscribe_payments({tag:"chatelet", link_id:})`, wired via -`register_link_owner_resolver`). A **guest** can't subscribe to the operator's -wallet, so the guest confirms by polling `chatelet_booking_get` until -`confirmed` (a guest push would need a NIP-17 DM — issue #5). - -> **Custom kinds vs the RPC — training wheels, not redundancy:** the -> `chatelet_availability` RPC is what ships first, but the ephemeral -> `kind:22000/22001` availability query/response (ADR-0001) is **retained as a -> proposal**, not dropped. The RPC is the training wheel; the public event kind -> is the client-agnostic destination — a generic Nostr client must one day be -> able to query availability *without* speaking our private RPC (workspace -> doctrine: extensions aim to be Nostr-client-agnostic). It will be published -> over the nostrclient relay path alongside the NIP-99/52 discovery events -> (issue #2). +([`../views_api.py`](../views_api.py)) and the Nostr subscription +([`../nostr/service.py`](../nostr/service.py)). Both funnel through +[`../crud.py`](../crud.py) so arbitration + quoting live in one place. ## Actors diff --git a/services.py b/services.py deleted file mode 100644 index e0a12ae..0000000 --- a/services.py +++ /dev/null @@ -1,147 +0,0 @@ -"""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, - ) diff --git a/transport_rpcs.py b/transport_rpcs.py deleted file mode 100644 index f8f634b..0000000 --- a/transport_rpcs.py +++ /dev/null @@ -1,201 +0,0 @@ -"""Nostr-transport RPC handlers for Chatelet. - -Exposes the same booking flow as views_api.py, but encrypted over kind-21000 -events through the core LNbits nostr transport. Both doors delegate to -services.py — no logic is duplicated here. Mirrors lnurlp/transport_rpcs.py. - -Auth model (set by the registrations in __init__.py:chatelet_start): - -- room create/update/publish, block create → AUTH_WALLET. The transport - resolves the caller's pubkey to a wallet; handlers read auth.wallet.id and - enforce room ownership (room.wallet == caller's wallet). -- room list-mine → AUTH_ACCOUNT (operator lists rooms across their wallets). -- availability, room list/get, booking request/get → AUTH_NONE (public; - guests are external Nostr users with no LNbits wallet). The guest's Nostr - identity arrives as request.sender_pubkey — no separate guest_pubkey needed. - -The guest can't stream the operator's wallet via subscribe_payments (that's -wallet-owner-scoped), so a guest confirms a booking by polling -chatelet_booking_get. resolve_chatelet_owner lets the OPERATOR subscribe to -their booking settlements: subscribe_payments({tag:"chatelet", link_id:}). -""" - -from __future__ import annotations - -import json - -from lnbits.core.crud.wallets import get_wallets -from lnbits.core.models import Account -from lnbits.core.models.wallets import WalletTypeInfo -from lnbits.core.services.nostr_transport.models import NostrRpcRequest - -from . import crud, services -from .models import BookingRequestData, CreateBlockData, CreateRoomData, RoomStatus - -# Fields a client may patch on a room via chatelet_room_update. Identity / -# counter fields (id, wallet, listing_event_id, created_at) are not mutable; -# status flips through chatelet_room_publish, not here. -_MUTABLE_ROOM = { - "title", - "description", - "price_amount", - "price_currency", - "price_frequency", - "max_guests", - "min_nights", - "amenities", - "location", - "geohash", - "images", -} - - -# --- operator: rooms (AUTH_WALLET) ----------------------------------------- - - -async def handle_room_create(auth: WalletTypeInfo, request: NostrRpcRequest) -> dict: - body = request.body or {} - body["wallet"] = auth.wallet.id # always create under the calling wallet - room = await crud.create_room(CreateRoomData(**body)) - return _to_dict(room) - - -async def handle_room_update(auth: WalletTypeInfo, request: NostrRpcRequest) -> dict: - room = await _require_owned_room(_require_id(request), auth.wallet.id) - for k, v in (request.body or {}).items(): - if k in _MUTABLE_ROOM: - setattr(room, k, v) - return _to_dict(await crud.update_room(room)) - - -async def handle_room_publish(auth: WalletTypeInfo, request: NostrRpcRequest) -> dict: - from .nostr import service as nostr - - room = await _require_owned_room(_require_id(request), auth.wallet.id) - room.status = RoomStatus.active - room.listing_event_id = await nostr.publish_listing(room) or room.listing_event_id - return _to_dict(await crud.update_room(room)) - - -async def handle_room_list_mine(auth: Account, request: NostrRpcRequest) -> list[dict]: - """All rooms across wallets owned by the calling account.""" - wallet_ids = {w.id for w in await get_wallets(auth.id)} - rooms = await crud.get_rooms() - return [_to_dict(r) for r in rooms if r.wallet in wallet_ids] - - -# --- operator: blocks (AUTH_WALLET) ---------------------------------------- - - -async def handle_block_create(auth: WalletTypeInfo, request: NostrRpcRequest) -> dict: - from .nostr import service as nostr - - body = request.body or {} - room = await _require_owned_room(str(body.get("room_id", "")), auth.wallet.id) - block = await crud.create_block(CreateBlockData(**body)) - await nostr.publish_block_calendar( - room, block.start_date, block.end_date, block.id - ) - return _to_dict(block) - - -# --- public: discovery + booking (AUTH_NONE) ------------------------------- - - -async def handle_room_list(auth: None, request: NostrRpcRequest) -> list[dict]: - """Active rooms only, wallet id stripped (public discovery).""" - rooms = await crud.get_rooms() - return [_public_room(r) for r in rooms if r.status == RoomStatus.active] - - -async def handle_room_get(auth: None, request: NostrRpcRequest) -> dict: - room = await crud.get_room(_require_id(request)) - if not room or room.status != RoomStatus.active: - raise ValueError("Room not available") - return _public_room(room) - - -async def handle_availability(auth: None, request: NostrRpcRequest) -> dict: - body = request.body or {} - result = await services.get_availability( - _require(body, "room_id"), - _require(body, "check_in"), - _require(body, "check_out"), - ) - return _to_dict(result) - - -async def handle_booking_request(auth: None, request: NostrRpcRequest) -> dict: - """Guest requests a stay. The guest's identity is request.sender_pubkey - (from the signed kind-21000 event) — clients cannot spoof it.""" - sender = request.sender_pubkey - if not sender: - raise PermissionError("booking request: missing sender pubkey") - body = request.body or {} - data = BookingRequestData( - room_id=_require(body, "room_id"), - guest_pubkey=sender, - check_in=_require(body, "check_in"), - check_out=_require(body, "check_out"), - num_guests=body.get("num_guests", 1), - guest_contact=body.get("guest_contact"), - message=body.get("message"), - ) - quote = await services.request_booking(data) - return _to_dict(quote) - - -async def handle_booking_get(auth: None, request: NostrRpcRequest) -> dict: - booking = await crud.get_booking(_require_id(request)) - if not booking: - raise ValueError("Booking not found") - # Ownership: only the guest who made it can read it back. - if booking.guest_pubkey != request.sender_pubkey: - raise PermissionError("booking does not belong to caller") - return _to_dict(booking) - - -# --- subscription resolver ------------------------------------------------- - - -async def resolve_chatelet_owner(booking_id: str) -> str | None: - """For the core subscription module: booking_id -> owning wallet id, so - the operator can subscribe_payments({tag:"chatelet", link_id:booking_id}).""" - booking = await crud.get_booking(booking_id) - if not booking: - return None - room = await crud.get_room(booking.room_id) - return room.wallet if room else None - - -# --- helpers --------------------------------------------------------------- - - -def _require(body: dict, field: str) -> str: - val = body.get(field) - if not val: - raise ValueError(f"chatelet: body.{field} is required") - return str(val) - - -def _require_id(request: NostrRpcRequest) -> str: - return _require(request.body or {}, "id") - - -async def _require_owned_room(room_id: str, wallet_id: str): - room = await crud.get_room(room_id) - if room is None: - raise ValueError(f"chatelet: room not found: {room_id}") - if room.wallet != wallet_id: - raise PermissionError("chatelet: room does not belong to caller's wallet") - return room - - -def _to_dict(obj) -> dict: - return json.loads(obj.json()) - - -def _public_room(room) -> dict: - d = _to_dict(room) - d.pop("wallet", None) # wallet id is operator-internal, not for guests - return d diff --git a/views_api.py b/views_api.py index 91ec6b9..20ec9a1 100644 --- a/views_api.py +++ b/views_api.py @@ -1,23 +1,33 @@ """Chatelet REST API. -The REST surface and the Nostr-transport surface (transport_rpcs.py) are two -doors into the SAME booking flow — both delegate to services.py so -availability arbitration + quoting live in one place. Per the aiolabs -long-term direction (webapp<->lnbits over Nostr), REST is the transitional -door; keep new booking logic in services.py, not here. +The REST surface and the Nostr surface (nostr/service.py) are two doors +into the SAME booking flow — both funnel through crud + the helpers here so +availability arbitration and quoting live in exactly one place. Per the +aiolabs long-term direction (webapp<->lnbits over Nostr), REST is the +transitional door; don't grow booking logic that only the HTTP path knows. + +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 lnbits.core.models import WalletTypeInfo +from lnbits.core.services import create_invoice 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, services +from . import crud from .models import ( AvailabilityQuery, AvailabilityResult, Booking, BookingQuote, BookingRequestData, + BookingStatus, CreateBlockData, CreateRoomData, Room, @@ -27,15 +37,6 @@ from .nostr import service as nostr 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) ---------------------- @@ -69,10 +70,27 @@ async def api_publish_room( @chatelet_api_router.post("/api/v1/availability") async def api_check_availability(q: AvailabilityQuery) -> AvailabilityResult: - try: - return await services.get_availability(q.room_id, q.check_in, q.check_out) - except ValueError as exc: - raise _to_http(exc) from exc + room = await crud.get_room(q.room_id) + if not room: + raise HTTPException(404, "Room not found") + nights = crud.nights_between(q.check_in, q.check_out) + 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) ------------------------------- @@ -80,12 +98,78 @@ async def api_check_availability(q: AvailabilityQuery) -> AvailabilityResult: @chatelet_api_router.post("/api/v1/bookings", status_code=201) 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: - return await services.request_booking(data) - except services.BookingError as exc: - raise HTTPException(502, str(exc)) from exc - except ValueError as exc: - raise _to_http(exc) from exc + 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: + # 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}") @@ -112,3 +196,15 @@ async def api_create_block( room, block.start_date, block.end_date, block.id ) 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)