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:
@@ -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
|
||||
|
||||
94
backend/src/routes/yookassaWebhook.ts
Normal file
94
backend/src/routes/yookassaWebhook.ts
Normal 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
|
||||
Reference in New Issue
Block a user