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

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