- Widget white page fix: fallback photo when real rooms have no photos array - Long-stay deposits: if booking > threshold days and toggle on → full charge instead of hold (bypasses YooKassa 7-day limit) - DepositSettingsPage: toggle + day threshold input for long-stay full payment - Refund endpoint: POST /deposit/refund supports full and partial refunds via YooKassa refunds API - DepositWidget: refund UI for yookassa_charge deposits (full/partial, shows remaining amount) - partially_refunded status support with badge and "Вернуть ещё" button - Auto-confirm: webhook now auto-confirms booking status on payment.succeeded for widget bookings - PaymentSettingsPage: auto-confirm toggle per gateway (shown when booking-widget module active) - Migration 067: long_stay columns on hotel_deposit_settings, auto_confirm_on_payment on gateways, refunded_amount on deposits Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
118 lines
6.3 KiB
TypeScript
118 lines
6.3 KiB
TypeScript
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,
|
|
autoConfirmOnPayment: r.auto_confirm_on_payment ?? true,
|
|
}))
|
|
})
|
|
|
|
// ── 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,
|
|
autoConfirmOnPayment: r.auto_confirm_on_payment ?? true }
|
|
}
|
|
)
|
|
|
|
// ── PATCH /api/hotels/:slug/payment-gateways/:id ───────────────────────────
|
|
fastify.patch<SlugIdParam & { Body: { label?: string; shopId?: string; secretKey?: string; currency?: string; isActive?: boolean; modules?: string[]; autoConfirmOnPayment?: boolean } }>(
|
|
'/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, autoConfirmOnPayment } = 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),
|
|
auto_confirm_on_payment = COALESCE($7, auto_confirm_on_payment)
|
|
WHERE id = $8 AND hotel_id = $9 RETURNING *`,
|
|
[label ?? null, shopId ?? null, secretKey ?? null, currency ?? null,
|
|
isActive ?? null, modules ? JSON.stringify(modules) : null,
|
|
autoConfirmOnPayment ?? 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,
|
|
autoConfirmOnPayment: r.auto_confirm_on_payment ?? true }
|
|
}
|
|
)
|
|
|
|
// ── 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
|
|
}
|