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>
This commit is contained in:
parent
8557ce617e
commit
0dad30b648
11 changed files with 417 additions and 36 deletions
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue