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

@@ -45,6 +45,7 @@ import paymentsRoutes from './routes/payments'
import paymentMethodsRoutes from './routes/paymentMethods'
import paymentGatewaysRoutes from './routes/paymentGateways'
import publicWidgetRoutes from './routes/publicWidget'
import yookassaWebhookRoutes from './routes/yookassaWebhook'
import { setupAgentWsRoute } from './agent-ws'
import { startJobs } from './jobs'
@@ -142,6 +143,7 @@ export async function buildApp() {
await fastify.register(paymentMethodsRoutes)
await fastify.register(paymentGatewaysRoutes)
await fastify.register(publicWidgetRoutes)
await fastify.register(yookassaWebhookRoutes)
await fastify.register(setupAgentWsRoute)
startJobs()

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)
}

View File

@@ -337,32 +337,44 @@ const publicWidget: FastifyPluginAsync = async (fastify) => {
const gateway = await getGatewayForModule(hotel.id, 'booking-widget')
const paymentMethod = (gateway?.shop_id && gateway?.secret_key) ? 'yookassa' : 'none'
// Read payment timeout setting (in minutes, default 15)
const { rows: tRows } = await db.query(
`SELECT value FROM hotel_settings WHERE hotel_id = $1 AND key = 'widget_payment_timeout'`,
[hotel.id],
)
const paymentTimeoutMin: number = tRows[0]?.value ? Number(tRows[0].value) || 15 : 15
const paymentExpiresAt = paymentMethod === 'yookassa'
? new Date(Date.now() + paymentTimeoutMin * 60_000).toISOString()
: null
// Create online_booking record
const { rows } = await db.query(
`INSERT INTO online_bookings
(hotel_id, room_id, guest_name, guest_email, guest_phone, check_in, check_out,
adults, children, total_amount, notes, services, payment_method)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id`,
adults, children, total_amount, notes, services, payment_method, payment_expires_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING id`,
[hotel.id, roomId, guestName, guestEmail ?? null, guestPhone ?? null,
checkIn, checkOut, adults, children, totalAmount.toFixed(2), notes ?? null,
JSON.stringify(services ?? []), paymentMethod],
JSON.stringify(services ?? []), paymentMethod, paymentExpiresAt],
)
const onlineBookingId = rows[0].id
// Also create draft booking in main bookings table
// Create booking in main table
// status='reserved' while awaiting payment → 'confirmed' after webhook; 'confirmed' directly if no payment
const bookingStatus = paymentMethod === 'yookassa' ? 'reserved' : 'confirmed'
const { rows: bRows } = await db.query(
`INSERT INTO bookings
(hotel_id, room_id, guest_name, guest_email, guest_phone, check_in, check_out,
adults, children, total_amount, source, status, notes)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'website','inquiry',$11) RETURNING id`,
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'website',$11,$12) RETURNING id`,
[hotel.id, roomId, guestName, guestEmail ?? null, guestPhone ?? null,
checkIn, checkOut, adults, children, totalAmount.toFixed(2), notes ?? null],
checkIn, checkOut, adults, children, totalAmount.toFixed(2), bookingStatus, notes ?? null],
)
const bookingId = bRows[0].id
// Update online_booking with the main booking id
// Link online_booking booking
await db.query('UPDATE online_bookings SET booking_id = $1 WHERE id = $2',
[bookingId, onlineBookingId]).catch(() => {}) // column may not exist yet, ignore
[bookingId, onlineBookingId])
if (paymentMethod === 'yookassa') {
// Create YooKassa payment

View File

@@ -0,0 +1,94 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
import { broadcast } from './ws'
const yookassaWebhookRoutes: FastifyPluginAsync = async (fastify) => {
// POST /api/yookassa/webhook
// Receives payment status notifications from YooKassa.
// YooKassa requires a 200 response; any non-200 triggers retries.
fastify.post('/api/yookassa/webhook', async (req, reply) => {
try {
const body = req.body as Record<string, unknown>
const event = body?.event as string | undefined
const obj = body?.object as Record<string, unknown> | undefined
const paymentId = obj?.id as string | undefined
if (!paymentId || !event) return reply.code(200).send({ ok: true })
// Find the online booking linked to this payment
const { rows } = await db.query<{
id: string; hotel_id: string; booking_id: string | null
total_amount: string; slug: string
}>(
`SELECT ob.id, ob.hotel_id, ob.booking_id, ob.total_amount, h.slug
FROM online_bookings ob
JOIN hotels h ON h.id = ob.hotel_id
WHERE ob.yookassa_payment_id = $1
LIMIT 1`,
[paymentId],
)
if (!rows[0]) return reply.code(200).send({ ok: true })
const ob = rows[0]
if (event === 'payment.succeeded') {
await db.query(
`UPDATE online_bookings SET status = 'paid', yookassa_status = 'succeeded' WHERE id = $1`,
[ob.id],
)
if (ob.booking_id) {
const { rows: bRows } = await db.query(
`UPDATE bookings
SET status = 'confirmed',
payment_status = 'paid',
paid_amount = total_amount,
updated_at = NOW()
WHERE id = $1 RETURNING *`,
[ob.booking_id],
)
if (bRows[0]) {
broadcast(ob.slug, { type: 'booking:updated', booking: bRows[0] })
}
}
} else if (event === 'payment.canceled') {
await db.query(
`UPDATE online_bookings SET status = 'cancelled', yookassa_status = 'canceled' 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 RETURNING *`,
[ob.booking_id],
)
if (bRows[0]) {
broadcast(ob.slug, { type: 'booking:updated', booking: bRows[0] })
}
}
}
} catch (err) {
req.log.error(err, 'yookassa webhook error')
}
// Always return 200 so YooKassa does not retry
return reply.code(200).send({ ok: true })
})
// GET /api/online-bookings/:id — public status check (used by BookingConfirmPage)
fastify.get<{ Params: { id: string } }>('/api/online-bookings/:id', async (req, reply) => {
const { rows } = await db.query(
`SELECT ob.id, ob.status, ob.yookassa_status, ob.payment_expires_at,
ob.guest_name, ob.check_in, ob.check_out, ob.total_amount, ob.payment_method,
h.name AS hotel_name, h.slug
FROM online_bookings ob
JOIN hotels h ON h.id = ob.hotel_id
WHERE ob.id = $1`,
[req.params.id],
)
if (!rows[0]) return reply.code(404).send({ error: 'Not found' })
return rows[0]
})
}
export default yookassaWebhookRoutes