chatelet/tests/test_fiat_checkout.py
Padreug 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

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