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:
parent
c756ac7cf6
commit
ab892cabec
1 changed files with 208 additions and 0 deletions
208
models.py
Normal file
208
models.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue