diff --git a/__init__.py b/__init__.py
index 6d151fb..a8fa672 100644
--- a/__init__.py
+++ b/__init__.py
@@ -67,16 +67,12 @@ 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,
handle_room_list_mine,
handle_room_publish,
- handle_room_unavailable,
handle_room_update,
resolve_chatelet_owner,
)
@@ -87,16 +83,12 @@ 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)
- register_rpc("chatelet_room_unavailable", handle_room_unavailable, AUTH_NONE)
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 731995d..7ce0586 100644
--- a/config.json
+++ b/config.json
@@ -1,12 +1,12 @@
{
"id": "chatelet",
- "version": "0.5.0",
+ "version": "0.4.0",
"name": "Chatelet",
"repo": "https://git.atitlan.io/aiolabs/chatelet",
"short_description": "Nostr-native room rentals (Airbnb-style) for LNbits",
"description": "",
"tile": "/chatelet/static/image/aio.png",
- "min_lnbits_version": "1.4.1",
+ "min_lnbits_version": "1.4.0",
"contributors": [
{
"name": "padreug",
diff --git a/crud.py b/crud.py
index d863e11..8ba75dd 100644
--- a/crud.py
+++ b/crud.py
@@ -12,14 +12,12 @@ from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash
from .models import (
- OCCUPYING_STATUSES,
Block,
Booking,
BookingStatus,
ChateletSettings,
CreateBlockData,
CreateRoomData,
- OperatorSettings,
Room,
RoomStatus,
)
@@ -49,30 +47,6 @@ 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
# ---------------------------------------------------------------------------
@@ -139,18 +113,6 @@ 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",
@@ -226,9 +188,12 @@ async def is_available(room_id: str, check_in: str, check_out: str) -> bool:
return False
for b in await get_bookings_for_room(room_id):
- if b.status in OCCUPYING_STATUSES and _overlaps(
- check_in, check_out, b.check_in, b.check_out
- ):
+ if b.status in (
+ BookingStatus.held,
+ BookingStatus.awaiting_payment,
+ BookingStatus.confirmed,
+ BookingStatus.checked_in,
+ ) and _overlaps(check_in, check_out, b.check_in, b.check_out):
return False
for blk in await get_blocks_for_room(room_id):
@@ -238,54 +203,6 @@ async def is_available(room_id: str, check_in: str, check_out: str) -> bool:
return True
-def merge_ranges(
- ranges: list[tuple[str, str]], start: str, end: str
-) -> list[tuple[str, str]]:
- """Clip half-open [s, e) spans to [start, end), drop empties, sort, and
- coalesce overlapping *and adjacent* spans (a stay ending the day another
- begins becomes one span). Pure, so the calendar shape is unit-testable
- without a DB. Adjacent merging is deliberate: it hides where one
- occupant's dates stop and the next's begin."""
- clipped = sorted(
- (max(s, start), min(e, end)) for s, e in ranges if max(s, start) < min(e, end)
- )
- merged: list[tuple[str, str]] = []
- for s, e in clipped:
- if merged and s <= merged[-1][1]:
- merged[-1] = (merged[-1][0], max(merged[-1][1], e))
- else:
- merged.append((s, e))
- return merged
-
-
-async def get_occupied_ranges(
- room_id: str, start: str, end: str
-) -> list[tuple[str, str]]:
- """Occupying bookings + blocks that touch [start, end), as merged spans.
- Same statuses and the same half-open rule as `is_available`, so a night
- the calendar shows as free is one the arbiter will accept."""
- bookings = await db.fetchall(
- f"""
- SELECT * FROM chatelet.bookings
- WHERE room_id = :rid AND check_in < :end AND check_out > :start
- AND status IN ({", ".join(f"'{st.value}'" for st in OCCUPYING_STATUSES)})
- """,
- {"rid": room_id, "start": start, "end": end},
- Booking,
- )
- blocks = await db.fetchall(
- """
- SELECT * FROM chatelet.blocks
- WHERE room_id = :rid AND start_date < :end AND end_date > :start
- """,
- {"rid": room_id, "start": start, "end": end},
- Block,
- )
- spans = [(b.check_in, b.check_out) for b in bookings]
- spans += [(blk.start_date, blk.end_date) for blk in blocks]
- return merge_ranges(spans, start, end)
-
-
def nights_between(check_in: str, check_out: str) -> int:
d_in = date.fromisoformat(check_in)
d_out = date.fromisoformat(check_out)
diff --git a/docs/data-model.md b/docs/data-model.md
index e842fc5..b49b732 100644
--- a/docs/data-model.md
+++ b/docs/data-model.md
@@ -53,19 +53,6 @@ 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 5dc1cbf..8fce44a 100644
--- a/docs/event-flow.md
+++ b/docs/event-flow.md
@@ -25,13 +25,10 @@ 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_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_room_list` / `_get` | none | public discovery (active rooms, wallet id stripped) |
| `chatelet_availability` | none | is a range free + a quote |
-| `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_request` | none | guest requests a stay (guest id = signed `sender_pubkey`) |
| `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
deleted file mode 100644
index c1630f5..0000000
--- a/frontend.py
+++ /dev/null
@@ -1,47 +0,0 @@
-"""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 26fbc9a..4a6cb37 100644
--- a/migrations.py
+++ b/migrations.py
@@ -123,27 +123,3 @@ 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 0d49422..be6f22d 100644
--- a/models.py
+++ b/models.py
@@ -20,9 +20,8 @@ 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, validator
+from pydantic import BaseModel, Field
def _now() -> datetime:
@@ -66,28 +65,6 @@ 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
@@ -97,11 +74,9 @@ 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 = ""
+ cancellation_policy: str = "" # free-form markdown, surfaced in listings/DMs
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)
@@ -168,27 +143,6 @@ 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):
@@ -241,27 +195,19 @@ class Block(BaseModel):
# ---------------------------------------------------------------------------
-def public_room_dict(
- room: Room,
- *,
- house_rules: dict | None = None,
- payment_methods: list[str] | None = None,
-) -> dict:
+def public_room_dict(room: Room) -> 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. `house_rules` / `payment_methods` come from
- the owner's OperatorSettings (services.public_room_view resolves them)."""
+ doors so neither can leak 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
+class AvailabilityQuery(BaseModel):
+ room_id: str
def public_booking_dict(booking: "Booking") -> dict:
"""A Booking as public JSON for the guest who holds its id — lifecycle +
money + dates only. Strips the guest's own identity/contact (so the id
@@ -281,41 +227,8 @@ 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
- check_out: str # YYYY-MM-DD exclusive
-
-
-class DateRange(BaseModel):
- """Half-open [start, end) span of nights, YYYY-MM-DD. `end` is the morning
- the room frees up — a guest may check in on that day."""
-
- start: str
- end: str
-
-
-class UnavailableRanges(BaseModel):
- """Everything a guest calendar needs to grey out nights for one room
- over a window: bookings that still occupy the room (incl. live unpaid
- holds, so this always agrees with `POST /availability`) and manual
- blocks, merged into indistinguishable date spans — a guest can't tell a
- block from another guest's stay."""
-
- room_id: str
- start: str
- end: str
- ranges: list[DateRange] = Field(default_factory=list)
+ check_in: str # YYYY-MM-DD inclusive
+ check_out: str # YYYY-MM-DD exclusive
class AvailabilityResult(BaseModel):
@@ -338,9 +251,5 @@ class BookingQuote(BaseModel):
computes what they owe."""
booking: Booking
- payment_request: str | None # bolt11 — None on the card rail
+ payment_request: str
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 b558f0a..b0c9d8e 100644
--- a/nostr/events.py
+++ b/nostr/events.py
@@ -18,30 +18,15 @@ from .kinds import (
)
-def build_listing_event(
- room: Room,
- *,
- payment_methods: list[str] | None = None,
- house_rules: dict | None = None,
-) -> dict:
+def build_listing_event(room: Room) -> dict:
"""NIP-99 kind:30402 classified listing for a room. Public, signed by
- 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."""
+ the operator's identity. `d` == room.id so re-publishing replaces."""
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 c1c1a51..fc17c75 100644
--- a/nostr/service.py
+++ b/nostr/service.py
@@ -160,17 +160,8 @@ 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), 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),
- )
- )
+ """Publish/refresh a room's NIP-99 kind:30402 listing (public)."""
+ return await _sign_and_publish(events.build_listing_event(room))
async def publish_reservation(booking: Booking) -> str | None:
diff --git a/services.py b/services.py
index 2fc9662..7607fa8 100644
--- a/services.py
+++ b/services.py
@@ -14,32 +14,21 @@ backend failure and surfaces as a generic error over RPC (logged server-side).
import asyncio
from collections import defaultdict
-from datetime import date, datetime, timedelta, timezone
+from datetime import datetime, timedelta, timezone
-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.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 .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
@@ -70,137 +59,6 @@ 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
-MAX_CALENDAR_DAYS = 400
-
-
-async def get_unavailable_ranges(
- room_id: str, start: str | None = None, end: str | None = None
-) -> UnavailableRanges:
- """Occupied/blocked nights for one active room over [start, end).
- Defaults to today → +365 days (UTC dates)."""
- room = await crud.get_room(room_id)
- if not room or room.status != RoomStatus.active:
- raise NotFound("Room not available")
- try:
- d_start = (
- date.fromisoformat(start) if start else datetime.now(timezone.utc).date()
- )
- d_end = (
- date.fromisoformat(end)
- if end
- else d_start + timedelta(days=DEFAULT_CALENDAR_DAYS)
- )
- except ValueError as exc:
- raise ValueError("Dates must be YYYY-MM-DD") from exc
- if d_end <= d_start:
- raise ValueError("end must be after start")
- if (d_end - d_start).days > MAX_CALENDAR_DAYS:
- raise ValueError(f"Window may span at most {MAX_CALENDAR_DAYS} days")
- spans = await crud.get_occupied_ranges(
- room_id, d_start.isoformat(), d_end.isoformat()
- )
- return UnavailableRanges(
- room_id=room_id,
- start=d_start.isoformat(),
- end=d_end.isoformat(),
- ranges=[DateRange(start=s, end=e) for s, e in spans],
- )
-
-
async def get_availability(
room_id: str, check_in: str, check_out: str
) -> AvailabilityResult:
@@ -227,35 +85,10 @@ async def get_availability(
)
-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:
+async def request_booking(data: BookingRequestData) -> 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 + what confirms it: a bolt11, or on the
- card rail the provider's hosted-checkout URL."""
+ request. Returns the held booking + the bolt11 that will confirm it."""
room = await crud.get_room(data.room_id)
if not room or room.status != RoomStatus.active:
raise NotFound("Room not available")
@@ -266,10 +99,6 @@ async def request_booking(
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()
@@ -311,59 +140,30 @@ async def request_booking(
raise Unavailable("Those dates are no longer available")
await crud.create_booking(booking)
- # 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
- ),
- "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,
- )
+ # 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_payment_request(
- wallet_id=room.wallet, invoice_data=invoice
+ 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)"
+ ),
+ extra={"tag": "chatelet", "booking_id": booking.id},
)
- except (InvoiceError, ValueError) as exc:
+ except InvoiceError as exc:
booking.status = BookingStatus.declined # dead hold -> free the dates
await crud.update_booking(booking)
- raise BookingError(f"Could not create invoice: {exc}") from exc
+ raise BookingError(f"Could not create invoice: {exc.message}") 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=getattr(payment, "bolt11", None) or None,
+ payment_request=payment.bolt11,
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 ac32887..e1867de 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -29,9 +29,6 @@ window.app = Vue.createApp({
settings: {},
settingsLoading: false,
- operator: {},
- operatorLoading: false,
-
roomsColumns: [
{name: 'title', label: 'Room', field: 'title', align: 'left'},
{
@@ -240,41 +237,11 @@ 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 3529182..3532299 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, services
+from . import crud
from .models import BookingStatus
from .nostr import service as nostr
@@ -47,11 +47,9 @@ 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:
- # 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)
+ await nostr.send_checkin_dm(booking, room, settings)
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 ede4635..cb4b15f 100644
--- a/templates/chatelet/index.html
+++ b/templates/chatelet/index.html
@@ -171,6 +171,19 @@