feat: per-operator settings — house rules + card acceptance (multi-tenant)

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 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-09-16 12:16:14 +02:00
commit 9ab52f2c69
17 changed files with 531 additions and 42 deletions

View file

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