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:
23
backend/migrations/064_payment_methods.sql
Normal file
23
backend/migrations/064_payment_methods.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- Hotel payment methods (configurable per hotel)
|
||||
CREATE TABLE hotel_payment_methods (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'RUB',
|
||||
type TEXT NOT NULL DEFAULT 'cash', -- cash, card, transfer, other
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Require payment on check-in: none | soft | hard
|
||||
ALTER TABLE hotels ADD COLUMN IF NOT EXISTS require_payment_checkin TEXT NOT NULL DEFAULT 'none';
|
||||
|
||||
-- Seed default payment methods for existing hotels
|
||||
INSERT INTO hotel_payment_methods (hotel_id, name, currency, type, sort_order)
|
||||
SELECT id, 'Наличные', 'RUB', 'cash', 0 FROM hotels;
|
||||
|
||||
INSERT INTO hotel_payment_methods (hotel_id, name, currency, type, sort_order)
|
||||
SELECT id, 'Банковская карта', 'RUB', 'card', 1 FROM hotels;
|
||||
|
||||
INSERT INTO hotel_payment_methods (hotel_id, name, currency, type, sort_order)
|
||||
SELECT id, 'Перевод (СБП)', 'RUB', 'transfer', 2 FROM hotels;
|
||||
@@ -42,6 +42,7 @@ import checklistsRoutes from './routes/checklists'
|
||||
import minibarRoutes from './routes/minibar'
|
||||
import depositRoutes from './routes/deposit'
|
||||
import paymentsRoutes from './routes/payments'
|
||||
import paymentMethodsRoutes from './routes/paymentMethods'
|
||||
import { setupAgentWsRoute } from './agent-ws'
|
||||
import { startJobs } from './jobs'
|
||||
|
||||
@@ -136,6 +137,7 @@ export async function buildApp() {
|
||||
await fastify.register(minibarRoutes)
|
||||
await fastify.register(depositRoutes)
|
||||
await fastify.register(paymentsRoutes)
|
||||
await fastify.register(paymentMethodsRoutes)
|
||||
await fastify.register(setupAgentWsRoute)
|
||||
|
||||
startJobs()
|
||||
|
||||
162
backend/src/routes/paymentMethods.ts
Normal file
162
backend/src/routes/paymentMethods.ts
Normal 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
|
||||
Reference in New Issue
Block a user