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
94
services.py
94
services.py
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue