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:
parent
c369beab92
commit
56e1d7a780
4 changed files with 63 additions and 30 deletions
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
])
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue