Compare commits
No commits in common. "main" and "v0.4.0" have entirely different histories.
26 changed files with 92 additions and 1430 deletions
|
|
@ -67,16 +67,12 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
@ -87,16 +83,12 @@ 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.
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"id": "chatelet",
|
"id": "chatelet",
|
||||||
"version": "0.5.0",
|
"version": "0.4.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.1",
|
"min_lnbits_version": "1.4.0",
|
||||||
"contributors": [
|
"contributors": [
|
||||||
{
|
{
|
||||||
"name": "padreug",
|
"name": "padreug",
|
||||||
|
|
|
||||||
95
crud.py
95
crud.py
|
|
@ -12,14 +12,12 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
@ -49,30 +47,6 @@ 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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -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]:
|
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",
|
||||||
|
|
@ -226,9 +188,12 @@ 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 OCCUPYING_STATUSES and _overlaps(
|
if b.status in (
|
||||||
check_in, check_out, b.check_in, b.check_out
|
BookingStatus.held,
|
||||||
):
|
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):
|
||||||
|
|
@ -238,54 +203,6 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -53,19 +53,6 @@ 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)`.
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,10 @@ 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_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) |
|
||||||
| `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`); `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_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
47
frontend.py
|
|
@ -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("/")
|
|
||||||
|
|
@ -123,27 +123,3 @@ 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);"
|
|
||||||
)
|
|
||||||
|
|
|
||||||
105
models.py
105
models.py
|
|
@ -20,9 +20,8 @@ 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, validator
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
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):
|
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
|
||||||
|
|
@ -97,11 +74,9 @@ 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 = ""
|
cancellation_policy: str = "" # free-form markdown, surfaced in listings/DMs
|
||||||
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)
|
||||||
|
|
@ -168,27 +143,6 @@ 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):
|
||||||
|
|
@ -241,27 +195,19 @@ class Block(BaseModel):
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def public_room_dict(
|
def public_room_dict(room: 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. `house_rules` / `payment_methods` come from
|
doors so neither can leak them."""
|
||||||
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
|
||||||
|
|
@ -281,43 +227,10 @@ def public_booking_dict(booking: "Booking") -> dict:
|
||||||
return d
|
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_in: str # YYYY-MM-DD inclusive
|
||||||
check_out: str # YYYY-MM-DD exclusive
|
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):
|
||||||
room_id: str
|
room_id: str
|
||||||
check_in: str
|
check_in: str
|
||||||
|
|
@ -338,9 +251,5 @@ class BookingQuote(BaseModel):
|
||||||
computes what they owe."""
|
computes what they owe."""
|
||||||
|
|
||||||
booking: Booking
|
booking: Booking
|
||||||
payment_request: str | None # bolt11 — None on the card rail
|
payment_request: str
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -18,30 +18,15 @@ from .kinds import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_listing_event(
|
def build_listing_event(room: Room) -> dict:
|
||||||
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:
|
||||||
|
|
|
||||||
|
|
@ -160,17 +160,8 @@ 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), carrying
|
"""Publish/refresh a room's NIP-99 kind:30402 listing (public)."""
|
||||||
the owner's rails + house rules so the event matches the API view."""
|
return await _sign_and_publish(events.build_listing_event(room))
|
||||||
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:
|
||||||
|
|
|
||||||
236
services.py
236
services.py
|
|
@ -14,32 +14,21 @@ 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 date, datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from lnbits.core.crud.wallets import get_wallet
|
from lnbits.core.services import create_invoice
|
||||||
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
|
||||||
|
|
@ -70,137 +59,6 @@ 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:
|
||||||
|
|
@ -227,35 +85,10 @@ async def get_availability(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_rail(
|
async def request_booking(data: BookingRequestData) -> BookingQuote:
|
||||||
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 + what confirms it: a bolt11, or on the
|
request. Returns the held booking + the bolt11 that will confirm it."""
|
||||||
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")
|
||||||
|
|
@ -266,10 +99,6 @@ async def request_booking(
|
||||||
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()
|
||||||
|
|
@ -311,59 +140,30 @@ async def request_booking(
|
||||||
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)
|
||||||
|
|
||||||
# One invoice call for both rails (core forks on fiat_provider). Lightning
|
# Sats-denominated (deposit_sat locked at quote time) so FX drift before
|
||||||
# is sats-denominated (deposit_sat locked at quote time so FX drift can't
|
# payment can't change what's owed. tag+booking_id let
|
||||||
# change what's owed); card charges the same deposit share of the fiat
|
# tasks.on_invoice_paid match the settlement back to this booking.
|
||||||
# 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,
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
payment = await create_payment_request(
|
payment = await create_invoice(
|
||||||
wallet_id=room.wallet, invoice_data=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
|
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}") from exc
|
raise BookingError(f"Could not create invoice: {exc.message}") 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=getattr(payment, "bolt11", None) or None,
|
payment_request=payment.bolt11,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,6 @@ 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'},
|
||||||
{
|
{
|
||||||
|
|
@ -240,41 +237,11 @@ 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()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
8
tasks.py
8
tasks.py
|
|
@ -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, services
|
from . import crud
|
||||||
from .models import BookingStatus
|
from .models import BookingStatus
|
||||||
from .nostr import service as nostr
|
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.
|
# 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:
|
||||||
# House rules are the room owner's, not the instance's.
|
await nostr.send_checkin_dm(booking, room, settings)
|
||||||
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}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,19 @@
|
||||||
<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>
|
||||||
|
|
@ -180,48 +193,6 @@
|
||||||
: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 & 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>
|
||||||
|
|
|
||||||
|
|
@ -86,23 +86,3 @@ 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))
|
|
||||||
|
|
|
||||||
|
|
@ -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, patch_owner
|
from .conftest import make_request, make_room
|
||||||
|
|
||||||
|
|
||||||
def _setup(monkeypatch, room):
|
def _setup(monkeypatch, room):
|
||||||
|
|
@ -39,13 +39,10 @@ def _setup(monkeypatch, room):
|
||||||
async def fake_update_booking(booking):
|
async def fake_update_booking(booking):
|
||||||
return booking
|
return booking
|
||||||
|
|
||||||
async def fake_create_payment_request(*, wallet_id, invoice_data):
|
async def fake_create_invoice(**kwargs):
|
||||||
invoices.append(invoice_data)
|
invoices.append(kwargs)
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
payment_hash="ph_" + invoice_data.extra["booking_id"],
|
payment_hash="ph_" + kwargs["extra"]["booking_id"], bolt11="lnbc_fake"
|
||||||
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)
|
||||||
|
|
@ -53,10 +50,7 @@ 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(
|
monkeypatch.setattr(services, "create_invoice", fake_create_invoice)
|
||||||
services, "create_payment_request", fake_create_payment_request
|
|
||||||
)
|
|
||||||
patch_owner(monkeypatch)
|
|
||||||
return held, invoices
|
return held, invoices
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -9,16 +9,12 @@ 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, patch_owner
|
from .conftest import make_request, make_room
|
||||||
|
|
||||||
|
|
||||||
def _setup(
|
def _setup(monkeypatch, room, *, invoice_raises=False):
|
||||||
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
|
||||||
|
|
@ -40,36 +36,18 @@ def _setup(
|
||||||
updated.append(booking)
|
updated.append(booking)
|
||||||
return booking
|
return booking
|
||||||
|
|
||||||
async def fake_create_payment_request(*, wallet_id, invoice_data):
|
async def fake_create_invoice(**kwargs):
|
||||||
invoices.append(invoice_data)
|
|
||||||
if invoice_raises:
|
if invoice_raises:
|
||||||
raise InvoiceError("no funding source")
|
raise InvoiceError("no funding source")
|
||||||
if invoice_data.fiat_provider:
|
return SimpleNamespace(payment_hash="ph_1", bolt11="lnbc_fake")
|
||||||
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(
|
monkeypatch.setattr(services, "create_invoice", fake_create_invoice)
|
||||||
services, "create_payment_request", fake_create_payment_request
|
return created, updated
|
||||||
)
|
|
||||||
return created, updated, invoices
|
|
||||||
|
|
||||||
|
|
||||||
def test_happy_path_holds_then_awaits_payment(monkeypatch):
|
def test_happy_path_holds_then_awaits_payment(monkeypatch):
|
||||||
|
|
@ -92,7 +70,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):
|
||||||
|
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
"""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/")
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
"""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))
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
"""Per-operator settings (multi-tenant): what a guest sees on a room and how
|
|
||||||
the rails are derived. Chatelet never decides *whether* a user may charge
|
|
||||||
card — it asks LNbits core per owner — only whether the operator wants to."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
from .. import crud, services, transport_rpcs, views_api
|
|
||||||
from ..models import OperatorSettings, RoomStatus, UpdateOperatorSettings
|
|
||||||
from ..nostr import events
|
|
||||||
from .conftest import make_room, patch_owner
|
|
||||||
|
|
||||||
|
|
||||||
def _key(wallet_id="w1", user="u1"):
|
|
||||||
return SimpleNamespace(wallet=SimpleNamespace(id=wallet_id, user=user))
|
|
||||||
|
|
||||||
|
|
||||||
def test_rails_need_flag_provider_and_fiat_price(monkeypatch):
|
|
||||||
room = make_room(price=100.0, currency="EUR")
|
|
||||||
on = OperatorSettings(user_id="u1", accept_fiat=True)
|
|
||||||
off = OperatorSettings(user_id="u1", accept_fiat=False)
|
|
||||||
lightning_only = ["lightning"]
|
|
||||||
|
|
||||||
monkeypatch.setattr(services, "fiat_providers_for_user", lambda _u: ["stripe"])
|
|
||||||
both = ["lightning", "fiat"]
|
|
||||||
assert services.payment_methods_for_room(room, "u1", on) == both
|
|
||||||
assert services.payment_methods_for_room(room, "u1", off) == lightning_only
|
|
||||||
sat_room = make_room(price=1000.0, currency="sat")
|
|
||||||
assert services.payment_methods_for_room(sat_room, "u1", on) == lightning_only
|
|
||||||
monkeypatch.setattr(services, "fiat_providers_for_user", lambda _u: [])
|
|
||||||
assert services.payment_methods_for_room(room, "u1", on) == lightning_only
|
|
||||||
|
|
||||||
|
|
||||||
def test_public_room_view_attaches_rules_and_rails(monkeypatch):
|
|
||||||
patch_owner(monkeypatch, accept_fiat=True, providers=["stripe"])
|
|
||||||
room = make_room("a", status=RoomStatus.active, currency="EUR")
|
|
||||||
out = asyncio.run(services.public_room_view(room))
|
|
||||||
assert out["payment_methods"] == ["lightning", "fiat"]
|
|
||||||
assert out["house_rules"] == {
|
|
||||||
"checkin_time": "15:00",
|
|
||||||
"checkout_time": "11:00",
|
|
||||||
"cancellation_policy": "",
|
|
||||||
}
|
|
||||||
assert "wallet" not in out and "checkin_instructions" not in out
|
|
||||||
|
|
||||||
|
|
||||||
def test_batch_view_looks_owner_up_once_per_wallet(monkeypatch):
|
|
||||||
calls: list[str] = []
|
|
||||||
|
|
||||||
async def get_wallet(wallet_id):
|
|
||||||
calls.append(wallet_id)
|
|
||||||
return SimpleNamespace(user="u1")
|
|
||||||
|
|
||||||
async def ops(uid):
|
|
||||||
return OperatorSettings(user_id=uid)
|
|
||||||
|
|
||||||
monkeypatch.setattr(services, "get_wallet", get_wallet)
|
|
||||||
monkeypatch.setattr(crud, "get_or_create_operator_settings", ops)
|
|
||||||
monkeypatch.setattr(services, "fiat_providers_for_user", lambda _u: [])
|
|
||||||
rooms = [
|
|
||||||
make_room("a", wallet="w1"),
|
|
||||||
make_room("b", wallet="w1"),
|
|
||||||
make_room("c", wallet="w2"),
|
|
||||||
]
|
|
||||||
out = asyncio.run(services.public_room_views(rooms))
|
|
||||||
assert [r["id"] for r in out] == ["a", "b", "c"]
|
|
||||||
assert sorted(calls) == ["w1", "w2"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_listing_event_carries_rails_and_times():
|
|
||||||
room = make_room("a")
|
|
||||||
ev = events.build_listing_event(
|
|
||||||
room,
|
|
||||||
payment_methods=["lightning", "fiat"],
|
|
||||||
house_rules={
|
|
||||||
"checkin_time": "16:00",
|
|
||||||
"checkout_time": "10:00",
|
|
||||||
"cancellation_policy": "x",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
wanted = ("payment_methods", "checkin_time", "checkout_time")
|
|
||||||
tags = {t[0]: t[1:] for t in ev["tags"] if t[0] in wanted}
|
|
||||||
assert tags == {
|
|
||||||
"payment_methods": ["lightning,fiat"],
|
|
||||||
"checkin_time": ["16:00"],
|
|
||||||
"checkout_time": ["10:00"],
|
|
||||||
}
|
|
||||||
# long-form policy text stays off the listing tags
|
|
||||||
assert not any(t[0] == "cancellation_policy" for t in ev["tags"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_operator_endpoints_scope_to_calling_user(monkeypatch):
|
|
||||||
store: dict[str, OperatorSettings] = {}
|
|
||||||
|
|
||||||
async def get_or_create(uid):
|
|
||||||
return store.setdefault(uid, OperatorSettings(user_id=uid))
|
|
||||||
|
|
||||||
async def update(ops):
|
|
||||||
store[ops.user_id] = ops
|
|
||||||
return ops
|
|
||||||
|
|
||||||
async def no_rooms():
|
|
||||||
return []
|
|
||||||
|
|
||||||
monkeypatch.setattr(crud, "get_or_create_operator_settings", get_or_create)
|
|
||||||
monkeypatch.setattr(crud, "update_operator_settings", update)
|
|
||||||
monkeypatch.setattr(crud, "get_rooms", no_rooms)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
services, "fiat_providers_for_user", lambda u: ["stripe"] if u == "u1" else []
|
|
||||||
)
|
|
||||||
|
|
||||||
first = asyncio.run(views_api.api_get_operator_settings(key=_key(user="u1")))
|
|
||||||
assert first["user_id"] == "u1" and first["accept_fiat"] is False
|
|
||||||
assert first["available_fiat_providers"] == ["stripe"]
|
|
||||||
|
|
||||||
updated = asyncio.run(
|
|
||||||
views_api.api_update_operator_settings(
|
|
||||||
UpdateOperatorSettings(accept_fiat=True, checkin_time="16:00"),
|
|
||||||
key=_key(user="u1"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert updated["accept_fiat"] is True and updated["checkin_time"] == "16:00"
|
|
||||||
|
|
||||||
other = asyncio.run(views_api.api_get_operator_settings(key=_key(user="u2")))
|
|
||||||
assert other["accept_fiat"] is False and other["available_fiat_providers"] == []
|
|
||||||
|
|
||||||
# RPC twin reads the same row for the same wallet user
|
|
||||||
rpc = asyncio.run(
|
|
||||||
transport_rpcs.handle_operator_get(
|
|
||||||
_key(user="u1"),
|
|
||||||
transport_rpcs.NostrRpcRequest(
|
|
||||||
rpc_name="chatelet_operator_get", request_id="r", body={}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert rpc["accept_fiat"] is True and rpc["checkin_time"] == "16:00"
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
(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
|
||||||
|
|
@ -15,7 +14,7 @@ from ..models import (
|
||||||
public_booking_dict,
|
public_booking_dict,
|
||||||
public_room_dict,
|
public_room_dict,
|
||||||
)
|
)
|
||||||
from .conftest import make_room, patch_owner
|
from .conftest import make_room
|
||||||
|
|
||||||
|
|
||||||
def test_public_room_dict_strips_private_fields():
|
def test_public_room_dict_strips_private_fields():
|
||||||
|
|
@ -37,13 +36,10 @@ 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):
|
||||||
|
|
@ -64,15 +60,14 @@ 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: Any) -> Booking:
|
def _booking(**overrides) -> Booking:
|
||||||
base: dict[str, Any] = {
|
base = {
|
||||||
"id": "bk_1234567",
|
"id": "bk_1234567",
|
||||||
"room_id": "a",
|
"room_id": "a",
|
||||||
"guest_pubkey": "ab" * 32,
|
"guest_pubkey": "ab" * 32,
|
||||||
|
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -30,13 +30,7 @@ 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 (
|
from .models import BookingRequestData, CreateBlockData, CreateRoomData, RoomStatus
|
||||||
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;
|
||||||
|
|
@ -105,46 +99,20 @@ 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, owner's house rules + rails
|
"""Active rooms only, wallet id stripped (public discovery)."""
|
||||||
attached (public discovery) — same view as the HTTP door."""
|
rooms = await crud.get_rooms()
|
||||||
rooms = [r for r in await crud.get_rooms() if r.status == RoomStatus.active]
|
return [_public_room(r) for r in 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 await services.public_room_view(room)
|
return _public_room(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:
|
||||||
|
|
@ -172,23 +140,11 @@ 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:
|
||||||
|
|
@ -238,3 +194,11 @@ 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)
|
||||||
|
|
|
||||||
90
views_api.py
90
views_api.py
|
|
@ -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, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from lnbits.core.models import User, WalletTypeInfo
|
from lnbits.core.models import WalletTypeInfo
|
||||||
from lnbits.decorators import check_user_exists, require_admin_key, require_invoice_key
|
from lnbits.decorators import require_admin_key, require_invoice_key
|
||||||
|
|
||||||
from . import crud, services
|
from . import crud, services
|
||||||
from .models import (
|
from .models import (
|
||||||
|
|
@ -22,12 +22,10 @@ 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
|
||||||
|
|
||||||
|
|
@ -193,51 +191,17 @@ 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 guests — wallet + check-in instructions stripped,
|
"""Active rooms for guest browsing — operator-private fields stripped."""
|
||||||
owner's house rules + accepted rails attached."""
|
return [
|
||||||
rooms = [r for r in await crud.get_rooms() if r.status == RoomStatus.active]
|
public_room_dict(r)
|
||||||
return await services.public_room_views(rooms)
|
for r in await crud.get_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}")
|
||||||
|
|
@ -245,21 +209,7 @@ 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 await services.public_room_view(room)
|
return public_room_dict(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) --------------------------------------------
|
||||||
|
|
@ -277,29 +227,15 @@ 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(
|
async def api_request_booking(data: BookingRequestData) -> BookingQuote:
|
||||||
data: BookingRequestData, request: Request
|
|
||||||
) -> BookingQuote:
|
|
||||||
try:
|
try:
|
||||||
return await services.request_booking(
|
return await services.request_booking(data)
|
||||||
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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue