chatelet/services.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

369 lines
14 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 date, datetime, timedelta, timezone
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services import create_payment_request
from lnbits.exceptions import InvoiceError
from lnbits.helpers import urlsafe_short_hash
from lnbits.settings import settings as lnbits_settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from . import crud
from .frontend import resolve_frontend_root
from .models import (
HOUSE_RULE_FIELDS,
AvailabilityResult,
Booking,
BookingQuote,
BookingRequestData,
BookingStatus,
DateRange,
OperatorSettings,
Room,
RoomStatus,
UnavailableRanges,
guest_booking_dict,
public_room_dict,
)
# 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)
# ---------------------------------------------------------------------------
# Operators + the public room view
# ---------------------------------------------------------------------------
LIGHTNING = "lightning"
FIAT = "fiat"
def is_fiat_currency(currency: str) -> bool:
return currency.lower() not in ("sat", "sats")
def fiat_providers_for_user(user_id: str) -> list[str]:
"""Fiat providers LNbits core will let this user charge with. The one
place chatelet consults core about card payments (lnbits#67's per-user
Stripe creds land behind this call); module-level so tests can patch it —
the pydantic settings object refuses monkeypatched methods."""
return lnbits_settings.get_fiat_providers_for_user(user_id)
async def room_owner_id(room: Room) -> str:
"""The LNbits user who operates a room (rooms belong to wallets)."""
wallet = await get_wallet(room.wallet)
if not wallet:
raise NotFound("Room's wallet not found")
return wallet.user
def payment_methods_for_room(
room: Room, owner_id: str, ops: OperatorSettings
) -> list[str]:
"""Rails a guest may pay this room with. Card needs three things: the
operator opted in, LNbits core has a fiat provider for *that user* (the
single seam lnbits#67's per-user Stripe creds will plug into — chatelet
never sees credentials), and a fiat-denominated price (core cannot bill
a sat amount through a fiat provider)."""
rails = [LIGHTNING]
if (
ops.accept_fiat
and is_fiat_currency(room.price_currency)
and fiat_providers_for_user(owner_id)
):
rails.append(FIAT)
return rails
def house_rules_dict(ops: OperatorSettings) -> dict:
return {k: getattr(ops, k) for k in HOUSE_RULE_FIELDS}
async def public_room_view(room: Room) -> dict:
"""public_room_dict + the owner's house rules and rails. Used by both
guest doors so a room looks the same over HTTP and RPC."""
owner = await room_owner_id(room)
ops = await crud.get_or_create_operator_settings(owner)
return public_room_dict(
room,
house_rules=house_rules_dict(ops),
payment_methods=payment_methods_for_room(room, owner, ops),
)
async def public_room_views(rooms: list[Room]) -> list[dict]:
"""Batch form: one owner/settings lookup per distinct wallet."""
owners: dict[str, str] = {}
ops_by_owner: dict[str, OperatorSettings] = {}
out = []
for room in rooms:
if room.wallet not in owners:
owners[room.wallet] = await room_owner_id(room)
owner = owners[room.wallet]
if owner not in ops_by_owner:
ops_by_owner[owner] = await crud.get_or_create_operator_settings(owner)
ops = ops_by_owner[owner]
out.append(
public_room_dict(
room,
house_rules=house_rules_dict(ops),
payment_methods=payment_methods_for_room(room, owner, ops),
)
)
return out
async def list_guest_bookings(guest_pubkey: str) -> list[dict]:
"""The caller's own bookings (identity established by the door: LNbits
account pubkey over HTTP, signed sender_pubkey over RPC)."""
rows = await crud.get_bookings_for_guest(guest_pubkey)
return [guest_booking_dict(b) for b in rows]
# A guest calendar asks for a year by default; cap the window so a bad client
# can't make us scan and ship an unbounded span.
DEFAULT_CALENDAR_DAYS = 365
MAX_CALENDAR_DAYS = 400
async def get_unavailable_ranges(
room_id: str, start: str | None = None, end: str | None = None
) -> UnavailableRanges:
"""Occupied/blocked nights for one active room over [start, end).
Defaults to today → +365 days (UTC dates)."""
room = await crud.get_room(room_id)
if not room or room.status != RoomStatus.active:
raise NotFound("Room not available")
try:
d_start = (
date.fromisoformat(start) if start else datetime.now(timezone.utc).date()
)
d_end = (
date.fromisoformat(end)
if end
else d_start + timedelta(days=DEFAULT_CALENDAR_DAYS)
)
except ValueError as exc:
raise ValueError("Dates must be YYYY-MM-DD") from exc
if d_end <= d_start:
raise ValueError("end must be after start")
if (d_end - d_start).days > MAX_CALENDAR_DAYS:
raise ValueError(f"Window may span at most {MAX_CALENDAR_DAYS} days")
spans = await crud.get_occupied_ranges(
room_id, d_start.isoformat(), d_end.isoformat()
)
return UnavailableRanges(
room_id=room_id,
start=d_start.isoformat(),
end=d_end.isoformat(),
ranges=[DateRange(start=s, end=e) for s, e in spans],
)
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 _resolve_rail(
room: Room, data: BookingRequestData, base_url: str | None
) -> tuple[str | None, str]:
"""(fiat provider or None for Lightning, frontend root for the return
URLs). Providers come from LNbits core for the room *owner* — the seam
lnbits#67's per-user Stripe creds will plug into."""
method = (data.payment_method or LIGHTNING).lower()
if method not in (LIGHTNING, FIAT):
raise ValueError("Unknown payment method")
owner = await room_owner_id(room)
ops = await crud.get_or_create_operator_settings(owner)
if method not in payment_methods_for_room(room, owner, ops):
raise ValueError("Payment method not enabled for this room")
if method == LIGHTNING:
return None, ""
providers = fiat_providers_for_user(owner)
provider = data.fiat_provider or (providers[0] if providers else None)
if not provider or provider not in providers:
raise ValueError("No fiat payment provider configured")
return provider, resolve_frontend_root(data.frontend_url, base_url)
async def request_booking(
data: BookingRequestData, *, base_url: str | None = None
) -> 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 + what confirms it: a bolt11, or on the
card rail the provider's hosted-checkout URL."""
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")
# Rail + provider resolution happens before the hold so a refused rail
# never leaves a dead hold behind.
provider, frontend_root = await _resolve_rail(room, data, base_url)
# 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)
# One invoice call for both rails (core forks on fiat_provider). Lightning
# is sats-denominated (deposit_sat locked at quote time so FX drift can't
# change what's owed); card charges the same deposit share of the fiat
# price in the room's currency — core refuses sat units for fiat, which
# payment_methods_for_room already rules out. tag+booking_id let
# tasks.on_invoice_paid match the settlement back to this booking on
# either rail, since core settles Stripe onto the same invoice queue.
stay = f"{booking.check_in}→{booking.check_out} ({nights}n)"
memo = f"Chatelet · {room.title} · {stay}"
extra: dict = {"tag": "chatelet", "booking_id": booking.id}
invoice = CreateInvoice(
out=False, amount=booking.deposit_sat, unit="sat", memo=memo, extra=extra
)
if provider:
back = f"{frontend_root}/chatelet/{room.id}"
extra["checkout"] = {
"success_url": f"{back}?checkout=success&booking={booking.id}",
"cancel_url": f"{back}?checkout=cancelled&booking={booking.id}",
"customer_email": (
data.guest_contact
if data.guest_contact and "@" in data.guest_contact
else None
),
"line_item_name": f"{room.title} · {stay}",
"metadata": {"booking_id": booking.id, "room_id": room.id},
}
invoice = CreateInvoice(
out=False,
amount=round(price_fiat * settings.deposit_percent / 100, 2),
unit=room.price_currency,
fiat_provider=provider,
memo=memo,
extra=extra,
)
try:
payment = await create_payment_request(
wallet_id=room.wallet, invoice_data=invoice
)
except (InvoiceError, ValueError) as exc:
booking.status = BookingStatus.declined # dead hold -> free the dates
await crud.update_booking(booking)
raise BookingError(f"Could not create invoice: {exc}") from exc
booking.payment_hash = payment.payment_hash
booking.status = BookingStatus.awaiting_payment
await crud.update_booking(booking)
payment_extra = getattr(payment, "extra", None) or {}
return BookingQuote(
booking=booking,
payment_request=getattr(payment, "bolt11", None) or None,
payment_hash=payment.payment_hash,
fiat_payment_request=payment_extra.get("fiat_payment_request"),
fiat_provider=getattr(payment, "fiat_provider", None) or provider,
is_fiat=provider is not None,
)