Chatelet is multi-tenant: any LNbits user can host rooms. What an operator decides for all their rooms now lives in chatelet.operator_settings, keyed by user id and created lazily (m003, which also indexes bookings by guest): check-in/out times, cancellation policy, and accept_fiat. Guests see it: the public room view (both doors) gains house_rules and payment_methods, and the kind:30402 listing carries payment_methods, checkin_time and checkout_time tags so a generic Nostr client can render the right pay buttons and rules without our RPC. The check-in DM reads the room owner's rules instead of the instance row. Card is offered only when the operator opted in, the room is fiat-priced, and LNbits core has a fiat provider for that user — resolved through settings.get_fiat_providers_for_user(owner), the one seam lnbits#67's per-user Stripe credentials will plug into; chatelet never sees creds. Operator endpoints: GET/PUT /api/v1/operator (admin key → wallet user) and RPC twins chatelet_operator_get/update (AUTH_WALLET); saving re-publishes the owner's active listings. Admin UI moves the house-rule inputs into a per-operator card with the card toggle and a provider hint. The old house-rule columns on settings stay for old rows but are no longer read. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
70 lines
2.8 KiB
Python
70 lines
2.8 KiB
Python
"""Background tasks: settle paid bookings, expire stale holds.
|
|
|
|
Payment is what promotes a booking from `held`/`awaiting_payment` to
|
|
`confirmed` — not any Nostr event. We listen on the LNbits invoice
|
|
dispatcher (same pattern as lnurlp/spirekeeper), and separately sweep
|
|
expired holds so abandoned requests free their dates.
|
|
"""
|
|
|
|
import asyncio
|
|
|
|
from lnbits.core.models import Payment
|
|
from lnbits.tasks import register_invoice_listener
|
|
from loguru import logger
|
|
|
|
from . import crud, services
|
|
from .models import BookingStatus
|
|
from .nostr import service as nostr
|
|
|
|
|
|
async def wait_for_paid_invoices():
|
|
invoice_queue: asyncio.Queue = asyncio.Queue()
|
|
register_invoice_listener(invoice_queue, "ext_chatelet")
|
|
while True:
|
|
payment = await invoice_queue.get()
|
|
await on_invoice_paid(payment)
|
|
|
|
|
|
async def on_invoice_paid(payment: Payment):
|
|
if not payment.extra or payment.extra.get("tag") != "chatelet":
|
|
return
|
|
booking = await crud.get_booking_by_payment_hash(payment.payment_hash)
|
|
if not booking:
|
|
logger.warning(f"chatelet: paid invoice with no matching booking: {payment.payment_hash}")
|
|
return
|
|
if booking.status not in (BookingStatus.held, BookingStatus.awaiting_payment):
|
|
return # already settled / cancelled — ignore duplicate settle
|
|
|
|
# Payment confirms the booking: hard-block the dates, clear the hold,
|
|
# publish the updated reservation object + send check-in details.
|
|
booking.status = BookingStatus.confirmed
|
|
booking.expires_at = None
|
|
await crud.update_booking(booking)
|
|
booking.reservation_event_id = await nostr.publish_reservation(booking)
|
|
await crud.update_booking(booking)
|
|
|
|
# Send the guest their private check-in details (NIP-17 gift-wrapped DM).
|
|
# Best-effort: a publish failure must not undo a confirmed, paid booking.
|
|
try:
|
|
room = await crud.get_room(booking.room_id)
|
|
if room:
|
|
# House rules are the room owner's, not the instance's.
|
|
owner = await services.room_owner_id(room)
|
|
ops = await crud.get_or_create_operator_settings(owner)
|
|
await nostr.send_checkin_dm(booking, room, ops)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning(f"chatelet: check-in DM failed for {booking.id} (continuing): {exc}")
|
|
|
|
logger.info(f"chatelet: booking {booking.id} confirmed")
|
|
|
|
|
|
async def expire_holds_loop():
|
|
"""Sweep lapsed holds every minute so abandoned requests free dates."""
|
|
while True:
|
|
try:
|
|
expired = await crud.expire_stale_holds()
|
|
for b in expired:
|
|
logger.debug(f"chatelet: hold {b.id} expired, dates freed")
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.error(f"chatelet: hold-expiry sweep failed: {exc}")
|
|
await asyncio.sleep(60)
|