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:
2026-04-06 11:47:51 +03:00
parent 754f03b7fa
commit a4fc4a0972
8 changed files with 481 additions and 33 deletions

View File

@@ -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 }
},
)