Compare commits

..

5 commits

Author SHA1 Message Date
34f989eedb Merge pull request 'feat: operator settings, guest booking list, card checkout' (#22) from feat/fiat-checkout into main
Reviewed-on: #22
2026-09-21 09:12:16 +00:00
f7e0d51fd6 chore(release): v0.5.0
#20 restores check_in/check_out on AvailabilityQuery (v0.4.0 shipped
the availability endpoint broken); #21 adds the keyless
/public/rooms/{id}/unavailable feed for the guest calendar.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 11:06:16 +02:00
b5b5141ea4 Merge pull request 'feat(api): keyless unavailable-ranges feed for the guest calendar' (#21) from feat/unavailable-ranges into main
Reviewed-on: #21
2026-09-21 09:05:33 +00:00
21aa6ea7c3 Merge pull request 'fix(models): restore check_in/check_out on AvailabilityQuery' (#20) from fix/availability-query-fields into main
Reviewed-on: #20
2026-09-21 09:03:31 +00:00
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
11 changed files with 418 additions and 37 deletions

View file

@ -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",

View file

@ -29,7 +29,7 @@ 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) | | `chatelet_booking_list_mine` | none | the caller's own bookings (`sender_pubkey`; HTTP twin `GET /api/v1/bookings/mine` uses the account's pubkey) |

47
frontend.py Normal file
View 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("/")

View file

@ -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):
@ -316,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

View file

@ -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,
@ -225,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")
@ -239,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()
@ -280,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(
extra={"tag": "chatelet", "booking_id": booking.id}, out=False, amount=booking.deposit_sat, unit="sat", memo=memo, extra=extra
) )
except InvoiceError as exc: 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 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,
) )

View file

@ -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

View file

@ -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
View 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

View 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/")

View file

@ -172,7 +172,12 @@ 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)

View file

@ -7,7 +7,7 @@ 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 User, WalletTypeInfo from lnbits.core.models import User, WalletTypeInfo
from lnbits.decorators import check_user_exists, require_admin_key, require_invoice_key from lnbits.decorators import check_user_exists, require_admin_key, require_invoice_key
@ -277,9 +277,13 @@ 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: