From 4df137190fb80f9223658c42b44a9e256eca3e2a Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 19 Jul 2026 00:26:14 +0200 Subject: [PATCH] feat: wire FX + deposit invoice into the booking hold (#3) Booking requests now complete end-to-end over HTTP: - _to_sats() converts fiat->sats via fiat_amount_as_satoshis (sat/sats pass through). This is the single conversion point; the result is the canonical amount_sat and is not recomputed downstream. - api_request_booking creates a sats-denominated deposit invoice (locked amount, immune to FX drift before payment), tagged {tag:chatelet, booking_id} so tasks.on_invoice_paid matches the settle. On InvoiceError the hold is released (status=declined) so dead holds don't block dates. - New BookingQuote response returns the held booking + bolt11 + hash. Settlement (tasks.on_invoice_paid: awaiting_payment -> confirmed, dates hard-blocked, reservation republished) was already in place and now fires on real payments. Testable on FakeWallet. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- models.py | 11 +++++++++++ views_api.py | 52 +++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/models.py b/models.py index d2c2b81..78b7e27 100644 --- a/models.py +++ b/models.py @@ -206,3 +206,14 @@ class AvailabilityResult(BaseModel): quote_sat: int | None = None quote_fiat: float | None = None currency: str | None = None + + +class BookingQuote(BaseModel): + """Response to a booking request: the held booking plus the bolt11 the + guest must pay to confirm it. payment_request covers deposit_sat (the + canonical amount already stored on the booking) — the guest never re- + computes what they owe.""" + + booking: Booking + payment_request: str + payment_hash: str diff --git a/views_api.py b/views_api.py index fb6ccda..20ec9a1 100644 --- a/views_api.py +++ b/views_api.py @@ -14,14 +14,18 @@ from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException from lnbits.core.models import WalletTypeInfo +from lnbits.core.services import create_invoice from lnbits.decorators import require_admin_key, require_invoice_key +from lnbits.exceptions import InvoiceError from lnbits.helpers import urlsafe_short_hash +from lnbits.utils.exchange_rates import fiat_amount_as_satoshis from . import crud from .models import ( AvailabilityQuery, AvailabilityResult, Booking, + BookingQuote, BookingRequestData, BookingStatus, CreateBlockData, @@ -93,10 +97,10 @@ async def api_check_availability(q: AvailabilityQuery) -> AvailabilityResult: @chatelet_api_router.post("/api/v1/bookings", status_code=201) -async def api_request_booking(data: BookingRequestData) -> Booking: +async def api_request_booking(data: BookingRequestData) -> BookingQuote: """Guest requests a stay. Check-then-hold is the lock: if available we - write a `held` booking with the canonical amount_sat and an expiry, then - (TODO) create the deposit invoice. The Nostr path calls the same steps.""" + write a `held` booking with the canonical amount_sat, create the deposit + invoice, and return the bolt11. The Nostr path calls the same steps.""" room = await crud.get_room(data.room_id) if not room or room.status != room.status.active: raise HTTPException(404, "Room not available") @@ -136,10 +140,36 @@ async def api_request_booking(data: BookingRequestData) -> Booking: ) await crud.create_booking(booking) - # TODO(payment): create_invoice(wallet_id=room.wallet, amount=deposit_sat, - # extra={"tag": "chatelet", "booking_id": booking.id}); store payment_hash; - # set status=awaiting_payment; DM the bolt11 to the guest (NIP-17). - return booking + # Invoice is denominated in sats (deposit_sat is already the canonical + # amount locked at quote time) so FX drift between now and payment can't + # change what the guest owes. 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)" + ), + extra={"tag": "chatelet", "booking_id": booking.id}, + ) + except InvoiceError as exc: + # Free the dates immediately — a hold with no payable invoice is dead. + booking.status = BookingStatus.declined + await crud.update_booking(booking) + raise HTTPException(502, f"Could not create invoice: {exc.message}") from exc + + booking.payment_hash = payment.payment_hash + booking.status = BookingStatus.awaiting_payment + await crud.update_booking(booking) + + # TODO(#5): DM the bolt11 to the guest over NIP-17 for the Nostr path. + return BookingQuote( + booking=booking, + payment_request=payment.bolt11, + payment_hash=payment.payment_hash, + ) @chatelet_api_router.get("/api/v1/bookings/{booking_id}") @@ -174,11 +204,7 @@ async def api_create_block( async def _to_sats(amount: float, currency: str) -> int: """Fiat/currency -> sats. Canonical conversion happens HERE, once, at quote/hold time; the result is stored as amount_sat and never recomputed - downstream. - - TODO(fx): if currency in ('sat','sats') return int(amount); else call - lnbits.utils.exchange_rates.fiat_amount_as_satoshis(amount, currency). - """ + downstream.""" if currency.lower() in ("sat", "sats"): return int(amount) - raise NotImplementedError("wire lnbits fiat_amount_as_satoshis") + return await fiat_amount_as_satoshis(amount, currency) -- 2.53.0