diff --git a/__init__.py b/__init__.py
index b2e1139..6d151fb 100644
--- a/__init__.py
+++ b/__init__.py
@@ -67,7 +67,10 @@ def chatelet_start():
handle_availability,
handle_block_create,
handle_booking_get,
+ handle_booking_list_mine,
handle_booking_request,
+ handle_operator_get,
+ handle_operator_update,
handle_room_create,
handle_room_get,
handle_room_list,
@@ -84,6 +87,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)
@@ -91,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/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/crud.py b/crud.py
index 765d703..d863e11 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
# ---------------------------------------------------------------------------
@@ -114,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/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..5dc1cbf 100644
--- a/docs/event-flow.md
+++ b/docs/event-flow.md
@@ -25,11 +25,13 @@ 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`) |
+| `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) |
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/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/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..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:
@@ -65,6 +66,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 +97,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)
@@ -143,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):
@@ -195,14 +241,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
@@ -225,6 +281,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
@@ -272,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/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..2fc9662 100644
--- a/services.py
+++ b/services.py
@@ -16,21 +16,30 @@ import asyncio
from collections import defaultdict
from datetime import date, datetime, timedelta, timezone
-from lnbits.core.services import create_invoice
+from lnbits.core.crud.wallets import get_wallet
+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,
Booking,
BookingQuote,
BookingRequestData,
BookingStatus,
DateRange,
+ OperatorSettings,
+ Room,
RoomStatus,
UnavailableRanges,
+ guest_booking_dict,
+ public_room_dict,
)
# Per-room lock serializing the availability read + the `held` write, so two
@@ -61,6 +70,97 @@ 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
+
+
+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)."""
+ 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
# can't make us scan and ship an unbounded span.
DEFAULT_CALENDAR_DAYS = 365
@@ -127,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")
@@ -141,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()
@@ -182,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/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 @@