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: {

View File

@@ -0,0 +1,113 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
type SlugIdParam = { Params: { slug: string; id: string } }
const payments: 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
// ── GET /api/hotels/:slug/bookings/:id/payments ───────────────────────────
fastify.get<SlugIdParam>(
'/api/hotels/:slug/bookings/:id/payments',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, id } = 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 p.*, u.name AS created_by_name
FROM booking_payments p
LEFT JOIN users u ON u.id = p.created_by
WHERE p.booking_id = $1 AND p.hotel_id = $2
ORDER BY p.created_at ASC`,
[id, hotelId],
)
return rows
},
)
// ── POST /api/hotels/:slug/bookings/:id/payments ──────────────────────────
fastify.post<SlugIdParam & { Body: { amount: number; method: string; note?: string } }>(
'/api/hotels/:slug/bookings/:id/payments',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, id } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
return reply.code(403).send({ error: 'Forbidden' })
}
if (request.user.role === 'housekeeper') {
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, method, note } = request.body
if (!amount || amount <= 0) {
return reply.code(400).send({ error: 'Invalid amount' })
}
// Insert payment
const { rows } = await db.query(
`INSERT INTO booking_payments (hotel_id, booking_id, amount, method, note, created_by)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
[hotelId, id, amount.toFixed(2), method ?? 'cash', note ?? null, (request.user as { id?: string }).id ?? null],
)
// Update paid_amount on booking
await db.query(
`UPDATE bookings SET paid_amount = (
SELECT COALESCE(SUM(amount), 0) FROM booking_payments
WHERE booking_id = $1 AND hotel_id = $2
) WHERE id = $1 AND hotel_id = $2`,
[id, hotelId],
)
return reply.code(201).send(rows[0])
},
)
// ── DELETE /api/hotels/:slug/bookings/:id/payments/:paymentId ─────────────
fastify.delete<{ Params: { slug: string; id: string; paymentId: string } }>(
'/api/hotels/:slug/bookings/:id/payments/:paymentId',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, id, paymentId } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
return reply.code(403).send({ error: 'Forbidden' })
}
if (!['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 booking_payments WHERE id = $1 AND booking_id = $2 AND hotel_id = $3`,
[paymentId, id, hotelId],
)
// Recalculate paid_amount
await db.query(
`UPDATE bookings SET paid_amount = (
SELECT COALESCE(SUM(amount), 0) FROM booking_payments
WHERE booking_id = $1 AND hotel_id = $2
) WHERE id = $1 AND hotel_id = $2`,
[id, hotelId],
)
return reply.code(204).send()
},
)
}
export default payments