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

View file

@ -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)"
# 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
)
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
),
extra={"tag": "chatelet", "booking_id": booking.id},
"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,
)
except InvoiceError as exc:
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,
)