feat: deposit deductions, booking payments persistence, minibar in release form

- Migrations 061 (booking_payments) + 062 (deposit_deduction_presets)
- Backend: booking payments CRUD (GET/POST/DELETE), auto-updates paid_amount on booking
- Backend: deposit deduction presets CRUD (GET/POST/PATCH/DELETE)
- Backend: GET /deposit/minibar endpoint returns minibar consumptions for booking
- Backend: release endpoint accepts items[] for itemized email to guest
- Frontend: payments loaded from API — persist across page reloads
- Frontend: deposit release form redesigned — preset buttons, minibar auto-fill, itemized list
- Frontend: onFocus select on all amount inputs (no more manual "0" removal)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 12:55:42 +03:00
parent 633625e7be
commit 1ab24dfe27
7 changed files with 478 additions and 87 deletions

View File

@@ -194,7 +194,7 @@ const deposit: FastifyPluginAsync = async (fastify) => {
)
// ── POST /api/hotels/:slug/bookings/:bookingId/deposit/release ────────────
fastify.post<SlugBookingParam & { Body: { captured_amount: number; reason?: string } }>(
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) => {
@@ -205,7 +205,7 @@ const deposit: FastifyPluginAsync = async (fastify) => {
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { captured_amount: capturedAmount, reason } = request.body
const { captured_amount: capturedAmount, reason, items } = request.body
// Get active deposit
const { rows: depRows } = await db.query(
@@ -268,14 +268,26 @@ const deposit: FastifyPluginAsync = async (fastify) => {
)
if (bRows[0]?.guest_email) {
const guest = bRows[0]
const actionText = capturedAmount === 0
? 'Депозит был полностью возвращён.'
: `Из депозита удержана сумма ${capturedAmount.toFixed(2)} руб.${reason ? ` Причина: ${reason}` : ''}`
let bodyLines: string[]
if (capturedAmount === 0) {
bodyLines = ['Депозит был полностью возвращён. Средства поступят на карту в течение нескольких рабочих дней.']
} else {
bodyLines = [`Из страхового депозита удержана сумма ${capturedAmount.toFixed(2)} ₽.`]
if (items && items.length > 0) {
bodyLines.push('', 'Позиции удержания:')
for (const item of items) {
bodyLines.push(`${item.name}${Number(item.amount).toFixed(2)}`)
}
}
if (reason) bodyLines.push('', `Комментарий: ${reason}`)
const refund = Number(dep.amount) - capturedAmount
if (refund > 0) bodyLines.push('', `Остаток депозита ${refund.toFixed(2)} ₽ возвращён.`)
}
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`,
text: [`Уважаемый(ая) ${guest.guest_name},`, '', ...bodyLines, '', 'Спасибо за проживание.', '', '© 2026 HotelSync'].join('\n'),
}).catch(() => {})
await db.query(
@@ -394,6 +406,131 @@ const deposit: FastifyPluginAsync = async (fastify) => {
},
)
// ── 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
const { rows } = await db.query(
`SELECT mc.quantity, mc.price_at_time,
mi.name AS item_name,
(mc.quantity * mc.price_at_time) 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.created_at >= (SELECT check_in FROM bookings WHERE id = $3)
ORDER BY mc.created_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: {