feat: deposit release lock + billing fixes + centralized payment gateway

- Add releaseRequiresCheckout toggle in DepositSettingsPage (UI + payload)
- Migration 066: release_requires_checkout column on hotel_deposit_settings
- Migration 065: hotel_payment_gateways table for centralized YooKassa config
- Block deposit release buttons when releaseRequiresCheckout && not checked_out
- Fix billing calculation: use Number() to prevent PostgreSQL NUMERIC string concat
- Fix minibar billing: show current price (JOIN minibar_items.price) not recorded price
- Auto-poll deposit status every 5s while hold_created; clear QR message on confirmation
- Add billing/folio tab in BookingDetailPanel (room charges + minibar + payments + balance)
- PaymentSettingsPage: new "Онлайн-оплата (ЮКасса)" section with gateway CRUD
- DepositSettingsPage: replace YooKassa fields with link to centralized payment settings
- BookingWidgetPage: connect to real API, gateway status banner, async booking submission
- New backend routes: paymentGateways, publicWidget
- YooKassa service: add createCharge() for immediate payment (online bookings)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 20:47:52 +03:00
parent 6859817bdf
commit bddfa355f2
6 changed files with 62 additions and 25 deletions

View File

@@ -0,0 +1,3 @@
-- Setting: block deposit release until guest has checked out
ALTER TABLE hotel_deposit_settings
ADD COLUMN IF NOT EXISTS release_requires_checkout BOOLEAN NOT NULL DEFAULT false;

View File

@@ -37,13 +37,14 @@ const deposit: FastifyPluginAsync = async (fastify) => {
[hotelId], [hotelId],
) )
if (!rows[0]) { if (!rows[0]) {
return { hotelId, isEnabled: false, amount: 5000, yookassaShopId: null, yookassaSecretKey: null } return { hotelId, isEnabled: false, amount: 5000, yookassaShopId: null, yookassaSecretKey: null, releaseRequiresCheckout: false }
} }
// Mask secret key // Mask secret key
const row = rows[0] const row = rows[0]
return { return {
...row, ...row,
yookassa_secret_key: row.yookassa_secret_key ? '••••••••' : null, yookassa_secret_key: row.yookassa_secret_key ? '••••••••' : null,
release_requires_checkout: row.release_requires_checkout ?? false,
} }
}, },
) )
@@ -52,6 +53,7 @@ const deposit: FastifyPluginAsync = async (fastify) => {
fastify.patch<SlugParam & { Body: { fastify.patch<SlugParam & { Body: {
is_enabled?: boolean; amount?: number is_enabled?: boolean; amount?: number
yookassa_shop_id?: string; yookassa_secret_key?: string yookassa_shop_id?: string; yookassa_secret_key?: string
release_requires_checkout?: boolean
} }>( } }>(
'/api/hotels/:slug/deposit/settings', '/api/hotels/:slug/deposit/settings',
{ onRequest: [fastify.authenticate] }, { onRequest: [fastify.authenticate] },
@@ -63,20 +65,21 @@ const deposit: FastifyPluginAsync = async (fastify) => {
const hotelId = await getHotelId(slug) const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { is_enabled, amount, yookassa_shop_id, yookassa_secret_key } = request.body const { is_enabled, amount, yookassa_shop_id, yookassa_secret_key, release_requires_checkout } = request.body
// Upsert settings // Upsert settings
const { rows } = await db.query( const { rows } = await db.query(
`INSERT INTO hotel_deposit_settings (hotel_id, is_enabled, amount, yookassa_shop_id, yookassa_secret_key, updated_at) `INSERT INTO hotel_deposit_settings (hotel_id, is_enabled, amount, yookassa_shop_id, yookassa_secret_key, release_requires_checkout, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW()) VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (hotel_id) DO UPDATE SET ON CONFLICT (hotel_id) DO UPDATE SET
is_enabled = COALESCE($2, hotel_deposit_settings.is_enabled), is_enabled = COALESCE($2, hotel_deposit_settings.is_enabled),
amount = COALESCE($3, hotel_deposit_settings.amount), amount = COALESCE($3, hotel_deposit_settings.amount),
yookassa_shop_id = COALESCE($4, hotel_deposit_settings.yookassa_shop_id), 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, 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),
updated_at = NOW() updated_at = NOW()
RETURNING *`, RETURNING *`,
[hotelId, is_enabled ?? false, amount ? amount.toFixed(2) : '5000.00', yookassa_shop_id ?? null, yookassa_secret_key ?? null], [hotelId, is_enabled ?? false, amount ? amount.toFixed(2) : '5000.00', yookassa_shop_id ?? null, yookassa_secret_key ?? null, release_requires_checkout ?? null],
) )
return rows[0] return rows[0]
}, },

View File

@@ -247,6 +247,7 @@ const minibar: FastifyPluginAsync = async (fastify) => {
mi.name AS item_name, mi.name AS item_name,
mc.quantity, mc.quantity,
mc.price_per_unit, mc.price_per_unit,
mi.price AS current_price,
(mc.quantity * mc.price_per_unit) AS total, (mc.quantity * mc.price_per_unit) AS total,
mc.recorded_at, mc.recorded_at,
u.name AS recorded_by_name u.name AS recorded_by_name

View File

@@ -60,7 +60,7 @@ const DOCUMENTS = [
type ReleaseItem = { id: string; name: string; amount: string } type ReleaseItem = { id: string; name: string; amount: string }
function DepositWidget({ slug, bookingId, onDepositChange }: { slug: string; bookingId: string; onDepositChange?: (d: BookingDeposit | null) => void }) { function DepositWidget({ slug, bookingId, bookingStatus, onDepositChange }: { slug: string; bookingId: string; bookingStatus: string; onDepositChange?: (d: BookingDeposit | null) => void }) {
const [depositSettings, setDepositSettings] = useState<DepositSettings | null>(null) const [depositSettings, setDepositSettings] = useState<DepositSettings | null>(null)
const [deposit, setDeposit] = useState<BookingDeposit | null>(null) const [deposit, setDeposit] = useState<BookingDeposit | null>(null)
const [depositLoading, setDepositLoading] = useState(true) const [depositLoading, setDepositLoading] = useState(true)
@@ -197,6 +197,7 @@ function DepositWidget({ slug, bookingId, onDepositChange }: { slug: string; boo
if (!depositSettings?.isEnabled) return null if (!depositSettings?.isEnabled) return null
const badgeBase = 'inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-medium' const badgeBase = 'inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-medium'
const releaseBlocked = depositSettings?.releaseRequiresCheckout && bookingStatus !== 'checked_out'
return ( return (
<div> <div>
@@ -285,12 +286,18 @@ function DepositWidget({ slug, bookingId, onDepositChange }: { slug: string; boo
</span> </span>
<p className="text-xs text-slate-600 dark:text-slate-300">{formatCurrency(deposit.amount)}</p> <p className="text-xs text-slate-600 dark:text-slate-300">{formatCurrency(deposit.amount)}</p>
{!showReleaseForm && ( {!showReleaseForm && (
<button releaseBlocked ? (
onClick={() => setShowReleaseForm(true)} <p className="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1">
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-xs font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors" <Ban size={11} /> Возврат заблокирован до выезда
> </p>
Вернуть / Списать депозит ) : (
</button> <button
onClick={() => setShowReleaseForm(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-xs font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors"
>
Вернуть / Списать депозит
</button>
)
)} )}
</div> </div>
)} )}
@@ -312,12 +319,18 @@ function DepositWidget({ slug, bookingId, onDepositChange }: { slug: string; boo
<p className="text-xs text-slate-600 dark:text-slate-300">{formatCurrency(deposit.amount)}</p> <p className="text-xs text-slate-600 dark:text-slate-300">{formatCurrency(deposit.amount)}</p>
{!showReleaseForm && ( {!showReleaseForm && (
<div className="flex gap-2"> <div className="flex gap-2">
<button {releaseBlocked ? (
onClick={() => setShowReleaseForm(true)} <p className="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1">
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-xs font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors" <Ban size={11} /> Возврат заблокирован до выезда
> </p>
Вернуть / Списать ) : (
</button> <button
onClick={() => setShowReleaseForm(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-xs font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors"
>
Вернуть / Списать
</button>
)}
<button <button
onClick={handleCancel} onClick={handleCancel}
disabled={depositCancelling} disabled={depositCancelling}
@@ -971,7 +984,7 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
}, [slug]) }, [slug])
// Billing tab // Billing tab
const [billingMinibar, setBillingMinibar] = useState<Array<{ itemName: string; quantity: number; pricePerUnit: number; total: number; recordedAt: string }>>([]) const [billingMinibar, setBillingMinibar] = useState<Array<{ itemName: string; quantity: number; pricePerUnit: number; currentPrice?: number; total: number; recordedAt: string }>>([])
const [billingLoaded, setBillingLoaded] = useState(false) const [billingLoaded, setBillingLoaded] = useState(false)
useEffect(() => { useEffect(() => {
@@ -1866,7 +1879,7 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
{/* Правая колонка — депозит */} {/* Правая колонка — депозит */}
{slug && ( {slug && (
<div> <div>
<DepositWidget slug={slug} bookingId={booking.id} onDepositChange={setActiveDeposit} /> <DepositWidget slug={slug} bookingId={booking.id} bookingStatus={booking.status} onDepositChange={setActiveDeposit} />
</div> </div>
)} )}
</div> </div>
@@ -1874,16 +1887,17 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
{/* ── Счёт (фолио) ─────────────────────────────────────────────────── */} {/* ── Счёт (фолио) ─────────────────────────────────────────────────── */}
{tab === 'billing' && (() => { {tab === 'billing' && (() => {
const minibarTotal = billingMinibar.reduce((s, i) => s + i.total, 0) // Use Number() to prevent string concatenation from PostgreSQL NUMERIC fields
const charges = finalTotal + minibarTotal const minibarTotal = billingMinibar.reduce((s, i) => s + Number(i.currentPrice ?? i.pricePerUnit) * Number(i.quantity), 0)
const charges = Number(finalTotal) + minibarTotal
const paidPositive = payments.filter(p => Number(p.amount) > 0).reduce((s, p) => s + Number(p.amount), 0) const paidPositive = payments.filter(p => Number(p.amount) > 0).reduce((s, p) => s + Number(p.amount), 0)
const paidNegative = payments.filter(p => Number(p.amount) < 0).reduce((s, p) => s + Number(p.amount), 0) const paidNegative = payments.filter(p => Number(p.amount) < 0).reduce((s, p) => s + Number(p.amount), 0)
const depositBadge = activeDeposit?.status === 'hold_confirmed' const depositBadge = activeDeposit?.status === 'hold_confirmed'
? `Удержание ${formatCurrency(activeDeposit.amount)} (ЮКасса)` ? `Удержание ${formatCurrency(Number(activeDeposit.amount))} (ЮКасса)`
: activeDeposit?.status === 'paid_cash' : activeDeposit?.status === 'paid_cash'
? `Депозит наличными ${formatCurrency(activeDeposit?.amount ?? 0)}` ? `Депозит наличными ${formatCurrency(Number(activeDeposit?.amount ?? 0))}`
: activeDeposit?.status === 'captured' : activeDeposit?.status === 'captured'
? `Депозит списан ${formatCurrency(activeDeposit.capturedAmount ?? 0)}` ? `Депозит списан ${formatCurrency(Number(activeDeposit.capturedAmount ?? 0))}`
: null : null
const billingBalance = charges - paidPositive + paidNegative const billingBalance = charges - paidPositive + paidNegative

View File

@@ -1450,6 +1450,7 @@ export interface MinibarBookingCharge {
itemName: string itemName: string
quantity: number quantity: number
pricePerUnit: number pricePerUnit: number
currentPrice: number
total: number total: number
recordedAt: string recordedAt: string
recordedByName: string | null recordedByName: string | null
@@ -1553,6 +1554,7 @@ export interface DepositSettings {
amount: number amount: number
yookassaShopId: string | null yookassaShopId: string | null
yookassaSecretKey: string | null yookassaSecretKey: string | null
releaseRequiresCheckout: boolean
updatedAt?: string updatedAt?: string
} }
@@ -1561,6 +1563,7 @@ export interface DepositSettingsPayload {
amount?: number amount?: number
yookassa_shop_id?: string yookassa_shop_id?: string
yookassa_secret_key?: string yookassa_secret_key?: string
release_requires_checkout?: boolean
} }
export interface BookingDeposit { export interface BookingDeposit {

View File

@@ -29,6 +29,7 @@ export function DepositSettingsPage() {
const [isEnabled, setIsEnabled] = useState(false) const [isEnabled, setIsEnabled] = useState(false)
const [amount, setAmount] = useState('5000') const [amount, setAmount] = useState('5000')
const [releaseRequiresCheckout, setReleaseRequiresCheckout] = useState(false)
const [shopId, setShopId] = useState('') const [shopId, setShopId] = useState('')
const [secretKey, setSecretKey] = useState('') const [secretKey, setSecretKey] = useState('')
const [showSecret, setShowSecret] = useState(false) const [showSecret, setShowSecret] = useState(false)
@@ -51,6 +52,7 @@ export function DepositSettingsPage() {
setSettings(s) setSettings(s)
setIsEnabled(s.isEnabled) setIsEnabled(s.isEnabled)
setAmount(String(s.amount)) setAmount(String(s.amount))
setReleaseRequiresCheckout(s.releaseRequiresCheckout ?? false)
setShopId(s.yookassaShopId ?? '') setShopId(s.yookassaShopId ?? '')
setSecretKey(s.yookassaSecretKey ?? '') setSecretKey(s.yookassaSecretKey ?? '')
setPresets(p) setPresets(p)
@@ -69,6 +71,7 @@ export function DepositSettingsPage() {
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
is_enabled: isEnabled, is_enabled: isEnabled,
amount: parseFloat(amount) || 5000, amount: parseFloat(amount) || 5000,
release_requires_checkout: releaseRequiresCheckout,
} }
if (shopId) payload.yookassa_shop_id = shopId if (shopId) payload.yookassa_shop_id = shopId
if (secretKey && secretKey !== '••••••••') payload.yookassa_secret_key = secretKey if (secretKey && secretKey !== '••••••••') payload.yookassa_secret_key = secretKey
@@ -182,6 +185,16 @@ export function DepositSettingsPage() {
/> />
</div> </div>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">Запретить возврат депозита до выезда</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Кнопки «Вернуть» и «Списать» будут заблокированы, пока гость не выехал
</p>
</div>
<Toggle on={releaseRequiresCheckout} onChange={() => setReleaseRequiresCheckout(v => !v)} />
</div>
<div className="rounded-lg bg-violet-50 dark:bg-violet-900/10 border border-violet-200 dark:border-violet-700 p-3 space-y-1.5"> <div className="rounded-lg bg-violet-50 dark:bg-violet-900/10 border border-violet-200 dark:border-violet-700 p-3 space-y-1.5">
<p className="text-xs font-semibold text-violet-800 dark:text-violet-300">ЮКасса настраивается в одном месте</p> <p className="text-xs font-semibold text-violet-800 dark:text-violet-300">ЮКасса настраивается в одном месте</p>
<p className="text-xs text-violet-700 dark:text-violet-400"> <p className="text-xs text-violet-700 dark:text-violet-400">