Compare commits
7 commits
feat/opera
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 34f989eedb | |||
| f7e0d51fd6 | |||
| b5b5141ea4 | |||
| 21aa6ea7c3 | |||
| 0dad30b648 | |||
| 8557ce617e | |||
| 8b816d83b0 |
14 changed files with 562 additions and 39 deletions
|
|
@ -67,6 +67,7 @@ def chatelet_start():
|
||||||
handle_availability,
|
handle_availability,
|
||||||
handle_block_create,
|
handle_block_create,
|
||||||
handle_booking_get,
|
handle_booking_get,
|
||||||
|
handle_booking_list_mine,
|
||||||
handle_booking_request,
|
handle_booking_request,
|
||||||
handle_operator_get,
|
handle_operator_get,
|
||||||
handle_operator_update,
|
handle_operator_update,
|
||||||
|
|
@ -95,6 +96,7 @@ def chatelet_start():
|
||||||
register_rpc("chatelet_availability", handle_availability, AUTH_NONE)
|
register_rpc("chatelet_availability", handle_availability, AUTH_NONE)
|
||||||
register_rpc("chatelet_booking_request", handle_booking_request, AUTH_NONE)
|
register_rpc("chatelet_booking_request", handle_booking_request, AUTH_NONE)
|
||||||
register_rpc("chatelet_booking_get", handle_booking_get, AUTH_NONE)
|
register_rpc("chatelet_booking_get", handle_booking_get, AUTH_NONE)
|
||||||
|
register_rpc("chatelet_booking_list_mine", handle_booking_list_mine, AUTH_NONE)
|
||||||
|
|
||||||
# tasks.py stamps extra["booking_id"] on settlement (see on_invoice_paid),
|
# tasks.py stamps extra["booking_id"] on settlement (see on_invoice_paid),
|
||||||
# so override the default link_extra_key ("link") to match.
|
# so override the default link_extra_key ("link") to match.
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"id": "chatelet",
|
"id": "chatelet",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"name": "Chatelet",
|
"name": "Chatelet",
|
||||||
"repo": "https://git.atitlan.io/aiolabs/chatelet",
|
"repo": "https://git.atitlan.io/aiolabs/chatelet",
|
||||||
"short_description": "Nostr-native room rentals (Airbnb-style) for LNbits",
|
"short_description": "Nostr-native room rentals (Airbnb-style) for LNbits",
|
||||||
"description": "",
|
"description": "",
|
||||||
"tile": "/chatelet/static/image/aio.png",
|
"tile": "/chatelet/static/image/aio.png",
|
||||||
"min_lnbits_version": "1.4.0",
|
"min_lnbits_version": "1.4.1",
|
||||||
"contributors": [
|
"contributors": [
|
||||||
{
|
{
|
||||||
"name": "padreug",
|
"name": "padreug",
|
||||||
|
|
|
||||||
12
crud.py
12
crud.py
|
|
@ -139,6 +139,18 @@ async def get_booking_by_payment_hash(payment_hash: str) -> Booking | None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_bookings_for_guest(guest_pubkey: str, limit: int = 200) -> list[Booking]:
|
||||||
|
"""A guest's own stays, newest check-in first (idx_bookings_guest_pubkey)."""
|
||||||
|
return await db.fetchall(
|
||||||
|
"""
|
||||||
|
SELECT * FROM chatelet.bookings WHERE guest_pubkey = :pk
|
||||||
|
ORDER BY check_in DESC LIMIT :limit
|
||||||
|
""",
|
||||||
|
{"pk": guest_pubkey, "limit": limit},
|
||||||
|
Booking,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_bookings_for_room(room_id: str) -> list[Booking]:
|
async def get_bookings_for_room(room_id: str) -> list[Booking]:
|
||||||
return await db.fetchall(
|
return await db.fetchall(
|
||||||
"SELECT * FROM chatelet.bookings WHERE room_id = :rid",
|
"SELECT * FROM chatelet.bookings WHERE room_id = :rid",
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,9 @@ flow runs over relays with no HTTP:
|
||||||
| `chatelet_room_list` / `_get` | none | public discovery (active rooms, wallet id stripped, owner's `house_rules` + `payment_methods` attached) |
|
| `chatelet_room_list` / `_get` | none | public discovery (active rooms, wallet id stripped, owner's `house_rules` + `payment_methods` attached) |
|
||||||
| `chatelet_room_unavailable` | none | merged occupied/blocked spans over a window — the guest calendar feed (HTTP twin: `GET /api/v1/public/rooms/{id}/unavailable`) |
|
| `chatelet_room_unavailable` | none | merged occupied/blocked spans over a window — the guest calendar feed (HTTP twin: `GET /api/v1/public/rooms/{id}/unavailable`) |
|
||||||
| `chatelet_availability` | none | is a range free + a quote |
|
| `chatelet_availability` | none | is a range free + a quote |
|
||||||
| `chatelet_booking_request` | none | guest requests a stay (guest id = signed `sender_pubkey`) |
|
| `chatelet_booking_request` | none | guest requests a stay (guest id = signed `sender_pubkey`); `payment_method` `lightning` (default) or `fiat` + optional `fiat_provider` / `frontend_url` — card returns `fiat_payment_request` (hosted checkout URL) instead of a bolt11 |
|
||||||
| `chatelet_booking_get` | none | guest reads back their booking (ownership by `sender_pubkey`) |
|
| `chatelet_booking_get` | none | guest reads back their booking (ownership by `sender_pubkey`) |
|
||||||
|
| `chatelet_booking_list_mine` | none | the caller's own bookings (`sender_pubkey`; HTTP twin `GET /api/v1/bookings/mine` uses the account's pubkey) |
|
||||||
|
|
||||||
Guest identity is the `sender_pubkey` the dispatcher lifts off the signed
|
Guest identity is the `sender_pubkey` the dispatcher lifts off the signed
|
||||||
kind-21000 event — unspoofable, and it means no separate `guest_pubkey` is
|
kind-21000 event — unspoofable, and it means no separate `guest_pubkey` is
|
||||||
|
|
|
||||||
47
frontend.py
Normal file
47
frontend.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""Where to send a guest back to after a hosted (Stripe) checkout.
|
||||||
|
|
||||||
|
Ported from the events extension. The calling app names itself via
|
||||||
|
`frontend_url`; we only honour origins the LNbits instance already trusts
|
||||||
|
(the CORS allow-list, its own base URL, the configured custom frontend), and
|
||||||
|
fail loud on anything else — a wrong root would strand the guest in the
|
||||||
|
wrong app after paying. Transport-agnostic: the HTTP door passes the request
|
||||||
|
base URL as fallback, the RPC door has none and falls back to the instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
def origin(url: str | None) -> str | None:
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
parts = urlsplit(url.strip())
|
||||||
|
if not parts.scheme or not parts.netloc:
|
||||||
|
return None
|
||||||
|
return f"{parts.scheme.lower()}://{parts.netloc.lower()}"
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_frontend_origins() -> set[str]:
|
||||||
|
origins: set[str] = set()
|
||||||
|
for candidate in [
|
||||||
|
*getattr(settings, "lnbits_cors_allowed_origins", []),
|
||||||
|
settings.lnbits_baseurl,
|
||||||
|
getattr(settings, "lnbits_custom_frontend_url", None),
|
||||||
|
]:
|
||||||
|
o = origin(candidate)
|
||||||
|
if o:
|
||||||
|
origins.add(o)
|
||||||
|
return origins
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_frontend_root(
|
||||||
|
frontend_url: str | None, fallback_base_url: str | None
|
||||||
|
) -> str:
|
||||||
|
"""Root under which `/chatelet/{room_id}` resolves for the guest."""
|
||||||
|
if not frontend_url:
|
||||||
|
return (fallback_base_url or settings.lnbits_baseurl or "").rstrip("/")
|
||||||
|
o = origin(frontend_url)
|
||||||
|
if not o or o not in allowed_frontend_origins():
|
||||||
|
raise ValueError("frontend_url origin is not allowed.")
|
||||||
|
return frontend_url.rstrip("/")
|
||||||
40
models.py
40
models.py
|
|
@ -20,8 +20,9 @@ Design notes carried into the field definitions:
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, validator
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
|
|
@ -167,6 +168,27 @@ class BookingRequestData(BaseModel):
|
||||||
num_guests: int = 1
|
num_guests: int = 1
|
||||||
guest_contact: str | None = None # optional email/phone/nostr note
|
guest_contact: str | None = None # optional email/phone/nostr note
|
||||||
message: str | None = None # free-form note to the host
|
message: str | None = None # free-form note to the host
|
||||||
|
# Rail the guest wants to pay with. "fiat" needs the room owner to accept
|
||||||
|
# card AND LNbits core to have a provider for them (services checks).
|
||||||
|
payment_method: str = "lightning"
|
||||||
|
fiat_provider: str | None = None # e.g. "stripe"; defaults to the owner's first
|
||||||
|
# Where the hosted checkout should send the guest back (the calling app);
|
||||||
|
# origin must be one the instance trusts — see frontend.resolve_frontend_root.
|
||||||
|
frontend_url: str | None = Field(default=None, max_length=512)
|
||||||
|
|
||||||
|
@validator("frontend_url")
|
||||||
|
def validate_frontend_url(cls, v): # noqa: N805
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
v = v.strip()
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
parts = urlsplit(v)
|
||||||
|
if parts.scheme not in ("http", "https") or not parts.netloc:
|
||||||
|
raise ValueError("frontend_url must be an absolute http(s) URL")
|
||||||
|
if parts.query or parts.fragment or ".." in parts.path:
|
||||||
|
raise ValueError("frontend_url must not contain a query, fragment or '..'")
|
||||||
|
return v.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
class Booking(BaseModel):
|
class Booking(BaseModel):
|
||||||
|
|
@ -259,6 +281,16 @@ def public_booking_dict(booking: "Booking") -> dict:
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def guest_booking_dict(booking: "Booking") -> dict:
|
||||||
|
"""A Booking for the guest who owns it (authenticated by pubkey on either
|
||||||
|
door): everything public_booking_dict shows plus their own contact and
|
||||||
|
guest count; the Lightning/Nostr plumbing stays internal."""
|
||||||
|
d = json.loads(booking.json())
|
||||||
|
for k in ("payment_hash", "request_event_id", "reservation_event_id"):
|
||||||
|
d.pop(k, None)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
class AvailabilityQuery(BaseModel):
|
class AvailabilityQuery(BaseModel):
|
||||||
room_id: str
|
room_id: str
|
||||||
check_in: str # YYYY-MM-DD inclusive
|
check_in: str # YYYY-MM-DD inclusive
|
||||||
|
|
@ -306,5 +338,9 @@ class BookingQuote(BaseModel):
|
||||||
computes what they owe."""
|
computes what they owe."""
|
||||||
|
|
||||||
booking: Booking
|
booking: Booking
|
||||||
payment_request: str
|
payment_request: str | None # bolt11 — None on the card rail
|
||||||
payment_hash: str
|
payment_hash: str
|
||||||
|
# Card rail: the provider's hosted checkout URL to send the guest to.
|
||||||
|
fiat_payment_request: str | None = None
|
||||||
|
fiat_provider: str | None = None
|
||||||
|
is_fiat: bool = False
|
||||||
|
|
|
||||||
102
services.py
102
services.py
|
|
@ -17,13 +17,15 @@ from collections import defaultdict
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
from lnbits.core.crud.wallets import get_wallet
|
from lnbits.core.crud.wallets import get_wallet
|
||||||
from lnbits.core.services import create_invoice
|
from lnbits.core.models.payments import CreateInvoice
|
||||||
|
from lnbits.core.services import create_payment_request
|
||||||
from lnbits.exceptions import InvoiceError
|
from lnbits.exceptions import InvoiceError
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
from lnbits.settings import settings as lnbits_settings
|
from lnbits.settings import settings as lnbits_settings
|
||||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||||
|
|
||||||
from . import crud
|
from . import crud
|
||||||
|
from .frontend import resolve_frontend_root
|
||||||
from .models import (
|
from .models import (
|
||||||
HOUSE_RULE_FIELDS,
|
HOUSE_RULE_FIELDS,
|
||||||
AvailabilityResult,
|
AvailabilityResult,
|
||||||
|
|
@ -36,6 +38,7 @@ from .models import (
|
||||||
Room,
|
Room,
|
||||||
RoomStatus,
|
RoomStatus,
|
||||||
UnavailableRanges,
|
UnavailableRanges,
|
||||||
|
guest_booking_dict,
|
||||||
public_room_dict,
|
public_room_dict,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -151,6 +154,13 @@ async def public_room_views(rooms: list[Room]) -> list[dict]:
|
||||||
return out
|
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
|
# 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.
|
# can't make us scan and ship an unbounded span.
|
||||||
DEFAULT_CALENDAR_DAYS = 365
|
DEFAULT_CALENDAR_DAYS = 365
|
||||||
|
|
@ -217,10 +227,35 @@ async def get_availability(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def request_booking(data: BookingRequestData) -> BookingQuote:
|
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`
|
"""Check-then-hold, then invoice. The `is_available` read + the `held`
|
||||||
write are the lock; TODO(#4) makes that pair atomic against a concurrent
|
write are the lock; TODO(#4) makes that pair atomic against a concurrent
|
||||||
request. Returns the held booking + the bolt11 that will confirm it."""
|
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)
|
room = await crud.get_room(data.room_id)
|
||||||
if not room or room.status != RoomStatus.active:
|
if not room or room.status != RoomStatus.active:
|
||||||
raise NotFound("Room not available")
|
raise NotFound("Room not available")
|
||||||
|
|
@ -231,6 +266,10 @@ async def request_booking(data: BookingRequestData) -> BookingQuote:
|
||||||
if data.num_guests > room.max_guests:
|
if data.num_guests > room.max_guests:
|
||||||
raise ValueError(f"Max {room.max_guests} 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
|
# Compute the canonical amount up front (FX call) so the lock below wraps
|
||||||
# only the DB check + insert, never the slow network work.
|
# only the DB check + insert, never the slow network work.
|
||||||
settings = await crud.get_or_create_settings()
|
settings = await crud.get_or_create_settings()
|
||||||
|
|
@ -272,30 +311,59 @@ async def request_booking(data: BookingRequestData) -> BookingQuote:
|
||||||
raise Unavailable("Those dates are no longer available")
|
raise Unavailable("Those dates are no longer available")
|
||||||
await crud.create_booking(booking)
|
await crud.create_booking(booking)
|
||||||
|
|
||||||
# Sats-denominated (deposit_sat locked at quote time) so FX drift before
|
# One invoice call for both rails (core forks on fiat_provider). Lightning
|
||||||
# payment can't change what's owed. tag+booking_id let
|
# is sats-denominated (deposit_sat locked at quote time so FX drift can't
|
||||||
# tasks.on_invoice_paid match the settlement back to this booking.
|
# change what's owed); card charges the same deposit share of the fiat
|
||||||
try:
|
# price in the room's currency — core refuses sat units for fiat, which
|
||||||
payment = await create_invoice(
|
# payment_methods_for_room already rules out. tag+booking_id let
|
||||||
wallet_id=room.wallet,
|
# tasks.on_invoice_paid match the settlement back to this booking on
|
||||||
amount=booking.deposit_sat,
|
# either rail, since core settles Stripe onto the same invoice queue.
|
||||||
memo=(
|
stay = f"{booking.check_in}→{booking.check_out} ({nights}n)"
|
||||||
f"Chatelet · {room.title} · "
|
memo = f"Chatelet · {room.title} · {stay}"
|
||||||
f"{booking.check_in}→{booking.check_out} ({nights}n)"
|
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
|
||||||
),
|
),
|
||||||
extra={"tag": "chatelet", "booking_id": booking.id},
|
"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,
|
||||||
)
|
)
|
||||||
except InvoiceError as exc:
|
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
|
booking.status = BookingStatus.declined # dead hold -> free the dates
|
||||||
await crud.update_booking(booking)
|
await crud.update_booking(booking)
|
||||||
raise BookingError(f"Could not create invoice: {exc.message}") from exc
|
raise BookingError(f"Could not create invoice: {exc}") from exc
|
||||||
|
|
||||||
booking.payment_hash = payment.payment_hash
|
booking.payment_hash = payment.payment_hash
|
||||||
booking.status = BookingStatus.awaiting_payment
|
booking.status = BookingStatus.awaiting_payment
|
||||||
await crud.update_booking(booking)
|
await crud.update_booking(booking)
|
||||||
|
|
||||||
|
payment_extra = getattr(payment, "extra", None) or {}
|
||||||
return BookingQuote(
|
return BookingQuote(
|
||||||
booking=booking,
|
booking=booking,
|
||||||
payment_request=payment.bolt11,
|
payment_request=getattr(payment, "bolt11", None) or None,
|
||||||
payment_hash=payment.payment_hash,
|
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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from types import SimpleNamespace
|
||||||
|
|
||||||
from .. import crud, services
|
from .. import crud, services
|
||||||
from ..models import BookingQuote
|
from ..models import BookingQuote
|
||||||
from .conftest import make_request, make_room
|
from .conftest import make_request, make_room, patch_owner
|
||||||
|
|
||||||
|
|
||||||
def _setup(monkeypatch, room):
|
def _setup(monkeypatch, room):
|
||||||
|
|
@ -39,10 +39,13 @@ def _setup(monkeypatch, room):
|
||||||
async def fake_update_booking(booking):
|
async def fake_update_booking(booking):
|
||||||
return booking
|
return booking
|
||||||
|
|
||||||
async def fake_create_invoice(**kwargs):
|
async def fake_create_payment_request(*, wallet_id, invoice_data):
|
||||||
invoices.append(kwargs)
|
invoices.append(invoice_data)
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
payment_hash="ph_" + kwargs["extra"]["booking_id"], bolt11="lnbc_fake"
|
payment_hash="ph_" + invoice_data.extra["booking_id"],
|
||||||
|
bolt11="lnbc_fake",
|
||||||
|
fiat_provider=None,
|
||||||
|
extra=invoice_data.extra,
|
||||||
)
|
)
|
||||||
|
|
||||||
monkeypatch.setattr(crud, "get_room", fake_get_room)
|
monkeypatch.setattr(crud, "get_room", fake_get_room)
|
||||||
|
|
@ -50,7 +53,10 @@ def _setup(monkeypatch, room):
|
||||||
monkeypatch.setattr(crud, "is_available", fake_is_available)
|
monkeypatch.setattr(crud, "is_available", fake_is_available)
|
||||||
monkeypatch.setattr(crud, "create_booking", fake_create_booking)
|
monkeypatch.setattr(crud, "create_booking", fake_create_booking)
|
||||||
monkeypatch.setattr(crud, "update_booking", fake_update_booking)
|
monkeypatch.setattr(crud, "update_booking", fake_update_booking)
|
||||||
monkeypatch.setattr(services, "create_invoice", fake_create_invoice)
|
monkeypatch.setattr(
|
||||||
|
services, "create_payment_request", fake_create_payment_request
|
||||||
|
)
|
||||||
|
patch_owner(monkeypatch)
|
||||||
return held, invoices
|
return held, invoices
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,16 @@ from lnbits.exceptions import InvoiceError
|
||||||
|
|
||||||
from .. import crud, services
|
from .. import crud, services
|
||||||
from ..models import BookingQuote, BookingStatus
|
from ..models import BookingQuote, BookingStatus
|
||||||
from .conftest import make_request, make_room
|
from .conftest import make_request, make_room, patch_owner
|
||||||
|
|
||||||
|
|
||||||
def _setup(monkeypatch, room, *, invoice_raises=False):
|
def _setup(
|
||||||
|
monkeypatch, room, *, invoice_raises=False, accept_fiat=False, providers=()
|
||||||
|
):
|
||||||
created: list = [] # bookings passed to create_booking
|
created: list = [] # bookings passed to create_booking
|
||||||
updated: list = [] # bookings passed to update_booking (captures final state)
|
updated: list = [] # bookings passed to update_booking (captures final state)
|
||||||
|
invoices: list = [] # CreateInvoice objects handed to core
|
||||||
|
patch_owner(monkeypatch, accept_fiat=accept_fiat, providers=providers)
|
||||||
|
|
||||||
async def fake_get_room(_):
|
async def fake_get_room(_):
|
||||||
return room
|
return room
|
||||||
|
|
@ -36,18 +40,36 @@ def _setup(monkeypatch, room, *, invoice_raises=False):
|
||||||
updated.append(booking)
|
updated.append(booking)
|
||||||
return booking
|
return booking
|
||||||
|
|
||||||
async def fake_create_invoice(**kwargs):
|
async def fake_create_payment_request(*, wallet_id, invoice_data):
|
||||||
|
invoices.append(invoice_data)
|
||||||
if invoice_raises:
|
if invoice_raises:
|
||||||
raise InvoiceError("no funding source")
|
raise InvoiceError("no funding source")
|
||||||
return SimpleNamespace(payment_hash="ph_1", bolt11="lnbc_fake")
|
if invoice_data.fiat_provider:
|
||||||
|
return SimpleNamespace(
|
||||||
|
payment_hash="ph_1",
|
||||||
|
bolt11=None,
|
||||||
|
fiat_provider=invoice_data.fiat_provider,
|
||||||
|
extra={
|
||||||
|
**invoice_data.extra,
|
||||||
|
"fiat_payment_request": "https://checkout.stripe.test/s/1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return SimpleNamespace(
|
||||||
|
payment_hash="ph_1",
|
||||||
|
bolt11="lnbc_fake",
|
||||||
|
fiat_provider=None,
|
||||||
|
extra=invoice_data.extra,
|
||||||
|
)
|
||||||
|
|
||||||
monkeypatch.setattr(crud, "get_room", fake_get_room)
|
monkeypatch.setattr(crud, "get_room", fake_get_room)
|
||||||
monkeypatch.setattr(crud, "get_or_create_settings", fake_settings)
|
monkeypatch.setattr(crud, "get_or_create_settings", fake_settings)
|
||||||
monkeypatch.setattr(crud, "is_available", fake_is_available)
|
monkeypatch.setattr(crud, "is_available", fake_is_available)
|
||||||
monkeypatch.setattr(crud, "create_booking", fake_create_booking)
|
monkeypatch.setattr(crud, "create_booking", fake_create_booking)
|
||||||
monkeypatch.setattr(crud, "update_booking", fake_update_booking)
|
monkeypatch.setattr(crud, "update_booking", fake_update_booking)
|
||||||
monkeypatch.setattr(services, "create_invoice", fake_create_invoice)
|
monkeypatch.setattr(
|
||||||
return created, updated
|
services, "create_payment_request", fake_create_payment_request
|
||||||
|
)
|
||||||
|
return created, updated, invoices
|
||||||
|
|
||||||
|
|
||||||
def test_happy_path_holds_then_awaits_payment(monkeypatch):
|
def test_happy_path_holds_then_awaits_payment(monkeypatch):
|
||||||
|
|
@ -70,7 +92,7 @@ def test_min_nights_enforced(monkeypatch):
|
||||||
|
|
||||||
|
|
||||||
def test_invoice_failure_releases_hold(monkeypatch):
|
def test_invoice_failure_releases_hold(monkeypatch):
|
||||||
created, updated = _setup(
|
created, updated, _ = _setup(
|
||||||
monkeypatch, make_room(), invoice_raises=True
|
monkeypatch, make_room(), invoice_raises=True
|
||||||
)
|
)
|
||||||
with pytest.raises(services.BookingError):
|
with pytest.raises(services.BookingError):
|
||||||
|
|
|
||||||
161
tests/test_fiat_checkout.py
Normal file
161
tests/test_fiat_checkout.py
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
"""Card rail on request_booking: the operator opted in, core has a provider
|
||||||
|
for that owner, the room is fiat-priced — and the hosted checkout returns the
|
||||||
|
guest to the room with the booking id."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
from .. import crud, services
|
||||||
|
from ..models import BookingRequestData, BookingStatus
|
||||||
|
from .conftest import make_room, patch_owner
|
||||||
|
|
||||||
|
|
||||||
|
def _wire(monkeypatch, room, *, accept_fiat=True, providers=("stripe",), fail=False):
|
||||||
|
patch_owner(monkeypatch, accept_fiat=accept_fiat, providers=providers)
|
||||||
|
monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
settings, "lnbits_cors_allowed_origins", ["https://app.example"], raising=False
|
||||||
|
)
|
||||||
|
invoices: list = []
|
||||||
|
updated: list = []
|
||||||
|
|
||||||
|
async def gr(_):
|
||||||
|
return room
|
||||||
|
|
||||||
|
async def gs():
|
||||||
|
return SimpleNamespace(deposit_percent=50, default_hold_minutes=30)
|
||||||
|
|
||||||
|
async def avail(*_):
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def create(b):
|
||||||
|
return b
|
||||||
|
|
||||||
|
async def update(b):
|
||||||
|
updated.append(b)
|
||||||
|
return b
|
||||||
|
|
||||||
|
async def fake_cpr(*, wallet_id, invoice_data):
|
||||||
|
invoices.append(invoice_data)
|
||||||
|
if fail:
|
||||||
|
raise ValueError("Cannot create payment request: provider down")
|
||||||
|
return SimpleNamespace(
|
||||||
|
payment_hash="ph_f",
|
||||||
|
bolt11=None,
|
||||||
|
fiat_provider=invoice_data.fiat_provider,
|
||||||
|
extra={
|
||||||
|
**invoice_data.extra,
|
||||||
|
"fiat_payment_request": "https://checkout.stripe.test/s/1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def rate(amount, currency):
|
||||||
|
return 150_000 # sats for the whole stay; irrelevant to the fiat charge
|
||||||
|
|
||||||
|
monkeypatch.setattr(crud, "get_room", gr)
|
||||||
|
monkeypatch.setattr(crud, "get_or_create_settings", gs)
|
||||||
|
monkeypatch.setattr(crud, "is_available", avail)
|
||||||
|
monkeypatch.setattr(crud, "create_booking", create)
|
||||||
|
monkeypatch.setattr(crud, "update_booking", update)
|
||||||
|
monkeypatch.setattr(services, "create_payment_request", fake_cpr)
|
||||||
|
monkeypatch.setattr(services, "fiat_amount_as_satoshis", rate)
|
||||||
|
return invoices, updated
|
||||||
|
|
||||||
|
|
||||||
|
def _req(**over: Any) -> BookingRequestData:
|
||||||
|
base: dict[str, Any] = {
|
||||||
|
"room_id": "room1",
|
||||||
|
"guest_pubkey": "ab" * 32,
|
||||||
|
"check_in": "2026-11-01",
|
||||||
|
"check_out": "2026-11-03",
|
||||||
|
"guest_contact": "guest@example.com",
|
||||||
|
"payment_method": "fiat",
|
||||||
|
"frontend_url": "https://app.example/chatelet",
|
||||||
|
}
|
||||||
|
base.update(over)
|
||||||
|
return BookingRequestData(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def test_card_happy_path_returns_checkout_url(monkeypatch):
|
||||||
|
room = make_room("room1", price=100.0, currency="EUR")
|
||||||
|
invoices, _ = _wire(monkeypatch, room)
|
||||||
|
quote = asyncio.run(services.request_booking(_req(), base_url="https://lnbits.example/"))
|
||||||
|
|
||||||
|
assert quote.is_fiat and quote.fiat_provider == "stripe"
|
||||||
|
assert quote.payment_request is None
|
||||||
|
assert quote.fiat_payment_request == "https://checkout.stripe.test/s/1"
|
||||||
|
assert quote.booking.status == BookingStatus.awaiting_payment
|
||||||
|
|
||||||
|
inv = invoices[0]
|
||||||
|
assert inv.fiat_provider == "stripe" and inv.unit == "EUR"
|
||||||
|
assert inv.amount == 100.0 # 2 nights x 100 EUR at deposit_percent 50
|
||||||
|
assert inv.extra["tag"] == "chatelet"
|
||||||
|
assert inv.extra["booking_id"] == quote.booking.id
|
||||||
|
co = inv.extra["checkout"]
|
||||||
|
assert co["success_url"] == (
|
||||||
|
"https://app.example/chatelet/chatelet/room1"
|
||||||
|
f"?checkout=success&booking={quote.booking.id}"
|
||||||
|
)
|
||||||
|
assert co["cancel_url"].endswith(f"?checkout=cancelled&booking={quote.booking.id}")
|
||||||
|
assert co["customer_email"] == "guest@example.com"
|
||||||
|
assert co["metadata"] == {"booking_id": quote.booking.id, "room_id": "room1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_lightning_still_uses_sats_and_bolt11(monkeypatch):
|
||||||
|
room = make_room("room1", price=100.0, currency="EUR")
|
||||||
|
invoices, _ = _wire(monkeypatch, room)
|
||||||
|
|
||||||
|
async def fake_cpr(*, wallet_id, invoice_data):
|
||||||
|
invoices.append(invoice_data)
|
||||||
|
return SimpleNamespace(
|
||||||
|
payment_hash="ph_l", bolt11="lnbc1", fiat_provider=None, extra={}
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(services, "create_payment_request", fake_cpr)
|
||||||
|
quote = asyncio.run(services.request_booking(_req(payment_method="lightning")))
|
||||||
|
assert not quote.is_fiat and quote.payment_request == "lnbc1"
|
||||||
|
assert invoices[0].unit == "sat" and invoices[0].fiat_provider is None
|
||||||
|
assert invoices[0].amount == 75_000 # deposit_percent 50 of 150k sats
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("kwargs", "message"),
|
||||||
|
[
|
||||||
|
({"accept_fiat": False}, "not enabled"),
|
||||||
|
({"providers": ()}, "not enabled"), # no provider → rail not offered at all
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_card_refused_before_any_hold(monkeypatch, kwargs, message):
|
||||||
|
room = make_room("room1", price=100.0, currency="EUR")
|
||||||
|
invoices, updated = _wire(monkeypatch, room, **kwargs)
|
||||||
|
with pytest.raises(ValueError, match=message):
|
||||||
|
asyncio.run(services.request_booking(_req()))
|
||||||
|
assert invoices == [] and updated == [] # refused up front, nothing held
|
||||||
|
|
||||||
|
|
||||||
|
def test_sat_priced_room_cannot_take_card(monkeypatch):
|
||||||
|
room = make_room("room1", price=1000.0, currency="sat")
|
||||||
|
invoices, _ = _wire(monkeypatch, room)
|
||||||
|
with pytest.raises(ValueError, match="not enabled"):
|
||||||
|
asyncio.run(services.request_booking(_req()))
|
||||||
|
assert invoices == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unlisted_frontend_is_rejected_before_hold(monkeypatch):
|
||||||
|
room = make_room("room1", price=100.0, currency="EUR")
|
||||||
|
invoices, updated = _wire(monkeypatch, room)
|
||||||
|
with pytest.raises(ValueError, match="frontend_url"):
|
||||||
|
asyncio.run(services.request_booking(_req(frontend_url="https://evil.example/x")))
|
||||||
|
assert invoices == [] and updated == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_failure_releases_hold(monkeypatch):
|
||||||
|
room = make_room("room1", price=100.0, currency="EUR")
|
||||||
|
_, updated = _wire(monkeypatch, room, fail=True)
|
||||||
|
with pytest.raises(services.BookingError):
|
||||||
|
asyncio.run(services.request_booking(_req()))
|
||||||
|
assert updated[-1].status == BookingStatus.declined
|
||||||
50
tests/test_frontend_root.py
Normal file
50
tests/test_frontend_root.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
"""Hosted-checkout return root: only origins the instance trusts."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
from ..frontend import allowed_frontend_origins, resolve_frontend_root
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def lnbits_settings(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
settings, "lnbits_cors_allowed_origins", ["https://app.example"], raising=False
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
settings,
|
||||||
|
"lnbits_custom_frontend_url",
|
||||||
|
"https://Front.Example/login",
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_allowlist_collects_every_configured_origin(lnbits_settings):
|
||||||
|
assert allowed_frontend_origins() == {
|
||||||
|
"https://lnbits.example",
|
||||||
|
"https://app.example",
|
||||||
|
"https://front.example",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_absent_frontend_url_falls_back_to_the_request_host(lnbits_settings):
|
||||||
|
root = resolve_frontend_root(None, "https://lnbits.example/")
|
||||||
|
assert root == "https://lnbits.example"
|
||||||
|
|
||||||
|
|
||||||
|
def test_absent_frontend_url_and_no_request_uses_instance_base(lnbits_settings):
|
||||||
|
# The RPC door has no request host.
|
||||||
|
assert resolve_frontend_root(None, None) == "https://lnbits.example"
|
||||||
|
|
||||||
|
|
||||||
|
def test_allowed_origin_is_returned_without_trailing_slash(lnbits_settings):
|
||||||
|
out = resolve_frontend_root(
|
||||||
|
"https://app.example/chatelet/", "https://lnbits.example/"
|
||||||
|
)
|
||||||
|
assert out == "https://app.example/chatelet"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unlisted_origin_is_rejected_loudly(lnbits_settings):
|
||||||
|
with pytest.raises(ValueError, match="frontend_url"):
|
||||||
|
resolve_frontend_root("https://evil.example/x", "https://lnbits.example/")
|
||||||
92
tests/test_my_bookings.py
Normal file
92
tests/test_my_bookings.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
"""A guest's own bookings, on both doors. HTTP identity is the LNbits account
|
||||||
|
pubkey; RPC identity is the signed sender_pubkey. Neither leaks another
|
||||||
|
guest's rows, and the Lightning/Nostr plumbing stays internal."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from .. import crud, transport_rpcs, views_api
|
||||||
|
from ..models import Booking, BookingStatus, guest_booking_dict
|
||||||
|
|
||||||
|
PK = "ab" * 32
|
||||||
|
|
||||||
|
|
||||||
|
def _booking(i: int, **over: Any) -> Booking:
|
||||||
|
base: dict[str, Any] = {
|
||||||
|
"id": f"bk{i}",
|
||||||
|
"room_id": "a",
|
||||||
|
"guest_pubkey": PK,
|
||||||
|
"guest_contact": "me@example.com",
|
||||||
|
"check_in": f"2026-10-{10 + i:02d}",
|
||||||
|
"check_out": f"2026-10-{12 + i:02d}",
|
||||||
|
"nights": 2,
|
||||||
|
"num_guests": 1,
|
||||||
|
"currency": "EUR",
|
||||||
|
"price_fiat": 200.0,
|
||||||
|
"amount_sat": 300000,
|
||||||
|
"deposit_sat": 300000,
|
||||||
|
"status": BookingStatus.confirmed,
|
||||||
|
"payment_hash": f"ph{i}",
|
||||||
|
"request_event_id": "req",
|
||||||
|
"reservation_event_id": "res",
|
||||||
|
}
|
||||||
|
base.update(over)
|
||||||
|
return Booking(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_store(monkeypatch, rows):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
async def for_guest(pubkey, limit=200):
|
||||||
|
seen["pubkey"] = pubkey
|
||||||
|
return [b for b in rows if b.guest_pubkey == pubkey]
|
||||||
|
|
||||||
|
monkeypatch.setattr(crud, "get_bookings_for_guest", for_guest)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def test_guest_dict_keeps_own_contact_but_hides_plumbing():
|
||||||
|
d = guest_booking_dict(_booking(1))
|
||||||
|
assert d["guest_contact"] == "me@example.com"
|
||||||
|
assert d["guest_pubkey"] == PK
|
||||||
|
for hidden in ("payment_hash", "request_event_id", "reservation_event_id"):
|
||||||
|
assert hidden not in d
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_lists_only_the_callers_rows(monkeypatch):
|
||||||
|
rows = [_booking(1), _booking(2, guest_pubkey="cd" * 32)]
|
||||||
|
seen = _patch_store(monkeypatch, rows)
|
||||||
|
user = SimpleNamespace(id="u1", pubkey=PK)
|
||||||
|
out = asyncio.run(views_api.api_my_bookings(user=user))
|
||||||
|
assert seen["pubkey"] == PK
|
||||||
|
assert [b["id"] for b in out] == ["bk1"]
|
||||||
|
assert "payment_hash" not in out[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_rejects_account_without_pubkey(monkeypatch):
|
||||||
|
_patch_store(monkeypatch, [])
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
asyncio.run(
|
||||||
|
views_api.api_my_bookings(user=SimpleNamespace(id="u1", pubkey=None))
|
||||||
|
)
|
||||||
|
assert e.value.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_rpc_scopes_by_sender_and_requires_it(monkeypatch):
|
||||||
|
rows = [_booking(1)]
|
||||||
|
_patch_store(monkeypatch, rows)
|
||||||
|
req = transport_rpcs.NostrRpcRequest(
|
||||||
|
rpc_name="chatelet_booking_list_mine", request_id="r", body={}, sender_pubkey=PK
|
||||||
|
)
|
||||||
|
out = asyncio.run(transport_rpcs.handle_booking_list_mine(None, req))
|
||||||
|
assert [b["id"] for b in out] == ["bk1"]
|
||||||
|
|
||||||
|
anon = transport_rpcs.NostrRpcRequest(
|
||||||
|
rpc_name="chatelet_booking_list_mine", request_id="r", body={}
|
||||||
|
)
|
||||||
|
with pytest.raises(PermissionError):
|
||||||
|
asyncio.run(transport_rpcs.handle_booking_list_mine(None, anon))
|
||||||
|
|
@ -172,11 +172,23 @@ async def handle_booking_request(auth: None, request: NostrRpcRequest) -> dict:
|
||||||
num_guests=body.get("num_guests", 1),
|
num_guests=body.get("num_guests", 1),
|
||||||
guest_contact=body.get("guest_contact"),
|
guest_contact=body.get("guest_contact"),
|
||||||
message=body.get("message"),
|
message=body.get("message"),
|
||||||
|
payment_method=body.get("payment_method", "lightning"),
|
||||||
|
fiat_provider=body.get("fiat_provider"),
|
||||||
|
frontend_url=body.get("frontend_url"),
|
||||||
)
|
)
|
||||||
|
# No request host on this door: the checkout returns to the instance root
|
||||||
|
# unless the client names its own (allow-listed) frontend_url.
|
||||||
quote = await services.request_booking(data)
|
quote = await services.request_booking(data)
|
||||||
return _to_dict(quote)
|
return _to_dict(quote)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_booking_list_mine(auth: None, request: NostrRpcRequest) -> list[dict]:
|
||||||
|
"""The caller's own bookings, scoped by the signed sender_pubkey."""
|
||||||
|
if not request.sender_pubkey:
|
||||||
|
raise PermissionError("chatelet: caller identity required")
|
||||||
|
return await services.list_guest_bookings(request.sender_pubkey)
|
||||||
|
|
||||||
|
|
||||||
async def handle_booking_get(auth: None, request: NostrRpcRequest) -> dict:
|
async def handle_booking_get(auth: None, request: NostrRpcRequest) -> dict:
|
||||||
booking = await crud.get_booking(_require_id(request))
|
booking = await crud.get_booking(_require_id(request))
|
||||||
if not booking:
|
if not booking:
|
||||||
|
|
|
||||||
24
views_api.py
24
views_api.py
|
|
@ -7,9 +7,9 @@ endpoints (room/block CRUD, settings) are HTTP-only and back the admin UI;
|
||||||
the guest-facing surface (availability, booking) is what also rides the RPC.
|
the guest-facing surface (availability, booking) is what also rides the RPC.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from lnbits.core.models import WalletTypeInfo
|
from lnbits.core.models import User, WalletTypeInfo
|
||||||
from lnbits.decorators import require_admin_key, require_invoice_key
|
from lnbits.decorators import check_user_exists, require_admin_key, require_invoice_key
|
||||||
|
|
||||||
from . import crud, services
|
from . import crud, services
|
||||||
from .models import (
|
from .models import (
|
||||||
|
|
@ -277,15 +277,29 @@ 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, request: Request
|
||||||
|
) -> BookingQuote:
|
||||||
try:
|
try:
|
||||||
return await services.request_booking(data)
|
return await services.request_booking(
|
||||||
|
data, base_url=str(request.base_url)
|
||||||
|
)
|
||||||
except services.BookingError as exc:
|
except services.BookingError as exc:
|
||||||
raise HTTPException(502, str(exc)) from exc
|
raise HTTPException(502, str(exc)) from exc
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise _to_http(exc) from exc
|
raise _to_http(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@chatelet_api_router.get("/api/v1/bookings/mine")
|
||||||
|
async def api_my_bookings(user: User = Depends(check_user_exists)) -> list[dict]:
|
||||||
|
"""The signed-in guest's own stays. Identity is the LNbits account's Nostr
|
||||||
|
pubkey — the same value the booking request carried as guest_pubkey.
|
||||||
|
Declared before /bookings/{booking_id} so "mine" is not read as an id."""
|
||||||
|
if not user.pubkey:
|
||||||
|
raise HTTPException(409, "This account has no Nostr pubkey")
|
||||||
|
return await services.list_guest_bookings(user.pubkey)
|
||||||
|
|
||||||
|
|
||||||
@chatelet_api_router.get("/api/v1/bookings/{booking_id}")
|
@chatelet_api_router.get("/api/v1/bookings/{booking_id}")
|
||||||
async def api_get_booking(
|
async def api_get_booking(
|
||||||
booking_id: str, key: WalletTypeInfo = Depends(require_invoice_key)
|
booking_id: str, key: WalletTypeInfo = Depends(require_invoice_key)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue