fix: unify YooKassa webhook to single URL + add webhook instruction in UI

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 03:08:02 +03:00
parent c8d2d87db4
commit 60c49d97d1
3 changed files with 85 additions and 111 deletions

View File

@@ -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) ? `<p style="color:#16a34a">Ос
}
}
// 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] })
}
}
}
}
}

View File

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