docs: data-model + event-flow reference
data-model.md: entities, the canonical-amount_sat and derived-availability invariants, booking lifecycle diagram. event-flow.md: actor/kind map, the happy-path sequence diagram, why payment is the commit point, and the check-then-hold concurrency requirement. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
This commit is contained in:
parent
910c284a7d
commit
dfd54123bb
2 changed files with 205 additions and 0 deletions
97
docs/data-model.md
Normal file
97
docs/data-model.md
Normal file
|
|
@ -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.
|
||||||
108
docs/event-flow.md
Normal file
108
docs/event-flow.md
Normal file
|
|
@ -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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue