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:
parent
feaaef93e3
commit
9ab52f2c69
17 changed files with 531 additions and 42 deletions
|
|
@ -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))
|
||||
|
|
|
|||
136
tests/test_operator_settings.py
Normal file
136
tests/test_operator_settings.py
Normal 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"
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue