diff --git a/models.py b/models.py new file mode 100644 index 0000000..d2c2b81 --- /dev/null +++ b/models.py @@ -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