feat(chatelet): let a guest resume a held-but-unpaid booking #160

Merged
padreug merged 1 commit from feat/chatelet-resume-pending-invoice into dev 2026-09-21 17:58:25 +00:00
5 changed files with 270 additions and 5 deletions
Showing only changes of commit 8d9a31ca1c - Show all commits

feat(chatelet): let a guest resume a held-but-unpaid booking

Closing the invoice dialog left the guest stranded: the hold blocks the
room until it expires, and the public booking read-back has no bolt11, so
there was no way back to the invoice — they had to wait out the hold to try
the room again.

Persist each hold's invoice on the device when it is created
(usePendingBooking, localStorage keyed by room, one per room, pruned on
expiry). On the room page, if a live hold exists for it (verified via the
public booking endpoint), show a 'Resume payment' banner that reopens the
invoice from the saved bolt11 and resumes the settlement poll. The saved
entry is cleared when the booking confirms, the hold lapses, or it expires.
Works without an account — the store is the browser's, not the server's.

Verified headlessly against a real hold: the banner shows for a live hold
and resumes to the invoice (QR + amount); an expired hold shows nothing and
is pruned. vue-tsc build passes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Padreug 2026-09-21 15:04:06 +02:00

View file

@ -28,6 +28,7 @@ import {
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { formatSats } from '@/lib/utils/formatting' import { formatSats } from '@/lib/utils/formatting'
import { useBookingFlow } from '../composables/useBookingFlow' import { useBookingFlow } from '../composables/useBookingFlow'
import type { PendingBooking } from '../composables/usePendingBooking'
import type { AvailabilityResult, Room } from '../types/room' import type { AvailabilityResult, Room } from '../types/room'
interface Props { interface Props {
@ -35,6 +36,11 @@ interface Props {
/** The availability quote the guest just saw — dates + nights + price. */ /** The availability quote the guest just saw — dates + nights + price. */
availability: AvailabilityResult availability: AvailabilityResult
isOpen: boolean isOpen: boolean
/**
* When set, open straight to the invoice for a held booking saved on this
* device (the guest closed the dialog earlier) instead of the guest form.
*/
resume?: PendingBooking | null
} }
interface Emits { interface Emits {
@ -161,11 +167,19 @@ watch(
if (open) { if (open) {
flow.reset() flow.reset()
confirmClose.value = false confirmClose.value = false
if (props.resume) {
// Skip the guest form: rehydrate the saved invoice and poll.
void flow.resume(props.resume)
} else {
form.resetForm({ values: { num_guests: 1, guest_contact: '', message: '' } }) form.resetForm({ values: { num_guests: 1, guest_contact: '', message: '' } })
}
} else { } else {
flow.reset() flow.reset()
} }
}, },
// The dialog is v-if-mounted with isOpen already true, so fire on mount —
// otherwise a resume never triggers flow.resume() and lands on the form.
{ immediate: true },
) )
</script> </script>

View file

@ -3,7 +3,8 @@ 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 { LOST_BOOKING_STATUSES, type BookingQuote, type BookingRequest, type BookingStatus } from '../types/booking' import { LOST_BOOKING_STATUSES, type Booking, type BookingQuote, type BookingRequest, type BookingStatus } from '../types/booking'
import { usePendingBooking, type PendingBooking } from './usePendingBooking'
const POLL_MS = 2000 const POLL_MS = 2000
@ -18,6 +19,7 @@ export function useBookingFlow() {
// DI, never a direct import (workspace rule). // DI, never a direct import (workspace rule).
const api = tryInjectService<ChateletApiService>(SERVICE_TOKENS.CHATELET_API) const api = tryInjectService<ChateletApiService>(SERVICE_TOKENS.CHATELET_API)
const paymentService = tryInjectService<PaymentService>(SERVICE_TOKENS.PAYMENT_SERVICE) const paymentService = tryInjectService<PaymentService>(SERVICE_TOKENS.PAYMENT_SERVICE)
const pending = usePendingBooking()
const quote = ref<BookingQuote | null>(null) const quote = ref<BookingQuote | null>(null)
const qrCode = ref<string | null>(null) const qrCode = ref<string | null>(null)
@ -44,6 +46,9 @@ export function useBookingFlow() {
try { try {
const q = await api.requestBooking(data) const q = await api.requestBooking(data)
quote.value = q quote.value = q
// Persist the invoice so closing the dialog doesn't strand the hold:
// the guest can return and pay it (the public read-back has no bolt11).
pending.save(q)
try { try {
qrCode.value = paymentService ? await paymentService.generateQRCode(q.payment_request) : null qrCode.value = paymentService ? await paymentService.generateQRCode(q.payment_request) : null
} catch (err) { } catch (err) {
@ -56,6 +61,42 @@ export function useBookingFlow() {
} }
} }
/**
* Re-open a held booking saved on this device (guest closed the dialog
* earlier). Rehydrates the invoice from local storage — no server round
* trip for the bolt11, which the public endpoint never returns — and
* resumes the settlement poll, which will surface a confirmed or lapsed
* hold on its first tick.
*/
async function resume(pb: PendingBooking): Promise<void> {
const booking: Booking = {
id: pb.bookingId,
room_id: pb.roomId,
guest_pubkey: '',
guest_contact: null,
check_in: pb.checkIn,
check_out: pb.checkOut,
nights: pb.nights,
num_guests: pb.numGuests,
currency: pb.currency ?? '',
price_fiat: pb.quoteFiat ?? 0,
amount_sat: pb.amountSat,
deposit_sat: pb.depositSat,
status: 'awaiting_payment',
payment_hash: pb.paymentHash,
expires_at: pb.expiresAt,
created_at: pb.savedAt,
updated_at: pb.savedAt,
}
quote.value = { booking, payment_request: pb.paymentRequest, payment_hash: pb.paymentHash }
try {
qrCode.value = paymentService ? await paymentService.generateQRCode(pb.paymentRequest) : null
} catch (err) {
console.error('Error generating QR code:', err)
}
startPolling(pb.bookingId)
}
function startPolling(bookingId: string) { function startPolling(bookingId: string) {
stopPolling() stopPolling()
isPaymentPending.value = true isPaymentPending.value = true
@ -67,9 +108,11 @@ export function useBookingFlow() {
if (quote.value) quote.value = { ...quote.value, booking: { ...quote.value.booking, ...b } } if (quote.value) quote.value = { ...quote.value, booking: { ...quote.value.booking, ...b } }
if (b.status === 'confirmed' || b.status === 'checked_in' || b.status === 'completed') { if (b.status === 'confirmed' || b.status === 'checked_in' || b.status === 'completed') {
isPaid.value = true isPaid.value = true
pending.remove(b.room_id)
stopPolling() stopPolling()
} else if (LOST_BOOKING_STATUSES.has(b.status)) { } else if (LOST_BOOKING_STATUSES.has(b.status)) {
lostStatus.value = b.status lostStatus.value = b.status
pending.remove(b.room_id)
stopPolling() stopPolling()
} }
} catch (err) { } catch (err) {
@ -142,6 +185,7 @@ export function useBookingFlow() {
hasWalletWithBalance, hasWalletWithBalance,
isPayingWithWallet, isPayingWithWallet,
request, request,
resume,
copyInvoice, copyInvoice,
openExternalWallet, openExternalWallet,
payWithWallet, payWithWallet,

View file

@ -2,6 +2,7 @@ import { storeToRefs } from 'pinia'
import { SERVICE_TOKENS, tryInjectService } from '@/core/di-container' import { SERVICE_TOKENS, tryInjectService } from '@/core/di-container'
import type { ChateletApiService } from '../services/ChateletApiService' import type { ChateletApiService } from '../services/ChateletApiService'
import type { AvailabilityResult, Room } from '../types/room' import type { AvailabilityResult, Room } from '../types/room'
import type { PublicBooking } from '../types/booking'
import { useChateletStore } from '../stores/chatelet' import { useChateletStore } from '../stores/chatelet'
export function useChatelet() { export function useChatelet() {
@ -34,5 +35,11 @@ export function useChatelet() {
return api.checkAvailability(roomId, checkIn, checkOut) return api.checkAvailability(roomId, checkIn, checkOut)
} }
return { rooms, isLoading, loadRooms, getRoom, checkAvailability } /** Keyless read-back of a booking by id (status, dates, amounts). */
async function getPublicBooking(bookingId: string): Promise<PublicBooking | null> {
if (!api) return null
return api.getPublicBooking(bookingId)
}
return { rooms, isLoading, loadRooms, getRoom, checkAvailability, getPublicBooking }
} }

View file

@ -0,0 +1,119 @@
import type { BookingQuote } from '../types/booking'
const STORAGE_KEY = 'chatelet:pending-bookings'
/**
* A held-but-unpaid booking kept on the device so the guest can return to
* its invoice. The public booking read-back strips the bolt11, so the only
* way back to the same invoice after the dialog is closed is to have saved
* it here.
*/
export interface PendingBooking {
bookingId: string
roomId: string
checkIn: string
checkOut: string
nights: number
numGuests: number
currency: string | null
quoteFiat: number | null
amountSat: number
depositSat: number
paymentRequest: string
paymentHash: string
/** ISO hold expiry, or null. Past-expiry entries are pruned on read. */
expiresAt: string | null
savedAt: string
}
type Store = Record<string, PendingBooking>
function readStore(): Store {
try {
const raw = localStorage.getItem(STORAGE_KEY)
const parsed = raw ? JSON.parse(raw) : {}
return parsed && typeof parsed === 'object' ? (parsed as Store) : {}
} catch {
return {}
}
}
function writeStore(store: Store): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(store))
} catch {
// Private mode / quota / storage disabled — recovery is best-effort.
}
}
function isLive(pb: PendingBooking): boolean {
if (!pb.expiresAt) return true
const t = new Date(pb.expiresAt).getTime()
return isNaN(t) || t > Date.now()
}
/** Drop entries whose hold has already expired. Returns whether it changed. */
function prune(store: Store): boolean {
let changed = false
for (const [roomId, pb] of Object.entries(store)) {
if (!isLive(pb)) {
delete store[roomId]
changed = true
}
}
return changed
}
function fromQuote(q: BookingQuote): PendingBooking {
const b = q.booking
return {
bookingId: b.id,
roomId: b.room_id,
checkIn: b.check_in,
checkOut: b.check_out,
nights: b.nights,
numGuests: b.num_guests,
currency: b.currency,
quoteFiat: b.price_fiat,
amountSat: b.amount_sat,
depositSat: b.deposit_sat,
paymentRequest: q.payment_request,
paymentHash: q.payment_hash,
expiresAt: b.expires_at,
savedAt: new Date().toISOString(),
}
}
/**
* Device-local record of held-but-unpaid bookings, one per room (a room
* holds at most one booking at a time). Lets a guest who closed the invoice
* dialog return and finish paying instead of waiting out the hold. Works
* without an account — the store is this browser's, not the server's.
*/
export function usePendingBooking() {
function save(q: BookingQuote): void {
if (!q.payment_request || !q.booking?.id) return
const store = readStore()
prune(store)
store[q.booking.room_id] = fromQuote(q)
writeStore(store)
}
function getForRoom(roomId: string): PendingBooking | null {
if (!roomId) return null
const store = readStore()
if (prune(store)) writeStore(store)
return store[roomId] ?? null
}
function remove(roomId: string): void {
if (!roomId) return
const store = readStore()
if (store[roomId]) {
delete store[roomId]
writeStore(store)
}
}
return { save, getForRoom, remove }
}

View file

@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { watchDebounced } from '@vueuse/core' import { watchDebounced } from '@vueuse/core'
import { format } from 'date-fns'
import { Clock, MapPin, Moon, Users } from 'lucide-vue-next' import { Clock, MapPin, Moon, Users } from 'lucide-vue-next'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@ -12,7 +13,9 @@ import { formatSats } from '@/lib/utils/formatting'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { useChatelet } from '../composables/useChatelet' import { useChatelet } from '../composables/useChatelet'
import { useRoomUnavailability } from '../composables/useRoomUnavailability' import { useRoomUnavailability } from '../composables/useRoomUnavailability'
import { usePendingBooking, type PendingBooking } from '../composables/usePendingBooking'
import { stayIsFree } from '../lib/unavailable' import { stayIsFree } from '../lib/unavailable'
import { LOST_BOOKING_STATUSES } from '../types/booking'
import BookRoomDialog from '../components/BookRoomDialog.vue' import BookRoomDialog from '../components/BookRoomDialog.vue'
import StayDatePicker from '../components/StayDatePicker.vue' import StayDatePicker from '../components/StayDatePicker.vue'
import type { AvailabilityResult, Room } from '../types/room' import type { AvailabilityResult, Room } from '../types/room'
@ -20,7 +23,8 @@ import type { AvailabilityResult, Room } from '../types/room'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { isAuthenticated } = useAuth() const { isAuthenticated } = useAuth()
const { getRoom, checkAvailability } = useChatelet() const { getRoom, checkAvailability, getPublicBooking } = useChatelet()
const pendingStore = usePendingBooking()
const room = ref<Room | null>(null) const room = ref<Room | null>(null)
const loading = ref(true) const loading = ref(true)
@ -32,6 +36,11 @@ const showBooking = ref(false)
/** Quote snapshot the dialog books against — survives the page's own /** Quote snapshot the dialog books against — survives the page's own
* `result` being cleared once the dates are taken. */ * `result` being cleared once the dates are taken. */
const bookingQuote = ref<AvailabilityResult | null>(null) const bookingQuote = ref<AvailabilityResult | null>(null)
/** A held-but-unpaid booking saved on this device, if the guest closed the
* invoice earlier — surfaced as a "resume" banner. */
const pendingBooking = ref<PendingBooking | null>(null)
/** Passed to the dialog to reopen the saved invoice instead of the form. */
const resumePayload = ref<PendingBooking | null>(null)
// Occupied nights for the calendar; refreshed after a booking and when a // Occupied nights for the calendar; refreshed after a booking and when a
// quote contradicts the local set (someone else's hold appeared). // quote contradicts the local set (someone else's hold appeared).
@ -68,6 +77,59 @@ onMounted(async () => {
checkIn.value = qi checkIn.value = qi
checkOut.value = qo checkOut.value = qo
} }
await refreshPending()
})
/**
* Surface a "resume payment" banner if this device holds an unpaid booking
* for this room. Verify server-side first: drop it if it already confirmed
* or the hold lapsed (the store also self-prunes past-expiry entries).
*/
async function refreshPending() {
const id = roomIdRef.value
if (!id) return
const pb = pendingStore.getForRoom(id)
if (!pb) {
pendingBooking.value = null
return
}
try {
const live = await getPublicBooking(pb.bookingId)
const status = live?.status
if (!status || status === 'confirmed' || status === 'checked_in' || status === 'completed' || LOST_BOOKING_STATUSES.has(status)) {
pendingStore.remove(id)
pendingBooking.value = null
return
}
} catch {
// Network hiccup — keep the local record and let the resumed poll decide.
}
pendingBooking.value = pb
}
function resumeBooking() {
const pb = pendingBooking.value
if (!pb || !room.value) return
// Rebuild the availability shape the dialog header reads from the saved hold.
bookingQuote.value = {
room_id: pb.roomId,
check_in: pb.checkIn,
check_out: pb.checkOut,
available: true,
nights: pb.nights,
quote_sat: pb.amountSat,
quote_fiat: pb.quoteFiat,
currency: pb.currency,
}
resumePayload.value = pb
showBooking.value = true
}
const pendingStayLabel = computed(() => {
const pb = pendingBooking.value
if (!pb) return ''
const fmt = (ymd: string) => format(new Date(ymd + 'T00:00:00'), 'MMM d')
return `${fmt(pb.checkIn)} → ${fmt(pb.checkOut)}`
}) })
async function onCheck() { async function onCheck() {
@ -116,11 +178,17 @@ function onBooked() {
result.value = null result.value = null
checkIn.value = '' checkIn.value = ''
checkOut.value = '' checkOut.value = ''
pendingBooking.value = null
void unavailability.refresh() void unavailability.refresh()
} }
watch(showBooking, (open) => { watch(showBooking, (open) => {
if (!open) bookingQuote.value = null if (!open) {
bookingQuote.value = null
resumePayload.value = null
// The hold may have been paid or lapsed while the dialog was open.
void refreshPending()
}
}) })
</script> </script>
@ -198,6 +266,18 @@ watch(showBooking, (open) => {
</CardContent> </CardContent>
</Card> </Card>
<Card v-if="pendingBooking && !showBooking" class="border-primary/40 bg-primary/5">
<CardContent class="p-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-0.5">
<p class="text-sm font-medium text-foreground">You have an unpaid hold</p>
<p class="text-sm text-muted-foreground">
{{ pendingStayLabel }} · {{ formatSats(pendingBooking.depositSat) }}
</p>
</div>
<Button size="sm" class="shrink-0" @click="resumeBooking">Resume payment</Button>
</CardContent>
</Card>
<Card> <Card>
<CardContent class="p-4 space-y-4"> <CardContent class="p-4 space-y-4">
<StayDatePicker <StayDatePicker
@ -243,6 +323,7 @@ watch(showBooking, (open) => {
v-if="room && bookingQuote" v-if="room && bookingQuote"
:room="room" :room="room"
:availability="bookingQuote" :availability="bookingQuote"
:resume="resumePayload"
v-model:is-open="showBooking" v-model:is-open="showBooking"
@booked="onBooked" @booked="onBooked"
/> />