GET /api/v1/bookings/mine (LNbits account auth; identity = the account's
Nostr pubkey, the same value the booking request carried) and RPC twin
chatelet_booking_list_mine (scoped by the signed sender_pubkey). Rows come
back newest check-in first via the m003 guest index, as guest_booking_dict:
the guest's own contact and counts, minus the Lightning/Nostr plumbing.
Declared ahead of /bookings/{booking_id} so 'mine' is not read as an id.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
320 lines
12 KiB
Python
320 lines
12 KiB
Python
"""Chatelet data model.
|
|
|
|
Room rentals over Nostr, with LNbits as the booking authority and payment
|
|
rail. See docs/data-model.md for the narrative and docs/event-flow.md for
|
|
how these rows map onto Nostr events.
|
|
|
|
Design notes carried into the field definitions:
|
|
|
|
* `amount_sat` on a Booking is the CANONICAL total. It is computed once,
|
|
at quote time, from the room price + nights + FX, and then propagated
|
|
as-is across every boundary (invoice, reservation event, receipt).
|
|
Downstream code MUST NOT re-derive it from `price_fiat * nights` — FX
|
|
drifts and rounding accumulates. (Workspace rule: source-of-truth,
|
|
don't re-derive.)
|
|
* Availability is DERIVED, never stored as a positive fact. A date is
|
|
free unless a Block or a live Booking overlaps it. The DB is the sole
|
|
arbiter of "is this range open" — Nostr events are requests, not locks.
|
|
"""
|
|
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from enum import Enum
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Enums
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class RoomStatus(str, Enum):
|
|
active = "active" # bookable + listing published to relays
|
|
inactive = "inactive" # hidden, listing unpublished
|
|
|
|
|
|
class BookingStatus(str, Enum):
|
|
# Happy path: held -> awaiting_payment -> confirmed -> checked_in -> completed
|
|
held = "held" # dates soft-locked, quote issued, invoice pending
|
|
awaiting_payment = "awaiting_payment" # invoice delivered to guest
|
|
confirmed = "confirmed" # paid; dates hard-blocked; check-in info sent
|
|
checked_in = "checked_in" # guest has arrived
|
|
completed = "completed" # stay finished
|
|
# Terminal negatives (all free the dates):
|
|
declined = "declined" # operator/authority rejected the request
|
|
cancelled = "cancelled" # cancelled by guest or operator post-confirm
|
|
expired = "expired" # hold lapsed before payment
|
|
|
|
|
|
# Statuses that occupy a room's calendar (block availability).
|
|
OCCUPYING_STATUSES = {
|
|
BookingStatus.held,
|
|
BookingStatus.awaiting_payment,
|
|
BookingStatus.confirmed,
|
|
BookingStatus.checked_in,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Settings (single row — one castle / operator per install)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Per-operator (LNbits user) choices that apply to all of that user's rooms.
|
|
HOUSE_RULE_FIELDS = ("checkin_time", "checkout_time", "cancellation_policy")
|
|
|
|
|
|
class UpdateOperatorSettings(BaseModel):
|
|
accept_fiat: bool = False
|
|
checkin_time: str = "15:00"
|
|
checkout_time: str = "11:00"
|
|
cancellation_policy: str = "" # shown to guests + in the check-in DM
|
|
|
|
|
|
class OperatorSettings(UpdateOperatorSettings):
|
|
"""One row per operator user, created on first read. `accept_fiat` is the
|
|
operator's *wish*; whether card is actually offered also depends on LNbits
|
|
core having a fiat provider for that user (services.payment_methods_for_room)
|
|
— chatelet never holds provider credentials."""
|
|
|
|
user_id: str
|
|
created_at: datetime = Field(default_factory=_now)
|
|
updated_at: datetime = Field(default_factory=_now)
|
|
|
|
|
|
class ChateletSettings(BaseModel):
|
|
# LNbits account whose Nostr signer publishes listings/receipts on the
|
|
# castle's behalf. Resolved via lnbits.core.signers.resolve_signer so
|
|
# this works with LocalSigner today and a NIP-46 bunker later (no nsec
|
|
# at rest — see lnbits#18 endgame). Nullable until onboarded.
|
|
operator_id: str | None = None
|
|
relays: list[str] = Field(default_factory=list) # where we publish/subscribe
|
|
default_hold_minutes: int = 30 # how long a `held` booking survives unpaid
|
|
deposit_percent: int = 100 # 100 = full prepay; <100 = deposit + balance
|
|
# Legacy (pre-m003): house rules are per operator now — see OperatorSettings.
|
|
# Columns kept so old rows load; nothing reads them.
|
|
checkin_time: str = "15:00"
|
|
checkout_time: str = "11:00"
|
|
cancellation_policy: str = ""
|
|
publish_availability: bool = True # mirror blocked dates to a public NIP-52 calendar
|
|
created_at: datetime = Field(default_factory=_now)
|
|
updated_at: datetime = Field(default_factory=_now)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Room (the rentable unit == one NIP-99 kind:30402 listing)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class CreateRoomData(BaseModel):
|
|
wallet: str | None = None # wallet that receives booking payments
|
|
title: str
|
|
description: str = "" # markdown -> listing .content
|
|
price_amount: float
|
|
price_currency: str = "EUR" # ISO-4217 or "sat"/"btc"
|
|
price_frequency: str = "night" # NIP-99 price frequency
|
|
max_guests: int = 2
|
|
min_nights: int = 1
|
|
amenities: list[str] = Field(default_factory=list) # -> NIP-99 "t" tags
|
|
location: str = ""
|
|
geohash: str = "" # -> NIP-99 "g" tag
|
|
images: list[str] = Field(default_factory=list)
|
|
# Private access details (address, gate/door code) sent to the guest only
|
|
# after payment, over the encrypted NIP-17 check-in DM. Never in the
|
|
# public listing.
|
|
checkin_instructions: str = ""
|
|
|
|
|
|
class Room(BaseModel):
|
|
id: str # short hash; also the kind:30402 "d" tag
|
|
wallet: str
|
|
title: str
|
|
description: str
|
|
price_amount: float
|
|
price_currency: str
|
|
price_frequency: str
|
|
max_guests: int
|
|
min_nights: int
|
|
amenities: list[str] = Field(default_factory=list)
|
|
location: str = ""
|
|
geohash: str = ""
|
|
images: list[str] = Field(default_factory=list)
|
|
checkin_instructions: str = "" # private; sent in the check-in DM only
|
|
status: RoomStatus = RoomStatus.inactive
|
|
listing_event_id: str | None = None # id of the last published kind:30402
|
|
created_at: datetime = Field(default_factory=_now)
|
|
updated_at: datetime = Field(default_factory=_now)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Booking (a reservation == one kind:30078 reservation object for the guest)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class BookingRequestData(BaseModel):
|
|
"""What a guest supplies to request a stay. Arrives either over the REST
|
|
API or decoded from a Nostr booking-request DM (see nostr/service.py)."""
|
|
|
|
room_id: str
|
|
guest_pubkey: str # hex npub of the requesting guest
|
|
check_in: str # YYYY-MM-DD (inclusive)
|
|
check_out: str # YYYY-MM-DD (exclusive)
|
|
num_guests: int = 1
|
|
guest_contact: str | None = None # optional email/phone/nostr note
|
|
message: str | None = None # free-form note to the host
|
|
|
|
|
|
class Booking(BaseModel):
|
|
id: str # reservation id; kind:30078 "d" tag
|
|
room_id: str
|
|
guest_pubkey: str
|
|
guest_contact: str | None = None
|
|
check_in: str # YYYY-MM-DD inclusive
|
|
check_out: str # YYYY-MM-DD exclusive
|
|
nights: int
|
|
num_guests: int
|
|
# --- money (canonical: amount_sat) ---
|
|
currency: str # snapshot of room.price_currency
|
|
price_fiat: float # snapshot of nights * room price (display only)
|
|
amount_sat: int # canonical total due; source of truth
|
|
deposit_sat: int # portion invoiced now (== amount_sat if 100%)
|
|
# --- lifecycle ---
|
|
status: BookingStatus = BookingStatus.held
|
|
payment_hash: str | None = None # LNbits invoice covering deposit_sat
|
|
request_event_id: str | None = None # guest's originating request event
|
|
reservation_event_id: str | None = None # our published kind:30078
|
|
expires_at: datetime | None = None # hold expiry (held/awaiting_payment only)
|
|
created_at: datetime = Field(default_factory=_now)
|
|
updated_at: datetime = Field(default_factory=_now)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Block (manual owner-side unavailability — maintenance, personal use)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class CreateBlockData(BaseModel):
|
|
room_id: str
|
|
start_date: str # YYYY-MM-DD inclusive
|
|
end_date: str # YYYY-MM-DD exclusive
|
|
reason: str = ""
|
|
|
|
|
|
class Block(BaseModel):
|
|
id: str
|
|
room_id: str
|
|
start_date: str
|
|
end_date: str
|
|
reason: str = ""
|
|
created_at: datetime = Field(default_factory=_now)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Availability query (read-only; answered from Bookings + Blocks)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def public_room_dict(
|
|
room: Room,
|
|
*,
|
|
house_rules: dict | None = None,
|
|
payment_methods: list[str] | None = None,
|
|
) -> dict:
|
|
"""A Room as public JSON for guests — strips operator-private fields: the
|
|
wallet id, and the check-in instructions (address/gate code, delivered only
|
|
in the encrypted post-payment DM). Shared by the HTTP and Nostr-RPC guest
|
|
doors so neither can leak them. `house_rules` / `payment_methods` come from
|
|
the owner's OperatorSettings (services.public_room_view resolves them)."""
|
|
d = json.loads(room.json())
|
|
d.pop("wallet", None)
|
|
d.pop("checkin_instructions", None)
|
|
if house_rules is not None:
|
|
d["house_rules"] = house_rules
|
|
if payment_methods is not None:
|
|
d["payment_methods"] = payment_methods
|
|
return d
|
|
|
|
|
|
def public_booking_dict(booking: "Booking") -> dict:
|
|
"""A Booking as public JSON for the guest who holds its id — lifecycle +
|
|
money + dates only. Strips the guest's own identity/contact (so the id
|
|
alone can't be turned into PII) and the internal Lightning/Nostr
|
|
plumbing. The 10-char id from the quote is the capability here, the same
|
|
trust level as the RPC door's chatelet_booking_get; chatelet_booking_get
|
|
additionally scopes by sender_pubkey, which HTTP can't."""
|
|
d = json.loads(booking.json())
|
|
for k in (
|
|
"guest_pubkey",
|
|
"guest_contact",
|
|
"payment_hash",
|
|
"request_event_id",
|
|
"reservation_event_id",
|
|
):
|
|
d.pop(k, None)
|
|
return d
|
|
|
|
|
|
def guest_booking_dict(booking: "Booking") -> dict:
|
|
"""A Booking for the guest who owns it (authenticated by pubkey on either
|
|
door): everything public_booking_dict shows plus their own contact and
|
|
guest count; the Lightning/Nostr plumbing stays internal."""
|
|
d = json.loads(booking.json())
|
|
for k in ("payment_hash", "request_event_id", "reservation_event_id"):
|
|
d.pop(k, None)
|
|
return d
|
|
|
|
|
|
class AvailabilityQuery(BaseModel):
|
|
room_id: str
|
|
check_in: str # YYYY-MM-DD inclusive
|
|
check_out: str # YYYY-MM-DD exclusive
|
|
|
|
|
|
class DateRange(BaseModel):
|
|
"""Half-open [start, end) span of nights, YYYY-MM-DD. `end` is the morning
|
|
the room frees up — a guest may check in on that day."""
|
|
|
|
start: str
|
|
end: str
|
|
|
|
|
|
class UnavailableRanges(BaseModel):
|
|
"""Everything a guest calendar needs to grey out nights for one room
|
|
over a window: bookings that still occupy the room (incl. live unpaid
|
|
holds, so this always agrees with `POST /availability`) and manual
|
|
blocks, merged into indistinguishable date spans — a guest can't tell a
|
|
block from another guest's stay."""
|
|
|
|
room_id: str
|
|
start: str
|
|
end: str
|
|
ranges: list[DateRange] = Field(default_factory=list)
|
|
|
|
|
|
class AvailabilityResult(BaseModel):
|
|
room_id: str
|
|
check_in: str
|
|
check_out: str
|
|
available: bool
|
|
nights: int
|
|
# Populated when available — a non-binding price preview. Becomes the
|
|
# canonical amount_sat only once a Booking is actually held.
|
|
quote_sat: int | None = None
|
|
quote_fiat: float | None = None
|
|
currency: str | None = None
|
|
|
|
|
|
class BookingQuote(BaseModel):
|
|
"""Response to a booking request: the held booking plus the bolt11 the
|
|
guest must pay to confirm it. payment_request covers deposit_sat (the
|
|
canonical amount already stored on the booking) — the guest never re-
|
|
computes what they owe."""
|
|
|
|
booking: Booking
|
|
payment_request: str
|
|
payment_hash: str
|