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 @@