Compare commits

...

4 commits

Author SHA1 Message Date
f2229fcb0a Merge pull request 'feat: expose Chatelet over the LNbits nostr transport (#1)' (#8) from feat/nostr-transport-rpcs into main
Reviewed-on: #8
2026-07-19 14:50:38 +00:00
1b65890f42 docs: reframe kind:22000/22001 as retained proposal, not redundant
Per the client-agnostic doctrine (workspace CLAUDE.md): the availability RPC
is the training wheel, the public kind:22000/22001 is the destination. Keep
the custom kinds as a proposal so a generic Nostr client can one day query
availability without our RPC. Published via the nostrclient relay path (#2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 15:37:06 +02:00
5b176e5186 feat: expose Chatelet over the LNbits nostr transport (#1)
Register kind-21000 RPC handlers on the core nostr_transport dispatcher so
the booking flow runs over relays with no HTTP, mirroring lnurlp:

- operator (AUTH_WALLET): room create/update/publish, block create — all
  ownership-checked; room_list_mine (AUTH_ACCOUNT).
- public (AUTH_NONE): room_list/get (wallet id stripped), availability,
  booking_request, booking_get. Guest identity is the signed sender_pubkey,
  so no guest_pubkey is trusted from the body.
- register_link_owner_resolver(tag=chatelet, key=booking_id) lets the
  operator stream settlements via subscribe_payments.

Handlers delegate to services.py — no logic duplicated. Graceful no-op if
the core transport module isn't in this LNbits build (pre-#4). Guests can't
subscribe to the operator wallet, so they poll booking_get to confirm.

Note documented in event-flow.md: with availability now an RPC, the custom
kind:22000/22001 pair is redundant for RPC clients (revisit in #2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 01:30:13 +02:00
5cfc9b893d 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
2026-07-19 01:30:13 +02:00
5 changed files with 466 additions and 129 deletions

View file

@ -35,12 +35,57 @@ def chatelet_start():
scheduled_tasks.append( scheduled_tasks.append(
create_permanent_unique_task("chatelet_hold_expiry", expire_holds_loop) create_permanent_unique_task("chatelet_hold_expiry", expire_holds_loop)
) )
# TODO(relay): once relay plumbing lands, also start the inbound Nostr
# subscription so guests can query availability + book over Nostr: # Expose the booking flow over the core LNbits nostr transport (kind-21000
# from .nostr.service import subscribe_inbound # RPC) so an HTTP-allergic client can drive Chatelet over relays. Also wire
# scheduled_tasks.append( # the booking-owner resolver so the operator can stream booking
# create_permanent_unique_task("chatelet_nostr_in", subscribe_inbound) # settlements via subscribe_payments({tag:"chatelet", link_id:<booking_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"
)
__all__ = [ __all__ = [

View file

@ -7,9 +7,49 @@ How rooms, guests, LNbits, and relays interact. The guiding rule:
> Lightning payment is the confirmation. > Lightning payment is the confirmation.
Two doors lead into the *same* booking flow — the REST API Two doors lead into the *same* booking flow — the REST API
([`../views_api.py`](../views_api.py)) and the Nostr subscription ([`../views_api.py`](../views_api.py)) and the Nostr-transport RPC layer
([`../nostr/service.py`](../nostr/service.py)). Both funnel through ([`../transport_rpcs.py`](../transport_rpcs.py)). Both delegate to
[`../crud.py`](../crud.py) so arbitration + quoting live in one place. [`../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:<booking_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).
## Actors ## Actors

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

201
transport_rpcs.py Normal file
View file

@ -0,0 +1,201 @@
"""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:<booking_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

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)