feat: long-stay deposit charge + refunds + auto-confirm widget + white page fix

- 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>
This commit is contained in:
2026-04-07 20:30:26 +03:00
parent bddfa355f2
commit 57531f2022
9 changed files with 368 additions and 53 deletions

View File

@@ -0,0 +1,12 @@
-- Long-stay: charge full deposit amount instead of hold (bypass 7-day YooKassa limit)
ALTER TABLE hotel_deposit_settings
ADD COLUMN IF NOT EXISTS long_stay_full_payment BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS long_stay_threshold_days INT NOT NULL DEFAULT 7;
-- Auto-confirm booking on successful online payment (per payment gateway)
ALTER TABLE hotel_payment_gateways
ADD COLUMN IF NOT EXISTS auto_confirm_on_payment BOOLEAN NOT NULL DEFAULT true;
-- Track refunded amount for captured (charged) deposits
ALTER TABLE booking_deposits
ADD COLUMN IF NOT EXISTS refunded_amount NUMERIC(12,2);

View File

@@ -1,6 +1,6 @@
import { FastifyPluginAsync } from 'fastify' import { FastifyPluginAsync } from 'fastify'
import { db } from '../db' import { db } from '../db'
import { createHold, capturePayment, cancelPayment } from '../services/yookassa' import { createHold, createCharge, capturePayment, cancelPayment, createRefund } from '../services/yookassa'
import { transporter } from '../email' import { transporter } from '../email'
type SlugParam = { Params: { slug: string } } type SlugParam = { Params: { slug: string } }
@@ -37,7 +37,7 @@ const deposit: FastifyPluginAsync = async (fastify) => {
[hotelId], [hotelId],
) )
if (!rows[0]) { if (!rows[0]) {
return { hotelId, isEnabled: false, amount: 5000, yookassaShopId: null, yookassaSecretKey: null, releaseRequiresCheckout: false } return { hotelId, isEnabled: false, amount: 5000, yookassaShopId: null, yookassaSecretKey: null, releaseRequiresCheckout: false, longStayFullPayment: false, longStayThresholdDays: 7 }
} }
// Mask secret key // Mask secret key
const row = rows[0] const row = rows[0]
@@ -54,6 +54,7 @@ const deposit: FastifyPluginAsync = async (fastify) => {
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 release_requires_checkout?: boolean
long_stay_full_payment?: boolean; long_stay_threshold_days?: number
} }>( } }>(
'/api/hotels/:slug/deposit/settings', '/api/hotels/:slug/deposit/settings',
{ onRequest: [fastify.authenticate] }, { onRequest: [fastify.authenticate] },
@@ -65,21 +66,24 @@ 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, release_requires_checkout } = request.body const { is_enabled, amount, yookassa_shop_id, yookassa_secret_key, release_requires_checkout,
long_stay_full_payment, long_stay_threshold_days } = request.body
// 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, release_requires_checkout, updated_at) `INSERT INTO hotel_deposit_settings (hotel_id, is_enabled, amount, yookassa_shop_id, yookassa_secret_key, release_requires_checkout, long_stay_full_payment, long_stay_threshold_days, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW()) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 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), release_requires_checkout = COALESCE($6, hotel_deposit_settings.release_requires_checkout),
long_stay_full_payment = COALESCE($7, hotel_deposit_settings.long_stay_full_payment),
long_stay_threshold_days = COALESCE($8, hotel_deposit_settings.long_stay_threshold_days),
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, release_requires_checkout ?? null], [hotelId, is_enabled ?? false, amount ? amount.toFixed(2) : '5000.00', yookassa_shop_id ?? null, yookassa_secret_key ?? null,
release_requires_checkout ?? null, long_stay_full_payment ?? null, long_stay_threshold_days ?? null],
) )
return rows[0] return rows[0]
}, },
@@ -166,27 +170,43 @@ const deposit: FastifyPluginAsync = async (fastify) => {
return reply.code(400).send({ error: 'YooKassa credentials not configured' }) return reply.code(400).send({ error: 'YooKassa credentials not configured' })
} }
// Get booking for description // Get booking dates + guest name
const { rows: bRows } = await db.query( const { rows: bRows } = await db.query(
'SELECT id, guest_name FROM bookings WHERE id = $1 AND hotel_id = $2', 'SELECT id, guest_name, check_in, check_out FROM bookings WHERE id = $1 AND hotel_id = $2',
[bookingId, hotelId], [bookingId, hotelId],
) )
if (!bRows[0]) return reply.code(404).send({ error: 'Booking not found' }) if (!bRows[0]) return reply.code(404).send({ error: 'Booking not found' })
const payment = await createHold({ const nights = Math.ceil(
shopId: settings.yookassa_shop_id, (new Date(bRows[0].check_out).getTime() - new Date(bRows[0].check_in).getTime()) / 86400000,
secretKey: settings.yookassa_secret_key, )
amount: Number(settings.amount), const threshold = settings.long_stay_threshold_days ?? 7
description: `Депозит за бронирование — ${bRows[0].guest_name}`, const useCharge = settings.long_stay_full_payment && nights > threshold
returnUrl: `${appUrl()}/${slug}/bookings`,
}) const payment = useCharge
? await createCharge({
shopId: settings.yookassa_shop_id,
secretKey: settings.yookassa_secret_key,
amount: Number(settings.amount),
description: `Депозит за бронирование — ${bRows[0].guest_name}`,
returnUrl: `${appUrl()}/${slug}/bookings`,
})
: await createHold({
shopId: settings.yookassa_shop_id,
secretKey: settings.yookassa_secret_key,
amount: Number(settings.amount),
description: `Депозит за бронирование — ${bRows[0].guest_name}`,
returnUrl: `${appUrl()}/${slug}/bookings`,
})
const paymentMethod = useCharge ? 'yookassa_charge' : 'yookassa_hold'
const { rows } = await db.query( const { rows } = await db.query(
`INSERT INTO booking_deposits `INSERT INTO booking_deposits
(hotel_id, booking_id, amount, status, payment_method, yookassa_payment_id, yookassa_confirmation_url) (hotel_id, booking_id, amount, status, payment_method, yookassa_payment_id, yookassa_confirmation_url)
VALUES ($1, $2, $3, 'hold_created', 'yookassa_hold', $4, $5) VALUES ($1, $2, $3, 'hold_created', $4, $5, $6)
RETURNING *`, RETURNING *`,
[hotelId, bookingId, settings.amount, payment.id, [hotelId, bookingId, settings.amount, paymentMethod, payment.id,
payment.confirmation?.confirmation_url ?? null], payment.confirmation?.confirmation_url ?? null],
) )
return reply.code(201).send({ return reply.code(201).send({
@@ -373,6 +393,67 @@ ${refund > 0 && (!items || items.length === 0) ? `<p style="color:#16a34a">Ос
}, },
) )
// ── POST /api/hotels/:slug/bookings/:bookingId/deposit/refund ────────────
// Partial or full refund of a captured (yookassa_charge) deposit
fastify.post<SlugBookingParam & { Body: { amount?: number; reason?: string } }>(
'/api/hotels/:slug/bookings/:bookingId/deposit/refund',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, bookingId } = 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 { amount: reqAmount, reason } = request.body
const { rows: depRows } = await db.query(
`SELECT * FROM booking_deposits
WHERE booking_id = $1 AND hotel_id = $2
AND payment_method = 'yookassa_charge' AND status = 'captured'
ORDER BY created_at DESC LIMIT 1`,
[bookingId, hotelId],
)
if (!depRows[0]) return reply.code(404).send({ error: 'No refundable deposit found' })
const dep = depRows[0]
const { rows: settingsRows } = await db.query(
'SELECT * FROM hotel_deposit_settings WHERE hotel_id = $1', [hotelId],
)
const settings = settingsRows[0]
if (!settings?.yookassa_shop_id || !settings?.yookassa_secret_key) {
return reply.code(400).send({ error: 'YooKassa credentials not configured' })
}
const depositAmount = Number(dep.amount)
const alreadyRefunded = Number(dep.refunded_amount ?? 0)
const remaining = depositAmount - alreadyRefunded
const refundAmount = reqAmount !== undefined ? Math.min(reqAmount, remaining) : remaining
if (refundAmount <= 0) return reply.code(400).send({ error: 'Nothing to refund' })
await createRefund({
shopId: settings.yookassa_shop_id,
secretKey: settings.yookassa_secret_key,
paymentId: dep.yookassa_payment_id,
amount: refundAmount,
})
const newRefunded = alreadyRefunded + refundAmount
const isFullRefund = newRefunded >= depositAmount - 0.01
const newStatus = isFullRefund ? 'refunded' : 'partially_refunded'
const { rows } = await db.query(
`UPDATE booking_deposits SET
status = $1, refunded_amount = $2, retention_reason = $3, released_at = COALESCE(released_at, NOW())
WHERE id = $4 RETURNING *`,
[newStatus, newRefunded.toFixed(2), reason ?? null, dep.id],
)
return rows[0]
},
)
// ── GET /api/pay/:slug — public, no auth ───────────────────────────────── // ── GET /api/pay/:slug — public, no auth ─────────────────────────────────
fastify.get<{ Params: { slug: string } }>( fastify.get<{ Params: { slug: string } }>(
'/api/pay/:slug', '/api/pay/:slug',
@@ -632,22 +713,54 @@ ${refund > 0 && (!items || items.length === 0) ? `<p style="color:#16a34a">Ос
canceled: 'cancelled', canceled: 'cancelled',
} }
const newStatus = statusMap[object.status] const newStatus = statusMap[object.status]
if (!newStatus) return { ok: true }
const card = object.payment_method?.card if (newStatus) {
if (card?.last4) { const card = object.payment_method?.card
await db.query( if (card?.last4) {
`UPDATE booking_deposits await db.query(
SET status = $1, card_last4 = $2, card_brand = $3 `UPDATE booking_deposits
WHERE yookassa_payment_id = $4`, SET status = $1, card_last4 = $2, card_brand = $3
[newStatus, card.last4, card.card_type ?? null, object.id], WHERE yookassa_payment_id = $4`,
) [newStatus, card.last4, card.card_type ?? null, object.id],
} else { )
await db.query( } else {
`UPDATE booking_deposits SET status = $1 WHERE yookassa_payment_id = $2`, await db.query(
[newStatus, object.id], `UPDATE booking_deposits SET status = $1 WHERE yookassa_payment_id = $2`,
) [newStatus, object.id],
)
}
} }
// Auto-confirm booking on successful online widget payment
if (object.status === 'succeeded') {
const { rows: obRows } = await db.query(
`SELECT ob.booking_id, ob.hotel_id
FROM online_bookings ob
WHERE ob.yookassa_payment_id = $1`,
[object.id],
)
if (obRows[0]?.booking_id) {
await db.query(
`UPDATE online_bookings SET yookassa_status = 'succeeded' WHERE yookassa_payment_id = $1`,
[object.id],
)
// Check gateway auto_confirm_on_payment
const { rows: gwRows } = await db.query(
`SELECT auto_confirm_on_payment FROM hotel_payment_gateways
WHERE hotel_id = $1 AND is_active = true
ORDER BY created_at LIMIT 1`,
[obRows[0].hotel_id],
)
const autoConfirm = gwRows[0]?.auto_confirm_on_payment !== false // default true
if (autoConfirm) {
await db.query(
`UPDATE bookings SET status = 'confirmed' WHERE id = $1 AND status = 'inquiry'`,
[obRows[0].booking_id],
)
}
}
}
return { ok: true } return { ok: true }
}, },
) )

View File

@@ -36,6 +36,7 @@ const paymentGateways: FastifyPluginAsync = async (fastify) => {
shopId: r.shop_id, secretKey: r.secret_key, shopId: r.shop_id, secretKey: r.secret_key,
currency: r.currency, isActive: r.is_active, currency: r.currency, isActive: r.is_active,
modules: r.modules ?? MODULES, createdAt: r.created_at, modules: r.modules ?? MODULES, createdAt: r.created_at,
autoConfirmOnPayment: r.auto_confirm_on_payment ?? true,
})) }))
}) })
@@ -55,35 +56,39 @@ const paymentGateways: FastifyPluginAsync = async (fastify) => {
const r = rows[0] const r = rows[0]
return { id: r.id, provider: r.provider, label: r.label, shopId: r.shop_id, return { id: r.id, provider: r.provider, label: r.label, shopId: r.shop_id,
secretKey: r.secret_key ? '••••••••' : null, secretKey: r.secret_key ? '••••••••' : null,
currency: r.currency, isActive: r.is_active, modules: r.modules } currency: r.currency, isActive: r.is_active, modules: r.modules,
autoConfirmOnPayment: r.auto_confirm_on_payment ?? true }
} }
) )
// ── PATCH /api/hotels/:slug/payment-gateways/:id ─────────────────────────── // ── PATCH /api/hotels/:slug/payment-gateways/:id ───────────────────────────
fastify.patch<SlugIdParam & { Body: { label?: string; shopId?: string; secretKey?: string; currency?: string; isActive?: boolean; modules?: string[] } }>( 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) => { '/api/hotels/:slug/payment-gateways/:id', { onRequest: [fastify.authenticate] }, async (req, reply) => {
const { slug, id } = req.params const { slug, id } = req.params
if (!canAccess(req.user.hotelSlug, req.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' }) if (!canAccess(req.user.hotelSlug, req.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' })
const hotelId = await getHotelId(slug) const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Not found' }) if (!hotelId) return reply.code(404).send({ error: 'Not found' })
const { label, shopId, secretKey, currency, isActive, modules } = req.body const { label, shopId, secretKey, currency, isActive, modules, autoConfirmOnPayment } = req.body
const { rows } = await db.query( const { rows } = await db.query(
`UPDATE hotel_payment_gateways SET `UPDATE hotel_payment_gateways SET
label = COALESCE($1, label), label = COALESCE($1, label),
shop_id = COALESCE($2, shop_id), shop_id = COALESCE($2, shop_id),
secret_key = CASE WHEN $3 IS NOT NULL AND $3 != '••••••••' THEN $3 ELSE secret_key END, secret_key = CASE WHEN $3 IS NOT NULL AND $3 != '••••••••' THEN $3 ELSE secret_key END,
currency = COALESCE($4, currency), currency = COALESCE($4, currency),
is_active = COALESCE($5, is_active), is_active = COALESCE($5, is_active),
modules = COALESCE($6::jsonb, modules) modules = COALESCE($6::jsonb, modules),
WHERE id = $7 AND hotel_id = $8 RETURNING *`, 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, [label ?? null, shopId ?? null, secretKey ?? null, currency ?? null,
isActive ?? null, modules ? JSON.stringify(modules) : null, id, hotelId], isActive ?? null, modules ? JSON.stringify(modules) : null,
autoConfirmOnPayment ?? null, id, hotelId],
) )
if (!rows[0]) return reply.code(404).send({ error: 'Not found' }) if (!rows[0]) return reply.code(404).send({ error: 'Not found' })
const r = rows[0] const r = rows[0]
return { id: r.id, provider: r.provider, label: r.label, shopId: r.shop_id, return { id: r.id, provider: r.provider, label: r.label, shopId: r.shop_id,
secretKey: r.secret_key ? '••••••••' : null, secretKey: r.secret_key ? '••••••••' : null,
currency: r.currency, isActive: r.is_active, modules: r.modules } currency: r.currency, isActive: r.is_active, modules: r.modules,
autoConfirmOnPayment: r.auto_confirm_on_payment ?? true }
} }
) )

View File

@@ -91,6 +91,32 @@ export async function capturePayment(params: {
} }
} }
// Full or partial refund of a captured (charged) payment
export async function createRefund(params: {
shopId: string
secretKey: string
paymentId: string
amount: number
}): Promise<void> {
const auth = Buffer.from(`${params.shopId}:${params.secretKey}`).toString('base64')
const res = await fetch('https://api.yookassa.ru/v3/refunds', {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json',
'Idempotence-Key': randomUUID(),
},
body: JSON.stringify({
payment_id: params.paymentId,
amount: { value: params.amount.toFixed(2), currency: 'RUB' },
}),
})
if (!res.ok) {
const err = await res.text()
throw new Error(`YooKassa refund error ${res.status}: ${err}`)
}
}
export async function cancelPayment(params: { export async function cancelPayment(params: {
shopId: string shopId: string
secretKey: string secretKey: string

View File

@@ -72,6 +72,10 @@ function DepositWidget({ slug, bookingId, bookingStatus, onDepositChange }: { sl
const [releaseComment, setReleaseComment] = useState('') const [releaseComment, setReleaseComment] = useState('')
const [yookassaMsg, setYookassaMsg] = useState<string | null>(null) const [yookassaMsg, setYookassaMsg] = useState<string | null>(null)
const [releaseError, setReleaseError] = useState<string | null>(null) const [releaseError, setReleaseError] = useState<string | null>(null)
const [showRefundForm, setShowRefundForm] = useState(false)
const [refundAmount, setRefundAmount] = useState('')
const [refundComment, setRefundComment] = useState('')
const [refunding, setRefunding] = useState(false)
const [presets, setPresets] = useState<DepositPreset[]>([]) const [presets, setPresets] = useState<DepositPreset[]>([])
const [minibarItems, setMinibarItems] = useState<Array<{ itemName: string; quantity: number; recordedPrice: number; currentPrice: number; lineTotal: number }>>([]) const [minibarItems, setMinibarItems] = useState<Array<{ itemName: string; quantity: number; recordedPrice: number; currentPrice: number; lineTotal: number }>>([])
const [minibarTotal, setMinibarTotal] = useState(0) const [minibarTotal, setMinibarTotal] = useState(0)
@@ -164,6 +168,22 @@ function DepositWidget({ slug, bookingId, bookingStatus, onDepositChange }: { sl
} }
} }
const handleRefund = async (full: boolean) => {
setRefunding(true)
try {
const amt = full ? undefined : (parseFloat(refundAmount) || undefined)
const dep = await api.deposits.refund(slug, bookingId, amt, refundComment || undefined)
setDeposit(dep)
setShowRefundForm(false)
setRefundAmount('')
setRefundComment('')
} catch {
setReleaseError('Не удалось выполнить возврат')
} finally {
setRefunding(false)
}
}
const addReleaseItem = (name: string, amount: number) => { const addReleaseItem = (name: string, amount: number) => {
setReleaseItems(prev => [...prev, { id: `item-${Date.now()}`, name, amount: String(amount) }]) setReleaseItems(prev => [...prev, { id: `item-${Date.now()}`, name, amount: String(amount) }])
} }
@@ -491,11 +511,93 @@ function DepositWidget({ slug, bookingId, bookingStatus, onDepositChange }: { sl
</div> </div>
)} )}
{/* Final statuses */} {/* captured (hold → taken or charge → taken) */}
{deposit?.status === 'captured' && ( {deposit?.status === 'captured' && (
<span className={cn(badgeBase, 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300')}> <div className="space-y-2">
Списано {formatCurrency(deposit.capturedAmount ?? 0)} <span className={cn(badgeBase, 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300')}>
</span> {deposit.paymentMethod === 'yookassa_charge' ? '✓ Оплачен' : 'Списано'} {formatCurrency(Number(deposit.amount))}
</span>
{/* Refund UI for charge-based deposits */}
{deposit.paymentMethod === 'yookassa_charge' && !showRefundForm && !releaseBlocked && (
<button
onClick={() => setShowRefundForm(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>
)}
{deposit.paymentMethod === 'yookassa_charge' && !showRefundForm && releaseBlocked && (
<p className="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1">
<Ban size={11} /> Возврат заблокирован до выезда
</p>
)}
{showRefundForm && deposit.paymentMethod === 'yookassa_charge' && (
<div className="rounded-xl border border-slate-200 dark:border-slate-600 p-3 space-y-2 bg-slate-50 dark:bg-slate-800/40">
<p className="text-xs font-semibold text-slate-700 dark:text-slate-300">Возврат депозита</p>
<div>
<label className="block text-xs text-slate-500 mb-1">
Сумма возврата (макс. {formatCurrency(Number(deposit.amount) - Number(deposit.refundedAmount ?? 0))})
</label>
<div className="flex items-center gap-2">
<input
type="number" min="0" step="100"
value={refundAmount}
onChange={e => setRefundAmount(e.target.value)}
onFocus={e => e.target.select()}
placeholder="Полный возврат"
className="input text-sm w-40"
/>
<span className="text-xs text-slate-400"></span>
</div>
</div>
<div>
<input
value={refundComment}
onChange={e => setRefundComment(e.target.value)}
placeholder="Комментарий (необязательно)"
className="input text-sm w-full"
/>
</div>
{releaseError && <p className="text-xs text-red-500">{releaseError}</p>}
<div className="flex gap-2">
<button
onClick={() => handleRefund(!refundAmount)}
disabled={refunding}
className="btn-primary py-1.5 px-3 text-xs flex items-center gap-1"
>
{refunding ? <Loader2 size={11} className="animate-spin" /> : null}
{refundAmount ? `Вернуть ${formatCurrency(parseFloat(refundAmount) || 0)}` : 'Вернуть полностью'}
</button>
<button onClick={() => { setShowRefundForm(false); setReleaseError(null) }} className="btn-secondary py-1.5 px-3 text-xs">
Отмена
</button>
</div>
</div>
)}
{deposit.refundedAmount && Number(deposit.refundedAmount) > 0 && (
<p className="text-xs text-emerald-600 dark:text-emerald-400">
Возвращено: {formatCurrency(Number(deposit.refundedAmount))}
</p>
)}
</div>
)}
{deposit?.status === 'partially_refunded' && (
<div className="space-y-1">
<span className={cn(badgeBase, 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400')}>
Частичный возврат
</span>
<p className="text-xs text-slate-500 dark:text-slate-400">
Возвращено: {formatCurrency(Number(deposit.refundedAmount ?? 0))} из {formatCurrency(Number(deposit.amount))}
</p>
{!showRefundForm && !releaseBlocked && (
<button
onClick={() => setShowRefundForm(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>
)} )}
{deposit?.status === 'refunded' && ( {deposit?.status === 'refunded' && (
<span className={cn(badgeBase, 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400')}> <span className={cn(badgeBase, 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400')}>

View File

@@ -805,6 +805,9 @@ export const api = {
cancel: (slug: string, bookingId: string) => cancel: (slug: string, bookingId: string) =>
req<void>('DELETE', `/api/hotels/${slug}/bookings/${bookingId}/deposit`), req<void>('DELETE', `/api/hotels/${slug}/bookings/${bookingId}/deposit`),
refund: (slug: string, bookingId: string, amount?: number, reason?: string) =>
req<BookingDeposit>('POST', `/api/hotels/${slug}/bookings/${bookingId}/deposit/refund`, { amount, reason }),
getMinibarForBooking: (slug: string, bookingId: string) => getMinibarForBooking: (slug: string, bookingId: string) =>
req<{ items: Array<{ itemName: string; quantity: number; recordedPrice: number; currentPrice: number; lineTotal: number }>; total: number }>( req<{ items: Array<{ itemName: string; quantity: number; recordedPrice: number; currentPrice: number; lineTotal: number }>; total: number }>(
'GET', `/api/hotels/${slug}/bookings/${bookingId}/deposit/minibar`, 'GET', `/api/hotels/${slug}/bookings/${bookingId}/deposit/minibar`,
@@ -1555,6 +1558,8 @@ export interface DepositSettings {
yookassaShopId: string | null yookassaShopId: string | null
yookassaSecretKey: string | null yookassaSecretKey: string | null
releaseRequiresCheckout: boolean releaseRequiresCheckout: boolean
longStayFullPayment: boolean
longStayThresholdDays: number
updatedAt?: string updatedAt?: string
} }
@@ -1564,6 +1569,8 @@ export interface DepositSettingsPayload {
yookassa_shop_id?: string yookassa_shop_id?: string
yookassa_secret_key?: string yookassa_secret_key?: string
release_requires_checkout?: boolean release_requires_checkout?: boolean
long_stay_full_payment?: boolean
long_stay_threshold_days?: number
} }
export interface BookingDeposit { export interface BookingDeposit {
@@ -1576,6 +1583,7 @@ export interface BookingDeposit {
yookassaPaymentId: string | null yookassaPaymentId: string | null
yookassaConfirmationUrl: string | null yookassaConfirmationUrl: string | null
capturedAmount: number | null capturedAmount: number | null
refundedAmount: number | null
retentionReason: string | null retentionReason: string | null
guestEmailSent: boolean guestEmailSent: boolean
cardLast4: string | null cardLast4: string | null
@@ -1600,6 +1608,7 @@ export interface PaymentGateway {
currency: string currency: string
isActive: boolean isActive: boolean
modules: string[] modules: string[]
autoConfirmOnPayment: boolean
createdAt: string createdAt: string
} }
@@ -1611,6 +1620,7 @@ export interface PaymentGatewayPayload {
currency?: string currency?: string
isActive?: boolean isActive?: boolean
modules?: string[] modules?: string[]
autoConfirmOnPayment?: boolean
} }
export interface WidgetRoom { export interface WidgetRoom {

View File

@@ -719,7 +719,7 @@ function WidgetPreview({ settings, slug, realRooms, paymentEnabled }: {
const isExpanded = expandedRoom === room.id const isExpanded = expandedRoom === room.id
const isSelected = selected === room.id const isSelected = selected === room.id
const curPhoto = photoIndex[room.id] ?? 0 const curPhoto = photoIndex[room.id] ?? 0
const photo = room.photos[curPhoto] const photo = room.photos[curPhoto] ?? { bg: 'from-slate-200 to-slate-300', emoji: '🛏️', label: 'Фото' }
return ( return (
<div <div
key={room.id} key={room.id}

View File

@@ -30,6 +30,8 @@ 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 [releaseRequiresCheckout, setReleaseRequiresCheckout] = useState(false)
const [longStayFullPayment, setLongStayFullPayment] = useState(false)
const [longStayThresholdDays, setLongStayThresholdDays] = useState('7')
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)
@@ -53,6 +55,8 @@ export function DepositSettingsPage() {
setIsEnabled(s.isEnabled) setIsEnabled(s.isEnabled)
setAmount(String(s.amount)) setAmount(String(s.amount))
setReleaseRequiresCheckout(s.releaseRequiresCheckout ?? false) setReleaseRequiresCheckout(s.releaseRequiresCheckout ?? false)
setLongStayFullPayment(s.longStayFullPayment ?? false)
setLongStayThresholdDays(String(s.longStayThresholdDays ?? 7))
setShopId(s.yookassaShopId ?? '') setShopId(s.yookassaShopId ?? '')
setSecretKey(s.yookassaSecretKey ?? '') setSecretKey(s.yookassaSecretKey ?? '')
setPresets(p) setPresets(p)
@@ -72,6 +76,8 @@ export function DepositSettingsPage() {
is_enabled: isEnabled, is_enabled: isEnabled,
amount: parseFloat(amount) || 5000, amount: parseFloat(amount) || 5000,
release_requires_checkout: releaseRequiresCheckout, release_requires_checkout: releaseRequiresCheckout,
long_stay_full_payment: longStayFullPayment,
long_stay_threshold_days: parseInt(longStayThresholdDays) || 7,
} }
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
@@ -195,6 +201,29 @@ export function DepositSettingsPage() {
<Toggle on={releaseRequiresCheckout} onChange={() => setReleaseRequiresCheckout(v => !v)} /> <Toggle on={releaseRequiresCheckout} onChange={() => setReleaseRequiresCheckout(v => !v)} />
</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">
Для броней длиннее указанного кол-ва ночей снимать депозит сразу (не замораживать). Обход 7-дневного лимита ЮКасса
</p>
{longStayFullPayment && (
<div className="flex items-center gap-2 mt-2">
<span className="text-xs text-slate-600 dark:text-slate-300">Порог:</span>
<input
type="number" min="1" max="365"
value={longStayThresholdDays}
onChange={e => setLongStayThresholdDays(e.target.value)}
onFocus={e => e.target.select()}
className="input w-20 text-sm"
/>
<span className="text-xs text-slate-500">ночей</span>
</div>
)}
</div>
<Toggle on={longStayFullPayment} onChange={() => setLongStayFullPayment(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">

View File

@@ -55,9 +55,10 @@ export function PaymentSettingsPage() {
const [gwShopId, setGwShopId] = useState('') const [gwShopId, setGwShopId] = useState('')
const [gwSecretKey, setGwSecretKey] = useState('') const [gwSecretKey, setGwSecretKey] = useState('')
const [gwCurrency, setGwCurrency] = useState('RUB') const [gwCurrency, setGwCurrency] = useState('RUB')
const [gwModules, setGwModules] = useState<string[]>(['deposit', 'booking-widget', 'room-service']) const [gwModules, setGwModules] = useState<string[]>(['deposit', 'booking-widget', 'room-service'])
const [gwSaving, setGwSaving] = useState(false) const [gwAutoConfirm, setGwAutoConfirm] = useState(true)
const [gwSecretVisible, setGwSecretVisible] = useState(false) const [gwSaving, setGwSaving] = useState(false)
const [gwSecretVisible, setGwSecretVisible] = useState(false)
useEffect(() => { useEffect(() => {
if (!slug) return if (!slug) return
@@ -137,9 +138,11 @@ export function PaymentSettingsPage() {
if (gw) { if (gw) {
setGwEditId(gw.id); setGwLabel(gw.label); setGwShopId(gw.shopId ?? '') setGwEditId(gw.id); setGwLabel(gw.label); setGwShopId(gw.shopId ?? '')
setGwSecretKey(''); setGwCurrency(gw.currency); setGwModules(gw.modules ?? ['deposit','booking-widget','room-service']) setGwSecretKey(''); setGwCurrency(gw.currency); setGwModules(gw.modules ?? ['deposit','booking-widget','room-service'])
setGwAutoConfirm(gw.autoConfirmOnPayment ?? true)
} else { } else {
setGwEditId(null); setGwLabel('ЮКасса'); setGwShopId(''); setGwSecretKey('') setGwEditId(null); setGwLabel('ЮКасса'); setGwShopId(''); setGwSecretKey('')
setGwCurrency('RUB'); setGwModules(['deposit','booking-widget','room-service']) setGwCurrency('RUB'); setGwModules(['deposit','booking-widget','room-service'])
setGwAutoConfirm(true)
} }
setGwFormOpen(true) setGwFormOpen(true)
setGwSecretVisible(false) setGwSecretVisible(false)
@@ -149,7 +152,7 @@ export function PaymentSettingsPage() {
if (!gwShopId.trim()) return if (!gwShopId.trim()) return
setGwSaving(true) setGwSaving(true)
try { try {
const data = { label: gwLabel, shopId: gwShopId.trim(), secretKey: gwSecretKey.trim() || undefined, currency: gwCurrency, modules: gwModules } const data = { label: gwLabel, shopId: gwShopId.trim(), secretKey: gwSecretKey.trim() || undefined, currency: gwCurrency, modules: gwModules, autoConfirmOnPayment: gwAutoConfirm }
if (gwEditId) { if (gwEditId) {
const updated = await api.paymentGateways.update(slug, gwEditId, data) const updated = await api.paymentGateways.update(slug, gwEditId, data)
setGateways(p => p.map(g => g.id === gwEditId ? updated : g)) setGateways(p => p.map(g => g.id === gwEditId ? updated : g))
@@ -429,6 +432,21 @@ export function PaymentSettingsPage() {
))} ))}
</div> </div>
</div> </div>
{gwModules.includes('booking-widget') && (
<div className="flex items-center justify-between gap-4 p-3 rounded-lg bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600">
<div>
<p className="text-xs font-medium text-slate-700 dark:text-slate-200">Авто-подтверждение брони при оплате</p>
<p className="text-xs text-slate-400 mt-0.5">Статус брони автоматически меняется на «Подтверждена» при успешной оплате через виджет</p>
</div>
<button
type="button"
onClick={() => setGwAutoConfirm(v => !v)}
className={cn('relative w-10 rounded-full transition-colors shrink-0 h-[22px]', gwAutoConfirm ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600')}
>
<div className={cn('absolute top-0.5 rounded-full bg-white shadow-sm transition-transform w-[18px] h-[18px]', gwAutoConfirm ? 'left-[20px]' : 'left-0.5')} />
</button>
</div>
)}
<div className="flex gap-2 pt-1"> <div className="flex gap-2 pt-1">
<button onClick={saveGateway} disabled={!gwShopId.trim() || gwSaving} className="btn-primary py-1.5 px-4 text-sm flex items-center gap-1.5"> <button onClick={saveGateway} disabled={!gwShopId.trim() || gwSaving} className="btn-primary py-1.5 px-4 text-sm flex items-center gap-1.5">
{gwSaving ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />} {gwSaving ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />}