diff --git a/services.py b/services.py new file mode 100644 index 0000000..e0a12ae --- /dev/null +++ b/services.py @@ -0,0 +1,147 @@ +"""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). +""" + +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, +) + + +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") + + # TODO(#4): wrap is_available + create_booking in a per-room lock / txn. + if not await crud.is_available(data.room_id, data.check_in, data.check_out): + raise Unavailable("Those dates are no longer available") + + 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), + ) + 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, + ) diff --git a/views_api.py b/views_api.py index 20ec9a1..91ec6b9 100644 --- a/views_api.py +++ b/views_api.py @@ -1,33 +1,23 @@ """Chatelet REST API. -The REST surface and the Nostr surface (nostr/service.py) are two doors -into the SAME booking flow — both funnel through crud + the helpers here so -availability arbitration and quoting live in exactly one place. Per the -aiolabs long-term direction (webapp<->lnbits over Nostr), REST is the -transitional door; don't grow booking logic that only the HTTP path knows. - -SKETCH: handlers show the intended flow and auth scopes; the pricing/FX and -invoice-creation calls are marked TODO where they'd touch core LNbits. +The REST surface and the Nostr-transport surface (transport_rpcs.py) are two +doors into the SAME booking flow — both delegate to services.py so +availability arbitration + quoting live in one place. Per the aiolabs +long-term direction (webapp<->lnbits over Nostr), REST is the transitional +door; keep new booking logic in services.py, not here. """ -from datetime import datetime, timedelta, timezone - from fastapi import APIRouter, Depends, HTTPException from lnbits.core.models import WalletTypeInfo -from lnbits.core.services import create_invoice from lnbits.decorators import require_admin_key, require_invoice_key -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 . import crud, services from .models import ( AvailabilityQuery, AvailabilityResult, Booking, BookingQuote, BookingRequestData, - BookingStatus, CreateBlockData, CreateRoomData, Room, @@ -37,6 +27,15 @@ from .nostr import service as nostr chatelet_api_router = APIRouter() +def _to_http(exc: ValueError) -> HTTPException: + """Map a services-layer ValueError subclass to an HTTP status.""" + if isinstance(exc, services.NotFound): + return HTTPException(404, str(exc)) + if isinstance(exc, services.Unavailable): + return HTTPException(409, str(exc)) + return HTTPException(400, str(exc)) + + # --- rooms (operator; admin-key scoped to own wallet) ---------------------- @@ -70,27 +69,10 @@ async def api_publish_room( @chatelet_api_router.post("/api/v1/availability") async def api_check_availability(q: AvailabilityQuery) -> AvailabilityResult: - room = await crud.get_room(q.room_id) - if not room: - raise HTTPException(404, "Room not found") - nights = crud.nights_between(q.check_in, q.check_out) - if nights < 1: - raise HTTPException(400, "check_out must be after check_in") - available = await crud.is_available(q.room_id, q.check_in, q.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=q.room_id, - check_in=q.check_in, - check_out=q.check_out, - available=available, - nights=nights, - quote_sat=quote_sat, - quote_fiat=quote_fiat, - currency=room.price_currency, - ) + try: + return await services.get_availability(q.room_id, q.check_in, q.check_out) + except ValueError as exc: + raise _to_http(exc) from exc # --- booking (public write; guest-initiated) ------------------------------- @@ -98,78 +80,12 @@ async def api_check_availability(q: AvailabilityQuery) -> AvailabilityResult: @chatelet_api_router.post("/api/v1/bookings", status_code=201) async def api_request_booking(data: BookingRequestData) -> BookingQuote: - """Guest requests a stay. Check-then-hold is the lock: if available we - write a `held` booking with the canonical amount_sat, create the deposit - invoice, and return the bolt11. The Nostr path calls the same steps.""" - room = await crud.get_room(data.room_id) - if not room or room.status != room.status.active: - raise HTTPException(404, "Room not available") - - nights = crud.nights_between(data.check_in, data.check_out) - if nights < room.min_nights: - raise HTTPException(400, f"Minimum stay is {room.min_nights} night(s)") - if data.num_guests > room.max_guests: - raise HTTPException(400, f"Max {room.max_guests} guests") - - # TODO(concurrency): wrap is_available + create_booking in a per-room - # lock / DB transaction so two requests can't both pass the read. - if not await crud.is_available(data.room_id, data.check_in, data.check_out): - raise HTTPException(409, "Those dates are no longer available") - - 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), - ) - await crud.create_booking(booking) - - # Invoice is denominated in sats (deposit_sat is already the canonical - # amount locked at quote time) so FX drift between now and payment can't - # change what the guest owes. 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: - # Free the dates immediately — a hold with no payable invoice is dead. - booking.status = BookingStatus.declined - await crud.update_booking(booking) - raise HTTPException(502, 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) - - # TODO(#5): DM the bolt11 to the guest over NIP-17 for the Nostr path. - return BookingQuote( - booking=booking, - payment_request=payment.bolt11, - payment_hash=payment.payment_hash, - ) + return await services.request_booking(data) + except services.BookingError as exc: + raise HTTPException(502, str(exc)) from exc + except ValueError as exc: + raise _to_http(exc) from exc @chatelet_api_router.get("/api/v1/bookings/{booking_id}") @@ -196,15 +112,3 @@ async def api_create_block( room, block.start_date, block.end_date, block.id ) return block - - -# --- helpers --------------------------------------------------------------- - - -async def _to_sats(amount: float, currency: str) -> int: - """Fiat/currency -> sats. Canonical conversion happens HERE, once, at - quote/hold time; the result is stored as amount_sat and never recomputed - downstream.""" - if currency.lower() in ("sat", "sats"): - return int(amount) - return await fiat_amount_as_satoshis(amount, currency)