From 60c49d97d1c4d865179415b186b389ba840dd481 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Wed, 8 Apr 2026 03:08:02 +0300 Subject: [PATCH] fix: unify YooKassa webhook to single URL + add webhook instruction in UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Consolidate online booking payment handling into existing /api/webhooks/yookassa (was previously split — deposit.ts had partial handling, new route had full) - deposit.ts webhook now fully handles online_bookings: reserved→confirmed, payment_status=paid, paid_amount, WS broadcast, and confirmation email - Remove POST /api/yookassa/webhook (keep only GET /api/online-bookings/:id) - PaymentSettingsPage: add webhook URL instruction panel with copy button showing https://api.hotelsync.ru/api/webhooks/yookassa and required events Co-Authored-By: Claude Sonnet 4.6 --- backend/src/routes/deposit.ts | 81 ++++++++++++++++------- backend/src/routes/yookassaWebhook.ts | 92 +-------------------------- src/pages/PaymentSettingsPage.tsx | 23 +++++++ 3 files changed, 85 insertions(+), 111 deletions(-) diff --git a/backend/src/routes/deposit.ts b/backend/src/routes/deposit.ts index 4f48a05..995fe06 100644 --- a/backend/src/routes/deposit.ts +++ b/backend/src/routes/deposit.ts @@ -1,7 +1,8 @@ import { FastifyPluginAsync } from 'fastify' import { db } from '../db' import { createHold, createCharge, capturePayment, cancelPayment, createRefund } from '../services/yookassa' -import { transporter } from '../email' +import { transporter, sendBookingConfirmedEmail } from '../email' +import { broadcast } from './ws' type SlugParam = { Params: { slug: string } } type SlugBookingParam = { Params: { slug: string; bookingId: string } } @@ -731,32 +732,68 @@ ${refund > 0 && (!items || items.length === 0) ? `

Ос } } - // Auto-confirm booking on successful online widget payment - if (object.status === 'succeeded') { - const { rows: obRows } = await db.query( - `SELECT ob.booking_id, ob.hotel_id + // Handle online widget booking payment + if (object.status === 'succeeded' || object.status === 'canceled') { + const { rows: obRows } = await db.query<{ + id: string; booking_id: string | null; hotel_id: string; slug: string + guest_name: string; guest_email: string | null + check_in: string; check_out: string; total_amount: string; hotel_name: string + }>( + `SELECT ob.id, ob.booking_id, ob.hotel_id, h.slug, h.name AS hotel_name, + ob.guest_name, ob.guest_email, ob.check_in, ob.check_out, ob.total_amount FROM online_bookings ob - WHERE ob.yookassa_payment_id = $1`, + JOIN hotels h ON h.id = ob.hotel_id + WHERE ob.yookassa_payment_id = $1 + LIMIT 1`, [object.id], ) - if (obRows[0]?.booking_id) { - await db.query( - `UPDATE online_bookings SET yookassa_status = 'succeeded' WHERE yookassa_payment_id = $1`, - [object.id], - ) - // Check gateway auto_confirm_on_payment - const { rows: gwRows } = await db.query( - `SELECT auto_confirm_on_payment FROM hotel_payment_gateways - WHERE hotel_id = $1 AND is_active = true - ORDER BY created_at LIMIT 1`, - [obRows[0].hotel_id], - ) - const autoConfirm = gwRows[0]?.auto_confirm_on_payment !== false // default true - if (autoConfirm) { + if (obRows[0]) { + const ob = obRows[0] + if (object.status === 'succeeded') { await db.query( - `UPDATE bookings SET status = 'confirmed' WHERE id = $1 AND status = 'inquiry'`, - [obRows[0].booking_id], + `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] }) + } + } + if (ob.guest_email) { + sendBookingConfirmedEmail({ + to: ob.guest_email, + guestName: ob.guest_name, + hotelName: ob.hotel_name, + checkIn: ob.check_in, + checkOut: ob.check_out, + totalAmount: ob.total_amount, + bookingConfirmUrl: `https://app.hotelsync.ru/booking-confirm/${ob.id}`, + }).catch(() => {}) + } + } else { + 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] }) + } + } } } } diff --git a/backend/src/routes/yookassaWebhook.ts b/backend/src/routes/yookassaWebhook.ts index 25f5ab5..45745fd 100644 --- a/backend/src/routes/yookassaWebhook.ts +++ b/backend/src/routes/yookassaWebhook.ts @@ -1,96 +1,10 @@ import { FastifyPluginAsync } from 'fastify' import { db } from '../db' -import { broadcast } from './ws' -import { sendBookingConfirmedEmail } from '../email' + +// This file provides a public status endpoint for the BookingConfirmPage. +// YooKassa webhook notifications are handled in deposit.ts at POST /api/webhooks/yookassa 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 - const event = body?.event as string | undefined - const obj = body?.object as Record | 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 - guest_name: string; guest_email: string | null - check_in: string; check_out: string; hotel_name: string - }>( - `SELECT ob.id, ob.hotel_id, ob.booking_id, ob.total_amount, h.slug, - ob.guest_name, ob.guest_email, ob.check_in, ob.check_out, h.name AS hotel_name - 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] }) - } - } - // Send confirmation email to guest - if (ob.guest_email) { - sendBookingConfirmedEmail({ - to: ob.guest_email, - guestName: ob.guest_name, - hotelName: ob.hotel_name, - checkIn: ob.check_in, - checkOut: ob.check_out, - totalAmount: ob.total_amount, - bookingConfirmUrl: `https://app.hotelsync.ru/booking-confirm/${ob.id}`, - }).catch(() => {}) // don't fail webhook on email error - } - } 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( diff --git a/src/pages/PaymentSettingsPage.tsx b/src/pages/PaymentSettingsPage.tsx index 117028a..b643df8 100644 --- a/src/pages/PaymentSettingsPage.tsx +++ b/src/pages/PaymentSettingsPage.tsx @@ -411,6 +411,29 @@ export function PaymentSettingsPage() { + {/* Webhook URL instruction */} +

+

Настройка вебхука в ЮКассе

+

+ В личном кабинете ЮКассы перейдите: Настройки → HTTP-уведомления и укажите этот URL для всех событий: +

+
+ + https://api.hotelsync.ru/api/webhooks/yookassa + + +
+

+ Включите события: payment.succeeded, payment.canceled, payment.waiting_for_capture +

+

Использовать в модулях: