feat: настройки оплаты — способы оплаты + контроль при заселении

- Миграция 064: таблица hotel_payment_methods, поле require_payment_checkin
- Бэкенд: CRUD /api/hotels/:slug/payment-methods, /api/hotels/:slug/payment-settings
- PaymentSettingsPage (/settings/payments): управление способами оплаты
  (название, валюта, тип, активность, сортировка), контроль при заселении (нет/мягкий/жёсткий)
- BookingDetailPanel: динамические методы оплаты вместо захардкоженных
- Кнопка «Заселить»: hard — заблокирована при балансе > 0; soft — предупреждение с выбором

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 16:12:38 +03:00
parent 45a8cf7c10
commit f94d4723d5
8 changed files with 632 additions and 24 deletions

View File

@@ -0,0 +1,162 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
type SlugParam = { Params: { slug: string } }
type SlugIdParam = { Params: { slug: string; id: string } }
const paymentMethods: 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)
// ── GET /api/hotels/:slug/payment-methods ─────────────────────────────────
fastify.get<SlugParam>(
'/api/hotels/:slug/payment-methods',
{ 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_payment_methods
WHERE hotel_id = $1
ORDER BY sort_order, name`,
[hotelId],
)
return rows
},
)
// ── POST /api/hotels/:slug/payment-methods ────────────────────────────────
fastify.post<SlugParam & { Body: { name: string; currency?: string; type?: string; sort_order?: number } }>(
'/api/hotels/:slug/payment-methods',
{ 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 { name, currency = 'RUB', type = 'cash', sort_order = 0 } = request.body
if (!name?.trim()) return reply.code(400).send({ error: 'Name required' })
const { rows } = await db.query(
`INSERT INTO hotel_payment_methods (hotel_id, name, currency, type, sort_order)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[hotelId, name.trim(), currency, type, sort_order],
)
return reply.code(201).send(rows[0])
},
)
// ── PATCH /api/hotels/:slug/payment-methods/:id ───────────────────────────
fastify.patch<SlugIdParam & { Body: { name?: string; currency?: string; type?: string; sort_order?: number; is_active?: boolean } }>(
'/api/hotels/:slug/payment-methods/:id',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, id } = 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 allowed = ['name', 'currency', 'type', 'sort_order', 'is_active']
const updates: string[] = []
const values: unknown[] = []
let idx = 1
const body = request.body as Record<string, unknown>
for (const key of allowed) {
if (body[key] !== undefined) {
updates.push(`${key} = $${idx}`)
values.push(body[key])
idx++
}
}
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
values.push(id, hotelId)
const { rows } = await db.query(
`UPDATE hotel_payment_methods SET ${updates.join(', ')}
WHERE id = $${idx} AND hotel_id = $${idx + 1} RETURNING *`,
values,
)
if (!rows[0]) return reply.code(404).send({ error: 'Not found' })
return rows[0]
},
)
// ── DELETE /api/hotels/:slug/payment-methods/:id ──────────────────────────
fastify.delete<SlugIdParam>(
'/api/hotels/:slug/payment-methods/:id',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, id } = 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' })
await db.query(
'DELETE FROM hotel_payment_methods WHERE id = $1 AND hotel_id = $2',
[id, hotelId],
)
return reply.code(204).send()
},
)
// ── GET /api/hotels/:slug/payment-settings ────────────────────────────────
fastify.get<SlugParam>(
'/api/hotels/:slug/payment-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 { rows } = await db.query(
`SELECT require_payment_checkin FROM hotels WHERE slug = $1`,
[slug],
)
if (!rows[0]) return reply.code(404).send({ error: 'Hotel not found' })
return { requirePaymentCheckin: rows[0].require_payment_checkin ?? 'none' }
},
)
// ── PATCH /api/hotels/:slug/payment-settings ──────────────────────────────
fastify.patch<SlugParam & { Body: { require_payment_checkin: string } }>(
'/api/hotels/:slug/payment-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 { require_payment_checkin } = request.body
if (!['none', 'soft', 'hard'].includes(require_payment_checkin)) {
return reply.code(400).send({ error: 'Invalid value' })
}
await db.query(
'UPDATE hotels SET require_payment_checkin = $1 WHERE slug = $2',
[require_payment_checkin, slug],
)
return { requirePaymentCheckin: require_payment_checkin }
},
)
}
export default paymentMethods