From 1ab24dfe27ad186675035f33865ef4ed2f8df2da Mon Sep 17 00:00:00 2001 From: HotelSync Date: Mon, 6 Apr 2026 12:55:42 +0300 Subject: [PATCH] feat: deposit deductions, booking payments persistence, minibar in release form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/migrations/061_booking_payments.sql | 11 + backend/migrations/062_deposit_presets.sql | 9 + backend/src/app.ts | 2 + backend/src/routes/deposit.ts | 149 ++++++++++- backend/src/routes/payments.ts | 113 +++++++++ .../bookings/BookingDetailPanel.tsx | 235 ++++++++++++------ src/lib/api.ts | 46 +++- 7 files changed, 478 insertions(+), 87 deletions(-) create mode 100644 backend/migrations/061_booking_payments.sql create mode 100644 backend/migrations/062_deposit_presets.sql create mode 100644 backend/src/routes/payments.ts diff --git a/backend/migrations/061_booking_payments.sql b/backend/migrations/061_booking_payments.sql new file mode 100644 index 0000000..17109e0 --- /dev/null +++ b/backend/migrations/061_booking_payments.sql @@ -0,0 +1,11 @@ +CREATE TABLE booking_payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id), + booking_id UUID NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, + amount NUMERIC(10,2) NOT NULL, + method TEXT NOT NULL DEFAULT 'cash', + note TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID REFERENCES users(id) +); +CREATE INDEX ON booking_payments(booking_id); diff --git a/backend/migrations/062_deposit_presets.sql b/backend/migrations/062_deposit_presets.sql new file mode 100644 index 0000000..c2e980f --- /dev/null +++ b/backend/migrations/062_deposit_presets.sql @@ -0,0 +1,9 @@ +CREATE TABLE deposit_deduction_presets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + name TEXT NOT NULL, + amount NUMERIC(10,2) NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX ON deposit_deduction_presets(hotel_id); diff --git a/backend/src/app.ts b/backend/src/app.ts index 8300896..ed659ee 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -41,6 +41,7 @@ import ttlockRoutes from './routes/ttlock' import checklistsRoutes from './routes/checklists' import minibarRoutes from './routes/minibar' import depositRoutes from './routes/deposit' +import paymentsRoutes from './routes/payments' import { setupAgentWsRoute } from './agent-ws' import { startJobs } from './jobs' @@ -134,6 +135,7 @@ export async function buildApp() { await fastify.register(checklistsRoutes) await fastify.register(minibarRoutes) await fastify.register(depositRoutes) + await fastify.register(paymentsRoutes) await fastify.register(setupAgentWsRoute) startJobs() diff --git a/backend/src/routes/deposit.ts b/backend/src/routes/deposit.ts index a499b75..5507181 100644 --- a/backend/src/routes/deposit.ts +++ b/backend/src/routes/deposit.ts @@ -194,7 +194,7 @@ const deposit: FastifyPluginAsync = async (fastify) => { ) // ── POST /api/hotels/:slug/bookings/:bookingId/deposit/release ──────────── - fastify.post( + fastify.post } }>( '/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( + '/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( + '/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( + '/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: { diff --git a/backend/src/routes/payments.ts b/backend/src/routes/payments.ts new file mode 100644 index 0000000..c271bb1 --- /dev/null +++ b/backend/src/routes/payments.ts @@ -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 => { + 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( + '/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( + '/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 diff --git a/src/components/bookings/BookingDetailPanel.tsx b/src/components/bookings/BookingDetailPanel.tsx index d6f4cfa..5ae138f 100644 --- a/src/components/bookings/BookingDetailPanel.tsx +++ b/src/components/bookings/BookingDetailPanel.tsx @@ -12,7 +12,7 @@ import { SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount, } from '../../lib/utils' import type { Booking, Room } from '../../types' -import { api, type BookingGuest, type BookingGuestPayload, type GuestApiType, type BookingDeposit, type DepositSettings } from '../../lib/api' +import { api, type BookingGuest, type BookingGuestPayload, type GuestApiType, type BookingDeposit, type DepositSettings, type BookingPayment, type DepositPreset } from '../../lib/api' import { getIdentity, type AgentIdentity } from '../../lib/agent' const fmtDate = (iso: string) => @@ -57,6 +57,8 @@ const DOCUMENTS = [ // ─── DepositWidget ──────────────────────────────────────────────────────────── +type ReleaseItem = { id: string; name: string; amount: string } + function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string }) { const [depositSettings, setDepositSettings] = useState(null) const [deposit, setDeposit] = useState(null) @@ -64,19 +66,27 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string }) const [depositCreating, setDepositCreating] = useState<'cash' | 'yookassa' | null>(null) const [depositCancelling, setDepositCancelling] = useState(false) const [depositReleasing, setDepositReleasing] = useState(false) - const [captureAmount, setCaptureAmount] = useState('0') - const [retentionReason, setRetentionReason] = useState('') const [showReleaseForm, setShowReleaseForm] = useState(false) + const [releaseItems, setReleaseItems] = useState([]) + const [releaseComment, setReleaseComment] = useState('') const [yookassaMsg, setYookassaMsg] = useState(null) const [releaseError, setReleaseError] = useState(null) + const [presets, setPresets] = useState([]) + const [minibarItems, setMinibarItems] = useState>([]) + const [minibarTotal, setMinibarTotal] = useState(0) useEffect(() => { Promise.all([ api.deposits.getSettings(slug), api.deposits.getBookingDeposit(slug, bookingId).catch(() => null), - ]).then(([settings, dep]) => { + api.deposits.getPresets(slug).catch(() => []), + api.deposits.getMinibarForBooking(slug, bookingId).catch(() => ({ items: [], total: 0 })), + ]).then(([settings, dep, presetList, minibar]) => { setDepositSettings(settings) setDeposit(dep) + setPresets(presetList) + setMinibarItems(minibar.items) + setMinibarTotal(minibar.total) }).catch(() => { // silently ignore — deposit module may not be available }).finally(() => setDepositLoading(false)) @@ -116,12 +126,16 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string }) } } + const releaseTotalAmount = releaseItems.reduce((s, i) => s + (parseFloat(i.amount) || 0), 0) + const handleRelease = async () => { setDepositReleasing(true) setReleaseError(null) try { - const captured = parseFloat(captureAmount) || 0 - const dep = await api.deposits.release(slug, bookingId, captured, retentionReason || undefined) + const items = releaseItems + .filter(i => parseFloat(i.amount) > 0) + .map(i => ({ name: i.name, amount: parseFloat(i.amount) })) + const dep = await api.deposits.release(slug, bookingId, releaseTotalAmount, releaseComment || undefined, items) setDeposit(dep) setShowReleaseForm(false) } catch { @@ -131,6 +145,14 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string }) } } + const addReleaseItem = (name: string, amount: number) => { + setReleaseItems(prev => [...prev, { id: `item-${Date.now()}`, name, amount: String(amount) }]) + } + + const removeReleaseItem = (id: string) => { + setReleaseItems(prev => prev.filter(i => i.id !== id)) + } + const handleCancel = async () => { if (!window.confirm('Отменить депозит?')) return setDepositCancelling(true) @@ -295,52 +317,90 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })

Возврат / Удержание депозита

- {/* Presets */} -
+ {/* Quick presets from settings */} + {(presets.length > 0 || minibarTotal > 0) && ( +
+

Добавить позицию:

+
+ {minibarTotal > 0 && ( + + )} + {presets.map(p => ( + + ))} + +
+
+ )} + + {/* Added items */} + {releaseItems.length > 0 && ( +
+ {releaseItems.map(item => ( +
+ {item.name} + e.target.select()} + onChange={e => setReleaseItems(prev => prev.map(i => i.id === item.id ? { ...i, amount: e.target.value } : i))} + className="input text-xs w-24 text-right" + placeholder="0" + /> + + +
+ ))} +
+ Итого удержание: + {formatCurrency(releaseTotalAmount)} +
+
+ )} + + {/* No items — full return */} + {releaseItems.length === 0 && ( +

✓ Полный возврат депозита

+ )} + + {/* Quick add if no presets */} + {presets.length === 0 && minibarTotal === 0 && ( - -
+ )}
- - setCaptureAmount(e.target.value)} - className="input text-sm w-full" - /> -
-
- +