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:
Padreug 2026-09-16 12:25:31 +02:00
commit 0dad30b648
11 changed files with 417 additions and 36 deletions

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