feat(chatelet): poll the booking itself, not the invoice

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 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-09-15 23:44:13 +02:00
commit 56e1d7a780
4 changed files with 63 additions and 30 deletions

View file

@ -5,7 +5,7 @@ import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod' import * as z from 'zod'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { format } from 'date-fns' 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 { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -112,12 +112,19 @@ const holdExpiry = computed(() => {
return isNaN(d.getTime()) ? '' : format(d, 'p') 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.isPaid.value) return 'confirmed'
if (flow.lostStatus.value) return 'lost'
if (flow.quote.value) return 'invoice' if (flow.quote.value) return 'invoice'
return 'form' 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( watch(
() => flow.isPaid.value, () => flow.isPaid.value,
(paid) => { (paid) => {
@ -303,6 +310,20 @@ watch(
</p> </p>
</div> </div>
<!-- Hold lost before payment: nothing to pay any more -->
<div v-else-if="step === 'lost'" class="py-4 space-y-4 text-center">
<TimerOff class="mx-auto h-12 w-12 text-muted-foreground" />
<div class="space-y-1">
<h3 class="text-lg font-semibold">Dates released</h3>
<p class="text-sm text-muted-foreground">{{ lostLabel }}</p>
</div>
<p class="text-xs text-muted-foreground">
If you already paid, the host will see it — otherwise check the dates
again and start over.
</p>
<Button class="w-full" @click="emit('update:isOpen', false)">Close</Button>
</div>
<!-- Step 3: paid --> <!-- Step 3: paid -->
<div v-else class="py-4 space-y-4 text-center"> <div v-else class="py-4 space-y-4 text-center">
<CheckCircle2 class="mx-auto h-12 w-12 text-primary" /> <CheckCircle2 class="mx-auto h-12 w-12 text-primary" />

View file

@ -3,7 +3,7 @@ import { toast } from 'vue-sonner'
import { SERVICE_TOKENS, tryInjectService } from '@/core/di-container' import { SERVICE_TOKENS, tryInjectService } from '@/core/di-container'
import type { PaymentService } from '@/core/services/PaymentService' import type { PaymentService } from '@/core/services/PaymentService'
import type { ChateletApiService } from '../services/ChateletApiService' 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 const POLL_MS = 2000
@ -11,10 +11,8 @@ const POLL_MS = 2000
* Drives one booking from request → invoice → paid. Modeled on events' * Drives one booking from request → invoice → paid. Modeled on events'
* useTicketPurchase: the backend holds the dates and returns a bolt11; * useTicketPurchase: the backend holds the dates and returns a bolt11;
* this composable renders it (QR / copy / open-in-wallet / pay from the * this composable renders it (QR / copy / open-in-wallet / pay from the
* LNbits wallet) and polls settlement until the invoice is paid. The * LNbits wallet) and polls the booking until the extension's paid-invoice
* extension's paid-invoice listener is what confirms the booking * listener flips it to `confirmed` — or the hold lapses (`expired`).
* server-side — the guest only needs to see "paid" to know the dates
* are theirs.
*/ */
export function useBookingFlow() { export function useBookingFlow() {
// DI, never a direct import (workspace rule). // DI, never a direct import (workspace rule).
@ -26,6 +24,8 @@ export function useBookingFlow() {
const isRequesting = ref(false) const isRequesting = ref(false)
const isPaymentPending = ref(false) const isPaymentPending = ref(false)
const isPaid = ref(false) const isPaid = ref(false)
/** Set when the hold is gone (expired / declined / cancelled) before payment. */
const lostStatus = ref<BookingStatus | null>(null)
const copiedInvoice = ref(false) const copiedInvoice = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null let pollTimer: ReturnType<typeof setInterval> | null = null
@ -49,27 +49,32 @@ export function useBookingFlow() {
} catch (err) { } catch (err) {
console.error('Error generating QR code:', err) console.error('Error generating QR code:', err)
} }
startPolling(q.payment_hash) startPolling(q.booking.id)
return q return q
} finally { } finally {
isRequesting.value = false isRequesting.value = false
} }
} }
function startPolling(hash: string) { function startPolling(bookingId: string) {
stopPolling() stopPolling()
isPaymentPending.value = true isPaymentPending.value = true
const check = async () => { const check = async () => {
try { try {
const status = await api?.getPaymentStatus(hash) const b = await api?.getPublicBooking(bookingId)
if (status?.paid) { 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 isPaid.value = true
stopPolling() stopPolling()
} else if (LOST_BOOKING_STATUSES.has(b.status)) {
lostStatus.value = b.status
stopPolling()
} }
} catch (err) { } catch (err) {
// Transient network errors just mean "try again next tick". // 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 quote.value = null
qrCode.value = null qrCode.value = null
isPaid.value = false isPaid.value = false
lostStatus.value = null
copiedInvoice.value = false copiedInvoice.value = false
} }
@ -130,6 +136,7 @@ export function useBookingFlow() {
isRequesting, isRequesting,
isPaymentPending, isPaymentPending,
isPaid, isPaid,
lostStatus,
copiedInvoice, copiedInvoice,
userWallets, userWallets,
hasWalletWithBalance, hasWalletWithBalance,

View file

@ -1,5 +1,5 @@
import type { AvailabilityResult, Room } from '../types/room' 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 { export interface ChateletApiConfig {
baseUrl: string baseUrl: string
@ -10,8 +10,8 @@ export interface ChateletApiConfig {
* HTTP client for the LNbits `chatelet` extension's guest surface. * HTTP client for the LNbits `chatelet` extension's guest surface.
* *
* Plain config-wrapper (same pattern as events' TicketApiService) — not a * Plain config-wrapper (same pattern as events' TicketApiService) — not a
* BaseService. Guest browsing, availability and the booking request are all * BaseService. Guest browsing, availability, the booking request and the
* public endpoints; settlement is polled on the invoice via LNbits core. * booking read-back are all keyless public endpoints.
* *
* Transport note: this is HTTP-for-now. The extension also exposes a * Transport note: this is HTTP-for-now. The extension also exposes a
* kind-21000 Nostr-RPC surface (chatelet_room_list / _availability / …), but * 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 * Keyless read-back of the guest's own booking — what the invoice screen
* `GET /bookings/{id}` needs a wallet key, so until a public booking * polls. `confirmed` means the extension's paid-invoice listener has
* status endpoint lands the guest polls the invoice instead — the * run; `expired` / `declined` mean the hold is gone.
* extension's paid-invoice listener is what flips the booking to
* `confirmed`.
*/ */
async getPaymentStatus(paymentHash: string): Promise<PaymentStatus> { async getPublicBooking(bookingId: string): Promise<PublicBooking> {
return this.request(`/api/v1/payments/${paymentHash}`, { method: 'GET' }) return this.request(`/chatelet/api/v1/public/bookings/${bookingId}`, { method: 'GET' })
} }
private async request(path: string, init: RequestInit = {}): Promise<any> { private async request(path: string, init: RequestInit = {}): Promise<any> {

View file

@ -60,12 +60,19 @@ export interface BookingQuote {
} }
/** /**
* Anonymous read of LNbits core `GET /api/v1/payments/{hash}` — the guest * Keyless read-back of the guest's own booking
* cannot read their booking over HTTP yet (that endpoint needs a wallet * (`GET /chatelet/api/v1/public/bookings/{id}`, chatelet ≥ v0.4.0): the
* key), so settlement is polled on the invoice instead. `status` is only * Booking minus the guest's pubkey/contact and the Lightning/Nostr plumbing.
* present on a failed payment. * The booking id from the quote is the capability.
*/ */
export interface PaymentStatus { export type PublicBooking = Omit<
paid: boolean Booking,
status?: string '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<string> = new Set([
'expired',
'declined',
'cancelled',
])