feat: deposit release lock + billing fixes + centralized payment gateway
- Add releaseRequiresCheckout toggle in DepositSettingsPage (UI + payload) - Migration 066: release_requires_checkout column on hotel_deposit_settings - Migration 065: hotel_payment_gateways table for centralized YooKassa config - Block deposit release buttons when releaseRequiresCheckout && not checked_out - Fix billing calculation: use Number() to prevent PostgreSQL NUMERIC string concat - Fix minibar billing: show current price (JOIN minibar_items.price) not recorded price - Auto-poll deposit status every 5s while hold_created; clear QR message on confirmation - Add billing/folio tab in BookingDetailPanel (room charges + minibar + payments + balance) - PaymentSettingsPage: new "Онлайн-оплата (ЮКасса)" section with gateway CRUD - DepositSettingsPage: replace YooKassa fields with link to centralized payment settings - BookingWidgetPage: connect to real API, gateway status banner, async booking submission - New backend routes: paymentGateways, publicWidget - YooKassa service: add createCharge() for immediate payment (online bookings) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -60,7 +60,7 @@ const DOCUMENTS = [
|
||||
|
||||
type ReleaseItem = { id: string; name: string; amount: string }
|
||||
|
||||
function DepositWidget({ slug, bookingId, onDepositChange }: { slug: string; bookingId: string; onDepositChange?: (d: BookingDeposit | null) => void }) {
|
||||
function DepositWidget({ slug, bookingId, bookingStatus, onDepositChange }: { slug: string; bookingId: string; bookingStatus: string; onDepositChange?: (d: BookingDeposit | null) => void }) {
|
||||
const [depositSettings, setDepositSettings] = useState<DepositSettings | null>(null)
|
||||
const [deposit, setDeposit] = useState<BookingDeposit | null>(null)
|
||||
const [depositLoading, setDepositLoading] = useState(true)
|
||||
@@ -197,6 +197,7 @@ function DepositWidget({ slug, bookingId, onDepositChange }: { slug: string; boo
|
||||
if (!depositSettings?.isEnabled) return null
|
||||
|
||||
const badgeBase = 'inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-medium'
|
||||
const releaseBlocked = depositSettings?.releaseRequiresCheckout && bookingStatus !== 'checked_out'
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -285,12 +286,18 @@ function DepositWidget({ slug, bookingId, onDepositChange }: { slug: string; boo
|
||||
</span>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-300">{formatCurrency(deposit.amount)}</p>
|
||||
{!showReleaseForm && (
|
||||
<button
|
||||
onClick={() => setShowReleaseForm(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>
|
||||
releaseBlocked ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1">
|
||||
<Ban size={11} /> Возврат заблокирован до выезда
|
||||
</p>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowReleaseForm(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>
|
||||
)}
|
||||
@@ -312,12 +319,18 @@ function DepositWidget({ slug, bookingId, onDepositChange }: { slug: string; boo
|
||||
<p className="text-xs text-slate-600 dark:text-slate-300">{formatCurrency(deposit.amount)}</p>
|
||||
{!showReleaseForm && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowReleaseForm(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>
|
||||
{releaseBlocked ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1">
|
||||
<Ban size={11} /> Возврат заблокирован до выезда
|
||||
</p>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowReleaseForm(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>
|
||||
)}
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={depositCancelling}
|
||||
@@ -971,7 +984,7 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
||||
}, [slug])
|
||||
|
||||
// Billing tab
|
||||
const [billingMinibar, setBillingMinibar] = useState<Array<{ itemName: string; quantity: number; pricePerUnit: number; total: number; recordedAt: string }>>([])
|
||||
const [billingMinibar, setBillingMinibar] = useState<Array<{ itemName: string; quantity: number; pricePerUnit: number; currentPrice?: number; total: number; recordedAt: string }>>([])
|
||||
const [billingLoaded, setBillingLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1866,7 +1879,7 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
||||
{/* Правая колонка — депозит */}
|
||||
{slug && (
|
||||
<div>
|
||||
<DepositWidget slug={slug} bookingId={booking.id} onDepositChange={setActiveDeposit} />
|
||||
<DepositWidget slug={slug} bookingId={booking.id} bookingStatus={booking.status} onDepositChange={setActiveDeposit} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1874,16 +1887,17 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
||||
|
||||
{/* ── Счёт (фолио) ─────────────────────────────────────────────────── */}
|
||||
{tab === 'billing' && (() => {
|
||||
const minibarTotal = billingMinibar.reduce((s, i) => s + i.total, 0)
|
||||
const charges = finalTotal + minibarTotal
|
||||
// Use Number() to prevent string concatenation from PostgreSQL NUMERIC fields
|
||||
const minibarTotal = billingMinibar.reduce((s, i) => s + Number(i.currentPrice ?? i.pricePerUnit) * Number(i.quantity), 0)
|
||||
const charges = Number(finalTotal) + minibarTotal
|
||||
const paidPositive = payments.filter(p => Number(p.amount) > 0).reduce((s, p) => s + Number(p.amount), 0)
|
||||
const paidNegative = payments.filter(p => Number(p.amount) < 0).reduce((s, p) => s + Number(p.amount), 0)
|
||||
const depositBadge = activeDeposit?.status === 'hold_confirmed'
|
||||
? `Удержание ${formatCurrency(activeDeposit.amount)} (ЮКасса)`
|
||||
? `Удержание ${formatCurrency(Number(activeDeposit.amount))} (ЮКасса)`
|
||||
: activeDeposit?.status === 'paid_cash'
|
||||
? `Депозит наличными ${formatCurrency(activeDeposit?.amount ?? 0)}`
|
||||
? `Депозит наличными ${formatCurrency(Number(activeDeposit?.amount ?? 0))}`
|
||||
: activeDeposit?.status === 'captured'
|
||||
? `Депозит списан ${formatCurrency(activeDeposit.capturedAmount ?? 0)}`
|
||||
? `Депозит списан ${formatCurrency(Number(activeDeposit.capturedAmount ?? 0))}`
|
||||
: null
|
||||
const billingBalance = charges - paidPositive + paidNegative
|
||||
|
||||
|
||||
Reference in New Issue
Block a user