From 56e1d7a7804034dd4f89650c68541d8d4bdf153d Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 15 Sep 2026 23:44:13 +0200 Subject: [PATCH] feat(chatelet): poll the booking itself, not the invoice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the invoice screen's poll from LNbits core's payment status to chatelet's new keyless GET /api/v1/public/bookings/{id} (chatelet#18). The dialog now waits for the booking to reach confirmed — meaning the extension's paid listener has actually run — and shows a 'Dates released' screen when the hold expires or is declined before payment instead of spinning forever. Needs chatelet >= the build that ships the endpoint on the target instance (staging must be upgraded before this reaches dev). Co-Authored-By: Claude Fable 5.1 --- .../chatelet/components/BookRoomDialog.vue | 25 +++++++++++++++-- .../chatelet/composables/useBookingFlow.ts | 27 ++++++++++++------- .../chatelet/services/ChateletApiService.ts | 18 ++++++------- src/modules/chatelet/types/booking.ts | 23 ++++++++++------ 4 files changed, 63 insertions(+), 30 deletions(-) diff --git a/src/modules/chatelet/components/BookRoomDialog.vue b/src/modules/chatelet/components/BookRoomDialog.vue index eccdb5d..61381ff 100644 --- a/src/modules/chatelet/components/BookRoomDialog.vue +++ b/src/modules/chatelet/components/BookRoomDialog.vue @@ -5,7 +5,7 @@ import { toTypedSchema } from '@vee-validate/zod' import * as z from 'zod' import { toast } from 'vue-sonner' import { format } from 'date-fns' -import { Check, CheckCircle2, Copy, Loader2, Wallet, Zap } from 'lucide-vue-next' +import { Check, CheckCircle2, Copy, Loader2, TimerOff, Wallet, Zap } from 'lucide-vue-next' import { Dialog, DialogContent, @@ -112,12 +112,19 @@ const holdExpiry = computed(() => { return isNaN(d.getTime()) ? '' : format(d, 'p') }) -const step = computed<'form' | 'invoice' | 'confirmed'>(() => { +const step = computed<'form' | 'invoice' | 'confirmed' | 'lost'>(() => { if (flow.isPaid.value) return 'confirmed' + if (flow.lostStatus.value) return 'lost' if (flow.quote.value) return 'invoice' return 'form' }) +const lostLabel = computed(() => + flow.lostStatus.value === 'expired' + ? 'The hold on your dates expired before the invoice was paid.' + : 'The host released these dates before the invoice was paid.', +) + watch( () => flow.isPaid.value, (paid) => { @@ -303,6 +310,20 @@ watch(

+ +
+ +
+

Dates released

+

{{ lostLabel }}

+
+

+ If you already paid, the host will see it — otherwise check the dates + again and start over. +

+ +
+
diff --git a/src/modules/chatelet/composables/useBookingFlow.ts b/src/modules/chatelet/composables/useBookingFlow.ts index 26e49fa..ea1d8af 100644 --- a/src/modules/chatelet/composables/useBookingFlow.ts +++ b/src/modules/chatelet/composables/useBookingFlow.ts @@ -3,7 +3,7 @@ import { toast } from 'vue-sonner' import { SERVICE_TOKENS, tryInjectService } from '@/core/di-container' import type { PaymentService } from '@/core/services/PaymentService' import type { ChateletApiService } from '../services/ChateletApiService' -import type { BookingQuote, BookingRequest } from '../types/booking' +import { LOST_BOOKING_STATUSES, type BookingQuote, type BookingRequest, type BookingStatus } from '../types/booking' const POLL_MS = 2000 @@ -11,10 +11,8 @@ const POLL_MS = 2000 * Drives one booking from request → invoice → paid. Modeled on events' * useTicketPurchase: the backend holds the dates and returns a bolt11; * this composable renders it (QR / copy / open-in-wallet / pay from the - * LNbits wallet) and polls settlement until the invoice is paid. The - * extension's paid-invoice listener is what confirms the booking - * server-side — the guest only needs to see "paid" to know the dates - * are theirs. + * LNbits wallet) and polls the booking until the extension's paid-invoice + * listener flips it to `confirmed` — or the hold lapses (`expired`). */ export function useBookingFlow() { // DI, never a direct import (workspace rule). @@ -26,6 +24,8 @@ export function useBookingFlow() { const isRequesting = ref(false) const isPaymentPending = ref(false) const isPaid = ref(false) + /** Set when the hold is gone (expired / declined / cancelled) before payment. */ + const lostStatus = ref(null) const copiedInvoice = ref(false) let pollTimer: ReturnType | null = null @@ -49,27 +49,32 @@ export function useBookingFlow() { } catch (err) { console.error('Error generating QR code:', err) } - startPolling(q.payment_hash) + startPolling(q.booking.id) return q } finally { isRequesting.value = false } } - function startPolling(hash: string) { + function startPolling(bookingId: string) { stopPolling() isPaymentPending.value = true const check = async () => { try { - const status = await api?.getPaymentStatus(hash) - if (status?.paid) { + const b = await api?.getPublicBooking(bookingId) + if (!b) return + if (quote.value) quote.value = { ...quote.value, booking: { ...quote.value.booking, ...b } } + if (b.status === 'confirmed' || b.status === 'checked_in' || b.status === 'completed') { isPaid.value = true stopPolling() + } else if (LOST_BOOKING_STATUSES.has(b.status)) { + lostStatus.value = b.status + stopPolling() } } catch (err) { // Transient network errors just mean "try again next tick". - console.error('Error checking booking payment status:', err) + console.error('Error checking booking status:', err) } } @@ -117,6 +122,7 @@ export function useBookingFlow() { quote.value = null qrCode.value = null isPaid.value = false + lostStatus.value = null copiedInvoice.value = false } @@ -130,6 +136,7 @@ export function useBookingFlow() { isRequesting, isPaymentPending, isPaid, + lostStatus, copiedInvoice, userWallets, hasWalletWithBalance, diff --git a/src/modules/chatelet/services/ChateletApiService.ts b/src/modules/chatelet/services/ChateletApiService.ts index c468c47..2be1400 100644 --- a/src/modules/chatelet/services/ChateletApiService.ts +++ b/src/modules/chatelet/services/ChateletApiService.ts @@ -1,5 +1,5 @@ import type { AvailabilityResult, Room } from '../types/room' -import type { BookingQuote, BookingRequest, PaymentStatus } from '../types/booking' +import type { BookingQuote, BookingRequest, PublicBooking } from '../types/booking' export interface ChateletApiConfig { baseUrl: string @@ -10,8 +10,8 @@ export interface ChateletApiConfig { * HTTP client for the LNbits `chatelet` extension's guest surface. * * Plain config-wrapper (same pattern as events' TicketApiService) — not a - * BaseService. Guest browsing, availability and the booking request are all - * public endpoints; settlement is polled on the invoice via LNbits core. + * BaseService. Guest browsing, availability, the booking request and the + * booking read-back are all keyless public endpoints. * * Transport note: this is HTTP-for-now. The extension also exposes a * kind-21000 Nostr-RPC surface (chatelet_room_list / _availability / …), but @@ -56,14 +56,12 @@ export class ChateletApiService { } /** - * Anonymous settlement check on LNbits core. Chatelet's own - * `GET /bookings/{id}` needs a wallet key, so until a public booking - * status endpoint lands the guest polls the invoice instead — the - * extension's paid-invoice listener is what flips the booking to - * `confirmed`. + * Keyless read-back of the guest's own booking — what the invoice screen + * polls. `confirmed` means the extension's paid-invoice listener has + * run; `expired` / `declined` mean the hold is gone. */ - async getPaymentStatus(paymentHash: string): Promise { - return this.request(`/api/v1/payments/${paymentHash}`, { method: 'GET' }) + async getPublicBooking(bookingId: string): Promise { + return this.request(`/chatelet/api/v1/public/bookings/${bookingId}`, { method: 'GET' }) } private async request(path: string, init: RequestInit = {}): Promise { diff --git a/src/modules/chatelet/types/booking.ts b/src/modules/chatelet/types/booking.ts index 8a843a2..83c0831 100644 --- a/src/modules/chatelet/types/booking.ts +++ b/src/modules/chatelet/types/booking.ts @@ -60,12 +60,19 @@ export interface BookingQuote { } /** - * Anonymous read of LNbits core `GET /api/v1/payments/{hash}` — the guest - * cannot read their booking over HTTP yet (that endpoint needs a wallet - * key), so settlement is polled on the invoice instead. `status` is only - * present on a failed payment. + * Keyless read-back of the guest's own booking + * (`GET /chatelet/api/v1/public/bookings/{id}`, chatelet ≥ v0.4.0): the + * Booking minus the guest's pubkey/contact and the Lightning/Nostr plumbing. + * The booking id from the quote is the capability. */ -export interface PaymentStatus { - paid: boolean - status?: string -} +export type PublicBooking = Omit< + Booking, + 'guest_pubkey' | 'guest_contact' | 'payment_hash' +> + +/** Statuses that mean the hold is gone and the invoice will never confirm. */ +export const LOST_BOOKING_STATUSES: ReadonlySet = new Set([ + 'expired', + 'declined', + 'cancelled', +])