refactor: extract booking flow into services.py
Move availability quoting + the check-then-hold + invoice orchestration out of views_api.py into a transport-agnostic services.py, with typed errors (NotFound/Unavailable/ValueError/BookingError). views_api becomes a thin HTTP door that maps those to status codes. No behavior change — this is so the incoming nostr-transport door can share one booking flow instead of duplicating it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
This commit is contained in:
parent
0cc419e5fa
commit
5cfc9b893d
2 changed files with 171 additions and 120 deletions
147
services.py
Normal file
147
services.py
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
144
views_api.py
144
views_api.py
|
|
@ -1,33 +1,23 @@
|
||||||
"""Chatelet REST API.
|
"""Chatelet REST API.
|
||||||
|
|
||||||
The REST surface and the Nostr surface (nostr/service.py) are two doors
|
The REST surface and the Nostr-transport surface (transport_rpcs.py) are two
|
||||||
into the SAME booking flow — both funnel through crud + the helpers here so
|
doors into the SAME booking flow — both delegate to services.py so
|
||||||
availability arbitration and quoting live in exactly one place. Per the
|
availability arbitration + quoting live in one place. Per the aiolabs
|
||||||
aiolabs long-term direction (webapp<->lnbits over Nostr), REST is the
|
long-term direction (webapp<->lnbits over Nostr), REST is the transitional
|
||||||
transitional door; don't grow booking logic that only the HTTP path knows.
|
door; keep new booking logic in services.py, not here.
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from lnbits.core.models import WalletTypeInfo
|
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.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 (
|
from .models import (
|
||||||
AvailabilityQuery,
|
AvailabilityQuery,
|
||||||
AvailabilityResult,
|
AvailabilityResult,
|
||||||
Booking,
|
Booking,
|
||||||
BookingQuote,
|
BookingQuote,
|
||||||
BookingRequestData,
|
BookingRequestData,
|
||||||
BookingStatus,
|
|
||||||
CreateBlockData,
|
CreateBlockData,
|
||||||
CreateRoomData,
|
CreateRoomData,
|
||||||
Room,
|
Room,
|
||||||
|
|
@ -37,6 +27,15 @@ from .nostr import service as nostr
|
||||||
chatelet_api_router = APIRouter()
|
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) ----------------------
|
# --- rooms (operator; admin-key scoped to own wallet) ----------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -70,27 +69,10 @@ async def api_publish_room(
|
||||||
|
|
||||||
@chatelet_api_router.post("/api/v1/availability")
|
@chatelet_api_router.post("/api/v1/availability")
|
||||||
async def api_check_availability(q: AvailabilityQuery) -> AvailabilityResult:
|
async def api_check_availability(q: AvailabilityQuery) -> AvailabilityResult:
|
||||||
room = await crud.get_room(q.room_id)
|
try:
|
||||||
if not room:
|
return await services.get_availability(q.room_id, q.check_in, q.check_out)
|
||||||
raise HTTPException(404, "Room not found")
|
except ValueError as exc:
|
||||||
nights = crud.nights_between(q.check_in, q.check_out)
|
raise _to_http(exc) from exc
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --- booking (public write; guest-initiated) -------------------------------
|
# --- 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)
|
@chatelet_api_router.post("/api/v1/bookings", status_code=201)
|
||||||
async def api_request_booking(data: BookingRequestData) -> BookingQuote:
|
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:
|
try:
|
||||||
payment = await create_invoice(
|
return await services.request_booking(data)
|
||||||
wallet_id=room.wallet,
|
except services.BookingError as exc:
|
||||||
amount=booking.deposit_sat,
|
raise HTTPException(502, str(exc)) from exc
|
||||||
memo=(
|
except ValueError as exc:
|
||||||
f"Chatelet · {room.title} · "
|
raise _to_http(exc) from exc
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@chatelet_api_router.get("/api/v1/bookings/{booking_id}")
|
@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
|
room, block.start_date, block.end_date, block.id
|
||||||
)
|
)
|
||||||
return block
|
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)
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue