diff --git a/docs/data-model.md b/docs/data-model.md new file mode 100644 index 0000000..b49b732 --- /dev/null +++ b/docs/data-model.md @@ -0,0 +1,97 @@ +# Chatelet — Data Model + +Tables live in `ext_chatelet.sqlite3` (SQLite) or the `chatelet` Postgres +schema. Defined in [`../migrations.py`](../migrations.py); typed in +[`../models.py`](../models.py). + +## Entities + +``` +settings (1 row) + │ +rooms ─────────────< bookings + │ (guest reservations; amount_sat canonical) + └──────────────< blocks + (manual owner-side unavailability) +``` + +### `settings` — one row, castle/operator-wide + +| Field | Purpose | +|---|---| +| `operator_id` | LNbits account whose Nostr signer publishes on the castle's behalf (resolved via `resolve_signer` — LocalSigner now, NIP-46 bunker later) | +| `relays` | relays we publish to / subscribe on | +| `default_hold_minutes` | how long a `held` booking survives before the sweep expires it | +| `deposit_percent` | 100 = full prepay; `<100` = deposit now, balance later | +| `checkin_time` / `checkout_time` | surfaced in listing + check-in DM | +| `cancellation_policy` | free-form markdown | +| `publish_availability` | mirror blocked dates to a public NIP-52 calendar | + +### `rooms` — the rentable unit (one NIP-99 `kind:30402` listing each) + +`id` (short hash) doubles as the listing's `d` tag, so re-publishing +replaces the same addressable event. Holds price (`amount`/`currency`/ +`frequency` map straight onto the NIP-99 `price` tag), capacity +(`max_guests`, `min_nights`), presentation (`images`, `amenities`→`t` tags, +`location`, `geohash`→`g` tag), `status` (`active`/`inactive`), and +`listing_event_id` (last published event id). + +### `bookings` — a reservation (one NIP-78 `kind:30078` object each) + +`id` doubles as the reservation object's `d` tag. Key fields: + +- **`amount_sat` — canonical total.** Computed once at hold/quote time from + price × nights × FX, then propagated as-is to the invoice, the reservation + event, and any receipt. **Never re-derived** from `price_fiat` downstream + (FX drifts, rounding accumulates — workspace source-of-truth rule). + `price_fiat` is a display snapshot only. +- **`deposit_sat`** — portion invoiced now (`== amount_sat` when deposit is + 100%). +- **`status`** — lifecycle below. +- **`payment_hash`** — LNbits invoice covering the deposit. +- **`request_event_id` / `reservation_event_id`** — inbound request and our + published reservation object. +- **`expires_at`** — hold expiry (set while `held`/`awaiting_payment`). + +### `blocks` — manual owner unavailability + +Maintenance, personal use, off-season. Half-open `[start_date, end_date)`. + +## Booking lifecycle + +``` + request + │ + ▼ + ┌───────────────────┐ invoice sent ┌────────────────────┐ + │ held │ ────────────────▶ │ awaiting_payment │ + └───────────────────┘ └────────────────────┘ + │ │ │ │ + hold │ │ declined paid │ │ hold + expires│ ▼ ▼ │ expires + │ ┌──────────┐ ┌────────────┐ │ + └─▶│ expired │ │ confirmed │◀───┘ (paid before expiry) + └──────────┘ └────────────┘ + │ + check-in │ cancelled + ▼ + ┌────────────┐ + │ checked_in │──▶ completed + └────────────┘ +``` + +`OCCUPYING_STATUSES = {held, awaiting_payment, confirmed, checked_in}` — the +statuses that block a room's calendar. Terminal negatives (`declined`, +`cancelled`, `expired`) free the dates. + +## Availability is derived, not stored + +There is **no availability table**. `crud.is_available(room, in, out)` +returns true iff the room is `active` and no *occupying* booking and no +block overlaps `[check_in, check_out)`. Half-open intervals mean +back-to-back stays (one guest out, next guest in, same day) don't collide. + +This keeps a single source of truth: the only way to make dates unavailable +is to write a booking or a block. See +[`event-flow.md`](event-flow.md) § Concurrency for the check-then-hold +locking requirement. diff --git a/docs/event-flow.md b/docs/event-flow.md new file mode 100644 index 0000000..3355acb --- /dev/null +++ b/docs/event-flow.md @@ -0,0 +1,108 @@ +# Chatelet — Event Flow + +How rooms, guests, LNbits, and relays interact. The guiding rule: + +> **LNbits is the booking authority. Nostr carries discovery, requests, and +> receipts — it never holds the lock.** The DB write is the lock; the +> Lightning payment is the confirmation. + +Two doors lead into the *same* booking flow — the REST API +([`../views_api.py`](../views_api.py)) and the Nostr subscription +([`../nostr/service.py`](../nostr/service.py)). Both funnel through +[`../crud.py`](../crud.py) so arbitration + quoting live in one place. + +## Actors + +- **Operator** — the castle. Owns the LNbits account + Nostr identity that + signs listings and reservation receipts (via `resolve_signer`). +- **Guest** — a Nostr user (npub) discovering and booking a room. +- **LNbits (Chatelet ext)** — the authority: arbitrates availability, holds + dates, issues invoices, confirms on payment. +- **Relays** — dumb transport for discovery + messaging. + +## Kinds at a glance + +| Kind | NIP | Who signs | Purpose | +|---|---|---|---| +| `30402` | 99 | operator | Room listing (public, addressable) | +| `30078` | 78 | operator | Guest's reservation object (NIP-44 encrypted, addressable) | +| `31923/31924` | 52 | operator | Public availability calendar (no PII) | +| `1059` | 17/59 | both | Private booking DMs (request → quote → confirm → check-in) | +| `22000/22001` | aiolabs | guest/operator | Live availability query + response (ephemeral) | + +## Happy path + +```mermaid +sequenceDiagram + participant O as Operator (LNbits) + participant R as Relays + participant G as Guest + + Note over O,R: Discovery + O->>R: publish kind:30402 listing (per room) + O->>R: publish kind:31923 blocked ranges (no PII) + G->>R: subscribe kind:30402 (+ optional 31923) + + Note over G,O: Availability (live) + G->>R: kind:22000 query {room, in, out} (NIP-44) + R->>O: deliver query + O->>O: crud.is_available() + O->>R: kind:22001 {available, quote_sat} (NIP-44) + R->>G: deliver response + + Note over G,O: Booking request → hold + G->>R: NIP-59 giftwrap: booking request + R->>O: deliver request + O->>O: is_available? → write `held` booking (amount_sat canonical) + O->>O: create LNbits invoice (deposit_sat) + O->>R: NIP-59 giftwrap: quote {bolt11, expires_at} + R->>G: deliver quote (status: awaiting_payment) + + Note over G,O: Payment confirms (NOT a nostr event) + G->>O: pay bolt11 (Lightning) + O->>O: invoice listener → status = confirmed, dates hard-blocked + O->>R: publish/replace kind:30078 reservation (NIP-44 to guest) + O->>R: NIP-59 giftwrap: check-in details (address, gate code, times) + R->>G: deliver receipt + check-in +``` + +If the guest never pays, the hold-expiry sweep +([`../tasks.py`](../tasks.py) `expire_holds_loop`) flips the booking to +`expired` after `default_hold_minutes` and the dates free themselves. + +## Why payment (not a Nostr confirm event) is the commit point + +A "confirm" event could be forged, replayed, or arrive out of order, and +relays give no ordering or delivery guarantees. The Lightning payment is +unforgeable and already the thing we actually care about. So the invoice +listener is the *only* writer that sets `confirmed`. Nostr just announces +the result. + +## Concurrency — the check-then-hold lock + +`is_available()` followed by writing the `held` row is the critical section. +Two simultaneous requests for the same nights could both pass the read +before either writes. Required mitigation (marked `TODO(concurrency)` in +`views_api.py`): + +- wrap check + insert in a DB transaction, **or** +- take a per-room `asyncio.Lock` around the section. + +Because the check reads *occupying* bookings (`held` included), once one +request wins and writes `held`, the loser's re-check fails → `409`. The DB +is the arbiter; the lock just makes the read-write atomic. + +## Cancellation & refunds (design intent) + +- Cancel frees dates immediately (status → `cancelled`) and republishes the + `kind:30078` reservation with the new status so the guest's copy updates. +- Refund policy (`settings.cancellation_policy`) drives whether/how much is + returned — via LNURL-withdraw or a manual payout. Not auto-refunding on + chain; kept operator-mediated for a small castle. + +## Nostr-native direction + +Per the workspace long-term goal (webapp ↔ LNbits over Nostr, no HTTP), the +Nostr door is primary and REST is transitional. Keep new booking logic in +`crud.py` so both doors share it — don't grow HTTP-only paths that later +need ripping out.