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>
161 lines
6 KiB
Python
161 lines
6 KiB
Python
"""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
|