feat: centralized payment gateways + booking widget API
Backend:
- Migration 065: hotel_payment_gateways table (migrates YooKassa from deposit_settings)
- online_bookings table for widget submissions
- Routes: CRUD /api/hotels/:slug/payment-gateways
- Public widget API: /api/widget/:slug/{config,availability,bookings}
- yookassa.ts: add createCharge() for immediate capture
Frontend:
- PaymentSettingsPage: add 'Онлайн-оплата' section with gateway CRUD and module toggles
- DepositSettingsPage: replace YooKassa fields with link to centralized settings
- BookingWidgetPage: connect to real API, show gateway status, real booking submit
- api.ts: add PaymentGateway types, paymentGateways and widget API methods
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
112
backend/src/routes/paymentGateways.ts
Normal file
112
backend/src/routes/paymentGateways.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import { db } from '../db'
|
||||
|
||||
type SlugParam = { Params: { slug: string } }
|
||||
type SlugIdParam = { Params: { slug: string; id: string } }
|
||||
|
||||
const MODULES = ['deposit', 'booking-widget', 'room-service'] as const
|
||||
|
||||
function canAccess(userSlug: string | null, role: string, slug: string) {
|
||||
if (role === 'super_admin') return true
|
||||
return userSlug === slug
|
||||
}
|
||||
|
||||
const paymentGateways: 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
|
||||
}
|
||||
|
||||
// ── GET /api/hotels/:slug/payment-gateways ─────────────────────────────────
|
||||
fastify.get<SlugParam>('/api/hotels/:slug/payment-gateways', { onRequest: [fastify.authenticate] }, async (req, reply) => {
|
||||
const { slug } = req.params
|
||||
if (!canAccess(req.user.hotelSlug, req.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' })
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Not found' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT id, provider, label, shop_id,
|
||||
CASE WHEN secret_key IS NOT NULL THEN '••••••••' ELSE NULL END AS secret_key,
|
||||
currency, is_active, modules, created_at
|
||||
FROM hotel_payment_gateways WHERE hotel_id = $1 ORDER BY created_at`,
|
||||
[hotelId],
|
||||
)
|
||||
return rows.map(r => ({
|
||||
id: r.id, provider: r.provider, label: r.label,
|
||||
shopId: r.shop_id, secretKey: r.secret_key,
|
||||
currency: r.currency, isActive: r.is_active,
|
||||
modules: r.modules ?? MODULES, createdAt: r.created_at,
|
||||
}))
|
||||
})
|
||||
|
||||
// ── POST /api/hotels/:slug/payment-gateways ────────────────────────────────
|
||||
fastify.post<SlugParam & { Body: { provider: string; label: string; shopId: string; secretKey: string; currency: string; modules: string[] } }>(
|
||||
'/api/hotels/:slug/payment-gateways', { onRequest: [fastify.authenticate] }, async (req, reply) => {
|
||||
const { slug } = req.params
|
||||
if (!canAccess(req.user.hotelSlug, req.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' })
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Not found' })
|
||||
const { provider = 'yookassa', label, shopId, secretKey, currency = 'RUB', modules = MODULES } = req.body
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO hotel_payment_gateways (hotel_id, provider, label, shop_id, secret_key, currency, modules)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
|
||||
[hotelId, provider, label, shopId || null, secretKey || null, currency, JSON.stringify(modules)],
|
||||
)
|
||||
const r = rows[0]
|
||||
return { id: r.id, provider: r.provider, label: r.label, shopId: r.shop_id,
|
||||
secretKey: r.secret_key ? '••••••••' : null,
|
||||
currency: r.currency, isActive: r.is_active, modules: r.modules }
|
||||
}
|
||||
)
|
||||
|
||||
// ── PATCH /api/hotels/:slug/payment-gateways/:id ───────────────────────────
|
||||
fastify.patch<SlugIdParam & { Body: { label?: string; shopId?: string; secretKey?: string; currency?: string; isActive?: boolean; modules?: string[] } }>(
|
||||
'/api/hotels/:slug/payment-gateways/:id', { onRequest: [fastify.authenticate] }, async (req, reply) => {
|
||||
const { slug, id } = req.params
|
||||
if (!canAccess(req.user.hotelSlug, req.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' })
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Not found' })
|
||||
const { label, shopId, secretKey, currency, isActive, modules } = req.body
|
||||
const { rows } = await db.query(
|
||||
`UPDATE hotel_payment_gateways SET
|
||||
label = COALESCE($1, label),
|
||||
shop_id = COALESCE($2, shop_id),
|
||||
secret_key = CASE WHEN $3 IS NOT NULL AND $3 != '••••••••' THEN $3 ELSE secret_key END,
|
||||
currency = COALESCE($4, currency),
|
||||
is_active = COALESCE($5, is_active),
|
||||
modules = COALESCE($6::jsonb, modules)
|
||||
WHERE id = $7 AND hotel_id = $8 RETURNING *`,
|
||||
[label ?? null, shopId ?? null, secretKey ?? null, currency ?? null,
|
||||
isActive ?? null, modules ? JSON.stringify(modules) : null, id, hotelId],
|
||||
)
|
||||
if (!rows[0]) return reply.code(404).send({ error: 'Not found' })
|
||||
const r = rows[0]
|
||||
return { id: r.id, provider: r.provider, label: r.label, shopId: r.shop_id,
|
||||
secretKey: r.secret_key ? '••••••••' : null,
|
||||
currency: r.currency, isActive: r.is_active, modules: r.modules }
|
||||
}
|
||||
)
|
||||
|
||||
// ── DELETE /api/hotels/:slug/payment-gateways/:id ──────────────────────────
|
||||
fastify.delete<SlugIdParam>('/api/hotels/:slug/payment-gateways/:id', { onRequest: [fastify.authenticate] }, async (req, reply) => {
|
||||
const { slug, id } = req.params
|
||||
if (!canAccess(req.user.hotelSlug, req.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' })
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Not found' })
|
||||
await db.query('DELETE FROM hotel_payment_gateways WHERE id = $1 AND hotel_id = $2', [id, hotelId])
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
|
||||
export default paymentGateways
|
||||
|
||||
// Helper: get active gateway for a given module
|
||||
export async function getGatewayForModule(hotelId: string, module: string) {
|
||||
const { rows } = await db.query(
|
||||
`SELECT * FROM hotel_payment_gateways
|
||||
WHERE hotel_id = $1 AND is_active = true AND modules @> $2::jsonb
|
||||
ORDER BY created_at LIMIT 1`,
|
||||
[hotelId, JSON.stringify([module])],
|
||||
)
|
||||
return rows[0] ?? null
|
||||
}
|
||||
209
backend/src/routes/publicWidget.ts
Normal file
209
backend/src/routes/publicWidget.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import { db } from '../db'
|
||||
import { getGatewayForModule } from './paymentGateways'
|
||||
import { createCharge } from '../services/yookassa'
|
||||
|
||||
type SlugParam = { Params: { slug: string } }
|
||||
type SlugIdParam = { Params: { slug: string; bookingId: string } }
|
||||
|
||||
const publicWidget: FastifyPluginAsync = async (fastify) => {
|
||||
const getHotelId = async (slug: string) => {
|
||||
const { rows } = await db.query('SELECT id, name FROM hotels WHERE slug = $1', [slug])
|
||||
return rows[0] ?? null
|
||||
}
|
||||
|
||||
// ── GET /api/widget/:slug/config ──────────────────────────────────────────
|
||||
// Returns hotel info + widget settings (no auth)
|
||||
fastify.get<SlugParam>('/api/widget/:slug/config', async (req, reply) => {
|
||||
const hotel = await getHotelId(req.params.slug)
|
||||
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { rows: rooms } = await db.query(
|
||||
`SELECT id, number, name, type, floor, max_guests, base_rate, amenities, description, photos,
|
||||
allow_hourly, hourly_rate, extra_place, child_policy, status
|
||||
FROM rooms
|
||||
WHERE hotel_id = $1 AND status != 'inactive'
|
||||
ORDER BY sort_order, number`,
|
||||
[hotel.id],
|
||||
)
|
||||
|
||||
// Check if YooKassa gateway is configured for booking-widget
|
||||
const gateway = await getGatewayForModule(hotel.id, 'booking-widget')
|
||||
|
||||
return {
|
||||
hotelId: hotel.id,
|
||||
hotelName: hotel.name,
|
||||
slug: req.params.slug,
|
||||
paymentEnabled: !!(gateway?.shop_id && gateway?.secret_key),
|
||||
currency: gateway?.currency ?? 'RUB',
|
||||
rooms: rooms.map(r => ({
|
||||
id: r.id,
|
||||
number: r.number,
|
||||
name: r.name || `Номер ${r.number}`,
|
||||
type: r.type,
|
||||
floor: r.floor,
|
||||
maxGuests: r.max_guests,
|
||||
baseRate: Number(r.base_rate),
|
||||
amenities: r.amenities ?? [],
|
||||
description: r.description ?? '',
|
||||
photos: r.photos ?? [],
|
||||
allowHourly: r.allow_hourly,
|
||||
hourlyRate: r.hourly_rate ? Number(r.hourly_rate) : null,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
// ── GET /api/widget/:slug/availability ────────────────────────────────────
|
||||
// Returns available rooms for given dates
|
||||
fastify.get<SlugParam & { Querystring: { checkIn: string; checkOut: string } }>(
|
||||
'/api/widget/:slug/availability', async (req, reply) => {
|
||||
const hotel = await getHotelId(req.params.slug)
|
||||
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
const { checkIn, checkOut } = req.query
|
||||
if (!checkIn || !checkOut) return reply.code(400).send({ error: 'checkIn and checkOut required' })
|
||||
|
||||
// Rooms occupied during requested dates
|
||||
const { rows: occupied } = await db.query(
|
||||
`SELECT DISTINCT room_id FROM bookings
|
||||
WHERE hotel_id = $1
|
||||
AND status NOT IN ('cancelled','no_show','checked_out')
|
||||
AND check_in < $3
|
||||
AND check_out > $2`,
|
||||
[hotel.id, checkIn, checkOut],
|
||||
)
|
||||
const occupiedIds = new Set(occupied.map((r: any) => r.room_id))
|
||||
|
||||
const { rows: rooms } = await db.query(
|
||||
`SELECT id, number, name, type, floor, max_guests, base_rate, amenities, description, photos
|
||||
FROM rooms WHERE hotel_id = $1 AND status = 'available'
|
||||
ORDER BY sort_order, number`,
|
||||
[hotel.id],
|
||||
)
|
||||
|
||||
return rooms
|
||||
.filter((r: any) => !occupiedIds.has(r.id))
|
||||
.map((r: any) => ({
|
||||
id: r.id,
|
||||
number: r.number,
|
||||
name: r.name || `Номер ${r.number}`,
|
||||
type: r.type,
|
||||
floor: r.floor,
|
||||
maxGuests: r.max_guests,
|
||||
baseRate: Number(r.base_rate),
|
||||
amenities: r.amenities ?? [],
|
||||
description: r.description ?? '',
|
||||
photos: r.photos ?? [],
|
||||
}))
|
||||
}
|
||||
)
|
||||
|
||||
// ── POST /api/widget/:slug/bookings ───────────────────────────────────────
|
||||
// Create an online booking (no auth)
|
||||
fastify.post<SlugParam & {
|
||||
Body: {
|
||||
roomId: string; checkIn: string; checkOut: string
|
||||
guestName: string; guestEmail?: string; guestPhone?: string
|
||||
adults?: number; children?: number
|
||||
totalAmount: number; notes?: string
|
||||
services?: Array<{ name: string; price: number }>
|
||||
}
|
||||
}>('/api/widget/:slug/bookings', async (req, reply) => {
|
||||
const hotel = await getHotelId(req.params.slug)
|
||||
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { roomId, checkIn, checkOut, guestName, guestEmail, guestPhone,
|
||||
adults = 1, children = 0, totalAmount, notes, services } = req.body
|
||||
|
||||
if (!roomId || !checkIn || !checkOut || !guestName || !totalAmount) {
|
||||
return reply.code(400).send({ error: 'Missing required fields' })
|
||||
}
|
||||
|
||||
// Check availability
|
||||
const { rows: conflict } = await db.query(
|
||||
`SELECT id FROM bookings
|
||||
WHERE hotel_id = $1 AND room_id = $2
|
||||
AND status NOT IN ('cancelled','no_show','checked_out')
|
||||
AND check_in < $4 AND check_out > $3`,
|
||||
[hotel.id, roomId, checkIn, checkOut],
|
||||
)
|
||||
if (conflict.length > 0) {
|
||||
return reply.code(409).send({ error: 'Room not available for selected dates' })
|
||||
}
|
||||
|
||||
// Check if gateway configured
|
||||
const gateway = await getGatewayForModule(hotel.id, 'booking-widget')
|
||||
const paymentMethod = (gateway?.shop_id && gateway?.secret_key) ? 'yookassa' : 'none'
|
||||
|
||||
// Create online_booking record
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO online_bookings
|
||||
(hotel_id, room_id, guest_name, guest_email, guest_phone, check_in, check_out,
|
||||
adults, children, total_amount, notes, services, payment_method)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id`,
|
||||
[hotel.id, roomId, guestName, guestEmail ?? null, guestPhone ?? null,
|
||||
checkIn, checkOut, adults, children, totalAmount.toFixed(2), notes ?? null,
|
||||
JSON.stringify(services ?? []), paymentMethod],
|
||||
)
|
||||
const onlineBookingId = rows[0].id
|
||||
|
||||
// Also create draft booking in main bookings table
|
||||
const { rows: bRows } = await db.query(
|
||||
`INSERT INTO bookings
|
||||
(hotel_id, room_id, guest_name, guest_email, guest_phone, check_in, check_out,
|
||||
adults, children, total_amount, source, status, notes)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'website','inquiry',$11) RETURNING id`,
|
||||
[hotel.id, roomId, guestName, guestEmail ?? null, guestPhone ?? null,
|
||||
checkIn, checkOut, adults, children, totalAmount.toFixed(2), notes ?? null],
|
||||
)
|
||||
const bookingId = bRows[0].id
|
||||
|
||||
// Update online_booking with the main booking id
|
||||
await db.query('UPDATE online_bookings SET booking_id = $1 WHERE id = $2',
|
||||
[bookingId, onlineBookingId]).catch(() => {}) // column may not exist yet, ignore
|
||||
|
||||
if (paymentMethod === 'yookassa') {
|
||||
// Create YooKassa payment
|
||||
try {
|
||||
const nights = Math.ceil((new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000)
|
||||
const result = await createCharge({
|
||||
shopId: gateway.shop_id,
|
||||
secretKey: gateway.secret_key,
|
||||
amount: totalAmount,
|
||||
description: `Бронирование: ${guestName}, ${nights} ${nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'}`,
|
||||
returnUrl: `https://app.hotelsync.ru/booking-confirm/${onlineBookingId}`,
|
||||
})
|
||||
await db.query(
|
||||
`UPDATE online_bookings SET yookassa_payment_id = $1, yookassa_confirmation_url = $2
|
||||
WHERE id = $3`,
|
||||
[result.id, result.confirmation?.confirmation_url ?? null, onlineBookingId],
|
||||
)
|
||||
return {
|
||||
bookingId: onlineBookingId,
|
||||
status: 'pending_payment',
|
||||
confirmationUrl: result.confirmation?.confirmation_url,
|
||||
}
|
||||
} catch (err) {
|
||||
// Payment creation failed — still return booking as pending
|
||||
return { bookingId: onlineBookingId, status: 'pending', confirmationUrl: null }
|
||||
}
|
||||
}
|
||||
|
||||
return { bookingId: onlineBookingId, status: 'confirmed', confirmationUrl: null }
|
||||
})
|
||||
|
||||
// ── GET /api/widget/:slug/bookings/:bookingId/status ──────────────────────
|
||||
fastify.get<SlugIdParam>('/api/widget/:slug/bookings/:bookingId/status', async (req, reply) => {
|
||||
const { slug, bookingId } = req.params
|
||||
const hotel = await getHotelId(slug)
|
||||
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
'SELECT status, yookassa_status, yookassa_confirmation_url FROM online_bookings WHERE id = $1 AND hotel_id = $2',
|
||||
[bookingId, hotel.id],
|
||||
)
|
||||
if (!rows[0]) return reply.code(404).send({ error: 'Booking not found' })
|
||||
return rows[0]
|
||||
})
|
||||
}
|
||||
|
||||
export default publicWidget
|
||||
Reference in New Issue
Block a user