From 43cfa5d9211f15c4d17a31697e09e364f9551fde Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 19 Jul 2026 00:16:57 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20background=20tasks=20=E2=80=94=20settle?= =?UTF-8?q?=20on=20payment,=20expire=20holds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Payment (not any nostr event) is the commit point: the invoice listener promotes held/awaiting_payment bookings to confirmed, hard-blocks the dates, and republishes the encrypted reservation object. expire_holds_loop sweeps lapsed holds every minute so abandoned requests free their dates. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- tasks.py | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tasks.py diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..ea27b8e --- /dev/null +++ b/tasks.py @@ -0,0 +1,59 @@ +"""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 +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) + # TODO(checkin): DM check-in details (address, gate code, times) to the + # guest via NIP-17 giftwrap once relay plumbing lands. + 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)