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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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