Compare commits

...

10 commits

Author SHA1 Message Date
34f989eedb Merge pull request 'feat: operator settings, guest booking list, card checkout' (#22) from feat/fiat-checkout into main
Reviewed-on: #22
2026-09-21 09:12:16 +00:00
f7e0d51fd6 chore(release): v0.5.0
#20 restores check_in/check_out on AvailabilityQuery (v0.4.0 shipped
the availability endpoint broken); #21 adds the keyless
/public/rooms/{id}/unavailable feed for the guest calendar.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 11:06:16 +02:00
b5b5141ea4 Merge pull request 'feat(api): keyless unavailable-ranges feed for the guest calendar' (#21) from feat/unavailable-ranges into main
Reviewed-on: #21
2026-09-21 09:05:33 +00:00
21aa6ea7c3 Merge pull request 'fix(models): restore check_in/check_out on AvailabilityQuery' (#20) from fix/availability-query-fields into main
Reviewed-on: #20
2026-09-21 09:03:31 +00:00
0dad30b648 feat: card rail via LNbits fiat providers (Stripe), per operator
A guest may pay a fiat-priced room by card when its owner has opted in and
LNbits core has a fiat provider for that user — resolved through
settings.get_fiat_providers_for_user(owner), the seam lnbits#67's per-user
Stripe Connect credentials will plug into; chatelet stores no credentials.

Both rails now go through create_payment_request: Lightning unchanged
(sats, deposit_sat), card charges the same deposit share of the fiat price
in the room's currency with extra.checkout parameterising the hosted
Stripe page — success/cancel return to {frontend}/chatelet/{room}?checkout=…
&booking=<id>, customer_email, line item, metadata. frontend_url is
allow-listed against the instance's trusted origins (ported from events)
and resolved before the hold so a refused rail never leaves a dead hold.
Core settles the Stripe webhook onto the same invoice queue, so
tasks.on_invoice_paid confirms card bookings unchanged.

BookingRequestData gains payment_method / fiat_provider / frontend_url;
BookingQuote gains fiat_payment_request / fiat_provider / is_fiat and a
nullable payment_request. RPC chatelet_booking_request passes the fields
through. min_lnbits_version → 1.4.1 (events' floor for these APIs).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 12:25:31 +02:00
8557ce617e style: ruff nits in the my-bookings slice
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 12:18:15 +02:00
8b816d83b0 feat(api): a guest's own bookings on both doors
GET /api/v1/bookings/mine (LNbits account auth; identity = the account's
Nostr pubkey, the same value the booking request carried) and RPC twin
chatelet_booking_list_mine (scoped by the signed sender_pubkey). Rows come
back newest check-in first via the m003 guest index, as guest_booking_dict:
the guest's own contact and counts, minus the Lightning/Nostr plumbing.
Declared ahead of /bookings/{booking_id} so 'mine' is not read as an id.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 12:17:39 +02:00
9ab52f2c69 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>
2026-09-16 12:16:14 +02:00
feaaef93e3 feat(api): keyless unavailable-ranges feed for the guest calendar
GET /api/v1/public/rooms/{id}/unavailable?start=&end= and its RPC twin
chatelet_room_unavailable return the nights a guest cannot book over a
window (default today → +365 d, capped at 400 d): occupying bookings —
live holds included, so the feed always agrees with POST /availability —
and manual blocks, clipped, sorted and coalesced into anonymous half-open
spans. Adjacent spans merge on purpose so a guest cannot tell where one
occupant's dates end and the next begin, nor a block from a stay.

is_available now uses the OCCUPYING_STATUSES constant it was inlining.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:45:51 +02:00
64607a00bc fix(models): restore check_in/check_out on AvailabilityQuery
v0.4.0's merge of public_booking_dict landed inside AvailabilityQuery and
left the two date fields orphaned after the helper's return, so
POST /api/v1/availability raised AttributeError on q.check_in (500) on
every install of that version. The RPC door reads the raw body and was
unaffected, which is why nothing noticed.

Put the helper next to public_room_dict and add a regression test that
constructs the query model and drives the endpoint through the
services layer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:42:43 +02:00
26 changed files with 1430 additions and 92 deletions

View file

@ -67,12 +67,16 @@ def chatelet_start():
handle_availability, handle_availability,
handle_block_create, handle_block_create,
handle_booking_get, handle_booking_get,
handle_booking_list_mine,
handle_booking_request, handle_booking_request,
handle_operator_get,
handle_operator_update,
handle_room_create, handle_room_create,
handle_room_get, handle_room_get,
handle_room_list, handle_room_list,
handle_room_list_mine, handle_room_list_mine,
handle_room_publish, handle_room_publish,
handle_room_unavailable,
handle_room_update, handle_room_update,
resolve_chatelet_owner, resolve_chatelet_owner,
) )
@ -83,12 +87,16 @@ def chatelet_start():
register_rpc("chatelet_room_publish", handle_room_publish, AUTH_WALLET) register_rpc("chatelet_room_publish", handle_room_publish, AUTH_WALLET)
register_rpc("chatelet_block_create", handle_block_create, 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_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) # public (discovery + guest booking)
register_rpc("chatelet_room_list", handle_room_list, AUTH_NONE) register_rpc("chatelet_room_list", handle_room_list, AUTH_NONE)
register_rpc("chatelet_room_get", handle_room_get, 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_availability", handle_availability, AUTH_NONE)
register_rpc("chatelet_booking_request", handle_booking_request, 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_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), # tasks.py stamps extra["booking_id"] on settlement (see on_invoice_paid),
# so override the default link_extra_key ("link") to match. # so override the default link_extra_key ("link") to match.

View file

@ -1,12 +1,12 @@
{ {
"id": "chatelet", "id": "chatelet",
"version": "0.4.0", "version": "0.5.0",
"name": "Chatelet", "name": "Chatelet",
"repo": "https://git.atitlan.io/aiolabs/chatelet", "repo": "https://git.atitlan.io/aiolabs/chatelet",
"short_description": "Nostr-native room rentals (Airbnb-style) for LNbits", "short_description": "Nostr-native room rentals (Airbnb-style) for LNbits",
"description": "", "description": "",
"tile": "/chatelet/static/image/aio.png", "tile": "/chatelet/static/image/aio.png",
"min_lnbits_version": "1.4.0", "min_lnbits_version": "1.4.1",
"contributors": [ "contributors": [
{ {
"name": "padreug", "name": "padreug",

95
crud.py
View file

@ -12,12 +12,14 @@ from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from .models import ( from .models import (
OCCUPYING_STATUSES,
Block, Block,
Booking, Booking,
BookingStatus, BookingStatus,
ChateletSettings, ChateletSettings,
CreateBlockData, CreateBlockData,
CreateRoomData, CreateRoomData,
OperatorSettings,
Room, Room,
RoomStatus, RoomStatus,
) )
@ -47,6 +49,30 @@ async def update_settings(settings: ChateletSettings) -> ChateletSettings:
return settings 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 # Rooms
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -113,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]: async def get_bookings_for_room(room_id: str) -> list[Booking]:
return await db.fetchall( return await db.fetchall(
"SELECT * FROM chatelet.bookings WHERE room_id = :rid", "SELECT * FROM chatelet.bookings WHERE room_id = :rid",
@ -188,12 +226,9 @@ async def is_available(room_id: str, check_in: str, check_out: str) -> bool:
return False return False
for b in await get_bookings_for_room(room_id): for b in await get_bookings_for_room(room_id):
if b.status in ( if b.status in OCCUPYING_STATUSES and _overlaps(
BookingStatus.held, check_in, check_out, b.check_in, b.check_out
BookingStatus.awaiting_payment, ):
BookingStatus.confirmed,
BookingStatus.checked_in,
) and _overlaps(check_in, check_out, b.check_in, b.check_out):
return False return False
for blk in await get_blocks_for_room(room_id): for blk in await get_blocks_for_room(room_id):
@ -203,6 +238,54 @@ async def is_available(room_id: str, check_in: str, check_out: str) -> bool:
return True 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: def nights_between(check_in: str, check_out: str) -> int:
d_in = date.fromisoformat(check_in) d_in = date.fromisoformat(check_in)
d_out = date.fromisoformat(check_out) d_out = date.fromisoformat(check_out)

View file

@ -53,6 +53,19 @@ replaces the same addressable event. Holds price (`amount`/`currency`/
published reservation object. published reservation object.
- **`expires_at`** — hold expiry (set while `held`/`awaiting_payment`). - **`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 ### `blocks` — manual owner unavailability
Maintenance, personal use, off-season. Half-open `[start_date, end_date)`. Maintenance, personal use, off-season. Half-open `[start_date, end_date)`.

View file

@ -25,10 +25,13 @@ flow runs over relays with no HTTP:
| `chatelet_room_create` / `_update` / `_publish` | wallet | operator room CRUD (ownership-checked) | | `chatelet_room_create` / `_update` / `_publish` | wallet | operator room CRUD (ownership-checked) |
| `chatelet_block_create` | wallet | operator blocks a range | | `chatelet_block_create` | wallet | operator blocks a range |
| `chatelet_room_list_mine` | account | operator's rooms across their wallets | | `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_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_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 Guest identity is the `sender_pubkey` the dispatcher lifts off the signed
kind-21000 event — unspoofable, and it means no separate `guest_pubkey` is kind-21000 event — unspoofable, and it means no separate `guest_pubkey` is

47
frontend.py Normal file
View file

@ -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("/")

View file

@ -123,3 +123,27 @@ async def m002_room_checkin_instructions(db):
"ALTER TABLE chatelet.rooms ADD COLUMN checkin_instructions TEXT " "ALTER TABLE chatelet.rooms ADD COLUMN checkin_instructions TEXT "
"NOT NULL DEFAULT '';" "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);"
)

109
models.py
View file

@ -20,8 +20,9 @@ Design notes carried into the field definitions:
import json import json
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum from enum import Enum
from urllib.parse import urlsplit
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, validator
def _now() -> datetime: 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): class ChateletSettings(BaseModel):
# LNbits account whose Nostr signer publishes listings/receipts on the # LNbits account whose Nostr signer publishes listings/receipts on the
# castle's behalf. Resolved via lnbits.core.signers.resolve_signer so # 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 relays: list[str] = Field(default_factory=list) # where we publish/subscribe
default_hold_minutes: int = 30 # how long a `held` booking survives unpaid default_hold_minutes: int = 30 # how long a `held` booking survives unpaid
deposit_percent: int = 100 # 100 = full prepay; <100 = deposit + balance 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" checkin_time: str = "15:00"
checkout_time: str = "11: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 publish_availability: bool = True # mirror blocked dates to a public NIP-52 calendar
created_at: datetime = Field(default_factory=_now) created_at: datetime = Field(default_factory=_now)
updated_at: datetime = Field(default_factory=_now) updated_at: datetime = Field(default_factory=_now)
@ -143,6 +168,27 @@ class BookingRequestData(BaseModel):
num_guests: int = 1 num_guests: int = 1
guest_contact: str | None = None # optional email/phone/nostr note guest_contact: str | None = None # optional email/phone/nostr note
message: str | None = None # free-form note to the host 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): class Booking(BaseModel):
@ -195,19 +241,27 @@ 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 """A Room as public JSON for guests — strips operator-private fields: the
wallet id, and the check-in instructions (address/gate code, delivered only 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 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 = json.loads(room.json())
d.pop("wallet", None) d.pop("wallet", None)
d.pop("checkin_instructions", 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 return d
class AvailabilityQuery(BaseModel):
room_id: str
def public_booking_dict(booking: "Booking") -> dict: def public_booking_dict(booking: "Booking") -> dict:
"""A Booking as public JSON for the guest who holds its id — lifecycle + """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 money + dates only. Strips the guest's own identity/contact (so the id
@ -227,8 +281,41 @@ def public_booking_dict(booking: "Booking") -> dict:
return d return d
check_in: str # YYYY-MM-DD inclusive def guest_booking_dict(booking: "Booking") -> dict:
check_out: str # YYYY-MM-DD exclusive """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)
class AvailabilityResult(BaseModel): class AvailabilityResult(BaseModel):
@ -251,5 +338,9 @@ class BookingQuote(BaseModel):
computes what they owe.""" computes what they owe."""
booking: Booking booking: Booking
payment_request: str payment_request: str | None # bolt11 — None on the card rail
payment_hash: 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

View file

@ -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 """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 = [ tags = [
["d", room.id], ["d", room.id],
["title", room.title], ["title", room.title],
["price", str(room.price_amount), room.price_currency, room.price_frequency], ["price", str(room.price_amount), room.price_currency, room.price_frequency],
["status", "active"], ["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: if room.location:
tags.append(["location", room.location]) tags.append(["location", room.location])
if room.geohash: if room.geohash:

View file

@ -160,8 +160,17 @@ def _checkin_message(booking, room, settings) -> str:
async def publish_listing(room: Room) -> str | None: async def publish_listing(room: Room) -> str | None:
"""Publish/refresh a room's NIP-99 kind:30402 listing (public).""" """Publish/refresh a room's NIP-99 kind:30402 listing (public), carrying
return await _sign_and_publish(events.build_listing_event(room)) 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: async def publish_reservation(booking: Booking) -> str | None:

View file

@ -14,21 +14,32 @@ backend failure and surfaces as a generic error over RPC (logged server-side).
import asyncio import asyncio
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta, timezone 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.exceptions import InvoiceError
from lnbits.helpers import urlsafe_short_hash 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 lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from . import crud from . import crud
from .frontend import resolve_frontend_root
from .models import ( from .models import (
HOUSE_RULE_FIELDS,
AvailabilityResult, AvailabilityResult,
Booking, Booking,
BookingQuote, BookingQuote,
BookingRequestData, BookingRequestData,
BookingStatus, BookingStatus,
DateRange,
OperatorSettings,
Room,
RoomStatus, RoomStatus,
UnavailableRanges,
guest_booking_dict,
public_room_dict,
) )
# Per-room lock serializing the availability read + the `held` write, so two # Per-room lock serializing the availability read + the `held` write, so two
@ -59,6 +70,137 @@ async def to_sats(amount: float, currency: str) -> int:
return await fiat_amount_as_satoshis(amount, currency) 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( async def get_availability(
room_id: str, check_in: str, check_out: str room_id: str, check_in: str, check_out: str
) -> AvailabilityResult: ) -> AvailabilityResult:
@ -85,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` """Check-then-hold, then invoice. The `is_available` read + the `held`
write are the lock; TODO(#4) makes that pair atomic against a concurrent 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) room = await crud.get_room(data.room_id)
if not room or room.status != RoomStatus.active: if not room or room.status != RoomStatus.active:
raise NotFound("Room not available") raise NotFound("Room not available")
@ -99,6 +266,10 @@ async def request_booking(data: BookingRequestData) -> BookingQuote:
if data.num_guests > room.max_guests: if data.num_guests > room.max_guests:
raise ValueError(f"Max {room.max_guests} 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 # Compute the canonical amount up front (FX call) so the lock below wraps
# only the DB check + insert, never the slow network work. # only the DB check + insert, never the slow network work.
settings = await crud.get_or_create_settings() settings = await crud.get_or_create_settings()
@ -140,30 +311,59 @@ async def request_booking(data: BookingRequestData) -> BookingQuote:
raise Unavailable("Those dates are no longer available") raise Unavailable("Those dates are no longer available")
await crud.create_booking(booking) await crud.create_booking(booking)
# Sats-denominated (deposit_sat locked at quote time) so FX drift before # One invoice call for both rails (core forks on fiat_provider). Lightning
# payment can't change what's owed. tag+booking_id let # is sats-denominated (deposit_sat locked at quote time so FX drift can't
# tasks.on_invoice_paid match the settlement back to this booking. # change what's owed); card charges the same deposit share of the fiat
try: # price in the room's currency — core refuses sat units for fiat, which
payment = await create_invoice( # payment_methods_for_room already rules out. tag+booking_id let
wallet_id=room.wallet, # tasks.on_invoice_paid match the settlement back to this booking on
amount=booking.deposit_sat, # either rail, since core settles Stripe onto the same invoice queue.
memo=( stay = f"{booking.check_in}→{booking.check_out} ({nights}n)"
f"Chatelet · {room.title} · " memo = f"Chatelet · {room.title} · {stay}"
f"{booking.check_in}→{booking.check_out} ({nights}n)" 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 booking.status = BookingStatus.declined # dead hold -> free the dates
await crud.update_booking(booking) 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.payment_hash = payment.payment_hash
booking.status = BookingStatus.awaiting_payment booking.status = BookingStatus.awaiting_payment
await crud.update_booking(booking) await crud.update_booking(booking)
payment_extra = getattr(payment, "extra", None) or {}
return BookingQuote( return BookingQuote(
booking=booking, booking=booking,
payment_request=payment.bolt11, payment_request=getattr(payment, "bolt11", None) or None,
payment_hash=payment.payment_hash, 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,
) )

View file

@ -29,6 +29,9 @@ window.app = Vue.createApp({
settings: {}, settings: {},
settingsLoading: false, settingsLoading: false,
operator: {},
operatorLoading: false,
roomsColumns: [ roomsColumns: [
{name: 'title', label: 'Room', field: 'title', align: 'left'}, {name: 'title', label: 'Room', field: 'title', align: 'left'},
{ {
@ -237,11 +240,41 @@ window.app = Vue.createApp({
} catch (err) { } catch (err) {
this._err(err, 'Could not save settings') 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() { created() {
this.getRooms() this.getRooms()
this.getSettings() this.getSettings()
this.getOperator()
} }
}) })

View file

@ -12,7 +12,7 @@ from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from loguru import logger from loguru import logger
from . import crud from . import crud, services
from .models import BookingStatus from .models import BookingStatus
from .nostr import service as nostr 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. # Best-effort: a publish failure must not undo a confirmed, paid booking.
try: try:
room = await crud.get_room(booking.room_id) room = await crud.get_room(booking.room_id)
settings = await crud.get_or_create_settings()
if room: 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 except Exception as exc: # noqa: BLE001
logger.warning(f"chatelet: check-in DM failed for {booking.id} (continuing): {exc}") logger.warning(f"chatelet: check-in DM failed for {booking.id} (continuing): {exc}")

View file

@ -171,19 +171,6 @@
<q-input outlined dense type="number" label="Deposit %" <q-input outlined dense type="number" label="Deposit %"
v-model.number="settings.deposit_percent"></q-input> v-model.number="settings.deposit_percent"></q-input>
</div> </div>
<div class="col-6 col-md-3">
<q-input outlined dense label="Check-in time"
v-model="settings.checkin_time"></q-input>
</div>
<div class="col-6 col-md-3">
<q-input outlined dense label="Check-out time"
v-model="settings.checkout_time"></q-input>
</div>
<div class="col-12">
<q-input outlined dense type="textarea" autogrow
label="Cancellation policy"
v-model="settings.cancellation_policy"></q-input>
</div>
<div class="col-12"> <div class="col-12">
<q-toggle v-model="settings.publish_availability" <q-toggle v-model="settings.publish_availability"
label="Publish blocked dates to a public calendar (kind:31923)"></q-toggle> label="Publish blocked dates to a public calendar (kind:31923)"></q-toggle>
@ -193,6 +180,48 @@
:loading="settingsLoading"></q-btn> :loading="settingsLoading"></q-btn>
</div> </div>
</div> </div>
<q-separator class="q-my-lg"></q-separator>
<!-- Per-operator: applies to every room owned by this account -->
<div class="text-h6 q-mb-xs">Your rooms: house rules &amp; payments</div>
<div class="text-caption text-grey q-mb-md">
Shown to guests on each of your rooms and sent in the check-in message.
</div>
<div class="row q-col-gutter-md" style="max-width: 760px">
<div class="col-6 col-md-3">
<q-input outlined dense label="Check-in time"
v-model="operator.checkin_time"></q-input>
</div>
<div class="col-6 col-md-3">
<q-input outlined dense label="Check-out time"
v-model="operator.checkout_time"></q-input>
</div>
<div class="col-12">
<q-input outlined dense type="textarea" autogrow
label="Cancellation policy"
v-model="operator.cancellation_policy"></q-input>
</div>
<div class="col-12">
<q-toggle v-model="operator.accept_fiat"
:disable="!(operator.available_fiat_providers || []).length"
label="Accept card payments for my rooms"></q-toggle>
<div class="text-caption text-grey">
<span v-if="(operator.available_fiat_providers || []).length">
Via {{ (operator.available_fiat_providers || []).join(', ') }}. Guests can pay
a fiat-priced room by card; sat-priced rooms stay Lightning-only.
</span>
<span v-else>
No fiat payment provider is enabled for your account — ask the
LNbits admin to enable one (e.g. Stripe) before turning this on.
</span>
</div>
</div>
<div class="col-12">
<q-btn color="primary" label="Save my settings" @click="saveOperator"
:loading="operatorLoading"></q-btn>
</div>
</div>
</q-tab-panel> </q-tab-panel>
</q-tab-panels> </q-tab-panels>

View file

@ -86,3 +86,23 @@ def make_request(
check_out=check_out, check_out=check_out,
num_guests=num_guests, 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))

View file

@ -12,7 +12,7 @@ from types import SimpleNamespace
from .. import crud, services from .. import crud, services
from ..models import BookingQuote from ..models import BookingQuote
from .conftest import make_request, make_room from .conftest import make_request, make_room, patch_owner
def _setup(monkeypatch, room): def _setup(monkeypatch, room):
@ -39,10 +39,13 @@ def _setup(monkeypatch, room):
async def fake_update_booking(booking): async def fake_update_booking(booking):
return booking return booking
async def fake_create_invoice(**kwargs): async def fake_create_payment_request(*, wallet_id, invoice_data):
invoices.append(kwargs) invoices.append(invoice_data)
return SimpleNamespace( return SimpleNamespace(
payment_hash="ph_" + kwargs["extra"]["booking_id"], bolt11="lnbc_fake" payment_hash="ph_" + invoice_data.extra["booking_id"],
bolt11="lnbc_fake",
fiat_provider=None,
extra=invoice_data.extra,
) )
monkeypatch.setattr(crud, "get_room", fake_get_room) monkeypatch.setattr(crud, "get_room", fake_get_room)
@ -50,7 +53,10 @@ def _setup(monkeypatch, room):
monkeypatch.setattr(crud, "is_available", fake_is_available) monkeypatch.setattr(crud, "is_available", fake_is_available)
monkeypatch.setattr(crud, "create_booking", fake_create_booking) monkeypatch.setattr(crud, "create_booking", fake_create_booking)
monkeypatch.setattr(crud, "update_booking", fake_update_booking) monkeypatch.setattr(crud, "update_booking", fake_update_booking)
monkeypatch.setattr(services, "create_invoice", fake_create_invoice) monkeypatch.setattr(
services, "create_payment_request", fake_create_payment_request
)
patch_owner(monkeypatch)
return held, invoices return held, invoices

View file

@ -0,0 +1,56 @@
"""Guest availability endpoint. Regression guard for v0.4.0, where a bad merge
left AvailabilityQuery with only `room_id` and POST /availability 500'd on
`q.check_in` — the RPC door reads the raw body, so only HTTP was broken and no
test exercised it."""
import asyncio
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from .. import services, views_api
from ..models import AvailabilityQuery, AvailabilityResult
def test_availability_query_requires_both_dates():
q = AvailabilityQuery(room_id="r", check_in="2026-10-01", check_out="2026-10-03")
assert (q.room_id, q.check_in, q.check_out) == ("r", "2026-10-01", "2026-10-03")
assert set(AvailabilityQuery.__fields__) == {"room_id", "check_in", "check_out"}
with pytest.raises(ValidationError):
AvailabilityQuery(room_id="r") # type: ignore[call-arg]
def test_availability_endpoint_forwards_all_three_fields(monkeypatch):
seen: list[tuple] = []
async def fake(room_id, check_in, check_out):
seen.append((room_id, check_in, check_out))
return AvailabilityResult(
room_id=room_id,
check_in=check_in,
check_out=check_out,
available=True,
nights=2,
)
monkeypatch.setattr(services, "get_availability", fake)
q = AvailabilityQuery(room_id="r", check_in="2026-10-01", check_out="2026-10-03")
out = asyncio.run(views_api.api_check_availability(q))
assert seen == [("r", "2026-10-01", "2026-10-03")]
assert out.available and out.nights == 2
@pytest.mark.parametrize(
("exc", "status"),
[(services.NotFound("Room not found"), 404), (ValueError("bad dates"), 400)],
)
def test_availability_endpoint_maps_service_errors(monkeypatch, exc, status):
async def fake(*_):
raise exc
monkeypatch.setattr(services, "get_availability", fake)
q = AvailabilityQuery(room_id="r", check_in="2026-10-03", check_out="2026-10-01")
with pytest.raises(HTTPException) as e:
asyncio.run(views_api.api_check_availability(q))
assert e.value.status_code == status

View file

@ -9,12 +9,16 @@ from lnbits.exceptions import InvoiceError
from .. import crud, services from .. import crud, services
from ..models import BookingQuote, BookingStatus from ..models import BookingQuote, BookingStatus
from .conftest import make_request, make_room from .conftest import make_request, make_room, patch_owner
def _setup(monkeypatch, room, *, invoice_raises=False): def _setup(
monkeypatch, room, *, invoice_raises=False, accept_fiat=False, providers=()
):
created: list = [] # bookings passed to create_booking created: list = [] # bookings passed to create_booking
updated: list = [] # bookings passed to update_booking (captures final state) updated: list = [] # bookings passed to update_booking (captures final state)
invoices: list = [] # CreateInvoice objects handed to core
patch_owner(monkeypatch, accept_fiat=accept_fiat, providers=providers)
async def fake_get_room(_): async def fake_get_room(_):
return room return room
@ -36,18 +40,36 @@ def _setup(monkeypatch, room, *, invoice_raises=False):
updated.append(booking) updated.append(booking)
return booking return booking
async def fake_create_invoice(**kwargs): async def fake_create_payment_request(*, wallet_id, invoice_data):
invoices.append(invoice_data)
if invoice_raises: if invoice_raises:
raise InvoiceError("no funding source") raise InvoiceError("no funding source")
return SimpleNamespace(payment_hash="ph_1", bolt11="lnbc_fake") if invoice_data.fiat_provider:
return SimpleNamespace(
payment_hash="ph_1",
bolt11=None,
fiat_provider=invoice_data.fiat_provider,
extra={
**invoice_data.extra,
"fiat_payment_request": "https://checkout.stripe.test/s/1",
},
)
return SimpleNamespace(
payment_hash="ph_1",
bolt11="lnbc_fake",
fiat_provider=None,
extra=invoice_data.extra,
)
monkeypatch.setattr(crud, "get_room", fake_get_room) monkeypatch.setattr(crud, "get_room", fake_get_room)
monkeypatch.setattr(crud, "get_or_create_settings", fake_settings) monkeypatch.setattr(crud, "get_or_create_settings", fake_settings)
monkeypatch.setattr(crud, "is_available", fake_is_available) monkeypatch.setattr(crud, "is_available", fake_is_available)
monkeypatch.setattr(crud, "create_booking", fake_create_booking) monkeypatch.setattr(crud, "create_booking", fake_create_booking)
monkeypatch.setattr(crud, "update_booking", fake_update_booking) monkeypatch.setattr(crud, "update_booking", fake_update_booking)
monkeypatch.setattr(services, "create_invoice", fake_create_invoice) monkeypatch.setattr(
return created, updated services, "create_payment_request", fake_create_payment_request
)
return created, updated, invoices
def test_happy_path_holds_then_awaits_payment(monkeypatch): def test_happy_path_holds_then_awaits_payment(monkeypatch):
@ -70,7 +92,7 @@ def test_min_nights_enforced(monkeypatch):
def test_invoice_failure_releases_hold(monkeypatch): def test_invoice_failure_releases_hold(monkeypatch):
created, updated = _setup( created, updated, _ = _setup(
monkeypatch, make_room(), invoice_raises=True monkeypatch, make_room(), invoice_raises=True
) )
with pytest.raises(services.BookingError): with pytest.raises(services.BookingError):

161
tests/test_fiat_checkout.py Normal file
View file

@ -0,0 +1,161 @@
"""Card rail on request_booking: the operator opted in, core has a provider
for that owner, the room is fiat-priced — and the hosted checkout returns the
guest to the room with the booking id."""
import asyncio
from types import SimpleNamespace
from typing import Any
import pytest
from lnbits.settings import settings
from .. import crud, services
from ..models import BookingRequestData, BookingStatus
from .conftest import make_room, patch_owner
def _wire(monkeypatch, room, *, accept_fiat=True, providers=("stripe",), fail=False):
patch_owner(monkeypatch, accept_fiat=accept_fiat, providers=providers)
monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/")
monkeypatch.setattr(
settings, "lnbits_cors_allowed_origins", ["https://app.example"], raising=False
)
invoices: list = []
updated: list = []
async def gr(_):
return room
async def gs():
return SimpleNamespace(deposit_percent=50, default_hold_minutes=30)
async def avail(*_):
return True
async def create(b):
return b
async def update(b):
updated.append(b)
return b
async def fake_cpr(*, wallet_id, invoice_data):
invoices.append(invoice_data)
if fail:
raise ValueError("Cannot create payment request: provider down")
return SimpleNamespace(
payment_hash="ph_f",
bolt11=None,
fiat_provider=invoice_data.fiat_provider,
extra={
**invoice_data.extra,
"fiat_payment_request": "https://checkout.stripe.test/s/1",
},
)
async def rate(amount, currency):
return 150_000 # sats for the whole stay; irrelevant to the fiat charge
monkeypatch.setattr(crud, "get_room", gr)
monkeypatch.setattr(crud, "get_or_create_settings", gs)
monkeypatch.setattr(crud, "is_available", avail)
monkeypatch.setattr(crud, "create_booking", create)
monkeypatch.setattr(crud, "update_booking", update)
monkeypatch.setattr(services, "create_payment_request", fake_cpr)
monkeypatch.setattr(services, "fiat_amount_as_satoshis", rate)
return invoices, updated
def _req(**over: Any) -> BookingRequestData:
base: dict[str, Any] = {
"room_id": "room1",
"guest_pubkey": "ab" * 32,
"check_in": "2026-11-01",
"check_out": "2026-11-03",
"guest_contact": "guest@example.com",
"payment_method": "fiat",
"frontend_url": "https://app.example/chatelet",
}
base.update(over)
return BookingRequestData(**base)
def test_card_happy_path_returns_checkout_url(monkeypatch):
room = make_room("room1", price=100.0, currency="EUR")
invoices, _ = _wire(monkeypatch, room)
quote = asyncio.run(services.request_booking(_req(), base_url="https://lnbits.example/"))
assert quote.is_fiat and quote.fiat_provider == "stripe"
assert quote.payment_request is None
assert quote.fiat_payment_request == "https://checkout.stripe.test/s/1"
assert quote.booking.status == BookingStatus.awaiting_payment
inv = invoices[0]
assert inv.fiat_provider == "stripe" and inv.unit == "EUR"
assert inv.amount == 100.0 # 2 nights x 100 EUR at deposit_percent 50
assert inv.extra["tag"] == "chatelet"
assert inv.extra["booking_id"] == quote.booking.id
co = inv.extra["checkout"]
assert co["success_url"] == (
"https://app.example/chatelet/chatelet/room1"
f"?checkout=success&booking={quote.booking.id}"
)
assert co["cancel_url"].endswith(f"?checkout=cancelled&booking={quote.booking.id}")
assert co["customer_email"] == "guest@example.com"
assert co["metadata"] == {"booking_id": quote.booking.id, "room_id": "room1"}
def test_lightning_still_uses_sats_and_bolt11(monkeypatch):
room = make_room("room1", price=100.0, currency="EUR")
invoices, _ = _wire(monkeypatch, room)
async def fake_cpr(*, wallet_id, invoice_data):
invoices.append(invoice_data)
return SimpleNamespace(
payment_hash="ph_l", bolt11="lnbc1", fiat_provider=None, extra={}
)
monkeypatch.setattr(services, "create_payment_request", fake_cpr)
quote = asyncio.run(services.request_booking(_req(payment_method="lightning")))
assert not quote.is_fiat and quote.payment_request == "lnbc1"
assert invoices[0].unit == "sat" and invoices[0].fiat_provider is None
assert invoices[0].amount == 75_000 # deposit_percent 50 of 150k sats
@pytest.mark.parametrize(
("kwargs", "message"),
[
({"accept_fiat": False}, "not enabled"),
({"providers": ()}, "not enabled"), # no provider → rail not offered at all
],
)
def test_card_refused_before_any_hold(monkeypatch, kwargs, message):
room = make_room("room1", price=100.0, currency="EUR")
invoices, updated = _wire(monkeypatch, room, **kwargs)
with pytest.raises(ValueError, match=message):
asyncio.run(services.request_booking(_req()))
assert invoices == [] and updated == [] # refused up front, nothing held
def test_sat_priced_room_cannot_take_card(monkeypatch):
room = make_room("room1", price=1000.0, currency="sat")
invoices, _ = _wire(monkeypatch, room)
with pytest.raises(ValueError, match="not enabled"):
asyncio.run(services.request_booking(_req()))
assert invoices == []
def test_unlisted_frontend_is_rejected_before_hold(monkeypatch):
room = make_room("room1", price=100.0, currency="EUR")
invoices, updated = _wire(monkeypatch, room)
with pytest.raises(ValueError, match="frontend_url"):
asyncio.run(services.request_booking(_req(frontend_url="https://evil.example/x")))
assert invoices == [] and updated == []
def test_provider_failure_releases_hold(monkeypatch):
room = make_room("room1", price=100.0, currency="EUR")
_, updated = _wire(monkeypatch, room, fail=True)
with pytest.raises(services.BookingError):
asyncio.run(services.request_booking(_req()))
assert updated[-1].status == BookingStatus.declined

View file

@ -0,0 +1,50 @@
"""Hosted-checkout return root: only origins the instance trusts."""
import pytest
from lnbits.settings import settings
from ..frontend import allowed_frontend_origins, resolve_frontend_root
@pytest.fixture
def lnbits_settings(monkeypatch):
monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/")
monkeypatch.setattr(
settings, "lnbits_cors_allowed_origins", ["https://app.example"], raising=False
)
monkeypatch.setattr(
settings,
"lnbits_custom_frontend_url",
"https://Front.Example/login",
raising=False,
)
def test_allowlist_collects_every_configured_origin(lnbits_settings):
assert allowed_frontend_origins() == {
"https://lnbits.example",
"https://app.example",
"https://front.example",
}
def test_absent_frontend_url_falls_back_to_the_request_host(lnbits_settings):
root = resolve_frontend_root(None, "https://lnbits.example/")
assert root == "https://lnbits.example"
def test_absent_frontend_url_and_no_request_uses_instance_base(lnbits_settings):
# The RPC door has no request host.
assert resolve_frontend_root(None, None) == "https://lnbits.example"
def test_allowed_origin_is_returned_without_trailing_slash(lnbits_settings):
out = resolve_frontend_root(
"https://app.example/chatelet/", "https://lnbits.example/"
)
assert out == "https://app.example/chatelet"
def test_unlisted_origin_is_rejected_loudly(lnbits_settings):
with pytest.raises(ValueError, match="frontend_url"):
resolve_frontend_root("https://evil.example/x", "https://lnbits.example/")

92
tests/test_my_bookings.py Normal file
View file

@ -0,0 +1,92 @@
"""A guest's own bookings, on both doors. HTTP identity is the LNbits account
pubkey; RPC identity is the signed sender_pubkey. Neither leaks another
guest's rows, and the Lightning/Nostr plumbing stays internal."""
import asyncio
from types import SimpleNamespace
from typing import Any
import pytest
from fastapi import HTTPException
from .. import crud, transport_rpcs, views_api
from ..models import Booking, BookingStatus, guest_booking_dict
PK = "ab" * 32
def _booking(i: int, **over: Any) -> Booking:
base: dict[str, Any] = {
"id": f"bk{i}",
"room_id": "a",
"guest_pubkey": PK,
"guest_contact": "me@example.com",
"check_in": f"2026-10-{10 + i:02d}",
"check_out": f"2026-10-{12 + i:02d}",
"nights": 2,
"num_guests": 1,
"currency": "EUR",
"price_fiat": 200.0,
"amount_sat": 300000,
"deposit_sat": 300000,
"status": BookingStatus.confirmed,
"payment_hash": f"ph{i}",
"request_event_id": "req",
"reservation_event_id": "res",
}
base.update(over)
return Booking(**base)
def _patch_store(monkeypatch, rows):
seen = {}
async def for_guest(pubkey, limit=200):
seen["pubkey"] = pubkey
return [b for b in rows if b.guest_pubkey == pubkey]
monkeypatch.setattr(crud, "get_bookings_for_guest", for_guest)
return seen
def test_guest_dict_keeps_own_contact_but_hides_plumbing():
d = guest_booking_dict(_booking(1))
assert d["guest_contact"] == "me@example.com"
assert d["guest_pubkey"] == PK
for hidden in ("payment_hash", "request_event_id", "reservation_event_id"):
assert hidden not in d
def test_http_lists_only_the_callers_rows(monkeypatch):
rows = [_booking(1), _booking(2, guest_pubkey="cd" * 32)]
seen = _patch_store(monkeypatch, rows)
user = SimpleNamespace(id="u1", pubkey=PK)
out = asyncio.run(views_api.api_my_bookings(user=user))
assert seen["pubkey"] == PK
assert [b["id"] for b in out] == ["bk1"]
assert "payment_hash" not in out[0]
def test_http_rejects_account_without_pubkey(monkeypatch):
_patch_store(monkeypatch, [])
with pytest.raises(HTTPException) as e:
asyncio.run(
views_api.api_my_bookings(user=SimpleNamespace(id="u1", pubkey=None))
)
assert e.value.status_code == 409
def test_rpc_scopes_by_sender_and_requires_it(monkeypatch):
rows = [_booking(1)]
_patch_store(monkeypatch, rows)
req = transport_rpcs.NostrRpcRequest(
rpc_name="chatelet_booking_list_mine", request_id="r", body={}, sender_pubkey=PK
)
out = asyncio.run(transport_rpcs.handle_booking_list_mine(None, req))
assert [b["id"] for b in out] == ["bk1"]
anon = transport_rpcs.NostrRpcRequest(
rpc_name="chatelet_booking_list_mine", request_id="r", body={}
)
with pytest.raises(PermissionError):
asyncio.run(transport_rpcs.handle_booking_list_mine(None, anon))

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"

View file

@ -2,6 +2,7 @@
(privacy: check-in instructions must never reach a guest).""" (privacy: check-in instructions must never reach a guest)."""
import asyncio import asyncio
from typing import Any
import pytest import pytest
from fastapi import HTTPException from fastapi import HTTPException
@ -14,7 +15,7 @@ from ..models import (
public_booking_dict, public_booking_dict,
public_room_dict, public_room_dict,
) )
from .conftest import make_room from .conftest import make_room, patch_owner
def test_public_room_dict_strips_private_fields(): 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] return [active, inactive]
monkeypatch.setattr(crud, "get_rooms", gr) monkeypatch.setattr(crud, "get_rooms", gr)
patch_owner(monkeypatch)
out = asyncio.run(views_api.api_public_rooms()) out = asyncio.run(views_api.api_public_rooms())
assert [r["id"] for r in out] == ["a"] # inactive hidden from guests assert [r["id"] for r in out] == ["a"] # inactive hidden from guests
assert "checkin_instructions" not in out[0] assert "checkin_instructions" not in out[0]
assert "wallet" 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): def test_public_room_404_when_inactive(monkeypatch):
@ -60,14 +64,15 @@ def test_public_room_returns_stripped_when_active(monkeypatch):
return room return room
monkeypatch.setattr(crud, "get_room", gr) monkeypatch.setattr(crud, "get_room", gr)
patch_owner(monkeypatch)
out = asyncio.run(views_api.api_public_room("a")) out = asyncio.run(views_api.api_public_room("a"))
assert out["id"] == "a" assert out["id"] == "a"
assert "checkin_instructions" not in out assert "checkin_instructions" not in out
assert "wallet" not in out assert "wallet" not in out
def _booking(**overrides) -> Booking: def _booking(**overrides: Any) -> Booking:
base = { base: dict[str, Any] = {
"id": "bk_1234567", "id": "bk_1234567",
"room_id": "a", "room_id": "a",
"guest_pubkey": "ab" * 32, "guest_pubkey": "ab" * 32,

View file

@ -0,0 +1,133 @@
"""Guest calendar feed: merged, anonymous occupied spans for one room."""
import asyncio
from datetime import datetime, timedelta, timezone
import pytest
from fastapi import HTTPException
from .. import crud, services, transport_rpcs, views_api
from ..models import RoomStatus
from .conftest import make_room
# --- merge_ranges (pure) ---------------------------------------------------
def test_merge_sorts_and_coalesces_overlap_and_adjacency():
spans = [
("2026-10-10", "2026-10-12"),
("2026-10-01", "2026-10-04"),
("2026-10-04", "2026-10-06"), # adjacent to the first: one span
("2026-10-11", "2026-10-15"), # overlaps the 10-12 stay
]
out = crud.merge_ranges(spans, "2026-09-01", "2027-09-01")
assert out == [("2026-10-01", "2026-10-06"), ("2026-10-10", "2026-10-15")]
def test_merge_clips_to_window_and_drops_outside():
spans = [
("2026-08-20", "2026-09-05"), # straddles the window start
("2026-12-28", "2027-01-10"), # straddles the window end
("2026-07-01", "2026-07-03"), # entirely before
("2027-02-01", "2027-02-03"), # entirely after
]
out = crud.merge_ranges(spans, "2026-09-01", "2027-01-01")
assert out == [("2026-09-01", "2026-09-05"), ("2026-12-28", "2027-01-01")]
def test_merge_empty():
assert crud.merge_ranges([], "2026-09-01", "2027-01-01") == []
# --- services.get_unavailable_ranges ----------------------------------------
def _patch(monkeypatch, room, spans):
seen: dict = {}
async def gr(_):
return room
async def occupied(room_id, start, end):
seen.update(room_id=room_id, start=start, end=end)
return spans
monkeypatch.setattr(crud, "get_room", gr)
monkeypatch.setattr(crud, "get_occupied_ranges", occupied)
return seen
def test_defaults_to_today_plus_365(monkeypatch):
seen = _patch(monkeypatch, make_room("a", status=RoomStatus.active), [])
out = asyncio.run(services.get_unavailable_ranges("a"))
today = datetime.now(timezone.utc).date()
assert out.start == today.isoformat()
assert out.end == (today + timedelta(days=365)).isoformat()
assert (seen["start"], seen["end"]) == (out.start, out.end)
assert out.ranges == []
def test_explicit_window_and_shape(monkeypatch):
_patch(
monkeypatch,
make_room("a", status=RoomStatus.active),
[("2026-10-05", "2026-10-07")],
)
out = asyncio.run(services.get_unavailable_ranges("a", "2026-10-01", "2026-11-01"))
assert (out.room_id, out.start, out.end) == ("a", "2026-10-01", "2026-11-01")
assert [(r.start, r.end) for r in out.ranges] == [("2026-10-05", "2026-10-07")]
@pytest.mark.parametrize(
("start", "end"),
[
("2026-10-10", "2026-10-10"), # empty window
("2026-10-10", "2026-10-01"), # reversed
("2026-01-01", "2027-03-01"), # > 400 days
("not-a-date", None),
],
)
def test_window_validation(monkeypatch, start, end):
_patch(monkeypatch, make_room("a", status=RoomStatus.active), [])
with pytest.raises(ValueError):
asyncio.run(services.get_unavailable_ranges("a", start, end))
def test_inactive_room_is_not_found(monkeypatch):
_patch(monkeypatch, make_room("a", status=RoomStatus.inactive), [])
with pytest.raises(services.NotFound):
asyncio.run(services.get_unavailable_ranges("a"))
# --- both doors -------------------------------------------------------------
def test_http_and_rpc_doors_agree(monkeypatch):
_patch(
monkeypatch,
make_room("a", status=RoomStatus.active),
[("2026-10-05", "2026-10-07")],
)
http = asyncio.run(
views_api.api_public_room_unavailable("a", "2026-10-01", "2026-11-01")
)
rpc = asyncio.run(
transport_rpcs.handle_room_unavailable(
None,
transport_rpcs.NostrRpcRequest(
rpc_name="chatelet_room_unavailable",
request_id="req-1",
body={"room_id": "a", "start": "2026-10-01", "end": "2026-11-01"},
sender_pubkey="ab" * 32,
),
)
)
assert rpc == http.dict()
assert rpc["ranges"] == [{"start": "2026-10-05", "end": "2026-10-07"}]
def test_http_maps_errors(monkeypatch):
_patch(monkeypatch, make_room("a", status=RoomStatus.inactive), [])
with pytest.raises(HTTPException) as e:
asyncio.run(views_api.api_public_room_unavailable("a", None, None))
assert e.value.status_code == 404

View file

@ -30,7 +30,13 @@ from lnbits.core.models.wallets import WalletTypeInfo
from lnbits.core.services.nostr_transport.models import NostrRpcRequest from lnbits.core.services.nostr_transport.models import NostrRpcRequest
from . import crud, services from . import crud, services
from .models import BookingRequestData, CreateBlockData, CreateRoomData, RoomStatus from .models import (
BookingRequestData,
CreateBlockData,
CreateRoomData,
RoomStatus,
UpdateOperatorSettings,
)
# Fields a client may patch on a room via chatelet_room_update. Identity / # Fields a client may patch on a room via chatelet_room_update. Identity /
# counter fields (id, wallet, listing_event_id, created_at) are not mutable; # counter fields (id, wallet, listing_event_id, created_at) are not mutable;
@ -99,20 +105,46 @@ async def handle_block_create(auth: WalletTypeInfo, request: NostrRpcRequest) ->
return _to_dict(block) return _to_dict(block)
async def handle_operator_get(auth: WalletTypeInfo, request: NostrRpcRequest) -> dict:
ops = await crud.get_or_create_operator_settings(auth.wallet.user)
return _to_dict(ops)
async def handle_operator_update(
auth: WalletTypeInfo, request: NostrRpcRequest
) -> dict:
ops = await crud.get_or_create_operator_settings(auth.wallet.user)
for k, v in (request.body or {}).items():
if k in UpdateOperatorSettings.__fields__:
setattr(ops, k, v)
return _to_dict(await crud.update_operator_settings(ops))
# --- public: discovery + booking (AUTH_NONE) ------------------------------- # --- public: discovery + booking (AUTH_NONE) -------------------------------
async def handle_room_list(auth: None, request: NostrRpcRequest) -> list[dict]: async def handle_room_list(auth: None, request: NostrRpcRequest) -> list[dict]:
"""Active rooms only, wallet id stripped (public discovery).""" """Active rooms only, wallet id stripped, owner's house rules + rails
rooms = await crud.get_rooms() attached (public discovery) — same view as the HTTP door."""
return [_public_room(r) for r in rooms if r.status == RoomStatus.active] rooms = [r for r in await crud.get_rooms() if r.status == RoomStatus.active]
return await services.public_room_views(rooms)
async def handle_room_get(auth: None, request: NostrRpcRequest) -> dict: async def handle_room_get(auth: None, request: NostrRpcRequest) -> dict:
room = await crud.get_room(_require_id(request)) room = await crud.get_room(_require_id(request))
if not room or room.status != RoomStatus.active: if not room or room.status != RoomStatus.active:
raise ValueError("Room not available") raise ValueError("Room not available")
return _public_room(room) return await services.public_room_view(room)
async def handle_room_unavailable(auth: None, request: NostrRpcRequest) -> dict:
"""Merged occupied/blocked spans for one room — the calendar feed.
Body: {room_id, start?, end?} (YYYY-MM-DD, end exclusive)."""
body = request.body or {}
result = await services.get_unavailable_ranges(
_require(body, "room_id"), body.get("start"), body.get("end")
)
return _to_dict(result)
async def handle_availability(auth: None, request: NostrRpcRequest) -> dict: async def handle_availability(auth: None, request: NostrRpcRequest) -> dict:
@ -140,11 +172,23 @@ async def handle_booking_request(auth: None, request: NostrRpcRequest) -> dict:
num_guests=body.get("num_guests", 1), num_guests=body.get("num_guests", 1),
guest_contact=body.get("guest_contact"), guest_contact=body.get("guest_contact"),
message=body.get("message"), message=body.get("message"),
payment_method=body.get("payment_method", "lightning"),
fiat_provider=body.get("fiat_provider"),
frontend_url=body.get("frontend_url"),
) )
# No request host on this door: the checkout returns to the instance root
# unless the client names its own (allow-listed) frontend_url.
quote = await services.request_booking(data) quote = await services.request_booking(data)
return _to_dict(quote) return _to_dict(quote)
async def handle_booking_list_mine(auth: None, request: NostrRpcRequest) -> list[dict]:
"""The caller's own bookings, scoped by the signed sender_pubkey."""
if not request.sender_pubkey:
raise PermissionError("chatelet: caller identity required")
return await services.list_guest_bookings(request.sender_pubkey)
async def handle_booking_get(auth: None, request: NostrRpcRequest) -> dict: async def handle_booking_get(auth: None, request: NostrRpcRequest) -> dict:
booking = await crud.get_booking(_require_id(request)) booking = await crud.get_booking(_require_id(request))
if not booking: if not booking:
@ -194,11 +238,3 @@ async def _require_owned_room(room_id: str, wallet_id: str):
def _to_dict(obj) -> dict: def _to_dict(obj) -> dict:
return json.loads(obj.json()) return json.loads(obj.json())
def _public_room(room) -> dict:
# Shared with the HTTP door; strips wallet id AND checkin_instructions
# (the latter was leaking to guests before — added after this file's
# original public dict).
from .models import public_room_dict
return public_room_dict(room)

View file

@ -7,9 +7,9 @@ endpoints (room/block CRUD, settings) are HTTP-only and back the admin UI;
the guest-facing surface (availability, booking) is what also rides the RPC. the guest-facing surface (availability, booking) is what also rides the RPC.
""" """
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query, Request
from lnbits.core.models import WalletTypeInfo from lnbits.core.models import User, WalletTypeInfo
from lnbits.decorators import require_admin_key, require_invoice_key from lnbits.decorators import check_user_exists, require_admin_key, require_invoice_key
from . import crud, services from . import crud, services
from .models import ( from .models import (
@ -22,10 +22,12 @@ from .models import (
ChateletSettings, ChateletSettings,
CreateBlockData, CreateBlockData,
CreateRoomData, CreateRoomData,
OperatorSettings,
Room, Room,
RoomStatus, RoomStatus,
UnavailableRanges,
UpdateOperatorSettings,
public_booking_dict, public_booking_dict,
public_room_dict,
) )
from .nostr import service as nostr from .nostr import service as nostr
@ -191,17 +193,51 @@ async def api_update_settings(
return await crud.update_settings(settings) return await crud.update_settings(settings)
# --- operator settings (per LNbits user; admin key → wallet → user) ----------
def _with_providers(ops: OperatorSettings) -> dict:
"""The row plus what core would actually let this user charge with, so
the admin UI can explain a card toggle that has no provider behind it."""
d = ops.dict()
d["available_fiat_providers"] = services.fiat_providers_for_user(ops.user_id)
return d
@chatelet_api_router.get("/api/v1/operator")
async def api_get_operator_settings(
key: WalletTypeInfo = Depends(require_admin_key),
) -> dict:
ops = await crud.get_or_create_operator_settings(key.wallet.user)
return _with_providers(ops)
@chatelet_api_router.put("/api/v1/operator")
async def api_update_operator_settings(
data: UpdateOperatorSettings, key: WalletTypeInfo = Depends(require_admin_key)
) -> dict:
ops = await crud.get_or_create_operator_settings(key.wallet.user)
for field in UpdateOperatorSettings.__fields__:
setattr(ops, field, getattr(data, field))
ops = await crud.update_operator_settings(ops)
# Rails/house rules ride on the public listing — refresh the owner's rooms.
for room in await crud.get_rooms():
if room.status == RoomStatus.active:
owner = await services.room_owner_id(room)
if owner == ops.user_id:
await nostr.publish_listing(room)
return _with_providers(ops)
# --- public guest discovery (no auth) -------------------------------------- # --- public guest discovery (no auth) --------------------------------------
@chatelet_api_router.get("/api/v1/public/rooms") @chatelet_api_router.get("/api/v1/public/rooms")
async def api_public_rooms() -> list[dict]: async def api_public_rooms() -> list[dict]:
"""Active rooms for guest browsing — operator-private fields stripped.""" """Active rooms for guests — wallet + check-in instructions stripped,
return [ owner's house rules + accepted rails attached."""
public_room_dict(r) rooms = [r for r in await crud.get_rooms() if r.status == RoomStatus.active]
for r in await crud.get_rooms() return await services.public_room_views(rooms)
if r.status == RoomStatus.active
]
@chatelet_api_router.get("/api/v1/public/rooms/{room_id}") @chatelet_api_router.get("/api/v1/public/rooms/{room_id}")
@ -209,7 +245,21 @@ async def api_public_room(room_id: str) -> dict:
room = await crud.get_room(room_id) room = await crud.get_room(room_id)
if not room or room.status != RoomStatus.active: if not room or room.status != RoomStatus.active:
raise HTTPException(404, "Room not available") raise HTTPException(404, "Room not available")
return public_room_dict(room) return await services.public_room_view(room)
@chatelet_api_router.get("/api/v1/public/rooms/{room_id}/unavailable")
async def api_public_room_unavailable(
room_id: str,
start: str | None = Query(None, description="YYYY-MM-DD, default today"),
end: str | None = Query(None, description="YYYY-MM-DD exclusive, default +365d"),
) -> UnavailableRanges:
"""Nights a guest cannot book, merged and anonymous — what a calendar
greys out. Keyless, like the room read it hangs off."""
try:
return await services.get_unavailable_ranges(room_id, start, end)
except ValueError as exc:
raise _to_http(exc) from exc
# --- availability (public read) -------------------------------------------- # --- availability (public read) --------------------------------------------
@ -227,15 +277,29 @@ async def api_check_availability(q: AvailabilityQuery) -> AvailabilityResult:
@chatelet_api_router.post("/api/v1/bookings", status_code=201) @chatelet_api_router.post("/api/v1/bookings", status_code=201)
async def api_request_booking(data: BookingRequestData) -> BookingQuote: async def api_request_booking(
data: BookingRequestData, request: Request
) -> BookingQuote:
try: try:
return await services.request_booking(data) return await services.request_booking(
data, base_url=str(request.base_url)
)
except services.BookingError as exc: except services.BookingError as exc:
raise HTTPException(502, str(exc)) from exc raise HTTPException(502, str(exc)) from exc
except ValueError as exc: except ValueError as exc:
raise _to_http(exc) from exc raise _to_http(exc) from exc
@chatelet_api_router.get("/api/v1/bookings/mine")
async def api_my_bookings(user: User = Depends(check_user_exists)) -> list[dict]:
"""The signed-in guest's own stays. Identity is the LNbits account's Nostr
pubkey — the same value the booking request carried as guest_pubkey.
Declared before /bookings/{booking_id} so "mine" is not read as an id."""
if not user.pubkey:
raise HTTPException(409, "This account has no Nostr pubkey")
return await services.list_guest_bookings(user.pubkey)
@chatelet_api_router.get("/api/v1/bookings/{booking_id}") @chatelet_api_router.get("/api/v1/bookings/{booking_id}")
async def api_get_booking( async def api_get_booking(
booking_id: str, key: WalletTypeInfo = Depends(require_invoice_key) booking_id: str, key: WalletTypeInfo = Depends(require_invoice_key)