From 9ab52f2c699129bd9764e8b9756a2fc8ba81252a Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 16 Sep 2026 12:16:14 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20per-operator=20settings=20=E2=80=94?= =?UTF-8?q?=20house=20rules=20+=20card=20acceptance=20(multi-tenant)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chatelet is multi-tenant: any LNbits user can host rooms. What an operator decides for all their rooms now lives in chatelet.operator_settings, keyed by user id and created lazily (m003, which also indexes bookings by guest): check-in/out times, cancellation policy, and accept_fiat. Guests see it: the public room view (both doors) gains house_rules and payment_methods, and the kind:30402 listing carries payment_methods, checkin_time and checkout_time tags so a generic Nostr client can render the right pay buttons and rules without our RPC. The check-in DM reads the room owner's rules instead of the instance row. Card is offered only when the operator opted in, the room is fiat-priced, and LNbits core has a fiat provider for that user — resolved through settings.get_fiat_providers_for_user(owner), the one seam lnbits#67's per-user Stripe credentials will plug into; chatelet never sees creds. Operator endpoints: GET/PUT /api/v1/operator (admin key → wallet user) and RPC twins chatelet_operator_get/update (AUTH_WALLET); saving re-publishes the owner's active listings. Admin UI moves the house-rule inputs into a per-operator card with the card toggle and a provider hint. The old house-rule columns on settings stay for old rows but are no longer read. Co-Authored-By: Claude Fable 5.1 --- __init__.py | 4 + crud.py | 25 ++++++ docs/data-model.md | 13 +++ docs/event-flow.md | 3 +- migrations.py | 24 ++++++ models.py | 40 +++++++++- nostr/events.py | 19 ++++- nostr/service.py | 13 ++- services.py | 90 +++++++++++++++++++++ static/js/index.js | 33 ++++++++ tasks.py | 8 +- templates/chatelet/index.html | 55 ++++++++++--- tests/conftest.py | 20 +++++ tests/test_operator_settings.py | 136 ++++++++++++++++++++++++++++++++ tests/test_public_endpoints.py | 11 ++- transport_rpcs.py | 40 +++++++--- views_api.py | 51 ++++++++++-- 17 files changed, 537 insertions(+), 48 deletions(-) create mode 100644 tests/test_operator_settings.py diff --git a/__init__.py b/__init__.py index b2e1139..61fe782 100644 --- a/__init__.py +++ b/__init__.py @@ -68,6 +68,8 @@ def chatelet_start(): handle_block_create, handle_booking_get, handle_booking_request, + handle_operator_get, + handle_operator_update, handle_room_create, handle_room_get, handle_room_list, @@ -84,6 +86,8 @@ 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) diff --git a/crud.py b/crud.py index 765d703..46e7ebf 100644 --- a/crud.py +++ b/crud.py @@ -19,6 +19,7 @@ from .models import ( ChateletSettings, CreateBlockData, CreateRoomData, + OperatorSettings, Room, RoomStatus, ) @@ -48,6 +49,30 @@ 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 # --------------------------------------------------------------------------- diff --git a/docs/data-model.md b/docs/data-model.md index b49b732..e842fc5 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -53,6 +53,19 @@ 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 8524ab4..124472f 100644 --- a/docs/event-flow.md +++ b/docs/event-flow.md @@ -25,7 +25,8 @@ 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_room_list` / `_get` | none | public discovery (active rooms, wallet id stripped) | +| `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_availability` | none | is a range free + a quote | | `chatelet_booking_request` | none | guest requests a stay (guest id = signed `sender_pubkey`) | diff --git a/migrations.py b/migrations.py index 4a6cb37..26fbc9a 100644 --- a/migrations.py +++ b/migrations.py @@ -123,3 +123,27 @@ 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 346b4e0..d243cca 100644 --- a/models.py +++ b/models.py @@ -65,6 +65,28 @@ 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 @@ -74,9 +96,11 @@ 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 = "" # free-form markdown, surfaced in listings/DMs + cancellation_policy: str = "" 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) @@ -195,14 +219,24 @@ class Block(BaseModel): # --------------------------------------------------------------------------- -def public_room_dict(room: Room) -> dict: +def public_room_dict( + room: Room, + *, + house_rules: dict | None = None, + payment_methods: list[str] | None = None, +) -> 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.""" + doors so neither can leak them. `house_rules` / `payment_methods` come from + the owner's OperatorSettings (services.public_room_view resolves 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 diff --git a/nostr/events.py b/nostr/events.py index b0c9d8e..b558f0a 100644 --- a/nostr/events.py +++ b/nostr/events.py @@ -18,15 +18,30 @@ from .kinds import ( ) -def build_listing_event(room: Room) -> dict: +def build_listing_event( + room: Room, + *, + payment_methods: list[str] | None = None, + house_rules: dict | None = None, +) -> dict: """NIP-99 kind:30402 classified listing for a room. Public, signed by - the operator's identity. `d` == room.id so re-publishing replaces.""" + 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.""" 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 fc17c75..c1c1a51 100644 --- a/nostr/service.py +++ b/nostr/service.py @@ -160,8 +160,17 @@ 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).""" - return await _sign_and_publish(events.build_listing_event(room)) + """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), + ) + ) async def publish_reservation(booking: Booking) -> str | None: diff --git a/services.py b/services.py index ea6e4f9..0502a7f 100644 --- a/services.py +++ b/services.py @@ -16,21 +16,27 @@ import asyncio from collections import defaultdict from datetime import date, datetime, timedelta, timezone +from lnbits.core.crud.wallets import get_wallet 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 .models import ( + HOUSE_RULE_FIELDS, AvailabilityResult, Booking, BookingQuote, BookingRequestData, BookingStatus, DateRange, + OperatorSettings, + Room, RoomStatus, UnavailableRanges, + public_room_dict, ) # Per-room lock serializing the availability read + the `held` write, so two @@ -61,6 +67,90 @@ 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 + + # 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 diff --git a/static/js/index.js b/static/js/index.js index e1867de..ac32887 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -29,6 +29,9 @@ window.app = Vue.createApp({ settings: {}, settingsLoading: false, + operator: {}, + operatorLoading: false, + roomsColumns: [ {name: 'title', label: 'Room', field: 'title', align: 'left'}, { @@ -237,11 +240,41 @@ 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 3532299..3529182 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 +from . import crud, services from .models import BookingStatus from .nostr import service as nostr @@ -47,9 +47,11 @@ 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: - await nostr.send_checkin_dm(booking, room, settings) + # 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) 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 cb4b15f..ede4635 100644 --- a/templates/chatelet/index.html +++ b/templates/chatelet/index.html @@ -171,19 +171,6 @@ -
- -
-
- -
-
- -
@@ -193,6 +180,48 @@ :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 3fe5037..c96c016 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -86,3 +86,23 @@ 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_operator_settings.py b/tests/test_operator_settings.py new file mode 100644 index 0000000..6df0e25 --- /dev/null +++ b/tests/test_operator_settings.py @@ -0,0 +1,136 @@ +"""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 8150b9a..752e7a1 100644 --- a/tests/test_public_endpoints.py +++ b/tests/test_public_endpoints.py @@ -2,6 +2,7 @@ (privacy: check-in instructions must never reach a guest).""" import asyncio +from typing import Any import pytest from fastapi import HTTPException @@ -14,7 +15,7 @@ from ..models import ( public_booking_dict, public_room_dict, ) -from .conftest import make_room +from .conftest import make_room, patch_owner def test_public_room_dict_strips_private_fields(): @@ -36,10 +37,13 @@ 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): @@ -60,14 +64,15 @@ 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) -> Booking: - base = { +def _booking(**overrides: Any) -> Booking: + base: dict[str, Any] = { "id": "bk_1234567", "room_id": "a", "guest_pubkey": "ab" * 32, diff --git a/transport_rpcs.py b/transport_rpcs.py index 71ee687..44785da 100644 --- a/transport_rpcs.py +++ b/transport_rpcs.py @@ -30,7 +30,13 @@ 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 +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; @@ -99,20 +105,36 @@ 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 (public discovery).""" - rooms = await crud.get_rooms() - return [_public_room(r) for r in rooms if r.status == RoomStatus.active] + """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 _public_room(room) + return await services.public_room_view(room) async def handle_room_unavailable(auth: None, request: NostrRpcRequest) -> dict: @@ -204,11 +226,3 @@ 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 2e306fd..2f7ee3e 100644 --- a/views_api.py +++ b/views_api.py @@ -22,11 +22,12 @@ from .models import ( ChateletSettings, CreateBlockData, CreateRoomData, + OperatorSettings, Room, RoomStatus, UnavailableRanges, + UpdateOperatorSettings, public_booking_dict, - public_room_dict, ) from .nostr import service as nostr @@ -192,17 +193,51 @@ 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 guest browsing — operator-private fields stripped.""" - return [ - public_room_dict(r) - for r in await crud.get_rooms() - if r.status == RoomStatus.active - ] + """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) @chatelet_api_router.get("/api/v1/public/rooms/{room_id}") @@ -210,7 +245,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 public_room_dict(room) + return await services.public_room_view(room) @chatelet_api_router.get("/api/v1/public/rooms/{room_id}/unavailable") From 8b816d83b0e6ef99a903dd838a0dfd60586fc4fc Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 16 Sep 2026 12:17:39 +0200 Subject: [PATCH 2/4] 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 --- __init__.py | 2 + crud.py | 12 ++++++ docs/event-flow.md | 1 + models.py | 10 +++++ services.py | 7 +++ tests/test_my_bookings.py | 89 +++++++++++++++++++++++++++++++++++++++ transport_rpcs.py | 7 +++ views_api.py | 14 +++++- 8 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 tests/test_my_bookings.py diff --git a/__init__.py b/__init__.py index 61fe782..6d151fb 100644 --- a/__init__.py +++ b/__init__.py @@ -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. diff --git a/crud.py b/crud.py index 46e7ebf..d863e11 100644 --- a/crud.py +++ b/crud.py @@ -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", diff --git a/docs/event-flow.md b/docs/event-flow.md index 124472f..c28b30c 100644 --- a/docs/event-flow.md +++ b/docs/event-flow.md @@ -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 diff --git a/models.py b/models.py index d243cca..7539436 100644 --- a/models.py +++ b/models.py @@ -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 diff --git a/services.py b/services.py index 0502a7f..3f23dfa 100644 --- a/services.py +++ b/services.py @@ -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 diff --git a/tests/test_my_bookings.py b/tests/test_my_bookings.py new file mode 100644 index 0000000..f76e37e --- /dev/null +++ b/tests/test_my_bookings.py @@ -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)) diff --git a/transport_rpcs.py b/transport_rpcs.py index 44785da..a6c755d 100644 --- a/transport_rpcs.py +++ b/transport_rpcs.py @@ -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: diff --git a/views_api.py b/views_api.py index 2f7ee3e..4682527 100644 --- a/views_api.py +++ b/views_api.py @@ -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) From 8557ce617e0f52df9670d335fcc1eb93229eeb46 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 16 Sep 2026 12:18:15 +0200 Subject: [PATCH 3/4] style: ruff nits in the my-bookings slice Co-Authored-By: Claude Fable 5.1 --- services.py | 3 ++- tests/test_my_bookings.py | 45 +++++++++++++++++++++------------------ 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/services.py b/services.py index 3f23dfa..d137a1c 100644 --- a/services.py +++ b/services.py @@ -155,7 +155,8 @@ async def public_room_views(rooms: list[Room]) -> list[dict]: 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)] + 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 diff --git a/tests/test_my_bookings.py b/tests/test_my_bookings.py index f76e37e..920cb46 100644 --- a/tests/test_my_bookings.py +++ b/tests/test_my_bookings.py @@ -4,6 +4,7 @@ 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 @@ -14,27 +15,27 @@ 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", - ) +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) # type: ignore[arg-type] + return Booking(**base) def _patch_store(monkeypatch, rows): @@ -69,7 +70,9 @@ def test_http_lists_only_the_callers_rows(monkeypatch): 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))) + asyncio.run( + views_api.api_my_bookings(user=SimpleNamespace(id="u1", pubkey=None)) + ) assert e.value.status_code == 409 From 0dad30b648a45f88329201c2a8d19c15adef42cb Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 16 Sep 2026 12:25:31 +0200 Subject: [PATCH 4/4] feat: card rail via LNbits fiat providers (Stripe), per operator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=, 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 --- config.json | 2 +- docs/event-flow.md | 2 +- frontend.py | 47 +++++++++++ models.py | 30 ++++++- services.py | 94 +++++++++++++++++---- tests/test_atomic_hold.py | 16 ++-- tests/test_booking_flow.py | 36 ++++++-- tests/test_fiat_checkout.py | 161 ++++++++++++++++++++++++++++++++++++ tests/test_frontend_root.py | 50 +++++++++++ transport_rpcs.py | 5 ++ views_api.py | 10 ++- 11 files changed, 417 insertions(+), 36 deletions(-) create mode 100644 frontend.py create mode 100644 tests/test_fiat_checkout.py create mode 100644 tests/test_frontend_root.py diff --git a/config.json b/config.json index 7ce0586..fe3f148 100644 --- a/config.json +++ b/config.json @@ -6,7 +6,7 @@ "short_description": "Nostr-native room rentals (Airbnb-style) for LNbits", "description": "", "tile": "/chatelet/static/image/aio.png", - "min_lnbits_version": "1.4.0", + "min_lnbits_version": "1.4.1", "contributors": [ { "name": "padreug", diff --git a/docs/event-flow.md b/docs/event-flow.md index c28b30c..5dc1cbf 100644 --- a/docs/event-flow.md +++ b/docs/event-flow.md @@ -29,7 +29,7 @@ flow runs over relays with no HTTP: | `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_availability` | none | is a range free + a quote | -| `chatelet_booking_request` | none | guest requests a stay (guest id = signed `sender_pubkey`) | +| `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_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) | diff --git a/frontend.py b/frontend.py new file mode 100644 index 0000000..c1630f5 --- /dev/null +++ b/frontend.py @@ -0,0 +1,47 @@ +"""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/models.py b/models.py index 7539436..0d49422 100644 --- a/models.py +++ b/models.py @@ -20,8 +20,9 @@ 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 +from pydantic import BaseModel, Field, validator def _now() -> datetime: @@ -167,6 +168,27 @@ 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): @@ -316,5 +338,9 @@ class BookingQuote(BaseModel): computes what they owe.""" booking: Booking - payment_request: str + payment_request: str | None # bolt11 — None on the card rail 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/services.py b/services.py index d137a1c..2fc9662 100644 --- a/services.py +++ b/services.py @@ -17,13 +17,15 @@ from collections import defaultdict from datetime import date, datetime, timedelta, timezone from lnbits.core.crud.wallets import get_wallet -from lnbits.core.services import create_invoice +from lnbits.core.models.payments import CreateInvoice +from lnbits.core.services import create_payment_request 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, @@ -225,10 +227,35 @@ async def get_availability( ) -async def request_booking(data: BookingRequestData) -> BookingQuote: +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: """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.""" + request. Returns the held booking + what confirms it: a bolt11, or on the + card rail the provider's hosted-checkout URL.""" room = await crud.get_room(data.room_id) if not room or room.status != RoomStatus.active: raise NotFound("Room not available") @@ -239,6 +266,10 @@ async def request_booking(data: BookingRequestData) -> BookingQuote: 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() @@ -280,30 +311,59 @@ async def request_booking(data: BookingRequestData) -> BookingQuote: raise Unavailable("Those dates are no longer available") 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)" + # 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 ), - extra={"tag": "chatelet", "booking_id": booking.id}, + "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, ) - except InvoiceError as exc: + try: + payment = await create_payment_request( + wallet_id=room.wallet, invoice_data=invoice + ) + except (InvoiceError, ValueError) 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 + raise BookingError(f"Could not create invoice: {exc}") 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=payment.bolt11, + payment_request=getattr(payment, "bolt11", None) or None, 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/tests/test_atomic_hold.py b/tests/test_atomic_hold.py index cf1ec0f..023f2aa 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 +from .conftest import make_request, make_room, patch_owner def _setup(monkeypatch, room): @@ -39,10 +39,13 @@ def _setup(monkeypatch, room): async def fake_update_booking(booking): return booking - async def fake_create_invoice(**kwargs): - invoices.append(kwargs) + async def fake_create_payment_request(*, wallet_id, invoice_data): + invoices.append(invoice_data) return SimpleNamespace( - payment_hash="ph_" + kwargs["extra"]["booking_id"], bolt11="lnbc_fake" + payment_hash="ph_" + invoice_data.extra["booking_id"], + bolt11="lnbc_fake", + fiat_provider=None, + extra=invoice_data.extra, ) monkeypatch.setattr(crud, "get_room", fake_get_room) @@ -50,7 +53,10 @@ 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_invoice", fake_create_invoice) + monkeypatch.setattr( + services, "create_payment_request", fake_create_payment_request + ) + patch_owner(monkeypatch) return held, invoices diff --git a/tests/test_booking_flow.py b/tests/test_booking_flow.py index a8e0e59..032eaef 100644 --- a/tests/test_booking_flow.py +++ b/tests/test_booking_flow.py @@ -9,12 +9,16 @@ from lnbits.exceptions import InvoiceError from .. import crud, services from ..models import BookingQuote, BookingStatus -from .conftest import make_request, make_room +from .conftest import make_request, make_room, patch_owner -def _setup(monkeypatch, room, *, invoice_raises=False): +def _setup( + monkeypatch, room, *, invoice_raises=False, accept_fiat=False, providers=() +): 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 @@ -36,18 +40,36 @@ def _setup(monkeypatch, room, *, invoice_raises=False): updated.append(booking) return booking - async def fake_create_invoice(**kwargs): + async def fake_create_payment_request(*, wallet_id, invoice_data): + invoices.append(invoice_data) if invoice_raises: raise InvoiceError("no funding source") - return SimpleNamespace(payment_hash="ph_1", bolt11="lnbc_fake") + 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, + ) 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_invoice", fake_create_invoice) - return created, updated + monkeypatch.setattr( + services, "create_payment_request", fake_create_payment_request + ) + return created, updated, invoices def test_happy_path_holds_then_awaits_payment(monkeypatch): @@ -70,7 +92,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 new file mode 100644 index 0000000..ea84359 --- /dev/null +++ b/tests/test_fiat_checkout.py @@ -0,0 +1,161 @@ +"""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 new file mode 100644 index 0000000..15ac279 --- /dev/null +++ b/tests/test_frontend_root.py @@ -0,0 +1,50 @@ +"""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/transport_rpcs.py b/transport_rpcs.py index a6c755d..b522520 100644 --- a/transport_rpcs.py +++ b/transport_rpcs.py @@ -172,7 +172,12 @@ 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) diff --git a/views_api.py b/views_api.py index 4682527..521267c 100644 --- a/views_api.py +++ b/views_api.py @@ -7,7 +7,7 @@ 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 +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 @@ -277,9 +277,13 @@ 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: +async def api_request_booking( + data: BookingRequestData, request: Request +) -> BookingQuote: try: - return await services.request_booking(data) + return await services.request_booking( + data, base_url=str(request.base_url) + ) except services.BookingError as exc: raise HTTPException(502, str(exc)) from exc except ValueError as exc: