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

@@ -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