feat: YooKassa webhook, payment timeout, reserved status, BookingConfirmPage

- Add YooKassa webhook handler (POST /api/yookassa/webhook) that updates
  booking to confirmed+paid on payment.succeeded, cancelled on payment.canceled
- Add public status endpoint GET /api/online-bookings/:id for return URL polling
- Add 'reserved' booking status (violet) shown in calendar while awaiting payment
- Add 'website' to BookingSource type
- Payment timeout job (every 2 min) auto-cancels expired unpaid bookings
- Widget setting: "Время ожидания оплаты" (5–60 min, default 15)
- BookingConfirmPage at /booking-confirm/:id — polls status, countdown timer,
  shows success/pending/cancelled state
- Widget bookings now use status='reserved' instead of 'inquiry' when YooKassa
  is configured; set to 'confirmed' by webhook on payment.succeeded
- Migration 070: add 'reserved' to bookings status constraint + payment_expires_at

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 02:55:24 +03:00
parent b0bbb11c87
commit fb7d907b40
13 changed files with 380 additions and 10 deletions

View File

@@ -3,7 +3,8 @@ import { broadcast } from './routes/ws'
import { createNotification } from './routes/notifications'
import { getHkSettings } from './routes/housekeeping-settings'
const JOB_INTERVAL_MS = 60 * 60 * 1000 // every 1 hour
const JOB_INTERVAL_MS = 60 * 60 * 1000 // every 1 hour
const PAYMENT_EXPIRY_INTERVAL = 2 * 60 * 1000 // every 2 minutes
async function runAutoJobs(): Promise<void> {
try {
@@ -14,6 +15,42 @@ async function runAutoJobs(): Promise<void> {
}
}
async function runPaymentExpiryJob(): Promise<void> {
try {
// Find online bookings whose payment window has expired and are still pending
const { rows: expired } = await db.query<{
id: string; booking_id: string | null; slug: string
}>(
`SELECT ob.id, ob.booking_id, h.slug
FROM online_bookings ob
JOIN hotels h ON h.id = ob.hotel_id
WHERE ob.payment_expires_at IS NOT NULL
AND ob.payment_expires_at < NOW()
AND ob.status = 'pending'
AND ob.payment_method = 'yookassa'`,
)
for (const ob of expired) {
await db.query(
`UPDATE online_bookings SET status = 'cancelled' WHERE id = $1`,
[ob.id],
)
if (ob.booking_id) {
const { rows: bRows } = await db.query(
`UPDATE bookings SET status = 'cancelled', updated_at = NOW()
WHERE id = $1 AND status = 'reserved' RETURNING *`,
[ob.booking_id],
)
if (bRows[0]) {
broadcast(ob.slug, { type: 'booking:updated', booking: bRows[0] })
}
}
}
} catch (err) {
console.error('[jobs] payment expiry error:', err)
}
}
async function runAutoCancelNoShows(): Promise<void> {
// Find hotels where auto_cancel_noshow_enabled = true
// Also fetch check_in_time to calculate from the correct arrival time
@@ -160,9 +197,14 @@ export function startJobs(): void {
// Small delay to let DB migrations finish on startup
setTimeout(() => {
runAutoJobs().catch(console.error)
runPaymentExpiryJob().catch(console.error)
}, 15_000)
setInterval(() => {
runAutoJobs().catch(console.error)
}, JOB_INTERVAL_MS)
setInterval(() => {
runPaymentExpiryJob().catch(console.error)
}, PAYMENT_EXPIRY_INTERVAL)
}