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:
@@ -1,7 +1,8 @@
|
|||||||
import { FastifyPluginAsync } from 'fastify'
|
import { FastifyPluginAsync } from 'fastify'
|
||||||
import { db } from '../db'
|
import { db } from '../db'
|
||||||
import { createHold, createCharge, capturePayment, cancelPayment, createRefund } from '../services/yookassa'
|
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 SlugParam = { Params: { slug: string } }
|
||||||
type SlugBookingParam = { Params: { slug: string; bookingId: 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
|
// Handle online widget booking payment
|
||||||
if (object.status === 'succeeded') {
|
if (object.status === 'succeeded' || object.status === 'canceled') {
|
||||||
const { rows: obRows } = await db.query(
|
const { rows: obRows } = await db.query<{
|
||||||
`SELECT ob.booking_id, ob.hotel_id
|
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
|
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],
|
[object.id],
|
||||||
)
|
)
|
||||||
if (obRows[0]?.booking_id) {
|
if (obRows[0]) {
|
||||||
|
const ob = obRows[0]
|
||||||
|
if (object.status === 'succeeded') {
|
||||||
await db.query(
|
await db.query(
|
||||||
`UPDATE online_bookings SET yookassa_status = 'succeeded' WHERE yookassa_payment_id = $1`,
|
`UPDATE online_bookings SET status = 'paid', yookassa_status = 'succeeded' WHERE id = $1`,
|
||||||
[object.id],
|
[ob.id],
|
||||||
)
|
)
|
||||||
// Check gateway auto_confirm_on_payment
|
if (ob.booking_id) {
|
||||||
const { rows: gwRows } = await db.query(
|
const { rows: bRows } = await db.query(
|
||||||
`SELECT auto_confirm_on_payment FROM hotel_payment_gateways
|
`UPDATE bookings
|
||||||
WHERE hotel_id = $1 AND is_active = true
|
SET status = 'confirmed',
|
||||||
ORDER BY created_at LIMIT 1`,
|
payment_status = 'paid',
|
||||||
[obRows[0].hotel_id],
|
paid_amount = total_amount,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1 RETURNING *`,
|
||||||
|
[ob.booking_id],
|
||||||
)
|
)
|
||||||
const autoConfirm = gwRows[0]?.auto_confirm_on_payment !== false // default true
|
if (bRows[0]) {
|
||||||
if (autoConfirm) {
|
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(
|
await db.query(
|
||||||
`UPDATE bookings SET status = 'confirmed' WHERE id = $1 AND status = 'inquiry'`,
|
`UPDATE online_bookings SET status = 'cancelled', yookassa_status = 'canceled' WHERE id = $1`,
|
||||||
[obRows[0].booking_id],
|
[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] })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,96 +1,10 @@
|
|||||||
import { FastifyPluginAsync } from 'fastify'
|
import { FastifyPluginAsync } from 'fastify'
|
||||||
import { db } from '../db'
|
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) => {
|
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)
|
// GET /api/online-bookings/:id — public status check (used by BookingConfirmPage)
|
||||||
fastify.get<{ Params: { id: string } }>('/api/online-bookings/:id', async (req, reply) => {
|
fastify.get<{ Params: { id: string } }>('/api/online-bookings/:id', async (req, reply) => {
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
|
|||||||
@@ -411,6 +411,29 @@ export function PaymentSettingsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Webhook URL instruction */}
|
||||||
|
<div className="rounded-lg bg-indigo-50 dark:bg-indigo-950/30 border border-indigo-200 dark:border-indigo-800 p-3">
|
||||||
|
<p className="text-xs font-semibold text-indigo-700 dark:text-indigo-300 mb-1">Настройка вебхука в ЮКассе</p>
|
||||||
|
<p className="text-xs text-indigo-600 dark:text-indigo-400 mb-2">
|
||||||
|
В личном кабинете ЮКассы перейдите: <strong>Настройки → HTTP-уведомления</strong> и укажите этот URL для всех событий:
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 bg-white dark:bg-slate-800 rounded-md px-2.5 py-1.5 border border-indigo-200 dark:border-indigo-700">
|
||||||
|
<code className="text-xs font-mono text-slate-700 dark:text-slate-200 flex-1 break-all">
|
||||||
|
https://api.hotelsync.ru/api/webhooks/yookassa
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigator.clipboard.writeText('https://api.hotelsync.ru/api/webhooks/yookassa')}
|
||||||
|
className="text-indigo-400 hover:text-indigo-600 shrink-0"
|
||||||
|
title="Скопировать"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-indigo-500 dark:text-indigo-400 mt-1.5">
|
||||||
|
Включите события: <strong>payment.succeeded</strong>, <strong>payment.canceled</strong>, <strong>payment.waiting_for_capture</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-slate-500 mb-1.5">Использовать в модулях:</p>
|
<p className="text-xs text-slate-500 mb-1.5">Использовать в модулях:</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user