Payment tab shows 'Оплачено' from booking_payments table, not from paid_amount. Webhook now inserts a payment record so the tab shows the correct amount. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
814 lines
35 KiB
TypeScript
814 lines
35 KiB
TypeScript
import { FastifyPluginAsync } from 'fastify'
|
||
import { db } from '../db'
|
||
import { createHold, createCharge, capturePayment, cancelPayment, createRefund } from '../services/yookassa'
|
||
import { transporter, sendBookingConfirmedEmail } from '../email'
|
||
import { broadcast } from './ws'
|
||
|
||
type SlugParam = { Params: { slug: string } }
|
||
type SlugBookingParam = { Params: { slug: string; bookingId: string } }
|
||
|
||
const deposit: FastifyPluginAsync = async (fastify) => {
|
||
const getHotelId = async (slug: string): Promise<string | null> => {
|
||
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
|
||
|
||
const isManager = (role: string) =>
|
||
['super_admin', 'hotel_admin', 'manager'].includes(role)
|
||
|
||
const appUrl = () => process.env.APP_URL ?? 'https://app.hotelsync.ru'
|
||
|
||
// ── GET /api/hotels/:slug/deposit/settings ────────────────────────────────
|
||
fastify.get<SlugParam>(
|
||
'/api/hotels/:slug/deposit/settings',
|
||
{ 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 hotel_deposit_settings WHERE hotel_id = $1',
|
||
[hotelId],
|
||
)
|
||
if (!rows[0]) {
|
||
return { hotelId, isEnabled: false, amount: 5000, yookassaShopId: null, yookassaSecretKey: null, releaseRequiresCheckout: false, longStayFullPayment: false, longStayThresholdDays: 7 }
|
||
}
|
||
// Mask secret key
|
||
const row = rows[0]
|
||
return {
|
||
...row,
|
||
yookassa_secret_key: row.yookassa_secret_key ? '••••••••' : null,
|
||
release_requires_checkout: row.release_requires_checkout ?? false,
|
||
}
|
||
},
|
||
)
|
||
|
||
// ── PATCH /api/hotels/:slug/deposit/settings ──────────────────────────────
|
||
fastify.patch<SlugParam & { Body: {
|
||
is_enabled?: boolean; amount?: number
|
||
yookassa_shop_id?: string; yookassa_secret_key?: string
|
||
release_requires_checkout?: boolean
|
||
long_stay_full_payment?: boolean; long_stay_threshold_days?: number
|
||
} }>(
|
||
'/api/hotels/:slug/deposit/settings',
|
||
{ onRequest: [fastify.authenticate] },
|
||
async (request, reply) => {
|
||
const { slug } = request.params
|
||
if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(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 { is_enabled, amount, yookassa_shop_id, yookassa_secret_key, release_requires_checkout,
|
||
long_stay_full_payment, long_stay_threshold_days } = request.body
|
||
|
||
const { rows } = await db.query(
|
||
`INSERT INTO hotel_deposit_settings (hotel_id, is_enabled, amount, yookassa_shop_id, yookassa_secret_key, release_requires_checkout, long_stay_full_payment, long_stay_threshold_days, updated_at)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
|
||
ON CONFLICT (hotel_id) DO UPDATE SET
|
||
is_enabled = COALESCE($2, hotel_deposit_settings.is_enabled),
|
||
amount = COALESCE($3, hotel_deposit_settings.amount),
|
||
yookassa_shop_id = COALESCE($4, hotel_deposit_settings.yookassa_shop_id),
|
||
yookassa_secret_key = CASE WHEN $5 IS NOT NULL AND $5 != '••••••••' THEN $5 ELSE hotel_deposit_settings.yookassa_secret_key END,
|
||
release_requires_checkout = COALESCE($6, hotel_deposit_settings.release_requires_checkout),
|
||
long_stay_full_payment = COALESCE($7, hotel_deposit_settings.long_stay_full_payment),
|
||
long_stay_threshold_days = COALESCE($8, hotel_deposit_settings.long_stay_threshold_days),
|
||
updated_at = NOW()
|
||
RETURNING *`,
|
||
[hotelId, is_enabled ?? false, amount ? amount.toFixed(2) : '5000.00', yookassa_shop_id ?? null, yookassa_secret_key ?? null,
|
||
release_requires_checkout ?? null, long_stay_full_payment ?? null, long_stay_threshold_days ?? null],
|
||
)
|
||
return rows[0]
|
||
},
|
||
)
|
||
|
||
// ── GET /api/hotels/:slug/bookings/:bookingId/deposit ─────────────────────
|
||
fastify.get<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 } = await db.query(
|
||
`SELECT * FROM booking_deposits WHERE booking_id = $1 AND hotel_id = $2 ORDER BY created_at DESC LIMIT 1`,
|
||
[bookingId, hotelId],
|
||
)
|
||
if (!rows[0]) return reply.code(404).send({ error: 'No deposit found' })
|
||
// Mask confirmation URL isn't needed — expose it for QR
|
||
return rows[0]
|
||
},
|
||
)
|
||
|
||
// ── POST /api/hotels/:slug/bookings/:bookingId/deposit/cash ───────────────
|
||
fastify.post<SlugBookingParam>(
|
||
'/api/hotels/:slug/bookings/:bookingId/deposit/cash',
|
||
{ 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' })
|
||
|
||
// Verify booking
|
||
const { rows: bRows } = await db.query(
|
||
'SELECT id FROM bookings WHERE id = $1 AND hotel_id = $2',
|
||
[bookingId, hotelId],
|
||
)
|
||
if (!bRows[0]) return reply.code(404).send({ error: 'Booking not found' })
|
||
|
||
// Get deposit settings amount
|
||
const { rows: settingsRows } = await db.query(
|
||
'SELECT amount FROM hotel_deposit_settings WHERE hotel_id = $1',
|
||
[hotelId],
|
||
)
|
||
const amount = settingsRows[0]?.amount ?? '5000.00'
|
||
|
||
const { rows } = await db.query(
|
||
`INSERT INTO booking_deposits (hotel_id, booking_id, amount, status, payment_method, paid_at)
|
||
VALUES ($1, $2, $3, 'paid_cash', 'cash', NOW())
|
||
RETURNING *`,
|
||
[hotelId, bookingId, amount],
|
||
)
|
||
return reply.code(201).send(rows[0])
|
||
},
|
||
)
|
||
|
||
// ── POST /api/hotels/:slug/bookings/:bookingId/deposit/yookassa ───────────
|
||
fastify.post<SlugBookingParam>(
|
||
'/api/hotels/:slug/bookings/:bookingId/deposit/yookassa',
|
||
{ 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 settings including YooKassa credentials
|
||
const { rows: settingsRows } = await db.query(
|
||
'SELECT * FROM hotel_deposit_settings WHERE hotel_id = $1',
|
||
[hotelId],
|
||
)
|
||
const settings = settingsRows[0]
|
||
if (!settings) return reply.code(400).send({ error: 'Deposit settings not configured' })
|
||
if (!settings.yookassa_shop_id || !settings.yookassa_secret_key) {
|
||
return reply.code(400).send({ error: 'YooKassa credentials not configured' })
|
||
}
|
||
|
||
// Get booking dates + guest name
|
||
const { rows: bRows } = await db.query(
|
||
'SELECT id, guest_name, check_in, check_out FROM bookings WHERE id = $1 AND hotel_id = $2',
|
||
[bookingId, hotelId],
|
||
)
|
||
if (!bRows[0]) return reply.code(404).send({ error: 'Booking not found' })
|
||
|
||
const nights = Math.ceil(
|
||
(new Date(bRows[0].check_out).getTime() - new Date(bRows[0].check_in).getTime()) / 86400000,
|
||
)
|
||
const threshold = settings.long_stay_threshold_days ?? 7
|
||
const useCharge = settings.long_stay_full_payment && nights > threshold
|
||
|
||
const payment = useCharge
|
||
? await createCharge({
|
||
shopId: settings.yookassa_shop_id,
|
||
secretKey: settings.yookassa_secret_key,
|
||
amount: Number(settings.amount),
|
||
description: `Депозит за бронирование — ${bRows[0].guest_name}`,
|
||
returnUrl: `${appUrl()}/${slug}/bookings`,
|
||
})
|
||
: await createHold({
|
||
shopId: settings.yookassa_shop_id,
|
||
secretKey: settings.yookassa_secret_key,
|
||
amount: Number(settings.amount),
|
||
description: `Депозит за бронирование — ${bRows[0].guest_name}`,
|
||
returnUrl: `${appUrl()}/${slug}/bookings`,
|
||
})
|
||
|
||
const paymentMethod = useCharge ? 'yookassa_charge' : 'yookassa_hold'
|
||
|
||
const { rows } = 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', $4, $5, $6)
|
||
RETURNING *`,
|
||
[hotelId, bookingId, settings.amount, paymentMethod, payment.id,
|
||
payment.confirmation?.confirmation_url ?? null],
|
||
)
|
||
return reply.code(201).send({
|
||
...rows[0],
|
||
confirmationUrl: payment.confirmation?.confirmation_url ?? null,
|
||
})
|
||
},
|
||
)
|
||
|
||
// ── POST /api/hotels/:slug/bookings/:bookingId/deposit/release ────────────
|
||
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) => {
|
||
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 { captured_amount: capturedAmount, reason, items } = request.body
|
||
|
||
// Get active deposit
|
||
const { rows: depRows } = await db.query(
|
||
`SELECT * FROM booking_deposits
|
||
WHERE booking_id = $1 AND hotel_id = $2
|
||
AND status IN ('paid_cash', 'hold_created', 'hold_confirmed')
|
||
ORDER BY created_at DESC LIMIT 1`,
|
||
[bookingId, hotelId],
|
||
)
|
||
if (!depRows[0]) return reply.code(404).send({ error: 'Active deposit not found' })
|
||
const dep = depRows[0]
|
||
|
||
let newStatus: string
|
||
if (dep.payment_method === 'yookassa_hold' && dep.yookassa_payment_id) {
|
||
// Get settings for credentials
|
||
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) {
|
||
return reply.code(400).send({ error: 'YooKassa credentials not configured' })
|
||
}
|
||
|
||
if (capturedAmount === 0) {
|
||
await cancelPayment({
|
||
shopId: settings.yookassa_shop_id,
|
||
secretKey: settings.yookassa_secret_key,
|
||
paymentId: dep.yookassa_payment_id,
|
||
})
|
||
newStatus = 'refunded'
|
||
} else {
|
||
await capturePayment({
|
||
shopId: settings.yookassa_shop_id,
|
||
secretKey: settings.yookassa_secret_key,
|
||
paymentId: dep.yookassa_payment_id,
|
||
amount: capturedAmount,
|
||
})
|
||
newStatus = 'captured'
|
||
}
|
||
} else {
|
||
// Cash deposit
|
||
newStatus = capturedAmount === 0 ? 'refunded' : 'captured'
|
||
}
|
||
|
||
const { rows } = await db.query(
|
||
`UPDATE booking_deposits SET
|
||
status = $1,
|
||
captured_amount = $2,
|
||
retention_reason = $3,
|
||
released_at = NOW()
|
||
WHERE id = $4 RETURNING *`,
|
||
[newStatus, capturedAmount.toFixed(2), reason ?? null, dep.id],
|
||
)
|
||
|
||
// Send email to guest if email is available
|
||
const { rows: bRows } = await db.query(
|
||
`SELECT b.guest_name, b.guest_email, h.name AS hotel_name
|
||
FROM bookings b
|
||
JOIN hotels h ON h.id = b.hotel_id
|
||
WHERE b.id = $1`,
|
||
[bookingId],
|
||
)
|
||
if (bRows[0]?.guest_email) {
|
||
const guest = bRows[0]
|
||
const hotelName: string = guest.hotel_name ?? 'HotelSync'
|
||
const fmt = (n: number) => n.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ₽'
|
||
const refund = Number(dep.amount) - capturedAmount
|
||
|
||
let html: string
|
||
if (capturedAmount === 0) {
|
||
html = `
|
||
<p>Уважаемый(ая) <strong>${guest.guest_name}</strong>,</p>
|
||
<p>Страховой депозит был полностью возвращён.</p>
|
||
<p style="color:#6b7280;font-size:13px">Средства поступят на вашу карту в течение нескольких рабочих дней.</p>`
|
||
} else {
|
||
const itemsHtml = items && items.length > 0
|
||
? `<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;margin:16px 0;font-size:14px">
|
||
<thead>
|
||
<tr style="background:#f1f5f9">
|
||
<th align="left" style="padding:8px 12px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569">Позиция</th>
|
||
<th align="right" style="padding:8px 12px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569">Сумма</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${items.map((it, i) => `
|
||
<tr style="background:${i % 2 === 0 ? '#fff' : '#f8fafc'}">
|
||
<td style="padding:8px 12px;border-bottom:1px solid #f1f5f9;color:#1e293b">${it.name}</td>
|
||
<td align="right" style="padding:8px 12px;border-bottom:1px solid #f1f5f9;color:#1e293b;white-space:nowrap">${fmt(Number(it.amount))}</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr style="background:#f1f5f9">
|
||
<td style="padding:8px 12px;font-weight:700;color:#0f172a">Итого удержано</td>
|
||
<td align="right" style="padding:8px 12px;font-weight:700;color:#0f172a;white-space:nowrap">${fmt(capturedAmount)}</td>
|
||
</tr>
|
||
${refund > 0 ? `<tr><td style="padding:8px 12px;color:#16a34a">Возврат остатка</td><td align="right" style="padding:8px 12px;color:#16a34a;white-space:nowrap">${fmt(refund)}</td></tr>` : ''}
|
||
</tfoot>
|
||
</table>`
|
||
: `<p>Удержана сумма: <strong>${fmt(capturedAmount)}</strong></p>${refund > 0 ? `<p style="color:#16a34a">Остаток <strong>${fmt(refund)}</strong> возвращён.</p>` : ''}`
|
||
|
||
html = `
|
||
<p>Уважаемый(ая) <strong>${guest.guest_name}</strong>,</p>
|
||
<p>Из страхового депозита удержана сумма за следующие позиции:</p>
|
||
${itemsHtml}
|
||
${reason ? `<p style="color:#6b7280;font-size:13px"><em>Комментарий: ${reason}</em></p>` : ''}
|
||
${refund > 0 && (!items || items.length === 0) ? `<p style="color:#16a34a">Остаток депозита <strong>${fmt(refund)}</strong> возвращён.</p>` : ''}`
|
||
}
|
||
|
||
const emailHtml = `<!DOCTYPE html>
|
||
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||
<body style="margin:0;padding:0;background:#f8fafc;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
|
||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;padding:32px 0">
|
||
<tr><td align="center">
|
||
<table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1)">
|
||
<tr><td style="background:#0f172a;padding:20px 32px">
|
||
<p style="margin:0;color:#fff;font-size:18px;font-weight:700">${hotelName}</p>
|
||
<p style="margin:4px 0 0;color:#94a3b8;font-size:13px">Информация о страховом депозите</p>
|
||
</td></tr>
|
||
<tr><td style="padding:28px 32px;color:#334155;font-size:15px;line-height:1.6">
|
||
${html}
|
||
<p style="margin-top:24px">Спасибо за проживание.</p>
|
||
</td></tr>
|
||
<tr><td style="background:#f1f5f9;padding:16px 32px;text-align:center">
|
||
<p style="margin:0;color:#94a3b8;font-size:12px">© 2026 ${hotelName} · Powered by HotelSync</p>
|
||
</td></tr>
|
||
</table>
|
||
</td></tr>
|
||
</table>
|
||
</body></html>`
|
||
|
||
const textFallback = capturedAmount === 0
|
||
? `Уважаемый(ая) ${guest.guest_name},\n\nСтраховой депозит полностью возвращён.\n\nСпасибо за проживание.\n\n© 2026 ${hotelName}`
|
||
: [
|
||
`Уважаемый(ая) ${guest.guest_name},`,
|
||
'',
|
||
`Из страхового депозита удержана сумма ${fmt(capturedAmount)}.`,
|
||
...(items && items.length > 0 ? ['', 'Позиции удержания:', ...items.map(it => ` • ${it.name} — ${fmt(Number(it.amount))}`)] : []),
|
||
...(reason ? ['', `Комментарий: ${reason}`] : []),
|
||
...(refund > 0 ? ['', `Остаток депозита ${fmt(refund)} возвращён.`] : []),
|
||
'',
|
||
'Спасибо за проживание.',
|
||
'',
|
||
`© 2026 ${hotelName}`,
|
||
].join('\n')
|
||
|
||
transporter.sendMail({
|
||
from: `"${hotelName}" <${process.env.SMTP_USER ?? 'noreply@hotelsync.ru'}>`,
|
||
to: guest.guest_email,
|
||
subject: `Информация о депозите — ${hotelName}`,
|
||
text: textFallback,
|
||
html: emailHtml,
|
||
}).catch(() => {})
|
||
|
||
await db.query(
|
||
'UPDATE booking_deposits SET guest_email_sent = true WHERE id = $1',
|
||
[dep.id],
|
||
)
|
||
}
|
||
|
||
return rows[0]
|
||
},
|
||
)
|
||
|
||
// ── POST /api/hotels/:slug/bookings/:bookingId/deposit/refund ────────────
|
||
// Partial or full refund of a captured (yookassa_charge) deposit
|
||
fastify.post<SlugBookingParam & { Body: { amount?: number; reason?: string } }>(
|
||
'/api/hotels/:slug/bookings/:bookingId/deposit/refund',
|
||
{ onRequest: [fastify.authenticate] },
|
||
async (request, reply) => {
|
||
const { slug, bookingId } = request.params
|
||
if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(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 { amount: reqAmount, reason } = request.body
|
||
|
||
const { rows: depRows } = await db.query(
|
||
`SELECT * FROM booking_deposits
|
||
WHERE booking_id = $1 AND hotel_id = $2
|
||
AND payment_method = 'yookassa_charge' AND status = 'captured'
|
||
ORDER BY created_at DESC LIMIT 1`,
|
||
[bookingId, hotelId],
|
||
)
|
||
if (!depRows[0]) return reply.code(404).send({ error: 'No refundable deposit found' })
|
||
const dep = depRows[0]
|
||
|
||
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) {
|
||
return reply.code(400).send({ error: 'YooKassa credentials not configured' })
|
||
}
|
||
|
||
const depositAmount = Number(dep.amount)
|
||
const alreadyRefunded = Number(dep.refunded_amount ?? 0)
|
||
const remaining = depositAmount - alreadyRefunded
|
||
const refundAmount = reqAmount !== undefined ? Math.min(reqAmount, remaining) : remaining
|
||
|
||
if (refundAmount <= 0) return reply.code(400).send({ error: 'Nothing to refund' })
|
||
|
||
await createRefund({
|
||
shopId: settings.yookassa_shop_id,
|
||
secretKey: settings.yookassa_secret_key,
|
||
paymentId: dep.yookassa_payment_id,
|
||
amount: refundAmount,
|
||
})
|
||
|
||
const newRefunded = alreadyRefunded + refundAmount
|
||
const isFullRefund = newRefunded >= depositAmount - 0.01
|
||
const newStatus = isFullRefund ? 'refunded' : 'partially_refunded'
|
||
|
||
const { rows } = await db.query(
|
||
`UPDATE booking_deposits SET
|
||
status = $1, refunded_amount = $2, retention_reason = $3, released_at = COALESCE(released_at, NOW())
|
||
WHERE id = $4 RETURNING *`,
|
||
[newStatus, newRefunded.toFixed(2), reason ?? null, dep.id],
|
||
)
|
||
return rows[0]
|
||
},
|
||
)
|
||
|
||
// ── GET /api/pay/:slug — public, no auth ─────────────────────────────────
|
||
fastify.get<{ Params: { slug: string } }>(
|
||
'/api/pay/:slug',
|
||
async (request, reply) => {
|
||
const { slug } = request.params
|
||
const hotelId = await getHotelId(slug)
|
||
if (!hotelId) return reply.code(404).send({ error: 'Not found' })
|
||
|
||
const { rows } = await db.query(
|
||
`SELECT d.yookassa_confirmation_url, d.amount, h.name AS hotel_name
|
||
FROM booking_deposits d
|
||
JOIN hotels h ON h.id = d.hotel_id
|
||
WHERE d.hotel_id = $1 AND d.status = 'hold_created'
|
||
ORDER BY d.created_at DESC LIMIT 1`,
|
||
[hotelId],
|
||
)
|
||
if (!rows[0]?.yookassa_confirmation_url) {
|
||
return reply.code(404).send({ error: 'No active payment' })
|
||
}
|
||
return {
|
||
confirmationUrl: rows[0].yookassa_confirmation_url,
|
||
amount: rows[0].amount,
|
||
hotelName: rows[0].hotel_name,
|
||
}
|
||
},
|
||
)
|
||
|
||
// ── 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
|
||
},
|
||
)
|
||
|
||
// ── 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
|
||
// Use current item price (mi.price) — so price changes in settings are reflected
|
||
const { rows } = await db.query(
|
||
`SELECT mc.quantity,
|
||
mc.price_per_unit AS recorded_price,
|
||
mi.price AS current_price,
|
||
mi.name AS item_name,
|
||
(mc.quantity * mi.price) 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.recorded_at >= (SELECT check_in FROM bookings WHERE id = $3)
|
||
ORDER BY mc.recorded_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: {
|
||
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
|
||
if (!object?.id) return reply.code(400).send({ error: 'Invalid webhook' })
|
||
|
||
const statusMap: Record<string, string> = {
|
||
waiting_for_capture: 'hold_confirmed',
|
||
succeeded: 'captured',
|
||
canceled: 'cancelled',
|
||
}
|
||
const newStatus = statusMap[object.status]
|
||
|
||
if (newStatus) {
|
||
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],
|
||
)
|
||
}
|
||
}
|
||
|
||
// Handle online widget booking payment
|
||
if (object.status === 'succeeded' || object.status === 'canceled') {
|
||
const { rows: obRows } = await db.query<{
|
||
id: string; booking_id: string | null; hotel_id: string; slug: string
|
||
guest_name: string; guest_email: string | null
|
||
check_in: string; check_out: string; total_amount: string; hotel_name: string
|
||
}>(
|
||
`SELECT ob.id, ob.booking_id, ob.hotel_id, h.slug, h.name AS hotel_name,
|
||
ob.guest_name, ob.guest_email, ob.check_in, ob.check_out, ob.total_amount
|
||
FROM online_bookings ob
|
||
JOIN hotels h ON h.id = ob.hotel_id
|
||
WHERE ob.yookassa_payment_id = $1
|
||
LIMIT 1`,
|
||
[object.id],
|
||
)
|
||
if (obRows[0]) {
|
||
const ob = obRows[0]
|
||
if (object.status === 'succeeded') {
|
||
await db.query(
|
||
`UPDATE online_bookings SET status = 'paid', yookassa_status = 'succeeded' WHERE id = $1`,
|
||
[ob.id],
|
||
)
|
||
if (ob.booking_id) {
|
||
const { rows: bRows } = await db.query(
|
||
`UPDATE bookings
|
||
SET status = 'confirmed',
|
||
payment_status = 'paid',
|
||
paid_amount = total_amount,
|
||
updated_at = NOW()
|
||
WHERE id = $1 RETURNING *`,
|
||
[ob.booking_id],
|
||
)
|
||
if (bRows[0]) {
|
||
broadcast(ob.slug, { type: 'booking:updated', booking: bRows[0] })
|
||
}
|
||
// Insert payment record so the payment tab shows the amount
|
||
await db.query(
|
||
`INSERT INTO booking_payments (hotel_id, booking_id, amount, method, note)
|
||
VALUES ($1, $2, $3, 'yookassa', 'Оплата через ЮКассу (онлайн-бронирование)')
|
||
ON CONFLICT DO NOTHING`,
|
||
[ob.hotel_id, ob.booking_id, ob.total_amount],
|
||
).catch(() => {})
|
||
}
|
||
if (ob.guest_email) {
|
||
sendBookingConfirmedEmail({
|
||
to: ob.guest_email,
|
||
guestName: ob.guest_name,
|
||
hotelName: ob.hotel_name,
|
||
checkIn: ob.check_in,
|
||
checkOut: ob.check_out,
|
||
totalAmount: ob.total_amount,
|
||
bookingConfirmUrl: `https://app.hotelsync.ru/booking-confirm/${ob.id}`,
|
||
}).catch(() => {})
|
||
}
|
||
} else {
|
||
await db.query(
|
||
`UPDATE online_bookings SET status = 'cancelled', yookassa_status = 'canceled' WHERE id = $1`,
|
||
[ob.id],
|
||
)
|
||
if (ob.booking_id) {
|
||
const { rows: bRows } = await db.query(
|
||
`UPDATE bookings SET status = 'cancelled', updated_at = NOW()
|
||
WHERE id = $1 RETURNING *`,
|
||
[ob.booking_id],
|
||
)
|
||
if (bRows[0]) {
|
||
broadcast(ob.slug, { type: 'booking:updated', booking: bRows[0] })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return { ok: true }
|
||
},
|
||
)
|
||
}
|
||
|
||
export default deposit
|