chatelet/transport_rpcs.py
Padreug 0dad30b648 feat: card rail via LNbits fiat providers (Stripe), per operator
A guest may pay a fiat-priced room by card when its owner has opted in and
LNbits core has a fiat provider for that user — resolved through
settings.get_fiat_providers_for_user(owner), the seam lnbits#67's per-user
Stripe Connect credentials will plug into; chatelet stores no credentials.

Both rails now go through create_payment_request: Lightning unchanged
(sats, deposit_sat), card charges the same deposit share of the fiat price
in the room's currency with extra.checkout parameterising the hosted
Stripe page — success/cancel return to {frontend}/chatelet/{room}?checkout=…
&booking=<id>, customer_email, line item, metadata. frontend_url is
allow-listed against the instance's trusted origins (ported from events)
and resolved before the hold so a refused rail never leaves a dead hold.
Core settles the Stripe webhook onto the same invoice queue, so
tasks.on_invoice_paid confirms card bookings unchanged.

BookingRequestData gains payment_method / fiat_provider / frontend_url;
BookingQuote gains fiat_payment_request / fiat_provider / is_fiat and a
nullable payment_request. RPC chatelet_booking_request passes the fields
through. min_lnbits_version → 1.4.1 (events' floor for these APIs).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 12:25:31 +02:00

240 lines
8.7 KiB
Python

"""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,
UpdateOperatorSettings,
)
# 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)
async def handle_operator_get(auth: WalletTypeInfo, request: NostrRpcRequest) -> dict:
ops = await crud.get_or_create_operator_settings(auth.wallet.user)
return _to_dict(ops)
async def handle_operator_update(
auth: WalletTypeInfo, request: NostrRpcRequest
) -> dict:
ops = await crud.get_or_create_operator_settings(auth.wallet.user)
for k, v in (request.body or {}).items():
if k in UpdateOperatorSettings.__fields__:
setattr(ops, k, v)
return _to_dict(await crud.update_operator_settings(ops))
# --- public: discovery + booking (AUTH_NONE) -------------------------------
async def handle_room_list(auth: None, request: NostrRpcRequest) -> list[dict]:
"""Active rooms only, wallet id stripped, owner's house rules + rails
attached (public discovery) — same view as the HTTP door."""
rooms = [r for r in await crud.get_rooms() if r.status == RoomStatus.active]
return await services.public_room_views(rooms)
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 await services.public_room_view(room)
async def handle_room_unavailable(auth: None, request: NostrRpcRequest) -> dict:
"""Merged occupied/blocked spans for one room — the calendar feed.
Body: {room_id, start?, end?} (YYYY-MM-DD, end exclusive)."""
body = request.body or {}
result = await services.get_unavailable_ranges(
_require(body, "room_id"), body.get("start"), body.get("end")
)
return _to_dict(result)
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"),
payment_method=body.get("payment_method", "lightning"),
fiat_provider=body.get("fiat_provider"),
frontend_url=body.get("frontend_url"),
)
# No request host on this door: the checkout returns to the instance root
# unless the client names its own (allow-listed) frontend_url.
quote = await services.request_booking(data)
return _to_dict(quote)
async def handle_booking_list_mine(auth: None, request: NostrRpcRequest) -> list[dict]:
"""The caller's own bookings, scoped by the signed sender_pubkey."""
if not request.sender_pubkey:
raise PermissionError("chatelet: caller identity required")
return await services.list_guest_bookings(request.sender_pubkey)
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())