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')}>