import { FastifyPluginAsync } from 'fastify' import { db } from '../db' import { createHold, createCharge, capturePayment, cancelPayment, createRefund } from '../services/yookassa' import { transporter, sendBookingConfirmedEmail } from '../email' import { broadcast } from './ws' type SlugParam = { Params: { slug: string } } type SlugBookingParam = { Params: { slug: string; bookingId: string } } const deposit: FastifyPluginAsync = async (fastify) => { const getHotelId = async (slug: string): Promise => { const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) return rows[0]?.id ?? null } const canAccess = (userSlug: string | null, role: string, slug: string) => role === 'super_admin' || userSlug === slug const isManager = (role: string) => ['super_admin', 'hotel_admin', 'manager'].includes(role) const appUrl = () => process.env.APP_URL ?? 'https://app.hotelsync.ru' // ── GET /api/hotels/:slug/deposit/settings ──────────────────────────────── fastify.get( '/api/hotels/:slug/deposit/settings', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { rows } = await db.query( 'SELECT * FROM hotel_deposit_settings WHERE hotel_id = $1', [hotelId], ) if (!rows[0]) { return { hotelId, isEnabled: false, amount: 5000, yookassaShopId: null, yookassaSecretKey: null, releaseRequiresCheckout: false, longStayFullPayment: false, longStayThresholdDays: 7 } } // Mask secret key const row = rows[0] return { ...row, yookassa_secret_key: row.yookassa_secret_key ? '••••••••' : null, release_requires_checkout: row.release_requires_checkout ?? false, } }, ) // ── PATCH /api/hotels/:slug/deposit/settings ────────────────────────────── fastify.patch( '/api/hotels/:slug/deposit/settings', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { is_enabled, amount, yookassa_shop_id, yookassa_secret_key, release_requires_checkout, long_stay_full_payment, long_stay_threshold_days } = request.body const { rows } = await db.query( `INSERT INTO hotel_deposit_settings (hotel_id, is_enabled, amount, yookassa_shop_id, yookassa_secret_key, release_requires_checkout, long_stay_full_payment, long_stay_threshold_days, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) ON CONFLICT (hotel_id) DO UPDATE SET is_enabled = COALESCE($2, hotel_deposit_settings.is_enabled), amount = COALESCE($3, hotel_deposit_settings.amount), yookassa_shop_id = COALESCE($4, hotel_deposit_settings.yookassa_shop_id), yookassa_secret_key = CASE WHEN $5 IS NOT NULL AND $5 != '••••••••' THEN $5 ELSE hotel_deposit_settings.yookassa_secret_key END, release_requires_checkout = COALESCE($6, hotel_deposit_settings.release_requires_checkout), long_stay_full_payment = COALESCE($7, hotel_deposit_settings.long_stay_full_payment), long_stay_threshold_days = COALESCE($8, hotel_deposit_settings.long_stay_threshold_days), updated_at = NOW() RETURNING *`, [hotelId, is_enabled ?? false, amount ? amount.toFixed(2) : '5000.00', yookassa_shop_id ?? null, yookassa_secret_key ?? null, release_requires_checkout ?? null, long_stay_full_payment ?? null, long_stay_threshold_days ?? null], ) return rows[0] }, ) // ── GET /api/hotels/:slug/bookings/:bookingId/deposit ───────────────────── fastify.get( '/api/hotels/:slug/bookings/:bookingId/deposit', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, bookingId } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { rows } = await db.query( `SELECT * FROM booking_deposits WHERE booking_id = $1 AND hotel_id = $2 ORDER BY created_at DESC LIMIT 1`, [bookingId, hotelId], ) if (!rows[0]) return reply.code(404).send({ error: 'No deposit found' }) // Mask confirmation URL isn't needed — expose it for QR return rows[0] }, ) // ── POST /api/hotels/:slug/bookings/:bookingId/deposit/cash ─────────────── fastify.post( '/api/hotels/:slug/bookings/:bookingId/deposit/cash', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, bookingId } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) // Verify booking const { rows: bRows } = await db.query( 'SELECT id FROM bookings WHERE id = $1 AND hotel_id = $2', [bookingId, hotelId], ) if (!bRows[0]) return reply.code(404).send({ error: 'Booking not found' }) // Get deposit settings amount const { rows: settingsRows } = await db.query( 'SELECT amount FROM hotel_deposit_settings WHERE hotel_id = $1', [hotelId], ) const amount = settingsRows[0]?.amount ?? '5000.00' const { rows } = await db.query( `INSERT INTO booking_deposits (hotel_id, booking_id, amount, status, payment_method, paid_at) VALUES ($1, $2, $3, 'paid_cash', 'cash', NOW()) RETURNING *`, [hotelId, bookingId, amount], ) return reply.code(201).send(rows[0]) }, ) // ── POST /api/hotels/:slug/bookings/:bookingId/deposit/yookassa ─────────── fastify.post( '/api/hotels/:slug/bookings/:bookingId/deposit/yookassa', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, bookingId } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) // Get settings including YooKassa credentials const { rows: settingsRows } = await db.query( 'SELECT * FROM hotel_deposit_settings WHERE hotel_id = $1', [hotelId], ) const settings = settingsRows[0] if (!settings) return reply.code(400).send({ error: 'Deposit settings not configured' }) if (!settings.yookassa_shop_id || !settings.yookassa_secret_key) { return reply.code(400).send({ error: 'YooKassa credentials not configured' }) } // Get booking dates + guest name const { rows: bRows } = await db.query( 'SELECT id, guest_name, check_in, check_out FROM bookings WHERE id = $1 AND hotel_id = $2', [bookingId, hotelId], ) if (!bRows[0]) return reply.code(404).send({ error: 'Booking not found' }) const nights = Math.ceil( (new Date(bRows[0].check_out).getTime() - new Date(bRows[0].check_in).getTime()) / 86400000, ) const threshold = settings.long_stay_threshold_days ?? 7 const useCharge = settings.long_stay_full_payment && nights > threshold const payment = useCharge ? await createCharge({ shopId: settings.yookassa_shop_id, secretKey: settings.yookassa_secret_key, amount: Number(settings.amount), description: `Депозит за бронирование — ${bRows[0].guest_name}`, returnUrl: `${appUrl()}/${slug}/bookings`, }) : await createHold({ shopId: settings.yookassa_shop_id, secretKey: settings.yookassa_secret_key, amount: Number(settings.amount), description: `Депозит за бронирование — ${bRows[0].guest_name}`, returnUrl: `${appUrl()}/${slug}/bookings`, }) const paymentMethod = useCharge ? 'yookassa_charge' : 'yookassa_hold' const { rows } = await db.query( `INSERT INTO booking_deposits (hotel_id, booking_id, amount, status, payment_method, yookassa_payment_id, yookassa_confirmation_url) VALUES ($1, $2, $3, 'hold_created', $4, $5, $6) RETURNING *`, [hotelId, bookingId, settings.amount, paymentMethod, payment.id, payment.confirmation?.confirmation_url ?? null], ) return reply.code(201).send({ ...rows[0], confirmationUrl: payment.confirmation?.confirmation_url ?? null, }) }, ) // ── POST /api/hotels/:slug/bookings/:bookingId/deposit/release ──────────── fastify.post } }>( '/api/hotels/:slug/bookings/:bookingId/deposit/release', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, bookingId } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { captured_amount: capturedAmount, reason, items } = request.body // Get active deposit const { rows: depRows } = await db.query( `SELECT * FROM booking_deposits WHERE booking_id = $1 AND hotel_id = $2 AND status IN ('paid_cash', 'hold_created', 'hold_confirmed') ORDER BY created_at DESC LIMIT 1`, [bookingId, hotelId], ) if (!depRows[0]) return reply.code(404).send({ error: 'Active deposit not found' }) const dep = depRows[0] let newStatus: string if (dep.payment_method === 'yookassa_hold' && dep.yookassa_payment_id) { // Get settings for credentials const { rows: settingsRows } = await db.query( 'SELECT * FROM hotel_deposit_settings WHERE hotel_id = $1', [hotelId], ) const settings = settingsRows[0] if (!settings?.yookassa_shop_id || !settings?.yookassa_secret_key) { return reply.code(400).send({ error: 'YooKassa credentials not configured' }) } if (capturedAmount === 0) { await cancelPayment({ shopId: settings.yookassa_shop_id, secretKey: settings.yookassa_secret_key, paymentId: dep.yookassa_payment_id, }) newStatus = 'refunded' } else { await capturePayment({ shopId: settings.yookassa_shop_id, secretKey: settings.yookassa_secret_key, paymentId: dep.yookassa_payment_id, amount: capturedAmount, }) newStatus = 'captured' } } else { // Cash deposit newStatus = capturedAmount === 0 ? 'refunded' : 'captured' } const { rows } = await db.query( `UPDATE booking_deposits SET status = $1, captured_amount = $2, retention_reason = $3, released_at = NOW() WHERE id = $4 RETURNING *`, [newStatus, capturedAmount.toFixed(2), reason ?? null, dep.id], ) // Send email to guest if email is available const { rows: bRows } = await db.query( `SELECT b.guest_name, b.guest_email, h.name AS hotel_name FROM bookings b JOIN hotels h ON h.id = b.hotel_id WHERE b.id = $1`, [bookingId], ) if (bRows[0]?.guest_email) { const guest = bRows[0] const hotelName: string = guest.hotel_name ?? 'HotelSync' const fmt = (n: number) => n.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ₽' const refund = Number(dep.amount) - capturedAmount let html: string if (capturedAmount === 0) { html = `

Уважаемый(ая) ${guest.guest_name},

Страховой депозит был полностью возвращён.

Средства поступят на вашу карту в течение нескольких рабочих дней.

` } else { const itemsHtml = items && items.length > 0 ? ` ${items.map((it, i) => ` `).join('')} ${refund > 0 ? `` : ''}
Позиция Сумма
${it.name} ${fmt(Number(it.amount))}
Итого удержано ${fmt(capturedAmount)}
Возврат остатка${fmt(refund)}
` : `

Удержана сумма: ${fmt(capturedAmount)}

${refund > 0 ? `

Остаток ${fmt(refund)} возвращён.

` : ''}` html = `

Уважаемый(ая) ${guest.guest_name},

Из страхового депозита удержана сумма за следующие позиции:

${itemsHtml} ${reason ? `

Комментарий: ${reason}

` : ''} ${refund > 0 && (!items || items.length === 0) ? `

Остаток депозита ${fmt(refund)} возвращён.

` : ''}` } const emailHtml = `

${hotelName}

Информация о страховом депозите

${html}

Спасибо за проживание.

© 2026 ${hotelName} · Powered by HotelSync

` const textFallback = capturedAmount === 0 ? `Уважаемый(ая) ${guest.guest_name},\n\nСтраховой депозит полностью возвращён.\n\nСпасибо за проживание.\n\n© 2026 ${hotelName}` : [ `Уважаемый(ая) ${guest.guest_name},`, '', `Из страхового депозита удержана сумма ${fmt(capturedAmount)}.`, ...(items && items.length > 0 ? ['', 'Позиции удержания:', ...items.map(it => ` • ${it.name} — ${fmt(Number(it.amount))}`)] : []), ...(reason ? ['', `Комментарий: ${reason}`] : []), ...(refund > 0 ? ['', `Остаток депозита ${fmt(refund)} возвращён.`] : []), '', 'Спасибо за проживание.', '', `© 2026 ${hotelName}`, ].join('\n') transporter.sendMail({ from: `"${hotelName}" <${process.env.SMTP_USER ?? 'noreply@hotelsync.ru'}>`, to: guest.guest_email, subject: `Информация о депозите — ${hotelName}`, text: textFallback, html: emailHtml, }).catch(() => {}) await db.query( 'UPDATE booking_deposits SET guest_email_sent = true WHERE id = $1', [dep.id], ) } return rows[0] }, ) // ── POST /api/hotels/:slug/bookings/:bookingId/deposit/refund ──────────── // Partial or full refund of a captured (yookassa_charge) deposit fastify.post( '/api/hotels/:slug/bookings/:bookingId/deposit/refund', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, bookingId } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { amount: reqAmount, reason } = request.body const { rows: depRows } = await db.query( `SELECT * FROM booking_deposits WHERE booking_id = $1 AND hotel_id = $2 AND payment_method = 'yookassa_charge' AND status = 'captured' ORDER BY created_at DESC LIMIT 1`, [bookingId, hotelId], ) if (!depRows[0]) return reply.code(404).send({ error: 'No refundable deposit found' }) const dep = depRows[0] const { rows: settingsRows } = await db.query( 'SELECT * FROM hotel_deposit_settings WHERE hotel_id = $1', [hotelId], ) const settings = settingsRows[0] if (!settings?.yookassa_shop_id || !settings?.yookassa_secret_key) { return reply.code(400).send({ error: 'YooKassa credentials not configured' }) } const depositAmount = Number(dep.amount) const alreadyRefunded = Number(dep.refunded_amount ?? 0) const remaining = depositAmount - alreadyRefunded const refundAmount = reqAmount !== undefined ? Math.min(reqAmount, remaining) : remaining if (refundAmount <= 0) return reply.code(400).send({ error: 'Nothing to refund' }) await createRefund({ shopId: settings.yookassa_shop_id, secretKey: settings.yookassa_secret_key, paymentId: dep.yookassa_payment_id, amount: refundAmount, }) const newRefunded = alreadyRefunded + refundAmount const isFullRefund = newRefunded >= depositAmount - 0.01 const newStatus = isFullRefund ? 'refunded' : 'partially_refunded' const { rows } = await db.query( `UPDATE booking_deposits SET status = $1, refunded_amount = $2, retention_reason = $3, released_at = COALESCE(released_at, NOW()) WHERE id = $4 RETURNING *`, [newStatus, newRefunded.toFixed(2), reason ?? null, dep.id], ) return rows[0] }, ) // ── GET /api/pay/:slug — public, no auth ───────────────────────────────── fastify.get<{ Params: { slug: string } }>( '/api/pay/:slug', async (request, reply) => { const { slug } = request.params const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Not found' }) const { rows } = await db.query( `SELECT d.yookassa_confirmation_url, d.amount, h.name AS hotel_name FROM booking_deposits d JOIN hotels h ON h.id = d.hotel_id WHERE d.hotel_id = $1 AND d.status = 'hold_created' ORDER BY d.created_at DESC LIMIT 1`, [hotelId], ) if (!rows[0]?.yookassa_confirmation_url) { return reply.code(404).send({ error: 'No active payment' }) } return { confirmationUrl: rows[0].yookassa_confirmation_url, amount: rows[0].amount, hotelName: rows[0].hotel_name, } }, ) // ── DELETE /api/hotels/:slug/bookings/:bookingId/deposit ───────────────── // Cancel/reset deposit (hold not paid yet, or cash entered by mistake) fastify.delete( '/api/hotels/:slug/bookings/:bookingId/deposit', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, bookingId } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { rows: depRows } = await db.query( `SELECT * FROM booking_deposits WHERE booking_id = $1 AND hotel_id = $2 AND status IN ('hold_created', 'paid_cash') ORDER BY created_at DESC LIMIT 1`, [bookingId, hotelId], ) if (!depRows[0]) return reply.code(404).send({ error: 'No cancellable deposit' }) const dep = depRows[0] // Try to cancel YooKassa hold if exists (ignore failures — payment may be in non-cancellable state) if (dep.yookassa_payment_id) { try { const { rows: settingsRows } = await db.query( 'SELECT * FROM hotel_deposit_settings WHERE hotel_id = $1', [hotelId], ) const settings = settingsRows[0] if (settings?.yookassa_shop_id && settings?.yookassa_secret_key) { await cancelPayment({ shopId: settings.yookassa_shop_id, secretKey: settings.yookassa_secret_key, paymentId: dep.yookassa_payment_id, }) } } catch { // Ignore — guest may not have started payment } } await db.query( `UPDATE booking_deposits SET status = 'cancelled', released_at = NOW() WHERE id = $1`, [dep.id], ) return reply.code(204).send() }, ) // ── GET /api/hotels/:slug/deposits — history ───────────────────────────── fastify.get( '/api/hotels/:slug/deposits', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { rows } = await db.query( `SELECT d.*, b.guest_name, b.guest_email, b.check_in, b.check_out, r.number AS room_number FROM booking_deposits d JOIN bookings b ON b.id = d.booking_id LEFT JOIN rooms r ON r.id = b.room_id WHERE d.hotel_id = $1 ORDER BY d.created_at DESC LIMIT 200`, [hotelId], ) return rows }, ) // ── GET /api/hotels/:slug/bookings/:bookingId/deposit/minibar ──────────── // Minibar consumptions for this booking (for pre-filling release form) fastify.get( '/api/hotels/:slug/bookings/:bookingId/deposit/minibar', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, bookingId } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) // Get room_id for this booking const { rows: bRows } = await db.query( 'SELECT room_id FROM bookings WHERE id = $1 AND hotel_id = $2', [bookingId, hotelId], ) if (!bRows[0]?.room_id) return { items: [], total: 0 } // Get all minibar consumptions from housekeeping tasks for this room // Use current item price (mi.price) — so price changes in settings are reflected const { rows } = await db.query( `SELECT mc.quantity, mc.price_per_unit AS recorded_price, mi.price AS current_price, mi.name AS item_name, (mc.quantity * mi.price) AS line_total FROM minibar_consumptions mc JOIN minibar_items mi ON mi.id = mc.item_id JOIN housekeeping_tasks ht ON ht.id = mc.task_id WHERE ht.room_id = $1 AND ht.hotel_id = $2 AND mc.recorded_at >= (SELECT check_in FROM bookings WHERE id = $3) ORDER BY mc.recorded_at ASC`, [bRows[0].room_id, hotelId, bookingId], ) const total = rows.reduce((s: number, r: { line_total: string }) => s + parseFloat(r.line_total), 0) return { items: rows, total } }, ) // ── GET /api/hotels/:slug/deposit/presets ───────────────────────────────── fastify.get( '/api/hotels/:slug/deposit/presets', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { rows } = await db.query( `SELECT * FROM deposit_deduction_presets WHERE hotel_id = $1 ORDER BY sort_order, created_at`, [hotelId], ) return rows }, ) // ── POST /api/hotels/:slug/deposit/presets ──────────────────────────────── fastify.post( '/api/hotels/:slug/deposit/presets', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !['super_admin','hotel_admin','manager'].includes(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { name, amount } = request.body const { rows } = await db.query( `INSERT INTO deposit_deduction_presets (hotel_id, name, amount) VALUES ($1, $2, $3) RETURNING *`, [hotelId, name, amount], ) return reply.code(201).send(rows[0]) }, ) // ── DELETE /api/hotels/:slug/deposit/presets/:presetId ──────────────────── fastify.delete<{ Params: { slug: string; presetId: string } }>( '/api/hotels/:slug/deposit/presets/:presetId', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, presetId } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !['super_admin','hotel_admin','manager'].includes(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) await db.query( `DELETE FROM deposit_deduction_presets WHERE id = $1 AND hotel_id = $2`, [presetId, hotelId], ) return reply.code(204).send() }, ) // ── PATCH /api/hotels/:slug/deposit/presets/:presetId ───────────────────── fastify.patch<{ Params: { slug: string; presetId: string }; Body: { name?: string; amount?: number } }>( '/api/hotels/:slug/deposit/presets/:presetId', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { slug, presetId } = request.params if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !['super_admin','hotel_admin','manager'].includes(request.user.role)) { return reply.code(403).send({ error: 'Forbidden' }) } const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { name, amount } = request.body const { rows } = await db.query( `UPDATE deposit_deduction_presets SET name = COALESCE($1, name), amount = COALESCE($2, amount) WHERE id = $3 AND hotel_id = $4 RETURNING *`, [name ?? null, amount ?? null, presetId, hotelId], ) if (!rows[0]) return reply.code(404).send({ error: 'Not found' }) return rows[0] }, ) // ── POST /api/webhooks/yookassa ─────────────────────────────────────────── fastify.post<{ Body: { event: string object: { id: string status: string payment_method?: { type?: string card?: { last4?: string; card_type?: string } } } } }>( '/api/webhooks/yookassa', async (request, reply) => { const { object } = request.body if (!object?.id) return reply.code(400).send({ error: 'Invalid webhook' }) const statusMap: Record = { waiting_for_capture: 'hold_confirmed', succeeded: 'captured', canceled: 'cancelled', } const newStatus = statusMap[object.status] if (newStatus) { const card = object.payment_method?.card if (card?.last4) { await db.query( `UPDATE booking_deposits SET status = $1, card_last4 = $2, card_brand = $3 WHERE yookassa_payment_id = $4`, [newStatus, card.last4, card.card_type ?? null, object.id], ) } else { await db.query( `UPDATE booking_deposits SET status = $1 WHERE yookassa_payment_id = $2`, [newStatus, object.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 JOIN hotels h ON h.id = ob.hotel_id WHERE ob.yookassa_payment_id = $1 LIMIT 1`, [object.id], ) if (obRows[0]) { const ob = obRows[0] if (object.status === '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] }) } // Insert payment record so the payment tab shows the amount await db.query( `INSERT INTO booking_payments (hotel_id, booking_id, amount, method, note) VALUES ($1, $2, $3, 'yookassa', 'Оплата через ЮКассу (онлайн-бронирование)') ON CONFLICT DO NOTHING`, [ob.hotel_id, ob.booking_id, ob.total_amount], ).catch(() => {}) } 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] }) } } } } } return { ok: true } }, ) } export default deposit