feat: data model

Pydantic entities: ChateletSettings (operator/castle config), Room (a
rentable unit == one NIP-99 listing), Booking (a reservation), Block
(manual owner unavailability), plus availability query/result DTOs.

Two invariants encoded here: amount_sat is the canonical booking total
(never re-derived downstream), and availability is derived, never stored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
This commit is contained in:
Padreug 2026-07-19 00:16:23 +02:00
commit ab892cabec

208
models.py Normal file
View file

@ -0,0 +1,208 @@
"""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)
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)
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