Compare commits

...

2 commits

Author SHA1 Message Date
0cc419e5fa Merge pull request 'feat: wire FX + deposit invoice into the booking hold (#3)' (#7) from feat/payment-settle into main
Reviewed-on: #7
2026-07-18 23:22:50 +00:00
4df137190f 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-07-19 00:26:14 +02:00
2 changed files with 50 additions and 13 deletions

View file

@ -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

View file

@ -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)