diff --git a/backend/migrations/066_deposit_lock.sql b/backend/migrations/066_deposit_lock.sql new file mode 100644 index 0000000..dada6e1 --- /dev/null +++ b/backend/migrations/066_deposit_lock.sql @@ -0,0 +1,3 @@ +-- Setting: block deposit release until guest has checked out +ALTER TABLE hotel_deposit_settings + ADD COLUMN IF NOT EXISTS release_requires_checkout BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/src/routes/deposit.ts b/backend/src/routes/deposit.ts index d0b1b1f..6773614 100644 --- a/backend/src/routes/deposit.ts +++ b/backend/src/routes/deposit.ts @@ -37,13 +37,14 @@ const deposit: FastifyPluginAsync = async (fastify) => { [hotelId], ) if (!rows[0]) { - return { hotelId, isEnabled: false, amount: 5000, yookassaShopId: null, yookassaSecretKey: null } + return { hotelId, isEnabled: false, amount: 5000, yookassaShopId: null, yookassaSecretKey: null, releaseRequiresCheckout: false } } // Mask secret key const row = rows[0] return { ...row, yookassa_secret_key: row.yookassa_secret_key ? '••••••••' : null, + release_requires_checkout: row.release_requires_checkout ?? false, } }, ) @@ -52,6 +53,7 @@ const deposit: FastifyPluginAsync = async (fastify) => { fastify.patch( '/api/hotels/:slug/deposit/settings', { onRequest: [fastify.authenticate] }, @@ -63,20 +65,21 @@ const deposit: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) - const { is_enabled, amount, yookassa_shop_id, yookassa_secret_key } = request.body + const { is_enabled, amount, yookassa_shop_id, yookassa_secret_key, release_requires_checkout } = request.body // Upsert settings const { rows } = await db.query( - `INSERT INTO hotel_deposit_settings (hotel_id, is_enabled, amount, yookassa_shop_id, yookassa_secret_key, updated_at) - VALUES ($1, $2, $3, $4, $5, NOW()) + `INSERT INTO hotel_deposit_settings (hotel_id, is_enabled, amount, yookassa_shop_id, yookassa_secret_key, release_requires_checkout, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW()) ON CONFLICT (hotel_id) DO UPDATE SET is_enabled = COALESCE($2, hotel_deposit_settings.is_enabled), amount = COALESCE($3, hotel_deposit_settings.amount), yookassa_shop_id = COALESCE($4, hotel_deposit_settings.yookassa_shop_id), yookassa_secret_key = CASE WHEN $5 IS NOT NULL AND $5 != '••••••••' THEN $5 ELSE hotel_deposit_settings.yookassa_secret_key END, + release_requires_checkout = COALESCE($6, hotel_deposit_settings.release_requires_checkout), updated_at = NOW() RETURNING *`, - [hotelId, is_enabled ?? false, amount ? amount.toFixed(2) : '5000.00', yookassa_shop_id ?? null, yookassa_secret_key ?? null], + [hotelId, is_enabled ?? false, amount ? amount.toFixed(2) : '5000.00', yookassa_shop_id ?? null, yookassa_secret_key ?? null, release_requires_checkout ?? null], ) return rows[0] }, diff --git a/backend/src/routes/minibar.ts b/backend/src/routes/minibar.ts index 1c08b72..0ee84b1 100644 --- a/backend/src/routes/minibar.ts +++ b/backend/src/routes/minibar.ts @@ -247,6 +247,7 @@ const minibar: FastifyPluginAsync = async (fastify) => { mi.name AS item_name, mc.quantity, mc.price_per_unit, + mi.price AS current_price, (mc.quantity * mc.price_per_unit) AS total, mc.recorded_at, u.name AS recorded_by_name diff --git a/src/components/bookings/BookingDetailPanel.tsx b/src/components/bookings/BookingDetailPanel.tsx index 4a69786..b512bbf 100644 --- a/src/components/bookings/BookingDetailPanel.tsx +++ b/src/components/bookings/BookingDetailPanel.tsx @@ -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(null) const [deposit, setDeposit] = useState(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 (
@@ -285,12 +286,18 @@ function DepositWidget({ slug, bookingId, onDepositChange }: { slug: string; boo

{formatCurrency(deposit.amount)}

{!showReleaseForm && ( - + releaseBlocked ? ( +

+ Возврат заблокирован до выезда +

+ ) : ( + + ) )}
)} @@ -312,12 +319,18 @@ function DepositWidget({ slug, bookingId, onDepositChange }: { slug: string; boo

{formatCurrency(deposit.amount)}

{!showReleaseForm && (
- + {releaseBlocked ? ( +

+ Возврат заблокирован до выезда +

+ ) : ( + + )}
@@ -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 diff --git a/src/lib/api.ts b/src/lib/api.ts index 1bd7216..faf4192 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1450,6 +1450,7 @@ export interface MinibarBookingCharge { itemName: string quantity: number pricePerUnit: number + currentPrice: number total: number recordedAt: string recordedByName: string | null @@ -1553,6 +1554,7 @@ export interface DepositSettings { amount: number yookassaShopId: string | null yookassaSecretKey: string | null + releaseRequiresCheckout: boolean updatedAt?: string } @@ -1561,6 +1563,7 @@ export interface DepositSettingsPayload { amount?: number yookassa_shop_id?: string yookassa_secret_key?: string + release_requires_checkout?: boolean } export interface BookingDeposit { diff --git a/src/pages/DepositSettingsPage.tsx b/src/pages/DepositSettingsPage.tsx index 08d726b..01781a5 100644 --- a/src/pages/DepositSettingsPage.tsx +++ b/src/pages/DepositSettingsPage.tsx @@ -29,6 +29,7 @@ export function DepositSettingsPage() { const [isEnabled, setIsEnabled] = useState(false) const [amount, setAmount] = useState('5000') + const [releaseRequiresCheckout, setReleaseRequiresCheckout] = useState(false) const [shopId, setShopId] = useState('') const [secretKey, setSecretKey] = useState('') const [showSecret, setShowSecret] = useState(false) @@ -51,6 +52,7 @@ export function DepositSettingsPage() { setSettings(s) setIsEnabled(s.isEnabled) setAmount(String(s.amount)) + setReleaseRequiresCheckout(s.releaseRequiresCheckout ?? false) setShopId(s.yookassaShopId ?? '') setSecretKey(s.yookassaSecretKey ?? '') setPresets(p) @@ -69,6 +71,7 @@ export function DepositSettingsPage() { const payload: Record = { is_enabled: isEnabled, amount: parseFloat(amount) || 5000, + release_requires_checkout: releaseRequiresCheckout, } if (shopId) payload.yookassa_shop_id = shopId if (secretKey && secretKey !== '••••••••') payload.yookassa_secret_key = secretKey @@ -182,6 +185,16 @@ export function DepositSettingsPage() { /> +
+
+

Запретить возврат депозита до выезда

+

+ Кнопки «Вернуть» и «Списать» будут заблокированы, пока гость не выехал +

+
+ setReleaseRequiresCheckout(v => !v)} /> +
+

ЮКасса настраивается в одном месте