diff --git a/__init__.py b/__init__.py index 6d151fb..a8fa672 100644 --- a/__init__.py +++ b/__init__.py @@ -67,16 +67,12 @@ def chatelet_start(): handle_availability, handle_block_create, handle_booking_get, - handle_booking_list_mine, handle_booking_request, - handle_operator_get, - handle_operator_update, handle_room_create, handle_room_get, handle_room_list, handle_room_list_mine, handle_room_publish, - handle_room_unavailable, handle_room_update, resolve_chatelet_owner, ) @@ -87,16 +83,12 @@ def chatelet_start(): 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) - register_rpc("chatelet_operator_get", handle_operator_get, AUTH_WALLET) - register_rpc("chatelet_operator_update", handle_operator_update, AUTH_WALLET) # 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_room_unavailable", handle_room_unavailable, 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) - 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. diff --git a/config.json b/config.json index 731995d..7ce0586 100644 --- a/config.json +++ b/config.json @@ -1,12 +1,12 @@ { "id": "chatelet", - "version": "0.5.0", + "version": "0.4.0", "name": "Chatelet", "repo": "https://git.atitlan.io/aiolabs/chatelet", "short_description": "Nostr-native room rentals (Airbnb-style) for LNbits", "description": "", "tile": "/chatelet/static/image/aio.png", - "min_lnbits_version": "1.4.1", + "min_lnbits_version": "1.4.0", "contributors": [ { "name": "padreug", diff --git a/crud.py b/crud.py index d863e11..8ba75dd 100644 --- a/crud.py +++ b/crud.py @@ -12,14 +12,12 @@ from lnbits.db import Database from lnbits.helpers import urlsafe_short_hash from .models import ( - OCCUPYING_STATUSES, Block, Booking, BookingStatus, ChateletSettings, CreateBlockData, CreateRoomData, - OperatorSettings, Room, RoomStatus, ) @@ -49,30 +47,6 @@ async def update_settings(settings: ChateletSettings) -> ChateletSettings: return settings -# --------------------------------------------------------------------------- -# Operator settings (per LNbits user — multi-tenant) -# --------------------------------------------------------------------------- - - -async def get_or_create_operator_settings(user_id: str) -> OperatorSettings: - row = await db.fetchone( - "SELECT * FROM chatelet.operator_settings WHERE user_id = :uid", - {"uid": user_id}, - OperatorSettings, - ) - if row: - return row - ops = OperatorSettings(user_id=user_id) - await db.insert("chatelet.operator_settings", ops) - return ops - - -async def update_operator_settings(ops: OperatorSettings) -> OperatorSettings: - ops.updated_at = datetime.now(timezone.utc) - await db.update("chatelet.operator_settings", ops, "WHERE user_id = :user_id") - return ops - - # --------------------------------------------------------------------------- # Rooms # --------------------------------------------------------------------------- @@ -139,18 +113,6 @@ 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", @@ -226,9 +188,12 @@ async def is_available(room_id: str, check_in: str, check_out: str) -> bool: return False for b in await get_bookings_for_room(room_id): - if b.status in OCCUPYING_STATUSES and _overlaps( - check_in, check_out, b.check_in, b.check_out - ): + if b.status in ( + BookingStatus.held, + BookingStatus.awaiting_payment, + BookingStatus.confirmed, + BookingStatus.checked_in, + ) and _overlaps(check_in, check_out, b.check_in, b.check_out): return False for blk in await get_blocks_for_room(room_id): @@ -238,54 +203,6 @@ async def is_available(room_id: str, check_in: str, check_out: str) -> bool: return True -def merge_ranges( - ranges: list[tuple[str, str]], start: str, end: str -) -> list[tuple[str, str]]: - """Clip half-open [s, e) spans to [start, end), drop empties, sort, and - coalesce overlapping *and adjacent* spans (a stay ending the day another - begins becomes one span). Pure, so the calendar shape is unit-testable - without a DB. Adjacent merging is deliberate: it hides where one - occupant's dates stop and the next's begin.""" - clipped = sorted( - (max(s, start), min(e, end)) for s, e in ranges if max(s, start) < min(e, end) - ) - merged: list[tuple[str, str]] = [] - for s, e in clipped: - if merged and s <= merged[-1][1]: - merged[-1] = (merged[-1][0], max(merged[-1][1], e)) - else: - merged.append((s, e)) - return merged - - -async def get_occupied_ranges( - room_id: str, start: str, end: str -) -> list[tuple[str, str]]: - """Occupying bookings + blocks that touch [start, end), as merged spans. - Same statuses and the same half-open rule as `is_available`, so a night - the calendar shows as free is one the arbiter will accept.""" - bookings = await db.fetchall( - f""" - SELECT * FROM chatelet.bookings - WHERE room_id = :rid AND check_in < :end AND check_out > :start - AND status IN ({", ".join(f"'{st.value}'" for st in OCCUPYING_STATUSES)}) - """, - {"rid": room_id, "start": start, "end": end}, - Booking, - ) - blocks = await db.fetchall( - """ - SELECT * FROM chatelet.blocks - WHERE room_id = :rid AND start_date < :end AND end_date > :start - """, - {"rid": room_id, "start": start, "end": end}, - Block, - ) - spans = [(b.check_in, b.check_out) for b in bookings] - spans += [(blk.start_date, blk.end_date) for blk in blocks] - return merge_ranges(spans, start, end) - - def nights_between(check_in: str, check_out: str) -> int: d_in = date.fromisoformat(check_in) d_out = date.fromisoformat(check_out) diff --git a/docs/data-model.md b/docs/data-model.md index e842fc5..b49b732 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -53,19 +53,6 @@ replaces the same addressable event. Holds price (`amount`/`currency`/ published reservation object. - **`expires_at`** — hold expiry (set while `held`/`awaiting_payment`). -### `operator_settings` — per LNbits user (multi-tenant, m003) - -Every LNbits user may host rooms; what they decide for *all their rooms* lives -here, keyed by user id and created on first read: - -| Field | Meaning | -|---|---| -| `accept_fiat` | operator wants card payments. Only *offered* when LNbits core also has a fiat provider for this user (`settings.get_fiat_providers_for_user`) and the room is fiat-priced — chatelet never stores provider credentials (lnbits#67 plugs per-user Stripe creds into that same call) | -| `checkin_time`, `checkout_time`, `cancellation_policy` | house rules — shown to guests (`house_rules` on the public room view, `checkin_time`/`checkout_time` tags on the kind:30402 listing) and put in the check-in DM | - -Rooms resolve their operator via `get_wallet(room.wallet).user`. The old -house-rule columns on `settings` are kept for old rows but no longer read. - ### `blocks` — manual owner unavailability Maintenance, personal use, off-season. Half-open `[start_date, end_date)`. diff --git a/docs/event-flow.md b/docs/event-flow.md index 5dc1cbf..8fce44a 100644 --- a/docs/event-flow.md +++ b/docs/event-flow.md @@ -25,13 +25,10 @@ flow runs over relays with no HTTP: | `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_operator_get` / `_update` | wallet | the caller's per-operator settings (house rules, card acceptance) | -| `chatelet_room_list` / `_get` | none | public discovery (active rooms, wallet id stripped, owner's `house_rules` + `payment_methods` attached) | -| `chatelet_room_unavailable` | none | merged occupied/blocked spans over a window — the guest calendar feed (HTTP twin: `GET /api/v1/public/rooms/{id}/unavailable`) | +| `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`); `payment_method` `lightning` (default) or `fiat` + optional `fiat_provider` / `frontend_url` — card returns `fiat_payment_request` (hosted checkout URL) instead of a bolt11 | +| `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 diff --git a/frontend.py b/frontend.py deleted file mode 100644 index c1630f5..0000000 --- a/frontend.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Where to send a guest back to after a hosted (Stripe) checkout. - -Ported from the events extension. The calling app names itself via -`frontend_url`; we only honour origins the LNbits instance already trusts -(the CORS allow-list, its own base URL, the configured custom frontend), and -fail loud on anything else — a wrong root would strand the guest in the -wrong app after paying. Transport-agnostic: the HTTP door passes the request -base URL as fallback, the RPC door has none and falls back to the instance. -""" - -from urllib.parse import urlsplit - -from lnbits.settings import settings - - -def origin(url: str | None) -> str | None: - if not url: - return None - parts = urlsplit(url.strip()) - if not parts.scheme or not parts.netloc: - return None - return f"{parts.scheme.lower()}://{parts.netloc.lower()}" - - -def allowed_frontend_origins() -> set[str]: - origins: set[str] = set() - for candidate in [ - *getattr(settings, "lnbits_cors_allowed_origins", []), - settings.lnbits_baseurl, - getattr(settings, "lnbits_custom_frontend_url", None), - ]: - o = origin(candidate) - if o: - origins.add(o) - return origins - - -def resolve_frontend_root( - frontend_url: str | None, fallback_base_url: str | None -) -> str: - """Root under which `/chatelet/{room_id}` resolves for the guest.""" - if not frontend_url: - return (fallback_base_url or settings.lnbits_baseurl or "").rstrip("/") - o = origin(frontend_url) - if not o or o not in allowed_frontend_origins(): - raise ValueError("frontend_url origin is not allowed.") - return frontend_url.rstrip("/") diff --git a/migrations.py b/migrations.py index 26fbc9a..4a6cb37 100644 --- a/migrations.py +++ b/migrations.py @@ -123,27 +123,3 @@ async def m002_room_checkin_instructions(db): "ALTER TABLE chatelet.rooms ADD COLUMN checkin_instructions TEXT " "NOT NULL DEFAULT '';" ) - - -async def m003_operator_settings_and_guest_index(db): - """Chatelet is multi-tenant: every LNbits user may host rooms. What an - operator decides for *all their rooms* — house rules and whether they - take card payments — lives here, keyed by LNbits user id, created lazily. - The single `chatelet.settings` row keeps only instance-wide knobs; its - old house-rule columns stay in place but are no longer read. - - Also indexes bookings by guest so a guest can list their own stays.""" - await db.execute(f""" - CREATE TABLE chatelet.operator_settings ( - user_id TEXT PRIMARY KEY, - accept_fiat BOOLEAN NOT NULL DEFAULT false, - checkin_time TEXT NOT NULL DEFAULT '15:00', - checkout_time TEXT NOT NULL DEFAULT '11:00', - cancellation_policy TEXT NOT NULL DEFAULT '', - created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, - updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} - ); - """) - await db.execute( - "CREATE INDEX chatelet.idx_bookings_guest_pubkey ON bookings (guest_pubkey);" - ) diff --git a/models.py b/models.py index 0d49422..be6f22d 100644 --- a/models.py +++ b/models.py @@ -20,9 +20,8 @@ Design notes carried into the field definitions: import json from datetime import datetime, timezone from enum import Enum -from urllib.parse import urlsplit -from pydantic import BaseModel, Field, validator +from pydantic import BaseModel, Field def _now() -> datetime: @@ -66,28 +65,6 @@ OCCUPYING_STATUSES = { # --------------------------------------------------------------------------- -# Per-operator (LNbits user) choices that apply to all of that user's rooms. -HOUSE_RULE_FIELDS = ("checkin_time", "checkout_time", "cancellation_policy") - - -class UpdateOperatorSettings(BaseModel): - accept_fiat: bool = False - checkin_time: str = "15:00" - checkout_time: str = "11:00" - cancellation_policy: str = "" # shown to guests + in the check-in DM - - -class OperatorSettings(UpdateOperatorSettings): - """One row per operator user, created on first read. `accept_fiat` is the - operator's *wish*; whether card is actually offered also depends on LNbits - core having a fiat provider for that user (services.payment_methods_for_room) - — chatelet never holds provider credentials.""" - - user_id: str - created_at: datetime = Field(default_factory=_now) - updated_at: datetime = Field(default_factory=_now) - - class ChateletSettings(BaseModel): # LNbits account whose Nostr signer publishes listings/receipts on the # castle's behalf. Resolved via lnbits.core.signers.resolve_signer so @@ -97,11 +74,9 @@ class ChateletSettings(BaseModel): relays: list[str] = Field(default_factory=list) # where we publish/subscribe default_hold_minutes: int = 30 # how long a `held` booking survives unpaid deposit_percent: int = 100 # 100 = full prepay; <100 = deposit + balance - # Legacy (pre-m003): house rules are per operator now — see OperatorSettings. - # Columns kept so old rows load; nothing reads them. checkin_time: str = "15:00" checkout_time: str = "11:00" - cancellation_policy: str = "" + cancellation_policy: str = "" # free-form markdown, surfaced in listings/DMs publish_availability: bool = True # mirror blocked dates to a public NIP-52 calendar created_at: datetime = Field(default_factory=_now) updated_at: datetime = Field(default_factory=_now) @@ -168,27 +143,6 @@ class BookingRequestData(BaseModel): num_guests: int = 1 guest_contact: str | None = None # optional email/phone/nostr note message: str | None = None # free-form note to the host - # Rail the guest wants to pay with. "fiat" needs the room owner to accept - # card AND LNbits core to have a provider for them (services checks). - payment_method: str = "lightning" - fiat_provider: str | None = None # e.g. "stripe"; defaults to the owner's first - # Where the hosted checkout should send the guest back (the calling app); - # origin must be one the instance trusts — see frontend.resolve_frontend_root. - frontend_url: str | None = Field(default=None, max_length=512) - - @validator("frontend_url") - def validate_frontend_url(cls, v): # noqa: N805 - if v is None: - return None - v = v.strip() - if not v: - return None - parts = urlsplit(v) - if parts.scheme not in ("http", "https") or not parts.netloc: - raise ValueError("frontend_url must be an absolute http(s) URL") - if parts.query or parts.fragment or ".." in parts.path: - raise ValueError("frontend_url must not contain a query, fragment or '..'") - return v.rstrip("/") class Booking(BaseModel): @@ -241,27 +195,19 @@ class Block(BaseModel): # --------------------------------------------------------------------------- -def public_room_dict( - room: Room, - *, - house_rules: dict | None = None, - payment_methods: list[str] | None = None, -) -> dict: +def public_room_dict(room: Room) -> dict: """A Room as public JSON for guests — strips operator-private fields: the wallet id, and the check-in instructions (address/gate code, delivered only in the encrypted post-payment DM). Shared by the HTTP and Nostr-RPC guest - doors so neither can leak them. `house_rules` / `payment_methods` come from - the owner's OperatorSettings (services.public_room_view resolves them).""" + doors so neither can leak them.""" d = json.loads(room.json()) d.pop("wallet", None) d.pop("checkin_instructions", None) - if house_rules is not None: - d["house_rules"] = house_rules - if payment_methods is not None: - d["payment_methods"] = payment_methods return d +class AvailabilityQuery(BaseModel): + room_id: str def public_booking_dict(booking: "Booking") -> dict: """A Booking as public JSON for the guest who holds its id — lifecycle + money + dates only. Strips the guest's own identity/contact (so the id @@ -281,41 +227,8 @@ 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 - check_out: str # YYYY-MM-DD exclusive - - -class DateRange(BaseModel): - """Half-open [start, end) span of nights, YYYY-MM-DD. `end` is the morning - the room frees up — a guest may check in on that day.""" - - start: str - end: str - - -class UnavailableRanges(BaseModel): - """Everything a guest calendar needs to grey out nights for one room - over a window: bookings that still occupy the room (incl. live unpaid - holds, so this always agrees with `POST /availability`) and manual - blocks, merged into indistinguishable date spans — a guest can't tell a - block from another guest's stay.""" - - room_id: str - start: str - end: str - ranges: list[DateRange] = Field(default_factory=list) + check_in: str # YYYY-MM-DD inclusive + check_out: str # YYYY-MM-DD exclusive class AvailabilityResult(BaseModel): @@ -338,9 +251,5 @@ class BookingQuote(BaseModel): computes what they owe.""" booking: Booking - payment_request: str | None # bolt11 — None on the card rail + payment_request: str payment_hash: str - # Card rail: the provider's hosted checkout URL to send the guest to. - fiat_payment_request: str | None = None - fiat_provider: str | None = None - is_fiat: bool = False diff --git a/nostr/events.py b/nostr/events.py index b558f0a..b0c9d8e 100644 --- a/nostr/events.py +++ b/nostr/events.py @@ -18,30 +18,15 @@ from .kinds import ( ) -def build_listing_event( - room: Room, - *, - payment_methods: list[str] | None = None, - house_rules: dict | None = None, -) -> dict: +def build_listing_event(room: Room) -> dict: """NIP-99 kind:30402 classified listing for a room. Public, signed by - the operator's identity. `d` == room.id so re-publishing replaces. - - `payment_methods` (comma-joined, like events' tickets_payment_methods) and - the check-in/out times ride as tags so a generic Nostr client can render - the right pay buttons and house rules without speaking our RPC.""" + the operator's identity. `d` == room.id so re-publishing replaces.""" tags = [ ["d", room.id], ["title", room.title], ["price", str(room.price_amount), room.price_currency, room.price_frequency], ["status", "active"], ] - if payment_methods: - tags.append(["payment_methods", ",".join(payment_methods)]) - if house_rules: - for key in ("checkin_time", "checkout_time"): - if house_rules.get(key): - tags.append([key, str(house_rules[key])]) if room.location: tags.append(["location", room.location]) if room.geohash: diff --git a/nostr/service.py b/nostr/service.py index c1c1a51..fc17c75 100644 --- a/nostr/service.py +++ b/nostr/service.py @@ -160,17 +160,8 @@ def _checkin_message(booking, room, settings) -> str: async def publish_listing(room: Room) -> str | None: - """Publish/refresh a room's NIP-99 kind:30402 listing (public), carrying - the owner's rails + house rules so the event matches the API view.""" - owner = await services.room_owner_id(room) - ops = await crud.get_or_create_operator_settings(owner) - return await _sign_and_publish( - events.build_listing_event( - room, - payment_methods=services.payment_methods_for_room(room, owner, ops), - house_rules=services.house_rules_dict(ops), - ) - ) + """Publish/refresh a room's NIP-99 kind:30402 listing (public).""" + return await _sign_and_publish(events.build_listing_event(room)) async def publish_reservation(booking: Booking) -> str | None: diff --git a/services.py b/services.py index 2fc9662..7607fa8 100644 --- a/services.py +++ b/services.py @@ -14,32 +14,21 @@ backend failure and surfaces as a generic error over RPC (logged server-side). import asyncio from collections import defaultdict -from datetime import date, datetime, timedelta, timezone +from datetime import datetime, timedelta, timezone -from lnbits.core.crud.wallets import get_wallet -from lnbits.core.models.payments import CreateInvoice -from lnbits.core.services import create_payment_request +from lnbits.core.services import create_invoice from lnbits.exceptions import InvoiceError from lnbits.helpers import urlsafe_short_hash -from lnbits.settings import settings as lnbits_settings from lnbits.utils.exchange_rates import fiat_amount_as_satoshis from . import crud -from .frontend import resolve_frontend_root from .models import ( - HOUSE_RULE_FIELDS, AvailabilityResult, Booking, BookingQuote, BookingRequestData, BookingStatus, - DateRange, - OperatorSettings, - Room, RoomStatus, - UnavailableRanges, - guest_booking_dict, - public_room_dict, ) # Per-room lock serializing the availability read + the `held` write, so two @@ -70,137 +59,6 @@ async def to_sats(amount: float, currency: str) -> int: return await fiat_amount_as_satoshis(amount, currency) -# --------------------------------------------------------------------------- -# Operators + the public room view -# --------------------------------------------------------------------------- - -LIGHTNING = "lightning" -FIAT = "fiat" - - -def is_fiat_currency(currency: str) -> bool: - return currency.lower() not in ("sat", "sats") - - -def fiat_providers_for_user(user_id: str) -> list[str]: - """Fiat providers LNbits core will let this user charge with. The one - place chatelet consults core about card payments (lnbits#67's per-user - Stripe creds land behind this call); module-level so tests can patch it — - the pydantic settings object refuses monkeypatched methods.""" - return lnbits_settings.get_fiat_providers_for_user(user_id) - - -async def room_owner_id(room: Room) -> str: - """The LNbits user who operates a room (rooms belong to wallets).""" - wallet = await get_wallet(room.wallet) - if not wallet: - raise NotFound("Room's wallet not found") - return wallet.user - - -def payment_methods_for_room( - room: Room, owner_id: str, ops: OperatorSettings -) -> list[str]: - """Rails a guest may pay this room with. Card needs three things: the - operator opted in, LNbits core has a fiat provider for *that user* (the - single seam lnbits#67's per-user Stripe creds will plug into — chatelet - never sees credentials), and a fiat-denominated price (core cannot bill - a sat amount through a fiat provider).""" - rails = [LIGHTNING] - if ( - ops.accept_fiat - and is_fiat_currency(room.price_currency) - and fiat_providers_for_user(owner_id) - ): - rails.append(FIAT) - return rails - - -def house_rules_dict(ops: OperatorSettings) -> dict: - return {k: getattr(ops, k) for k in HOUSE_RULE_FIELDS} - - -async def public_room_view(room: Room) -> dict: - """public_room_dict + the owner's house rules and rails. Used by both - guest doors so a room looks the same over HTTP and RPC.""" - owner = await room_owner_id(room) - ops = await crud.get_or_create_operator_settings(owner) - return public_room_dict( - room, - house_rules=house_rules_dict(ops), - payment_methods=payment_methods_for_room(room, owner, ops), - ) - - -async def public_room_views(rooms: list[Room]) -> list[dict]: - """Batch form: one owner/settings lookup per distinct wallet.""" - owners: dict[str, str] = {} - ops_by_owner: dict[str, OperatorSettings] = {} - out = [] - for room in rooms: - if room.wallet not in owners: - owners[room.wallet] = await room_owner_id(room) - owner = owners[room.wallet] - if owner not in ops_by_owner: - ops_by_owner[owner] = await crud.get_or_create_operator_settings(owner) - ops = ops_by_owner[owner] - out.append( - public_room_dict( - room, - house_rules=house_rules_dict(ops), - payment_methods=payment_methods_for_room(room, owner, ops), - ) - ) - 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).""" - rows = await crud.get_bookings_for_guest(guest_pubkey) - return [guest_booking_dict(b) for b in rows] - - -# 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 -MAX_CALENDAR_DAYS = 400 - - -async def get_unavailable_ranges( - room_id: str, start: str | None = None, end: str | None = None -) -> UnavailableRanges: - """Occupied/blocked nights for one active room over [start, end). - Defaults to today → +365 days (UTC dates).""" - room = await crud.get_room(room_id) - if not room or room.status != RoomStatus.active: - raise NotFound("Room not available") - try: - d_start = ( - date.fromisoformat(start) if start else datetime.now(timezone.utc).date() - ) - d_end = ( - date.fromisoformat(end) - if end - else d_start + timedelta(days=DEFAULT_CALENDAR_DAYS) - ) - except ValueError as exc: - raise ValueError("Dates must be YYYY-MM-DD") from exc - if d_end <= d_start: - raise ValueError("end must be after start") - if (d_end - d_start).days > MAX_CALENDAR_DAYS: - raise ValueError(f"Window may span at most {MAX_CALENDAR_DAYS} days") - spans = await crud.get_occupied_ranges( - room_id, d_start.isoformat(), d_end.isoformat() - ) - return UnavailableRanges( - room_id=room_id, - start=d_start.isoformat(), - end=d_end.isoformat(), - ranges=[DateRange(start=s, end=e) for s, e in spans], - ) - - async def get_availability( room_id: str, check_in: str, check_out: str ) -> AvailabilityResult: @@ -227,35 +85,10 @@ async def get_availability( ) -async def _resolve_rail( - room: Room, data: BookingRequestData, base_url: str | None -) -> tuple[str | None, str]: - """(fiat provider or None for Lightning, frontend root for the return - URLs). Providers come from LNbits core for the room *owner* — the seam - lnbits#67's per-user Stripe creds will plug into.""" - method = (data.payment_method or LIGHTNING).lower() - if method not in (LIGHTNING, FIAT): - raise ValueError("Unknown payment method") - owner = await room_owner_id(room) - ops = await crud.get_or_create_operator_settings(owner) - if method not in payment_methods_for_room(room, owner, ops): - raise ValueError("Payment method not enabled for this room") - if method == LIGHTNING: - return None, "" - providers = fiat_providers_for_user(owner) - provider = data.fiat_provider or (providers[0] if providers else None) - if not provider or provider not in providers: - raise ValueError("No fiat payment provider configured") - return provider, resolve_frontend_root(data.frontend_url, base_url) - - -async def request_booking( - data: BookingRequestData, *, base_url: str | None = None -) -> BookingQuote: +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 + what confirms it: a bolt11, or on the - card rail the provider's hosted-checkout URL.""" + 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") @@ -266,10 +99,6 @@ async def request_booking( if data.num_guests > room.max_guests: raise ValueError(f"Max {room.max_guests} guests") - # Rail + provider resolution happens before the hold so a refused rail - # never leaves a dead hold behind. - provider, frontend_root = await _resolve_rail(room, data, base_url) - # Compute the canonical amount up front (FX call) so the lock below wraps # only the DB check + insert, never the slow network work. settings = await crud.get_or_create_settings() @@ -311,59 +140,30 @@ async def request_booking( raise Unavailable("Those dates are no longer available") await crud.create_booking(booking) - # One invoice call for both rails (core forks on fiat_provider). Lightning - # is sats-denominated (deposit_sat locked at quote time so FX drift can't - # change what's owed); card charges the same deposit share of the fiat - # price in the room's currency — core refuses sat units for fiat, which - # payment_methods_for_room already rules out. tag+booking_id let - # tasks.on_invoice_paid match the settlement back to this booking on - # either rail, since core settles Stripe onto the same invoice queue. - stay = f"{booking.check_in}→{booking.check_out} ({nights}n)" - memo = f"Chatelet · {room.title} · {stay}" - extra: dict = {"tag": "chatelet", "booking_id": booking.id} - invoice = CreateInvoice( - out=False, amount=booking.deposit_sat, unit="sat", memo=memo, extra=extra - ) - if provider: - back = f"{frontend_root}/chatelet/{room.id}" - extra["checkout"] = { - "success_url": f"{back}?checkout=success&booking={booking.id}", - "cancel_url": f"{back}?checkout=cancelled&booking={booking.id}", - "customer_email": ( - data.guest_contact - if data.guest_contact and "@" in data.guest_contact - else None - ), - "line_item_name": f"{room.title} · {stay}", - "metadata": {"booking_id": booking.id, "room_id": room.id}, - } - invoice = CreateInvoice( - out=False, - amount=round(price_fiat * settings.deposit_percent / 100, 2), - unit=room.price_currency, - fiat_provider=provider, - memo=memo, - extra=extra, - ) + # 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_payment_request( - wallet_id=room.wallet, invoice_data=invoice + 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, ValueError) as exc: + 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}") from exc + 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) - payment_extra = getattr(payment, "extra", None) or {} return BookingQuote( booking=booking, - payment_request=getattr(payment, "bolt11", None) or None, + payment_request=payment.bolt11, payment_hash=payment.payment_hash, - fiat_payment_request=payment_extra.get("fiat_payment_request"), - fiat_provider=getattr(payment, "fiat_provider", None) or provider, - is_fiat=provider is not None, ) diff --git a/static/js/index.js b/static/js/index.js index ac32887..e1867de 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -29,9 +29,6 @@ window.app = Vue.createApp({ settings: {}, settingsLoading: false, - operator: {}, - operatorLoading: false, - roomsColumns: [ {name: 'title', label: 'Room', field: 'title', align: 'left'}, { @@ -240,41 +237,11 @@ window.app = Vue.createApp({ } catch (err) { this._err(err, 'Could not save settings') } - }, - - // --- per-operator settings (house rules, card acceptance) --- - async getOperator() { - this.operatorLoading = true - try { - const {data} = await LNbits.api.request('GET', `${API}/operator`, this.adminkey) - this.operator = data - } catch (err) { - this._err(err, 'Could not load your settings') - } finally { - this.operatorLoading = false - } - }, - async saveOperator() { - this.operatorLoading = true - try { - const {accept_fiat, checkin_time, checkout_time, cancellation_policy} = this.operator - const {data} = await LNbits.api.request( - 'PUT', `${API}/operator`, this.adminkey, - {accept_fiat, checkin_time, checkout_time, cancellation_policy} - ) - this.operator = data - Quasar.Notify.create({type: 'positive', message: 'Your settings saved'}) - } catch (err) { - this._err(err, 'Could not save your settings') - } finally { - this.operatorLoading = false - } } }, created() { this.getRooms() this.getSettings() - this.getOperator() } }) diff --git a/tasks.py b/tasks.py index 3529182..3532299 100644 --- a/tasks.py +++ b/tasks.py @@ -12,7 +12,7 @@ from lnbits.core.models import Payment from lnbits.tasks import register_invoice_listener from loguru import logger -from . import crud, services +from . import crud from .models import BookingStatus from .nostr import service as nostr @@ -47,11 +47,9 @@ async def on_invoice_paid(payment: Payment): # Best-effort: a publish failure must not undo a confirmed, paid booking. try: room = await crud.get_room(booking.room_id) + settings = await crud.get_or_create_settings() if room: - # House rules are the room owner's, not the instance's. - owner = await services.room_owner_id(room) - ops = await crud.get_or_create_operator_settings(owner) - await nostr.send_checkin_dm(booking, room, ops) + await nostr.send_checkin_dm(booking, room, settings) except Exception as exc: # noqa: BLE001 logger.warning(f"chatelet: check-in DM failed for {booking.id} (continuing): {exc}") diff --git a/templates/chatelet/index.html b/templates/chatelet/index.html index ede4635..cb4b15f 100644 --- a/templates/chatelet/index.html +++ b/templates/chatelet/index.html @@ -171,6 +171,19 @@ +
+ +
+
+ +
+
+ +
@@ -180,48 +193,6 @@ :loading="settingsLoading">
- - - - -
Your rooms: house rules & payments
-
- Shown to guests on each of your rooms and sent in the check-in message. -
-
-
- -
-
- -
-
- -
-
- -
- - Via {{ (operator.available_fiat_providers || []).join(', ') }}. Guests can pay - a fiat-priced room by card; sat-priced rooms stay Lightning-only. - - - No fiat payment provider is enabled for your account — ask the - LNbits admin to enable one (e.g. Stripe) before turning this on. - -
-
-
- -
-
diff --git a/tests/conftest.py b/tests/conftest.py index c96c016..3fe5037 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -86,23 +86,3 @@ def make_request( check_out=check_out, num_guests=num_guests, ) - - -def patch_owner(monkeypatch, *, user_id="u1", accept_fiat=False, providers=()): - """Route the public room view away from the core DB: rooms belong to - `user_id`, whose operator settings are fresh defaults (+ accept_fiat) and - who has `providers` enabled in LNbits core.""" - from types import SimpleNamespace - - from .. import crud, services - from ..models import OperatorSettings - - async def get_wallet(_wallet_id): - return SimpleNamespace(user=user_id) - - async def ops(uid): - return OperatorSettings(user_id=uid, accept_fiat=accept_fiat) - - monkeypatch.setattr(services, "get_wallet", get_wallet) - monkeypatch.setattr(crud, "get_or_create_operator_settings", ops) - monkeypatch.setattr(services, "fiat_providers_for_user", lambda _uid: list(providers)) diff --git a/tests/test_atomic_hold.py b/tests/test_atomic_hold.py index 023f2aa..cf1ec0f 100644 --- a/tests/test_atomic_hold.py +++ b/tests/test_atomic_hold.py @@ -12,7 +12,7 @@ from types import SimpleNamespace from .. import crud, services from ..models import BookingQuote -from .conftest import make_request, make_room, patch_owner +from .conftest import make_request, make_room def _setup(monkeypatch, room): @@ -39,13 +39,10 @@ def _setup(monkeypatch, room): async def fake_update_booking(booking): return booking - async def fake_create_payment_request(*, wallet_id, invoice_data): - invoices.append(invoice_data) + async def fake_create_invoice(**kwargs): + invoices.append(kwargs) return SimpleNamespace( - payment_hash="ph_" + invoice_data.extra["booking_id"], - bolt11="lnbc_fake", - fiat_provider=None, - extra=invoice_data.extra, + payment_hash="ph_" + kwargs["extra"]["booking_id"], bolt11="lnbc_fake" ) monkeypatch.setattr(crud, "get_room", fake_get_room) @@ -53,10 +50,7 @@ def _setup(monkeypatch, room): monkeypatch.setattr(crud, "is_available", fake_is_available) monkeypatch.setattr(crud, "create_booking", fake_create_booking) monkeypatch.setattr(crud, "update_booking", fake_update_booking) - monkeypatch.setattr( - services, "create_payment_request", fake_create_payment_request - ) - patch_owner(monkeypatch) + monkeypatch.setattr(services, "create_invoice", fake_create_invoice) return held, invoices diff --git a/tests/test_availability_endpoint.py b/tests/test_availability_endpoint.py deleted file mode 100644 index 9f6cbc1..0000000 --- a/tests/test_availability_endpoint.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Guest availability endpoint. Regression guard for v0.4.0, where a bad merge -left AvailabilityQuery with only `room_id` and POST /availability 500'd on -`q.check_in` — the RPC door reads the raw body, so only HTTP was broken and no -test exercised it.""" - -import asyncio - -import pytest -from fastapi import HTTPException -from pydantic import ValidationError - -from .. import services, views_api -from ..models import AvailabilityQuery, AvailabilityResult - - -def test_availability_query_requires_both_dates(): - q = AvailabilityQuery(room_id="r", check_in="2026-10-01", check_out="2026-10-03") - assert (q.room_id, q.check_in, q.check_out) == ("r", "2026-10-01", "2026-10-03") - assert set(AvailabilityQuery.__fields__) == {"room_id", "check_in", "check_out"} - with pytest.raises(ValidationError): - AvailabilityQuery(room_id="r") # type: ignore[call-arg] - - -def test_availability_endpoint_forwards_all_three_fields(monkeypatch): - seen: list[tuple] = [] - - async def fake(room_id, check_in, check_out): - seen.append((room_id, check_in, check_out)) - return AvailabilityResult( - room_id=room_id, - check_in=check_in, - check_out=check_out, - available=True, - nights=2, - ) - - monkeypatch.setattr(services, "get_availability", fake) - q = AvailabilityQuery(room_id="r", check_in="2026-10-01", check_out="2026-10-03") - out = asyncio.run(views_api.api_check_availability(q)) - assert seen == [("r", "2026-10-01", "2026-10-03")] - assert out.available and out.nights == 2 - - -@pytest.mark.parametrize( - ("exc", "status"), - [(services.NotFound("Room not found"), 404), (ValueError("bad dates"), 400)], -) -def test_availability_endpoint_maps_service_errors(monkeypatch, exc, status): - async def fake(*_): - raise exc - - monkeypatch.setattr(services, "get_availability", fake) - q = AvailabilityQuery(room_id="r", check_in="2026-10-03", check_out="2026-10-01") - with pytest.raises(HTTPException) as e: - asyncio.run(views_api.api_check_availability(q)) - assert e.value.status_code == status diff --git a/tests/test_booking_flow.py b/tests/test_booking_flow.py index 032eaef..a8e0e59 100644 --- a/tests/test_booking_flow.py +++ b/tests/test_booking_flow.py @@ -9,16 +9,12 @@ from lnbits.exceptions import InvoiceError from .. import crud, services from ..models import BookingQuote, BookingStatus -from .conftest import make_request, make_room, patch_owner +from .conftest import make_request, make_room -def _setup( - monkeypatch, room, *, invoice_raises=False, accept_fiat=False, providers=() -): +def _setup(monkeypatch, room, *, invoice_raises=False): created: list = [] # bookings passed to create_booking updated: list = [] # bookings passed to update_booking (captures final state) - invoices: list = [] # CreateInvoice objects handed to core - patch_owner(monkeypatch, accept_fiat=accept_fiat, providers=providers) async def fake_get_room(_): return room @@ -40,36 +36,18 @@ def _setup( updated.append(booking) return booking - async def fake_create_payment_request(*, wallet_id, invoice_data): - invoices.append(invoice_data) + async def fake_create_invoice(**kwargs): if invoice_raises: raise InvoiceError("no funding source") - if invoice_data.fiat_provider: - return SimpleNamespace( - payment_hash="ph_1", - bolt11=None, - fiat_provider=invoice_data.fiat_provider, - extra={ - **invoice_data.extra, - "fiat_payment_request": "https://checkout.stripe.test/s/1", - }, - ) - return SimpleNamespace( - payment_hash="ph_1", - bolt11="lnbc_fake", - fiat_provider=None, - extra=invoice_data.extra, - ) + return SimpleNamespace(payment_hash="ph_1", bolt11="lnbc_fake") monkeypatch.setattr(crud, "get_room", fake_get_room) monkeypatch.setattr(crud, "get_or_create_settings", fake_settings) monkeypatch.setattr(crud, "is_available", fake_is_available) monkeypatch.setattr(crud, "create_booking", fake_create_booking) monkeypatch.setattr(crud, "update_booking", fake_update_booking) - monkeypatch.setattr( - services, "create_payment_request", fake_create_payment_request - ) - return created, updated, invoices + monkeypatch.setattr(services, "create_invoice", fake_create_invoice) + return created, updated def test_happy_path_holds_then_awaits_payment(monkeypatch): @@ -92,7 +70,7 @@ def test_min_nights_enforced(monkeypatch): def test_invoice_failure_releases_hold(monkeypatch): - created, updated, _ = _setup( + created, updated = _setup( monkeypatch, make_room(), invoice_raises=True ) with pytest.raises(services.BookingError): diff --git a/tests/test_fiat_checkout.py b/tests/test_fiat_checkout.py deleted file mode 100644 index ea84359..0000000 --- a/tests/test_fiat_checkout.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Card rail on request_booking: the operator opted in, core has a provider -for that owner, the room is fiat-priced — and the hosted checkout returns the -guest to the room with the booking id.""" - -import asyncio -from types import SimpleNamespace -from typing import Any - -import pytest -from lnbits.settings import settings - -from .. import crud, services -from ..models import BookingRequestData, BookingStatus -from .conftest import make_room, patch_owner - - -def _wire(monkeypatch, room, *, accept_fiat=True, providers=("stripe",), fail=False): - patch_owner(monkeypatch, accept_fiat=accept_fiat, providers=providers) - monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/") - monkeypatch.setattr( - settings, "lnbits_cors_allowed_origins", ["https://app.example"], raising=False - ) - invoices: list = [] - updated: list = [] - - async def gr(_): - return room - - async def gs(): - return SimpleNamespace(deposit_percent=50, default_hold_minutes=30) - - async def avail(*_): - return True - - async def create(b): - return b - - async def update(b): - updated.append(b) - return b - - async def fake_cpr(*, wallet_id, invoice_data): - invoices.append(invoice_data) - if fail: - raise ValueError("Cannot create payment request: provider down") - return SimpleNamespace( - payment_hash="ph_f", - bolt11=None, - fiat_provider=invoice_data.fiat_provider, - extra={ - **invoice_data.extra, - "fiat_payment_request": "https://checkout.stripe.test/s/1", - }, - ) - - async def rate(amount, currency): - return 150_000 # sats for the whole stay; irrelevant to the fiat charge - - monkeypatch.setattr(crud, "get_room", gr) - monkeypatch.setattr(crud, "get_or_create_settings", gs) - monkeypatch.setattr(crud, "is_available", avail) - monkeypatch.setattr(crud, "create_booking", create) - monkeypatch.setattr(crud, "update_booking", update) - monkeypatch.setattr(services, "create_payment_request", fake_cpr) - monkeypatch.setattr(services, "fiat_amount_as_satoshis", rate) - return invoices, updated - - -def _req(**over: Any) -> BookingRequestData: - base: dict[str, Any] = { - "room_id": "room1", - "guest_pubkey": "ab" * 32, - "check_in": "2026-11-01", - "check_out": "2026-11-03", - "guest_contact": "guest@example.com", - "payment_method": "fiat", - "frontend_url": "https://app.example/chatelet", - } - base.update(over) - return BookingRequestData(**base) - - -def test_card_happy_path_returns_checkout_url(monkeypatch): - room = make_room("room1", price=100.0, currency="EUR") - invoices, _ = _wire(monkeypatch, room) - quote = asyncio.run(services.request_booking(_req(), base_url="https://lnbits.example/")) - - assert quote.is_fiat and quote.fiat_provider == "stripe" - assert quote.payment_request is None - assert quote.fiat_payment_request == "https://checkout.stripe.test/s/1" - assert quote.booking.status == BookingStatus.awaiting_payment - - inv = invoices[0] - assert inv.fiat_provider == "stripe" and inv.unit == "EUR" - assert inv.amount == 100.0 # 2 nights x 100 EUR at deposit_percent 50 - assert inv.extra["tag"] == "chatelet" - assert inv.extra["booking_id"] == quote.booking.id - co = inv.extra["checkout"] - assert co["success_url"] == ( - "https://app.example/chatelet/chatelet/room1" - f"?checkout=success&booking={quote.booking.id}" - ) - assert co["cancel_url"].endswith(f"?checkout=cancelled&booking={quote.booking.id}") - assert co["customer_email"] == "guest@example.com" - assert co["metadata"] == {"booking_id": quote.booking.id, "room_id": "room1"} - - -def test_lightning_still_uses_sats_and_bolt11(monkeypatch): - room = make_room("room1", price=100.0, currency="EUR") - invoices, _ = _wire(monkeypatch, room) - - async def fake_cpr(*, wallet_id, invoice_data): - invoices.append(invoice_data) - return SimpleNamespace( - payment_hash="ph_l", bolt11="lnbc1", fiat_provider=None, extra={} - ) - - monkeypatch.setattr(services, "create_payment_request", fake_cpr) - quote = asyncio.run(services.request_booking(_req(payment_method="lightning"))) - assert not quote.is_fiat and quote.payment_request == "lnbc1" - assert invoices[0].unit == "sat" and invoices[0].fiat_provider is None - assert invoices[0].amount == 75_000 # deposit_percent 50 of 150k sats - - -@pytest.mark.parametrize( - ("kwargs", "message"), - [ - ({"accept_fiat": False}, "not enabled"), - ({"providers": ()}, "not enabled"), # no provider → rail not offered at all - ], -) -def test_card_refused_before_any_hold(monkeypatch, kwargs, message): - room = make_room("room1", price=100.0, currency="EUR") - invoices, updated = _wire(monkeypatch, room, **kwargs) - with pytest.raises(ValueError, match=message): - asyncio.run(services.request_booking(_req())) - assert invoices == [] and updated == [] # refused up front, nothing held - - -def test_sat_priced_room_cannot_take_card(monkeypatch): - room = make_room("room1", price=1000.0, currency="sat") - invoices, _ = _wire(monkeypatch, room) - with pytest.raises(ValueError, match="not enabled"): - asyncio.run(services.request_booking(_req())) - assert invoices == [] - - -def test_unlisted_frontend_is_rejected_before_hold(monkeypatch): - room = make_room("room1", price=100.0, currency="EUR") - invoices, updated = _wire(monkeypatch, room) - with pytest.raises(ValueError, match="frontend_url"): - asyncio.run(services.request_booking(_req(frontend_url="https://evil.example/x"))) - assert invoices == [] and updated == [] - - -def test_provider_failure_releases_hold(monkeypatch): - room = make_room("room1", price=100.0, currency="EUR") - _, updated = _wire(monkeypatch, room, fail=True) - with pytest.raises(services.BookingError): - asyncio.run(services.request_booking(_req())) - assert updated[-1].status == BookingStatus.declined diff --git a/tests/test_frontend_root.py b/tests/test_frontend_root.py deleted file mode 100644 index 15ac279..0000000 --- a/tests/test_frontend_root.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Hosted-checkout return root: only origins the instance trusts.""" - -import pytest -from lnbits.settings import settings - -from ..frontend import allowed_frontend_origins, resolve_frontend_root - - -@pytest.fixture -def lnbits_settings(monkeypatch): - monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/") - monkeypatch.setattr( - settings, "lnbits_cors_allowed_origins", ["https://app.example"], raising=False - ) - monkeypatch.setattr( - settings, - "lnbits_custom_frontend_url", - "https://Front.Example/login", - raising=False, - ) - - -def test_allowlist_collects_every_configured_origin(lnbits_settings): - assert allowed_frontend_origins() == { - "https://lnbits.example", - "https://app.example", - "https://front.example", - } - - -def test_absent_frontend_url_falls_back_to_the_request_host(lnbits_settings): - root = resolve_frontend_root(None, "https://lnbits.example/") - assert root == "https://lnbits.example" - - -def test_absent_frontend_url_and_no_request_uses_instance_base(lnbits_settings): - # The RPC door has no request host. - assert resolve_frontend_root(None, None) == "https://lnbits.example" - - -def test_allowed_origin_is_returned_without_trailing_slash(lnbits_settings): - out = resolve_frontend_root( - "https://app.example/chatelet/", "https://lnbits.example/" - ) - assert out == "https://app.example/chatelet" - - -def test_unlisted_origin_is_rejected_loudly(lnbits_settings): - with pytest.raises(ValueError, match="frontend_url"): - resolve_frontend_root("https://evil.example/x", "https://lnbits.example/") diff --git a/tests/test_my_bookings.py b/tests/test_my_bookings.py deleted file mode 100644 index 920cb46..0000000 --- a/tests/test_my_bookings.py +++ /dev/null @@ -1,92 +0,0 @@ -"""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 -from typing import Any - -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: Any) -> Booking: - base: dict[str, Any] = { - "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) - - -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)) diff --git a/tests/test_operator_settings.py b/tests/test_operator_settings.py deleted file mode 100644 index 6df0e25..0000000 --- a/tests/test_operator_settings.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Per-operator settings (multi-tenant): what a guest sees on a room and how -the rails are derived. Chatelet never decides *whether* a user may charge -card — it asks LNbits core per owner — only whether the operator wants to.""" - -import asyncio -from types import SimpleNamespace - -from .. import crud, services, transport_rpcs, views_api -from ..models import OperatorSettings, RoomStatus, UpdateOperatorSettings -from ..nostr import events -from .conftest import make_room, patch_owner - - -def _key(wallet_id="w1", user="u1"): - return SimpleNamespace(wallet=SimpleNamespace(id=wallet_id, user=user)) - - -def test_rails_need_flag_provider_and_fiat_price(monkeypatch): - room = make_room(price=100.0, currency="EUR") - on = OperatorSettings(user_id="u1", accept_fiat=True) - off = OperatorSettings(user_id="u1", accept_fiat=False) - lightning_only = ["lightning"] - - monkeypatch.setattr(services, "fiat_providers_for_user", lambda _u: ["stripe"]) - both = ["lightning", "fiat"] - assert services.payment_methods_for_room(room, "u1", on) == both - assert services.payment_methods_for_room(room, "u1", off) == lightning_only - sat_room = make_room(price=1000.0, currency="sat") - assert services.payment_methods_for_room(sat_room, "u1", on) == lightning_only - monkeypatch.setattr(services, "fiat_providers_for_user", lambda _u: []) - assert services.payment_methods_for_room(room, "u1", on) == lightning_only - - -def test_public_room_view_attaches_rules_and_rails(monkeypatch): - patch_owner(monkeypatch, accept_fiat=True, providers=["stripe"]) - room = make_room("a", status=RoomStatus.active, currency="EUR") - out = asyncio.run(services.public_room_view(room)) - assert out["payment_methods"] == ["lightning", "fiat"] - assert out["house_rules"] == { - "checkin_time": "15:00", - "checkout_time": "11:00", - "cancellation_policy": "", - } - assert "wallet" not in out and "checkin_instructions" not in out - - -def test_batch_view_looks_owner_up_once_per_wallet(monkeypatch): - calls: list[str] = [] - - async def get_wallet(wallet_id): - calls.append(wallet_id) - return SimpleNamespace(user="u1") - - async def ops(uid): - return OperatorSettings(user_id=uid) - - monkeypatch.setattr(services, "get_wallet", get_wallet) - monkeypatch.setattr(crud, "get_or_create_operator_settings", ops) - monkeypatch.setattr(services, "fiat_providers_for_user", lambda _u: []) - rooms = [ - make_room("a", wallet="w1"), - make_room("b", wallet="w1"), - make_room("c", wallet="w2"), - ] - out = asyncio.run(services.public_room_views(rooms)) - assert [r["id"] for r in out] == ["a", "b", "c"] - assert sorted(calls) == ["w1", "w2"] - - -def test_listing_event_carries_rails_and_times(): - room = make_room("a") - ev = events.build_listing_event( - room, - payment_methods=["lightning", "fiat"], - house_rules={ - "checkin_time": "16:00", - "checkout_time": "10:00", - "cancellation_policy": "x", - }, - ) - wanted = ("payment_methods", "checkin_time", "checkout_time") - tags = {t[0]: t[1:] for t in ev["tags"] if t[0] in wanted} - assert tags == { - "payment_methods": ["lightning,fiat"], - "checkin_time": ["16:00"], - "checkout_time": ["10:00"], - } - # long-form policy text stays off the listing tags - assert not any(t[0] == "cancellation_policy" for t in ev["tags"]) - - -def test_operator_endpoints_scope_to_calling_user(monkeypatch): - store: dict[str, OperatorSettings] = {} - - async def get_or_create(uid): - return store.setdefault(uid, OperatorSettings(user_id=uid)) - - async def update(ops): - store[ops.user_id] = ops - return ops - - async def no_rooms(): - return [] - - monkeypatch.setattr(crud, "get_or_create_operator_settings", get_or_create) - monkeypatch.setattr(crud, "update_operator_settings", update) - monkeypatch.setattr(crud, "get_rooms", no_rooms) - monkeypatch.setattr( - services, "fiat_providers_for_user", lambda u: ["stripe"] if u == "u1" else [] - ) - - first = asyncio.run(views_api.api_get_operator_settings(key=_key(user="u1"))) - assert first["user_id"] == "u1" and first["accept_fiat"] is False - assert first["available_fiat_providers"] == ["stripe"] - - updated = asyncio.run( - views_api.api_update_operator_settings( - UpdateOperatorSettings(accept_fiat=True, checkin_time="16:00"), - key=_key(user="u1"), - ) - ) - assert updated["accept_fiat"] is True and updated["checkin_time"] == "16:00" - - other = asyncio.run(views_api.api_get_operator_settings(key=_key(user="u2"))) - assert other["accept_fiat"] is False and other["available_fiat_providers"] == [] - - # RPC twin reads the same row for the same wallet user - rpc = asyncio.run( - transport_rpcs.handle_operator_get( - _key(user="u1"), - transport_rpcs.NostrRpcRequest( - rpc_name="chatelet_operator_get", request_id="r", body={} - ), - ) - ) - assert rpc["accept_fiat"] is True and rpc["checkin_time"] == "16:00" diff --git a/tests/test_public_endpoints.py b/tests/test_public_endpoints.py index 752e7a1..8150b9a 100644 --- a/tests/test_public_endpoints.py +++ b/tests/test_public_endpoints.py @@ -2,7 +2,6 @@ (privacy: check-in instructions must never reach a guest).""" import asyncio -from typing import Any import pytest from fastapi import HTTPException @@ -15,7 +14,7 @@ from ..models import ( public_booking_dict, public_room_dict, ) -from .conftest import make_room, patch_owner +from .conftest import make_room def test_public_room_dict_strips_private_fields(): @@ -37,13 +36,10 @@ def test_public_rooms_lists_active_only_and_stripped(monkeypatch): return [active, inactive] monkeypatch.setattr(crud, "get_rooms", gr) - patch_owner(monkeypatch) out = asyncio.run(views_api.api_public_rooms()) assert [r["id"] for r in out] == ["a"] # inactive hidden from guests assert "checkin_instructions" not in out[0] assert "wallet" not in out[0] - assert out[0]["house_rules"]["checkin_time"] == "15:00" - assert out[0]["payment_methods"] == ["lightning"] def test_public_room_404_when_inactive(monkeypatch): @@ -64,15 +60,14 @@ def test_public_room_returns_stripped_when_active(monkeypatch): return room monkeypatch.setattr(crud, "get_room", gr) - patch_owner(monkeypatch) out = asyncio.run(views_api.api_public_room("a")) assert out["id"] == "a" assert "checkin_instructions" not in out assert "wallet" not in out -def _booking(**overrides: Any) -> Booking: - base: dict[str, Any] = { +def _booking(**overrides) -> Booking: + base = { "id": "bk_1234567", "room_id": "a", "guest_pubkey": "ab" * 32, diff --git a/tests/test_unavailable_ranges.py b/tests/test_unavailable_ranges.py deleted file mode 100644 index c5d7203..0000000 --- a/tests/test_unavailable_ranges.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Guest calendar feed: merged, anonymous occupied spans for one room.""" - -import asyncio -from datetime import datetime, timedelta, timezone - -import pytest -from fastapi import HTTPException - -from .. import crud, services, transport_rpcs, views_api -from ..models import RoomStatus -from .conftest import make_room - -# --- merge_ranges (pure) --------------------------------------------------- - - -def test_merge_sorts_and_coalesces_overlap_and_adjacency(): - spans = [ - ("2026-10-10", "2026-10-12"), - ("2026-10-01", "2026-10-04"), - ("2026-10-04", "2026-10-06"), # adjacent to the first: one span - ("2026-10-11", "2026-10-15"), # overlaps the 10-12 stay - ] - out = crud.merge_ranges(spans, "2026-09-01", "2027-09-01") - assert out == [("2026-10-01", "2026-10-06"), ("2026-10-10", "2026-10-15")] - - -def test_merge_clips_to_window_and_drops_outside(): - spans = [ - ("2026-08-20", "2026-09-05"), # straddles the window start - ("2026-12-28", "2027-01-10"), # straddles the window end - ("2026-07-01", "2026-07-03"), # entirely before - ("2027-02-01", "2027-02-03"), # entirely after - ] - out = crud.merge_ranges(spans, "2026-09-01", "2027-01-01") - assert out == [("2026-09-01", "2026-09-05"), ("2026-12-28", "2027-01-01")] - - -def test_merge_empty(): - assert crud.merge_ranges([], "2026-09-01", "2027-01-01") == [] - - -# --- services.get_unavailable_ranges ---------------------------------------- - - -def _patch(monkeypatch, room, spans): - seen: dict = {} - - async def gr(_): - return room - - async def occupied(room_id, start, end): - seen.update(room_id=room_id, start=start, end=end) - return spans - - monkeypatch.setattr(crud, "get_room", gr) - monkeypatch.setattr(crud, "get_occupied_ranges", occupied) - return seen - - -def test_defaults_to_today_plus_365(monkeypatch): - seen = _patch(monkeypatch, make_room("a", status=RoomStatus.active), []) - out = asyncio.run(services.get_unavailable_ranges("a")) - today = datetime.now(timezone.utc).date() - assert out.start == today.isoformat() - assert out.end == (today + timedelta(days=365)).isoformat() - assert (seen["start"], seen["end"]) == (out.start, out.end) - assert out.ranges == [] - - -def test_explicit_window_and_shape(monkeypatch): - _patch( - monkeypatch, - make_room("a", status=RoomStatus.active), - [("2026-10-05", "2026-10-07")], - ) - out = asyncio.run(services.get_unavailable_ranges("a", "2026-10-01", "2026-11-01")) - assert (out.room_id, out.start, out.end) == ("a", "2026-10-01", "2026-11-01") - assert [(r.start, r.end) for r in out.ranges] == [("2026-10-05", "2026-10-07")] - - -@pytest.mark.parametrize( - ("start", "end"), - [ - ("2026-10-10", "2026-10-10"), # empty window - ("2026-10-10", "2026-10-01"), # reversed - ("2026-01-01", "2027-03-01"), # > 400 days - ("not-a-date", None), - ], -) -def test_window_validation(monkeypatch, start, end): - _patch(monkeypatch, make_room("a", status=RoomStatus.active), []) - with pytest.raises(ValueError): - asyncio.run(services.get_unavailable_ranges("a", start, end)) - - -def test_inactive_room_is_not_found(monkeypatch): - _patch(monkeypatch, make_room("a", status=RoomStatus.inactive), []) - with pytest.raises(services.NotFound): - asyncio.run(services.get_unavailable_ranges("a")) - - -# --- both doors ------------------------------------------------------------- - - -def test_http_and_rpc_doors_agree(monkeypatch): - _patch( - monkeypatch, - make_room("a", status=RoomStatus.active), - [("2026-10-05", "2026-10-07")], - ) - http = asyncio.run( - views_api.api_public_room_unavailable("a", "2026-10-01", "2026-11-01") - ) - rpc = asyncio.run( - transport_rpcs.handle_room_unavailable( - None, - transport_rpcs.NostrRpcRequest( - rpc_name="chatelet_room_unavailable", - request_id="req-1", - body={"room_id": "a", "start": "2026-10-01", "end": "2026-11-01"}, - sender_pubkey="ab" * 32, - ), - ) - ) - assert rpc == http.dict() - assert rpc["ranges"] == [{"start": "2026-10-05", "end": "2026-10-07"}] - - -def test_http_maps_errors(monkeypatch): - _patch(monkeypatch, make_room("a", status=RoomStatus.inactive), []) - with pytest.raises(HTTPException) as e: - asyncio.run(views_api.api_public_room_unavailable("a", None, None)) - assert e.value.status_code == 404 diff --git a/transport_rpcs.py b/transport_rpcs.py index b522520..d3e5cc3 100644 --- a/transport_rpcs.py +++ b/transport_rpcs.py @@ -30,13 +30,7 @@ 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, -) +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; @@ -105,46 +99,20 @@ async def handle_block_create(auth: WalletTypeInfo, request: NostrRpcRequest) -> 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) + """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 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) + return _public_room(room) async def handle_availability(auth: None, request: NostrRpcRequest) -> dict: @@ -172,23 +140,11 @@ async def handle_booking_request(auth: None, request: NostrRpcRequest) -> dict: 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: @@ -238,3 +194,11 @@ async def _require_owned_room(room_id: str, wallet_id: str): def _to_dict(obj) -> dict: return json.loads(obj.json()) + +def _public_room(room) -> dict: + # Shared with the HTTP door; strips wallet id AND checkin_instructions + # (the latter was leaking to guests before — added after this file's + # original public dict). + from .models import public_room_dict + + return public_room_dict(room) diff --git a/views_api.py b/views_api.py index 521267c..cc45519 100644 --- a/views_api.py +++ b/views_api.py @@ -7,9 +7,9 @@ endpoints (room/block CRUD, settings) are HTTP-only and back the admin UI; the guest-facing surface (availability, booking) is what also rides the RPC. """ -from fastapi import APIRouter, Depends, HTTPException, Query, Request -from lnbits.core.models import User, WalletTypeInfo -from lnbits.decorators import check_user_exists, require_admin_key, require_invoice_key +from fastapi import APIRouter, Depends, HTTPException +from lnbits.core.models import WalletTypeInfo +from lnbits.decorators import require_admin_key, require_invoice_key from . import crud, services from .models import ( @@ -22,12 +22,10 @@ from .models import ( ChateletSettings, CreateBlockData, CreateRoomData, - OperatorSettings, Room, RoomStatus, - UnavailableRanges, - UpdateOperatorSettings, public_booking_dict, + public_room_dict, ) from .nostr import service as nostr @@ -193,51 +191,17 @@ async def api_update_settings( return await crud.update_settings(settings) -# --- operator settings (per LNbits user; admin key → wallet → user) ---------- - - -def _with_providers(ops: OperatorSettings) -> dict: - """The row plus what core would actually let this user charge with, so - the admin UI can explain a card toggle that has no provider behind it.""" - d = ops.dict() - d["available_fiat_providers"] = services.fiat_providers_for_user(ops.user_id) - return d - - -@chatelet_api_router.get("/api/v1/operator") -async def api_get_operator_settings( - key: WalletTypeInfo = Depends(require_admin_key), -) -> dict: - ops = await crud.get_or_create_operator_settings(key.wallet.user) - return _with_providers(ops) - - -@chatelet_api_router.put("/api/v1/operator") -async def api_update_operator_settings( - data: UpdateOperatorSettings, key: WalletTypeInfo = Depends(require_admin_key) -) -> dict: - ops = await crud.get_or_create_operator_settings(key.wallet.user) - for field in UpdateOperatorSettings.__fields__: - setattr(ops, field, getattr(data, field)) - ops = await crud.update_operator_settings(ops) - # Rails/house rules ride on the public listing — refresh the owner's rooms. - for room in await crud.get_rooms(): - if room.status == RoomStatus.active: - owner = await services.room_owner_id(room) - if owner == ops.user_id: - await nostr.publish_listing(room) - return _with_providers(ops) - - # --- public guest discovery (no auth) -------------------------------------- @chatelet_api_router.get("/api/v1/public/rooms") async def api_public_rooms() -> list[dict]: - """Active rooms for guests — wallet + check-in instructions stripped, - owner's house rules + accepted rails attached.""" - rooms = [r for r in await crud.get_rooms() if r.status == RoomStatus.active] - return await services.public_room_views(rooms) + """Active rooms for guest browsing — operator-private fields stripped.""" + return [ + public_room_dict(r) + for r in await crud.get_rooms() + if r.status == RoomStatus.active + ] @chatelet_api_router.get("/api/v1/public/rooms/{room_id}") @@ -245,21 +209,7 @@ async def api_public_room(room_id: str) -> dict: room = await crud.get_room(room_id) if not room or room.status != RoomStatus.active: raise HTTPException(404, "Room not available") - return await services.public_room_view(room) - - -@chatelet_api_router.get("/api/v1/public/rooms/{room_id}/unavailable") -async def api_public_room_unavailable( - room_id: str, - start: str | None = Query(None, description="YYYY-MM-DD, default today"), - end: str | None = Query(None, description="YYYY-MM-DD exclusive, default +365d"), -) -> UnavailableRanges: - """Nights a guest cannot book, merged and anonymous — what a calendar - greys out. Keyless, like the room read it hangs off.""" - try: - return await services.get_unavailable_ranges(room_id, start, end) - except ValueError as exc: - raise _to_http(exc) from exc + return public_room_dict(room) # --- availability (public read) -------------------------------------------- @@ -277,29 +227,15 @@ 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, request: Request -) -> BookingQuote: +async def api_request_booking(data: BookingRequestData) -> BookingQuote: try: - return await services.request_booking( - data, base_url=str(request.base_url) - ) + 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/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)