feat: operator settings, guest booking list, card checkout #22
11 changed files with 417 additions and 36 deletions
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>
commit
0dad30b648
|
|
@ -6,7 +6,7 @@
|
|||
"short_description": "Nostr-native room rentals (Airbnb-style) for LNbits",
|
||||
"description": "",
|
||||
"tile": "/chatelet/static/image/aio.png",
|
||||
"min_lnbits_version": "1.4.0",
|
||||
"min_lnbits_version": "1.4.1",
|
||||
"contributors": [
|
||||
{
|
||||
"name": "padreug",
|
||||
|
|
|
|||
|
|
@ -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_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_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_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
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("/")
|
||||
30
models.py
30
models.py
|
|
@ -20,8 +20,9 @@ Design notes carried into the field definitions:
|
|||
import json
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
|
|
@ -167,6 +168,27 @@ class BookingRequestData(BaseModel):
|
|||
num_guests: int = 1
|
||||
guest_contact: str | None = None # optional email/phone/nostr note
|
||||
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):
|
||||
|
|
@ -316,5 +338,9 @@ class BookingQuote(BaseModel):
|
|||
computes what they owe."""
|
||||
|
||||
booking: Booking
|
||||
payment_request: str
|
||||
payment_request: str | None # bolt11 — None on the card rail
|
||||
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
|
||||
|
|
|
|||
96
services.py
96
services.py
|
|
@ -17,13 +17,15 @@ from collections import defaultdict
|
|||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
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.helpers import urlsafe_short_hash
|
||||
from lnbits.settings import settings as lnbits_settings
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||
|
||||
from . import crud
|
||||
from .frontend import resolve_frontend_root
|
||||
from .models import (
|
||||
HOUSE_RULE_FIELDS,
|
||||
AvailabilityResult,
|
||||
|
|
@ -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`
|
||||
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)
|
||||
if not room or room.status != RoomStatus.active:
|
||||
raise NotFound("Room not available")
|
||||
|
|
@ -239,6 +266,10 @@ async def request_booking(data: BookingRequestData) -> BookingQuote:
|
|||
if data.num_guests > room.max_guests:
|
||||
raise ValueError(f"Max {room.max_guests} guests")
|
||||
|
||||
# Rail + provider resolution happens before the hold so a refused rail
|
||||
# never leaves a dead hold behind.
|
||||
provider, frontend_root = await _resolve_rail(room, data, base_url)
|
||||
|
||||
# Compute the canonical amount up front (FX call) so the lock below wraps
|
||||
# only the DB check + insert, never the slow network work.
|
||||
settings = await crud.get_or_create_settings()
|
||||
|
|
@ -280,30 +311,59 @@ async def request_booking(data: BookingRequestData) -> BookingQuote:
|
|||
raise Unavailable("Those dates are no longer available")
|
||||
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},
|
||||
# One invoice call for both rails (core forks on fiat_provider). Lightning
|
||||
# is sats-denominated (deposit_sat locked at quote time so FX drift can't
|
||||
# change what's owed); card charges the same deposit share of the fiat
|
||||
# price in the room's currency — core refuses sat units for fiat, which
|
||||
# payment_methods_for_room already rules out. tag+booking_id let
|
||||
# tasks.on_invoice_paid match the settlement back to this booking on
|
||||
# either rail, since core settles Stripe onto the same invoice queue.
|
||||
stay = f"{booking.check_in}→{booking.check_out} ({nights}n)"
|
||||
memo = f"Chatelet · {room.title} · {stay}"
|
||||
extra: dict = {"tag": "chatelet", "booking_id": booking.id}
|
||||
invoice = CreateInvoice(
|
||||
out=False, amount=booking.deposit_sat, unit="sat", memo=memo, extra=extra
|
||||
)
|
||||
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
|
||||
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.status = BookingStatus.awaiting_payment
|
||||
await crud.update_booking(booking)
|
||||
|
||||
payment_extra = getattr(payment, "extra", None) or {}
|
||||
return BookingQuote(
|
||||
booking=booking,
|
||||
payment_request=payment.bolt11,
|
||||
payment_request=getattr(payment, "bolt11", None) or None,
|
||||
payment_hash=payment.payment_hash,
|
||||
fiat_payment_request=payment_extra.get("fiat_payment_request"),
|
||||
fiat_provider=getattr(payment, "fiat_provider", None) or provider,
|
||||
is_fiat=provider is not None,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from types import SimpleNamespace
|
|||
|
||||
from .. import crud, services
|
||||
from ..models import BookingQuote
|
||||
from .conftest import make_request, make_room
|
||||
from .conftest import make_request, make_room, patch_owner
|
||||
|
||||
|
||||
def _setup(monkeypatch, room):
|
||||
|
|
@ -39,10 +39,13 @@ def _setup(monkeypatch, room):
|
|||
async def fake_update_booking(booking):
|
||||
return booking
|
||||
|
||||
async def fake_create_invoice(**kwargs):
|
||||
invoices.append(kwargs)
|
||||
async def fake_create_payment_request(*, wallet_id, invoice_data):
|
||||
invoices.append(invoice_data)
|
||||
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)
|
||||
|
|
@ -50,7 +53,10 @@ def _setup(monkeypatch, room):
|
|||
monkeypatch.setattr(crud, "is_available", fake_is_available)
|
||||
monkeypatch.setattr(crud, "create_booking", fake_create_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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -9,12 +9,16 @@ from lnbits.exceptions import InvoiceError
|
|||
|
||||
from .. import crud, services
|
||||
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
|
||||
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(_):
|
||||
return room
|
||||
|
|
@ -36,18 +40,36 @@ def _setup(monkeypatch, room, *, invoice_raises=False):
|
|||
updated.append(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:
|
||||
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_or_create_settings", fake_settings)
|
||||
monkeypatch.setattr(crud, "is_available", fake_is_available)
|
||||
monkeypatch.setattr(crud, "create_booking", fake_create_booking)
|
||||
monkeypatch.setattr(crud, "update_booking", fake_update_booking)
|
||||
monkeypatch.setattr(services, "create_invoice", fake_create_invoice)
|
||||
return created, updated
|
||||
monkeypatch.setattr(
|
||||
services, "create_payment_request", fake_create_payment_request
|
||||
)
|
||||
return created, updated, invoices
|
||||
|
||||
|
||||
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):
|
||||
created, updated = _setup(
|
||||
created, updated, _ = _setup(
|
||||
monkeypatch, make_room(), invoice_raises=True
|
||||
)
|
||||
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/")
|
||||
|
|
@ -172,7 +172,12 @@ async def handle_booking_request(auth: None, request: NostrRpcRequest) -> dict:
|
|||
num_guests=body.get("num_guests", 1),
|
||||
guest_contact=body.get("guest_contact"),
|
||||
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)
|
||||
return _to_dict(quote)
|
||||
|
||||
|
|
|
|||
10
views_api.py
10
views_api.py
|
|
@ -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.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from lnbits.core.models import User, WalletTypeInfo
|
||||
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)
|
||||
async def api_request_booking(data: BookingRequestData) -> BookingQuote:
|
||||
async def api_request_booking(
|
||||
data: BookingRequestData, request: Request
|
||||
) -> BookingQuote:
|
||||
try:
|
||||
return await services.request_booking(data)
|
||||
return await services.request_booking(
|
||||
data, base_url=str(request.base_url)
|
||||
)
|
||||
except services.BookingError as exc:
|
||||
raise HTTPException(502, str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue