From a4fc4a0972b5608cc858896c43824091f781c728 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Mon, 6 Apr 2026 11:47:51 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20deposit=20=E2=80=94=20history=20page,?= =?UTF-8?q?=20card=20info,=20cancel=20button,=20webhook=20notice,=20online?= =?UTF-8?q?=20booking=20email?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 060: card_last4, card_brand columns in booking_deposits - Backend: webhook saves card brand/last4 from YooKassa notification - Backend: GET /api/hotels/:slug/deposits — payment history with guest/room info - Backend: DELETE /api/hotels/:slug/bookings/:bookingId/deposit — cancel hold_created or paid_cash deposit - Backend: auto-create YooKassa hold + send deposit email for source='online' bookings - Frontend: DepositHistoryPage with filters, card display, retention reasons - Frontend: deposit cancel button for hold_created and paid_cash states - Frontend: show card last4/brand in hold_confirmed status - Frontend: release form presets (full return / hold all) + improved UX - Frontend: webhook setup instructions in DepositSettingsPage - Frontend: "История платежей" link in deposit settings header Co-Authored-By: Claude Sonnet 4.6 --- backend/migrations/060_deposit_card.sql | 2 + backend/src/routes/bookings.ts | 67 ++++++- backend/src/routes/deposit.ts | 111 +++++++++++- src/App.tsx | 4 +- .../bookings/BookingDetailPanel.tsx | 103 +++++++++-- src/lib/api.ts | 14 ++ src/pages/DepositHistoryPage.tsx | 165 ++++++++++++++++++ src/pages/DepositSettingsPage.tsx | 48 ++++- 8 files changed, 481 insertions(+), 33 deletions(-) create mode 100644 backend/migrations/060_deposit_card.sql create mode 100644 src/pages/DepositHistoryPage.tsx diff --git a/backend/migrations/060_deposit_card.sql b/backend/migrations/060_deposit_card.sql new file mode 100644 index 0000000..09c6821 --- /dev/null +++ b/backend/migrations/060_deposit_card.sql @@ -0,0 +1,2 @@ +ALTER TABLE booking_deposits ADD COLUMN IF NOT EXISTS card_last4 VARCHAR(4); +ALTER TABLE booking_deposits ADD COLUMN IF NOT EXISTS card_brand VARCHAR(50); diff --git a/backend/src/routes/bookings.ts b/backend/src/routes/bookings.ts index c1cf436..07e2782 100644 --- a/backend/src/routes/bookings.ts +++ b/backend/src/routes/bookings.ts @@ -3,6 +3,9 @@ import { db } from '../db' import { notifyNetupCheckin, notifyNetupCheckout } from './netup' import { getHkSettings } from './housekeeping-settings' import { broadcast } from './ws' +import { transporter } from '../email' +import { createHold } from '../services/yookassa' +import { randomUUID } from 'crypto' type SlugParam = { Params: { slug: string } } type SlugIdParam = { Params: { slug: string; id: string } } @@ -103,7 +106,69 @@ const bookings: FastifyPluginAsync = async (fastify) => { check_in, check_out, adults, children, status, source, total_amount ?? 0, paid_amount, notes ?? null, tariff_id ?? null], ) - return reply.code(201).send(rows[0]) + const booking = rows[0] + + // Auto-create YooKassa hold + send deposit email for online bookings with email + if (source === 'online' && guest_email) { + try { + const { rows: depSettings } = await db.query( + `SELECT * FROM hotel_deposit_settings WHERE hotel_id = $1 AND is_enabled = true`, + [hotelId], + ) + const depCfg = depSettings[0] + if (depCfg?.yookassa_shop_id && depCfg?.yookassa_secret_key) { + const { rows: hotelRows } = await db.query( + 'SELECT name, slug FROM hotels WHERE id = $1', [hotelId], + ) + const hotel = hotelRows[0] + const appUrl = process.env.APP_URL ?? 'https://app.hotelsync.ru' + const payment = await createHold({ + shopId: depCfg.yookassa_shop_id, + secretKey: depCfg.yookassa_secret_key, + amount: Number(depCfg.amount), + description: `Депозит — ${guest_name}`, + returnUrl: `${appUrl}/${hotel?.slug ?? slug}/bookings`, + idempotenceKey: randomUUID(), + }) + 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)`, + [hotelId, booking.id, depCfg.amount, payment.id, + payment.confirmation?.confirmation_url ?? null], + ) + const confirmUrl = payment.confirmation?.confirmation_url + if (confirmUrl) { + const checkInFmt = new Date(check_in).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }) + transporter.sendMail({ + from: `"${hotel?.name ?? 'HotelSync'}" <${process.env.SMTP_USER ?? 'noreply@hotelsync.ru'}>`, + to: guest_email, + subject: `Страховой депозит — бронирование ${hotel?.name ?? ''}`, + text: [ + `Уважаемый(ая) ${guest_name},`, + '', + `Ваше бронирование в ${hotel?.name ?? 'отеле'} на ${checkInFmt} подтверждено.`, + '', + `По условиям отеля при заселении взимается страховой депозит ${Number(depCfg.amount).toLocaleString('ru-RU')} ₽.`, + `Средства замораживаются (не списываются) и возвращаются при выезде.`, + '', + `Вы можете внести депозит заранее по ссылке:`, + confirmUrl, + '', + `Или отсканируйте QR-код на стойке ресепшена при заселении.`, + '', + `© ${hotel?.name ?? 'HotelSync'}`, + ].join('\n'), + }).catch(() => {}) + } + } + } catch { + // Non-critical: don't fail booking creation if deposit auto-send fails + } + } + + return reply.code(201).send(booking) }, ) diff --git a/backend/src/routes/deposit.ts b/backend/src/routes/deposit.ts index d7c43e8..6cbcba9 100644 --- a/backend/src/routes/deposit.ts +++ b/backend/src/routes/deposit.ts @@ -315,8 +315,99 @@ const deposit: FastifyPluginAsync = async (fastify) => { }, ) + // ── DELETE /api/hotels/:slug/bookings/:bookingId/deposit ───────────────── + // Cancel/reset deposit (hold not paid yet, or cash entered by mistake) + fastify.delete( + '/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: depRows } = await db.query( + `SELECT * FROM booking_deposits + WHERE booking_id = $1 AND hotel_id = $2 + AND status IN ('hold_created', 'paid_cash') + ORDER BY created_at DESC LIMIT 1`, + [bookingId, hotelId], + ) + if (!depRows[0]) return reply.code(404).send({ error: 'No cancellable deposit' }) + const dep = depRows[0] + + // Try to cancel YooKassa hold if exists (ignore failures — payment may be in non-cancellable state) + if (dep.yookassa_payment_id) { + try { + 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) { + await cancelPayment({ + shopId: settings.yookassa_shop_id, + secretKey: settings.yookassa_secret_key, + paymentId: dep.yookassa_payment_id, + }) + } + } catch { + // Ignore — guest may not have started payment + } + } + + await db.query( + `UPDATE booking_deposits SET status = 'cancelled', released_at = NOW() + WHERE id = $1`, + [dep.id], + ) + return reply.code(204).send() + }, + ) + + // ── GET /api/hotels/:slug/deposits — history ───────────────────────────── + fastify.get( + '/api/hotels/:slug/deposits', + { 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 d.*, + b.guest_name, b.guest_email, b.check_in, b.check_out, + r.number AS room_number + FROM booking_deposits d + JOIN bookings b ON b.id = d.booking_id + LEFT JOIN rooms r ON r.id = b.room_id + WHERE d.hotel_id = $1 + ORDER BY d.created_at DESC + LIMIT 200`, + [hotelId], + ) + return rows + }, + ) + // ── POST /api/webhooks/yookassa ─────────────────────────────────────────── - fastify.post<{ Body: { event: string; object: { id: string; status: string } } }>( + fastify.post<{ + Body: { + event: string + object: { + id: string + status: string + payment_method?: { + type?: string + card?: { last4?: string; card_type?: string } + } + } + } + }>( '/api/webhooks/yookassa', async (request, reply) => { const { object } = request.body @@ -330,10 +421,20 @@ const deposit: FastifyPluginAsync = async (fastify) => { 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], - ) + const card = object.payment_method?.card + if (card?.last4) { + await db.query( + `UPDATE booking_deposits + SET status = $1, card_last4 = $2, card_brand = $3 + WHERE yookassa_payment_id = $4`, + [newStatus, card.last4, card.card_type ?? null, object.id], + ) + } else { + await db.query( + `UPDATE booking_deposits SET status = $1 WHERE yookassa_payment_id = $2`, + [newStatus, object.id], + ) + } return { ok: true } }, ) diff --git a/src/App.tsx b/src/App.tsx index ea5193a..cd57e77 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -47,6 +47,7 @@ import { TTLockPage } from './pages/TTLockPage' import { ChecklistSettingsPage } from './pages/ChecklistSettingsPage' import { MinibarSettingsPage } from './pages/MinibarSettingsPage' import { DepositSettingsPage } from './pages/DepositSettingsPage' +import { DepositHistoryPage } from './pages/DepositHistoryPage' import { PayDepositPage } from './pages/PayDepositPage' import { ModuleGuard } from './components/ModuleGuard' @@ -105,7 +106,8 @@ export default function App() { } /> } /> } /> - } /> + } /> + } /> } /> diff --git a/src/components/bookings/BookingDetailPanel.tsx b/src/components/bookings/BookingDetailPanel.tsx index 0607016..d6f4cfa 100644 --- a/src/components/bookings/BookingDetailPanel.tsx +++ b/src/components/bookings/BookingDetailPanel.tsx @@ -5,7 +5,7 @@ import { Printer, ScanLine, Banknote, Building2, Plus, Pencil, FileText, FileCheck, Receipt, IdCard, AlertTriangle, Trash2, Loader2, UserCheck, Baby, Search, LogIn, KeyRound, - ShieldCheck, QrCode, Copy, RefreshCw, + ShieldCheck, QrCode, Copy, RefreshCw, Ban, } from 'lucide-react' import { cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS, @@ -62,6 +62,7 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string }) const [deposit, setDeposit] = useState(null) const [depositLoading, setDepositLoading] = useState(true) 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('') @@ -130,6 +131,20 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string }) } } + const handleCancel = async () => { + if (!window.confirm('Отменить депозит?')) return + setDepositCancelling(true) + try { + await api.deposits.cancel(slug, bookingId) + setDeposit(null) + setYookassaMsg(null) + } catch { + // ignore + } finally { + setDepositCancelling(false) + } + } + const copyPayLink = () => { navigator.clipboard.writeText(`https://app.hotelsync.ru/${slug}/pay`).catch(() => {}) } @@ -202,12 +217,22 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })

QR-код на ресепшене активен

- +
+ + +
)} @@ -229,6 +254,14 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string }) )} + {/* hold_confirmed — show card info */} + {deposit?.status === 'hold_confirmed' && deposit.cardLast4 && ( +
+ + {deposit.cardBrand ? `${deposit.cardBrand} ` : ''}•••• {deposit.cardLast4} +
+ )} + {/* paid_cash */} {deposit?.status === 'paid_cash' && (
@@ -237,20 +270,53 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })

{formatCurrency(deposit.amount)}

{!showReleaseForm && ( - +
+ + +
)}
)} {/* Release form */} {showReleaseForm && deposit && (deposit.status === 'hold_confirmed' || deposit.status === 'paid_cash') && ( -
-

Возврат депозита

+
+

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

+ + {/* Presets */} +
+ + +
+
- +