feat: operator settings, guest booking list, card checkout #22

Merged
padreug merged 4 commits from feat/fiat-checkout into main 2026-09-21 09:12:17 +00:00
8 changed files with 140 additions and 2 deletions
Showing only changes of commit 8b816d83b0 - Show all commits

feat(api): a guest's own bookings on both doors

GET /api/v1/bookings/mine (LNbits account auth; identity = the account's
Nostr pubkey, the same value the booking request carried) and RPC twin
chatelet_booking_list_mine (scoped by the signed sender_pubkey). Rows come
back newest check-in first via the m003 guest index, as guest_booking_dict:
the guest's own contact and counts, minus the Lightning/Nostr plumbing.
Declared ahead of /bookings/{booking_id} so 'mine' is not read as an id.

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

View file

@ -67,6 +67,7 @@ def chatelet_start():
handle_availability,
handle_block_create,
handle_booking_get,
handle_booking_list_mine,
handle_booking_request,
handle_operator_get,
handle_operator_update,
@ -95,6 +96,7 @@ def chatelet_start():
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)
register_rpc("chatelet_booking_list_mine", handle_booking_list_mine, AUTH_NONE)
# tasks.py stamps extra["booking_id"] on settlement (see on_invoice_paid),
# so override the default link_extra_key ("link") to match.

12
crud.py
View file

@ -139,6 +139,18 @@ async def get_booking_by_payment_hash(payment_hash: str) -> Booking | None:
)
async def get_bookings_for_guest(guest_pubkey: str, limit: int = 200) -> list[Booking]:
"""A guest's own stays, newest check-in first (idx_bookings_guest_pubkey)."""
return await db.fetchall(
"""
SELECT * FROM chatelet.bookings WHERE guest_pubkey = :pk
ORDER BY check_in DESC LIMIT :limit
""",
{"pk": guest_pubkey, "limit": limit},
Booking,
)
async def get_bookings_for_room(room_id: str) -> list[Booking]:
return await db.fetchall(
"SELECT * FROM chatelet.bookings WHERE room_id = :rid",

View file

@ -31,6 +31,7 @@ flow runs over relays with no HTTP:
| `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`) |
| `chatelet_booking_list_mine` | none | the caller's own bookings (`sender_pubkey`; HTTP twin `GET /api/v1/bookings/mine` uses the account's 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

View file

@ -259,6 +259,16 @@ def public_booking_dict(booking: "Booking") -> dict:
return d
def guest_booking_dict(booking: "Booking") -> dict:
"""A Booking for the guest who owns it (authenticated by pubkey on either
door): everything public_booking_dict shows plus their own contact and
guest count; the Lightning/Nostr plumbing stays internal."""
d = json.loads(booking.json())
for k in ("payment_hash", "request_event_id", "reservation_event_id"):
d.pop(k, None)
return d
class AvailabilityQuery(BaseModel):
room_id: str
check_in: str # YYYY-MM-DD inclusive

View file

@ -36,6 +36,7 @@ from .models import (
Room,
RoomStatus,
UnavailableRanges,
guest_booking_dict,
public_room_dict,
)
@ -151,6 +152,12 @@ async def public_room_views(rooms: list[Room]) -> list[dict]:
return out
async def list_guest_bookings(guest_pubkey: str) -> list[dict]:
"""The caller's own bookings (identity established by the door: LNbits
account pubkey over HTTP, signed sender_pubkey over RPC)."""
return [guest_booking_dict(b) for b in await crud.get_bookings_for_guest(guest_pubkey)]
# A guest calendar asks for a year by default; cap the window so a bad client
# can't make us scan and ship an unbounded span.
DEFAULT_CALENDAR_DAYS = 365

89
tests/test_my_bookings.py Normal file
View file

@ -0,0 +1,89 @@
"""A guest's own bookings, on both doors. HTTP identity is the LNbits account
pubkey; RPC identity is the signed sender_pubkey. Neither leaks another
guest's rows, and the Lightning/Nostr plumbing stays internal."""
import asyncio
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from .. import crud, transport_rpcs, views_api
from ..models import Booking, BookingStatus, guest_booking_dict
PK = "ab" * 32
def _booking(i: int, **over) -> Booking:
base = dict(
id=f"bk{i}",
room_id="a",
guest_pubkey=PK,
guest_contact="me@example.com",
check_in=f"2026-10-{10 + i:02d}",
check_out=f"2026-10-{12 + i:02d}",
nights=2,
num_guests=1,
currency="EUR",
price_fiat=200.0,
amount_sat=300000,
deposit_sat=300000,
status=BookingStatus.confirmed,
payment_hash=f"ph{i}",
request_event_id="req",
reservation_event_id="res",
)
base.update(over)
return Booking(**base) # type: ignore[arg-type]
def _patch_store(monkeypatch, rows):
seen = {}
async def for_guest(pubkey, limit=200):
seen["pubkey"] = pubkey
return [b for b in rows if b.guest_pubkey == pubkey]
monkeypatch.setattr(crud, "get_bookings_for_guest", for_guest)
return seen
def test_guest_dict_keeps_own_contact_but_hides_plumbing():
d = guest_booking_dict(_booking(1))
assert d["guest_contact"] == "me@example.com"
assert d["guest_pubkey"] == PK
for hidden in ("payment_hash", "request_event_id", "reservation_event_id"):
assert hidden not in d
def test_http_lists_only_the_callers_rows(monkeypatch):
rows = [_booking(1), _booking(2, guest_pubkey="cd" * 32)]
seen = _patch_store(monkeypatch, rows)
user = SimpleNamespace(id="u1", pubkey=PK)
out = asyncio.run(views_api.api_my_bookings(user=user))
assert seen["pubkey"] == PK
assert [b["id"] for b in out] == ["bk1"]
assert "payment_hash" not in out[0]
def test_http_rejects_account_without_pubkey(monkeypatch):
_patch_store(monkeypatch, [])
with pytest.raises(HTTPException) as e:
asyncio.run(views_api.api_my_bookings(user=SimpleNamespace(id="u1", pubkey=None)))
assert e.value.status_code == 409
def test_rpc_scopes_by_sender_and_requires_it(monkeypatch):
rows = [_booking(1)]
_patch_store(monkeypatch, rows)
req = transport_rpcs.NostrRpcRequest(
rpc_name="chatelet_booking_list_mine", request_id="r", body={}, sender_pubkey=PK
)
out = asyncio.run(transport_rpcs.handle_booking_list_mine(None, req))
assert [b["id"] for b in out] == ["bk1"]
anon = transport_rpcs.NostrRpcRequest(
rpc_name="chatelet_booking_list_mine", request_id="r", body={}
)
with pytest.raises(PermissionError):
asyncio.run(transport_rpcs.handle_booking_list_mine(None, anon))

View file

@ -177,6 +177,13 @@ async def handle_booking_request(auth: None, request: NostrRpcRequest) -> dict:
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:

View file

@ -8,8 +8,8 @@ the guest-facing surface (availability, booking) is what also rides the RPC.
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from lnbits.core.models import WalletTypeInfo
from lnbits.decorators import require_admin_key, require_invoice_key
from lnbits.core.models import User, WalletTypeInfo
from lnbits.decorators import check_user_exists, require_admin_key, require_invoice_key
from . import crud, services
from .models import (
@ -286,6 +286,16 @@ async def api_request_booking(data: BookingRequestData) -> BookingQuote:
raise _to_http(exc) from exc
@chatelet_api_router.get("/api/v1/bookings/mine")
async def api_my_bookings(user: User = Depends(check_user_exists)) -> list[dict]:
"""The signed-in guest's own stays. Identity is the LNbits account's Nostr
pubkey — the same value the booking request carried as guest_pubkey.
Declared before /bookings/{booking_id} so "mine" is not read as an id."""
if not user.pubkey:
raise HTTPException(409, "This account has no Nostr pubkey")
return await services.list_guest_bookings(user.pubkey)
@chatelet_api_router.get("/api/v1/bookings/{booking_id}")
async def api_get_booking(
booking_id: str, key: WalletTypeInfo = Depends(require_invoice_key)