Files
hotelsync/backend/src/routes/deposit.ts
HotelSync cda515c0d8 fix: create booking_payments record on YooKassa webhook payment.succeeded
Payment tab shows 'Оплачено' from booking_payments table, not from paid_amount.
Webhook now inserts a payment record so the tab shows the correct amount.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 03:23:21 +03:00

814 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string | null> => {
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<SlugParam>(
'/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<SlugParam & { Body: {
is_enabled?: boolean; amount?: number
yookassa_shop_id?: string; yookassa_secret_key?: string
release_requires_checkout?: boolean
long_stay_full_payment?: boolean; long_stay_threshold_days?: number
} }>(
'/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<SlugBookingParam>(
'/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<SlugBookingParam>(
'/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<SlugBookingParam>(
'/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<SlugBookingParam & { Body: { captured_amount: number; reason?: string; items?: Array<{ name: string; amount: number }> } }>(
'/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 = `
<p>Уважаемый(ая) <strong>${guest.guest_name}</strong>,</p>
<p>Страховой депозит был полностью возвращён.</p>
<p style="color:#6b7280;font-size:13px">Средства поступят на вашу карту в течение нескольких рабочих дней.</p>`
} else {
const itemsHtml = items && items.length > 0
? `<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;margin:16px 0;font-size:14px">
<thead>
<tr style="background:#f1f5f9">
<th align="left" style="padding:8px 12px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569">Позиция</th>
<th align="right" style="padding:8px 12px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569">Сумма</th>
</tr>
</thead>
<tbody>
${items.map((it, i) => `
<tr style="background:${i % 2 === 0 ? '#fff' : '#f8fafc'}">
<td style="padding:8px 12px;border-bottom:1px solid #f1f5f9;color:#1e293b">${it.name}</td>
<td align="right" style="padding:8px 12px;border-bottom:1px solid #f1f5f9;color:#1e293b;white-space:nowrap">${fmt(Number(it.amount))}</td>
</tr>`).join('')}
</tbody>
<tfoot>
<tr style="background:#f1f5f9">
<td style="padding:8px 12px;font-weight:700;color:#0f172a">Итого удержано</td>
<td align="right" style="padding:8px 12px;font-weight:700;color:#0f172a;white-space:nowrap">${fmt(capturedAmount)}</td>
</tr>
${refund > 0 ? `<tr><td style="padding:8px 12px;color:#16a34a">Возврат остатка</td><td align="right" style="padding:8px 12px;color:#16a34a;white-space:nowrap">${fmt(refund)}</td></tr>` : ''}
</tfoot>
</table>`
: `<p>Удержана сумма: <strong>${fmt(capturedAmount)}</strong></p>${refund > 0 ? `<p style="color:#16a34a">Остаток <strong>${fmt(refund)}</strong> возвращён.</p>` : ''}`
html = `
<p>Уважаемый(ая) <strong>${guest.guest_name}</strong>,</p>
<p>Из страхового депозита удержана сумма за следующие позиции:</p>
${itemsHtml}
${reason ? `<p style="color:#6b7280;font-size:13px"><em>Комментарий: ${reason}</em></p>` : ''}
${refund > 0 && (!items || items.length === 0) ? `<p style="color:#16a34a">Остаток депозита <strong>${fmt(refund)}</strong> возвращён.</p>` : ''}`
}
const emailHtml = `<!DOCTYPE html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f8fafc;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;padding:32px 0">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1)">
<tr><td style="background:#0f172a;padding:20px 32px">
<p style="margin:0;color:#fff;font-size:18px;font-weight:700">${hotelName}</p>
<p style="margin:4px 0 0;color:#94a3b8;font-size:13px">Информация о страховом депозите</p>
</td></tr>
<tr><td style="padding:28px 32px;color:#334155;font-size:15px;line-height:1.6">
${html}
<p style="margin-top:24px">Спасибо за проживание.</p>
</td></tr>
<tr><td style="background:#f1f5f9;padding:16px 32px;text-align:center">
<p style="margin:0;color:#94a3b8;font-size:12px">© 2026 ${hotelName} · Powered by HotelSync</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`
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<SlugBookingParam & { Body: { amount?: number; reason?: string } }>(
'/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<SlugBookingParam>(
'/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<SlugParam>(
'/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<SlugBookingParam>(
'/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<SlugParam>(
'/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<SlugParam & { Body: { name: string; amount: number } }>(
'/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<string, string> = {
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