Compare commits
2 commits
0cc419e5fa
...
5b176e5186
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b176e5186 | |||
| 5cfc9b893d |
5 changed files with 462 additions and 129 deletions
57
__init__.py
57
__init__.py
|
|
@ -35,12 +35,57 @@ def chatelet_start():
|
|||
scheduled_tasks.append(
|
||||
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:
|
||||
# from .nostr.service import subscribe_inbound
|
||||
# scheduled_tasks.append(
|
||||
# create_permanent_unique_task("chatelet_nostr_in", subscribe_inbound)
|
||||
# )
|
||||
|
||||
# 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:<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__ = [
|
||||
|
|
|
|||
|
|
@ -7,9 +7,45 @@ 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 subscription
|
||||
([`../nostr/service.py`](../nostr/service.py)). Both funnel through
|
||||
[`../crud.py`](../crud.py) so arbitration + quoting live in one place.
|
||||
([`../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:<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).
|
||||
|
||||
> **Consequence for the custom kinds:** with availability answered by the
|
||||
> `chatelet_availability` RPC, the ephemeral `kind:22000/22001` availability
|
||||
> query/response (ADR-0001) is **redundant for RPC clients**. It is only worth
|
||||
> keeping if we want non-RPC Nostr clients to query availability by publishing
|
||||
> an event. Revisit when the public-discovery transport is chosen (issue #2).
|
||||
|
||||
## Actors
|
||||
|
||||
|
|
|
|||
147
services.py
Normal file
147
services.py
Normal 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
201
transport_rpcs.py
Normal 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
|
||||
144
views_api.py
144
views_api.py
|
|
@ -1,33 +1,23 @@
|
|||
"""Chatelet REST API.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
|
||||
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
|
||||
from . import crud, services
|
||||
from .models import (
|
||||
AvailabilityQuery,
|
||||
AvailabilityResult,
|
||||
Booking,
|
||||
BookingQuote,
|
||||
BookingRequestData,
|
||||
BookingStatus,
|
||||
CreateBlockData,
|
||||
CreateRoomData,
|
||||
Room,
|
||||
|
|
@ -37,6 +27,15 @@ 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) ----------------------
|
||||
|
||||
|
||||
|
|
@ -70,27 +69,10 @@ async def api_publish_room(
|
|||
|
||||
@chatelet_api_router.post("/api/v1/availability")
|
||||
async def api_check_availability(q: AvailabilityQuery) -> AvailabilityResult:
|
||||
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,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
# --- 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)
|
||||
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:
|
||||
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,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@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
|
||||
)
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue