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

@@ -72,6 +72,10 @@ function DepositWidget({ slug, bookingId, bookingStatus, onDepositChange }: { sl
const [releaseComment, setReleaseComment] = useState('')
const [yookassaMsg, setYookassaMsg] = 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 [minibarItems, setMinibarItems] = useState<Array<{ itemName: string; quantity: number; recordedPrice: number; currentPrice: number; lineTotal: number }>>([])
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) => {
setReleaseItems(prev => [...prev, { id: `item-${Date.now()}`, name, amount: String(amount) }])
}
@@ -491,11 +511,93 @@ function DepositWidget({ slug, bookingId, bookingStatus, onDepositChange }: { sl
</div>
)}
{/* Final statuses */}
{/* captured (hold → taken or charge → taken) */}
{deposit?.status === 'captured' && (
<span className={cn(badgeBase, 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300')}>
Списано {formatCurrency(deposit.capturedAmount ?? 0)}
</span>
<div className="space-y-2">
<span className={cn(badgeBase, 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300')}>
{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' && (
<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) =>
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) =>
req<{ items: Array<{ itemName: string; quantity: number; recordedPrice: number; currentPrice: number; lineTotal: number }>; total: number }>(
'GET', `/api/hotels/${slug}/bookings/${bookingId}/deposit/minibar`,
@@ -1555,6 +1558,8 @@ export interface DepositSettings {
yookassaShopId: string | null
yookassaSecretKey: string | null
releaseRequiresCheckout: boolean
longStayFullPayment: boolean
longStayThresholdDays: number
updatedAt?: string
}
@@ -1564,6 +1569,8 @@ export interface DepositSettingsPayload {
yookassa_shop_id?: string
yookassa_secret_key?: string
release_requires_checkout?: boolean
long_stay_full_payment?: boolean
long_stay_threshold_days?: number
}
export interface BookingDeposit {
@@ -1576,6 +1583,7 @@ export interface BookingDeposit {
yookassaPaymentId: string | null
yookassaConfirmationUrl: string | null
capturedAmount: number | null
refundedAmount: number | null
retentionReason: string | null
guestEmailSent: boolean
cardLast4: string | null
@@ -1600,6 +1608,7 @@ export interface PaymentGateway {
currency: string
isActive: boolean
modules: string[]
autoConfirmOnPayment: boolean
createdAt: string
}
@@ -1611,6 +1620,7 @@ export interface PaymentGatewayPayload {
currency?: string
isActive?: boolean
modules?: string[]
autoConfirmOnPayment?: boolean
}
export interface WidgetRoom {

View File

@@ -719,7 +719,7 @@ function WidgetPreview({ settings, slug, realRooms, paymentEnabled }: {
const isExpanded = expandedRoom === room.id
const isSelected = selected === room.id
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 (
<div
key={room.id}

View File

@@ -30,6 +30,8 @@ export function DepositSettingsPage() {
const [isEnabled, setIsEnabled] = useState(false)
const [amount, setAmount] = useState('5000')
const [releaseRequiresCheckout, setReleaseRequiresCheckout] = useState(false)
const [longStayFullPayment, setLongStayFullPayment] = useState(false)
const [longStayThresholdDays, setLongStayThresholdDays] = useState('7')
const [shopId, setShopId] = useState('')
const [secretKey, setSecretKey] = useState('')
const [showSecret, setShowSecret] = useState(false)
@@ -53,6 +55,8 @@ export function DepositSettingsPage() {
setIsEnabled(s.isEnabled)
setAmount(String(s.amount))
setReleaseRequiresCheckout(s.releaseRequiresCheckout ?? false)
setLongStayFullPayment(s.longStayFullPayment ?? false)
setLongStayThresholdDays(String(s.longStayThresholdDays ?? 7))
setShopId(s.yookassaShopId ?? '')
setSecretKey(s.yookassaSecretKey ?? '')
setPresets(p)
@@ -72,6 +76,8 @@ export function DepositSettingsPage() {
is_enabled: isEnabled,
amount: parseFloat(amount) || 5000,
release_requires_checkout: releaseRequiresCheckout,
long_stay_full_payment: longStayFullPayment,
long_stay_threshold_days: parseInt(longStayThresholdDays) || 7,
}
if (shopId) payload.yookassa_shop_id = shopId
if (secretKey && secretKey !== '••••••••') payload.yookassa_secret_key = secretKey
@@ -195,6 +201,29 @@ export function DepositSettingsPage() {
<Toggle on={releaseRequiresCheckout} onChange={() => setReleaseRequiresCheckout(v => !v)} />
</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">
<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">

View File

@@ -55,9 +55,10 @@ export function PaymentSettingsPage() {
const [gwShopId, setGwShopId] = useState('')
const [gwSecretKey, setGwSecretKey] = useState('')
const [gwCurrency, setGwCurrency] = useState('RUB')
const [gwModules, setGwModules] = useState<string[]>(['deposit', 'booking-widget', 'room-service'])
const [gwSaving, setGwSaving] = useState(false)
const [gwSecretVisible, setGwSecretVisible] = useState(false)
const [gwModules, setGwModules] = useState<string[]>(['deposit', 'booking-widget', 'room-service'])
const [gwAutoConfirm, setGwAutoConfirm] = useState(true)
const [gwSaving, setGwSaving] = useState(false)
const [gwSecretVisible, setGwSecretVisible] = useState(false)
useEffect(() => {
if (!slug) return
@@ -137,9 +138,11 @@ export function PaymentSettingsPage() {
if (gw) {
setGwEditId(gw.id); setGwLabel(gw.label); setGwShopId(gw.shopId ?? '')
setGwSecretKey(''); setGwCurrency(gw.currency); setGwModules(gw.modules ?? ['deposit','booking-widget','room-service'])
setGwAutoConfirm(gw.autoConfirmOnPayment ?? true)
} else {
setGwEditId(null); setGwLabel('ЮКасса'); setGwShopId(''); setGwSecretKey('')
setGwCurrency('RUB'); setGwModules(['deposit','booking-widget','room-service'])
setGwAutoConfirm(true)
}
setGwFormOpen(true)
setGwSecretVisible(false)
@@ -149,7 +152,7 @@ export function PaymentSettingsPage() {
if (!gwShopId.trim()) return
setGwSaving(true)
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) {
const updated = await api.paymentGateways.update(slug, gwEditId, data)
setGateways(p => p.map(g => g.id === gwEditId ? updated : g))
@@ -429,6 +432,21 @@ export function PaymentSettingsPage() {
))}
</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">
<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} />}