chatelet/services.py
Padreug 9d87c129ad fix: make check-then-hold atomic against concurrent bookings (#4)
Two simultaneous requests for the same nights could both pass is_available()
before either wrote its held row, double-booking the dates. Wrap the
is_available -> create_booking pair in a per-room asyncio.Lock
(_room_locks[room_id]) in services.request_booking, which both the HTTP and
RPC doors funnel through. FX + invoice creation stay outside the lock, so it
covers only the DB critical section.

Single-loop scope (LNbits runs one worker); documented the multi-worker
caveat (needs a DB-level guard) in event-flow.md and the crud.is_available
note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 17:08:43 +02:00

169 lines
6.6 KiB
Python

"""Booking orchestration shared by both entry points.
`views_api.py` (HTTP) and `transport_rpcs.py` (Nostr kind-21000 RPC) are two
doors into the same flow; the availability arbiter + quoting + hold + invoice
logic lives here so neither door duplicates it and they can't drift. `crud.py`
is persistence; this module is the flow on top of it.
Exceptions are typed so each door can map them to its own error surface (HTTP
status / RPC error). All the client-facing ones subclass `ValueError` so the
Nostr dispatcher — which turns `ValueError`/`PermissionError` into a returned
error message — relays them to the caller verbatim; `BookingError` is a
backend failure and surfaces as a generic error over RPC (logged server-side).
"""
import asyncio
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from lnbits.core.services import create_invoice
from lnbits.exceptions import InvoiceError
from lnbits.helpers import urlsafe_short_hash
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from . import crud
from .models import (
AvailabilityResult,
Booking,
BookingQuote,
BookingRequestData,
BookingStatus,
RoomStatus,
)
# Per-room lock serializing the availability read + the `held` write, so two
# concurrent requests for the same nights can't both pass the check before
# either commits the hold (double-booking). Keyed by room id; the dict grows
# by distinct rooms only (bounded for a castle). Single-asyncio-loop scope —
# see the note in request_booking on the multi-worker caveat.
_room_locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
class NotFound(ValueError):
"""Referenced entity does not exist (-> HTTP 404)."""
class Unavailable(ValueError):
"""Room inactive or dates already taken (-> HTTP 409)."""
class BookingError(Exception):
"""Backend failure mid-booking, e.g. invoice creation (-> HTTP 502)."""
async def to_sats(amount: float, currency: str) -> int:
"""Fiat/currency -> sats. The single FX point; its result is the canonical
amount_sat and is never recomputed downstream (source-of-truth rule)."""
if currency.lower() in ("sat", "sats"):
return int(amount)
return await fiat_amount_as_satoshis(amount, currency)
async def get_availability(
room_id: str, check_in: str, check_out: str
) -> AvailabilityResult:
room = await crud.get_room(room_id)
if not room:
raise NotFound("Room not found")
nights = crud.nights_between(check_in, check_out)
if nights < 1:
raise ValueError("check_out must be after check_in")
available = await crud.is_available(room_id, check_in, check_out)
quote_sat = quote_fiat = None
if available:
quote_fiat = round(room.price_amount * nights, 2)
quote_sat = await to_sats(quote_fiat, room.price_currency)
return AvailabilityResult(
room_id=room_id,
check_in=check_in,
check_out=check_out,
available=available,
nights=nights,
quote_sat=quote_sat,
quote_fiat=quote_fiat,
currency=room.price_currency,
)
async def request_booking(data: BookingRequestData) -> BookingQuote:
"""Check-then-hold, then invoice. The `is_available` read + the `held`
write are the lock; TODO(#4) makes that pair atomic against a concurrent
request. Returns the held booking + the bolt11 that will confirm it."""
room = await crud.get_room(data.room_id)
if not room or room.status != RoomStatus.active:
raise NotFound("Room not available")
nights = crud.nights_between(data.check_in, data.check_out)
if nights < room.min_nights:
raise ValueError(f"Minimum stay is {room.min_nights} night(s)")
if data.num_guests > room.max_guests:
raise ValueError(f"Max {room.max_guests} guests")
# Compute the canonical amount up front (FX call) so the lock below wraps
# only the DB check + insert, never the slow network work.
settings = await crud.get_or_create_settings()
price_fiat = round(room.price_amount * nights, 2)
amount_sat = await to_sats(price_fiat, room.price_currency) # canonical
deposit_sat = amount_sat * settings.deposit_percent // 100
booking = Booking(
id=urlsafe_short_hash()[:10],
room_id=room.id,
guest_pubkey=data.guest_pubkey,
guest_contact=data.guest_contact,
check_in=data.check_in,
check_out=data.check_out,
nights=nights,
num_guests=data.num_guests,
currency=room.price_currency,
price_fiat=price_fiat,
amount_sat=amount_sat,
deposit_sat=deposit_sat,
status=BookingStatus.held,
expires_at=datetime.now(timezone.utc)
+ timedelta(minutes=settings.default_hold_minutes),
)
# Atomic check-then-hold. The `held` row is itself the lock on the dates
# (is_available counts held as occupying), so serializing the read+insert
# per room means the first request to commit wins and every later one sees
# it and gets Unavailable. FX + invoice creation stay outside the lock.
#
# Scope: a single asyncio loop. LNbits runs one worker, so an asyncio.Lock
# is sufficient; if it ever runs multi-worker/multi-process this must move
# to a DB-level guard (Postgres exclusion constraint or SELECT ... FOR
# UPDATE) — noted in issue #4 / event-flow.md.
async with _room_locks[room.id]:
if not await crud.is_available(
data.room_id, data.check_in, data.check_out
):
raise Unavailable("Those dates are no longer available")
await crud.create_booking(booking)
# Sats-denominated (deposit_sat locked at quote time) so FX drift before
# payment can't change what's owed. tag+booking_id let
# tasks.on_invoice_paid match the settlement back to this booking.
try:
payment = await create_invoice(
wallet_id=room.wallet,
amount=booking.deposit_sat,
memo=(
f"Chatelet · {room.title} · "
f"{booking.check_in}{booking.check_out} ({nights}n)"
),
extra={"tag": "chatelet", "booking_id": booking.id},
)
except InvoiceError as exc:
booking.status = BookingStatus.declined # dead hold -> free the dates
await crud.update_booking(booking)
raise BookingError(f"Could not create invoice: {exc.message}") from exc
booking.payment_hash = payment.payment_hash
booking.status = BookingStatus.awaiting_payment
await crud.update_booking(booking)
return BookingQuote(
booking=booking,
payment_request=payment.bolt11,
payment_hash=payment.payment_hash,
)