feat: checklists, minibar and deposit modules

- DB migrations 057-059: checklist_templates/items/completions, minibar_items/consumptions, hotel_deposit_settings, booking_deposits
- Backend routes: checklists (templates CRUD + task completions), minibar (items + consumptions), deposit (settings, cash/yookassa hold, release/capture)
- YooKassa service for hold/capture/cancel payments
- Frontend: ChecklistSettingsPage, MinibarSettingsPage, DepositSettingsPage
- HousekeepingPage: task cards now show checklist + minibar panel with checkboxes and quantity buttons
- BookingModal: minibar charges summary + deposit management (cash/YooKassa/release) for existing bookings
- Sidebar + App.tsx: new routes /settings/checklists, /settings/minibar, /settings/deposit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 19:24:53 +03:00
parent ed2dfd4c7f
commit 3c1a6ccebf
17 changed files with 2569 additions and 9 deletions

View File

@@ -0,0 +1,315 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
import { createHold, capturePayment, cancelPayment } from '../services/yookassa'
import { transporter } from '../email'
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 }
}
// Mask secret key
const row = rows[0]
return {
...row,
yookassa_secret_key: row.yookassa_secret_key ? '••••••••' : null,
}
},
)
// ── PATCH /api/hotels/:slug/deposit/settings ──────────────────────────────
fastify.patch<SlugParam & { Body: {
is_enabled?: boolean; amount?: number
yookassa_shop_id?: string; yookassa_secret_key?: string
} }>(
'/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 } = request.body
// Upsert settings
const { rows } = await db.query(
`INSERT INTO hotel_deposit_settings (hotel_id, is_enabled, amount, yookassa_shop_id, yookassa_secret_key, updated_at)
VALUES ($1, $2, $3, $4, $5, 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,
updated_at = NOW()
RETURNING *`,
[hotelId, is_enabled ?? false, amount ? amount.toFixed(2) : '5000.00', yookassa_shop_id ?? null, yookassa_secret_key ?? 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 for description
const { rows: bRows } = await db.query(
'SELECT id, guest_name FROM bookings WHERE id = $1 AND hotel_id = $2',
[bookingId, hotelId],
)
if (!bRows[0]) return reply.code(404).send({ error: 'Booking not found' })
const payment = 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 { 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', 'yookassa_hold', $4, $5)
RETURNING *`,
[hotelId, bookingId, settings.amount, 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: { capturedAmount: number; reason?: string } }>(
'/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 { capturedAmount, reason } = 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 guest_name, guest_email FROM bookings WHERE id = $1',
[bookingId],
)
if (bRows[0]?.guest_email) {
const guest = bRows[0]
const actionText = capturedAmount === 0
? 'Депозит был полностью возвращён.'
: `Из депозита удержана сумма ${capturedAmount.toFixed(2)} руб.${reason ? ` Причина: ${reason}` : ''}`
transporter.sendMail({
from: `"HotelSync" <${process.env.SMTP_USER ?? 'noreply@hotelsync.ru'}>`,
to: guest.guest_email,
subject: 'Информация о депозите — HotelSync',
text: `Уважаемый(ая) ${guest.guest_name},\n\n${actionText}\n\nСпасибо за проживание.\n\n© 2026 HotelSync`,
}).catch(() => {})
await db.query(
'UPDATE booking_deposits SET guest_email_sent = true WHERE id = $1',
[dep.id],
)
}
return rows[0]
},
)
// ── POST /api/webhooks/yookassa ───────────────────────────────────────────
fastify.post<{ Body: { event: string; object: { id: string; status: 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) return { ok: true }
await db.query(
`UPDATE booking_deposits SET status = $1 WHERE yookassa_payment_id = $2`,
[newStatus, object.id],
)
return { ok: true }
},
)
}
export default deposit