Merge pull request 'feat(chatelet): let a guest resume a held-but-unpaid booking' (#160) from feat/chatelet-resume-pending-invoice into dev

Reviewed-on: #160
This commit is contained in:
padreug 2026-09-21 17:58:24 +00:00
commit cc2adcb808
5 changed files with 270 additions and 5 deletions

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
form.resetForm({ values: { num_guests: 1, guest_contact: '', message: '' } }) 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: '' } })
}
} 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"
/> />