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:
@@ -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')}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
Reference in New Issue
Block a user