chatelet/models.py
Padreug df95fd37dc feat: add Room.checkin_instructions + migration
Private per-room access details (address, gate/door code). Sent to the
guest only in the encrypted check-in DM after payment — never in the
public listing. m002 adds the column (default '').

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 20:34:12 +02:00

224 lines
8.9 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.
"""
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)
# ---------------------------------------------------------------------------
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
checkin_time: str = "15:00"
checkout_time: str = "11:00"
cancellation_policy: str = "" # free-form markdown, surfaced in listings/DMs
publish_availability: bool = True # mirror blocked dates to a public NIP-52 calendar
created_at: datetime = Field(default_factory=_now)
updated_at: datetime = Field(default_factory=_now)
# ---------------------------------------------------------------------------
# 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)
# ---------------------------------------------------------------------------
class AvailabilityQuery(BaseModel):
room_id: str
check_in: str # YYYY-MM-DD inclusive
check_out: str # YYYY-MM-DD exclusive
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