feat: deposit — history page, card info, cancel button, webhook notice, online booking email
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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<SlugBookingParam>(
|
||||
'/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<SlugParam>(
|
||||
'/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 }
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user