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:
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user