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')}>
|
||||
|
||||
Reference in New Issue
Block a user