chatelet/models.py
Padreug 0dad30b648 feat: card rail via LNbits fiat providers (Stripe), per operator
A guest may pay a fiat-priced room by card when its owner has opted in and
LNbits core has a fiat provider for that user — resolved through
settings.get_fiat_providers_for_user(owner), the seam lnbits#67's per-user
Stripe Connect credentials will plug into; chatelet stores no credentials.

Both rails now go through create_payment_request: Lightning unchanged
(sats, deposit_sat), card charges the same deposit share of the fiat price
in the room's currency with extra.checkout parameterising the hosted
Stripe page — success/cancel return to {frontend}/chatelet/{room}?checkout=…
&booking=<id>, customer_email, line item, metadata. frontend_url is
allow-listed against the instance's trusted origins (ported from events)
and resolved before the hold so a refused rail never leaves a dead hold.
Core settles the Stripe webhook onto the same invoice queue, so
tasks.on_invoice_paid confirms card bookings unchanged.

BookingRequestData gains payment_method / fiat_provider / frontend_url;
BookingQuote gains fiat_payment_request / fiat_provider / is_fiat and a
nullable payment_request. RPC chatelet_booking_request passes the fields
through. min_lnbits_version → 1.4.1 (events' floor for these APIs).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 12:25:31 +02:00

346 lines
14 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 urllib.parse import urlsplit
from pydantic import BaseModel, Field, validator
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
# Rail the guest wants to pay with. "fiat" needs the room owner to accept
# card AND LNbits core to have a provider for them (services checks).
payment_method: str = "lightning"
fiat_provider: str | None = None # e.g. "stripe"; defaults to the owner's first
# Where the hosted checkout should send the guest back (the calling app);
# origin must be one the instance trusts — see frontend.resolve_frontend_root.
frontend_url: str | None = Field(default=None, max_length=512)
@validator("frontend_url")
def validate_frontend_url(cls, v): # noqa: N805
if v is None:
return None
v = v.strip()
if not v:
return None
parts = urlsplit(v)
if parts.scheme not in ("http", "https") or not parts.netloc:
raise ValueError("frontend_url must be an absolute http(s) URL")
if parts.query or parts.fragment or ".." in parts.path:
raise ValueError("frontend_url must not contain a query, fragment or '..'")
return v.rstrip("/")
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 | None # bolt11 — None on the card rail
payment_hash: str
# Card rail: the provider's hosted checkout URL to send the guest to.
fiat_payment_request: str | None = None
fiat_provider: str | None = None
is_fiat: bool = False