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

@@ -1,6 +1,6 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
import { createHold, capturePayment, cancelPayment } from '../services/yookassa'
import { createHold, createCharge, capturePayment, cancelPayment, createRefund } from '../services/yookassa'
import { transporter } from '../email'
type SlugParam = { Params: { slug: string } }
@@ -37,7 +37,7 @@ const deposit: FastifyPluginAsync = async (fastify) => {
[hotelId],
)
if (!rows[0]) {
return { hotelId, isEnabled: false, amount: 5000, yookassaShopId: null, yookassaSecretKey: null, releaseRequiresCheckout: false }
return { hotelId, isEnabled: false, amount: 5000, yookassaShopId: null, yookassaSecretKey: null, releaseRequiresCheckout: false, longStayFullPayment: false, longStayThresholdDays: 7 }
}
// Mask secret key
const row = rows[0]
@@ -54,6 +54,7 @@ const deposit: FastifyPluginAsync = async (fastify) => {
is_enabled?: boolean; amount?: number
yookassa_shop_id?: string; yookassa_secret_key?: string
release_requires_checkout?: boolean
long_stay_full_payment?: boolean; long_stay_threshold_days?: number
} }>(
'/api/hotels/:slug/deposit/settings',
{ onRequest: [fastify.authenticate] },
@@ -65,21 +66,24 @@ 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, release_requires_checkout } = request.body
const { is_enabled, amount, yookassa_shop_id, yookassa_secret_key, release_requires_checkout,
long_stay_full_payment, long_stay_threshold_days } = 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, release_requires_checkout, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
`INSERT INTO hotel_deposit_settings (hotel_id, is_enabled, amount, yookassa_shop_id, yookassa_secret_key, release_requires_checkout, long_stay_full_payment, long_stay_threshold_days, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 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),
long_stay_full_payment = COALESCE($7, hotel_deposit_settings.long_stay_full_payment),
long_stay_threshold_days = COALESCE($8, hotel_deposit_settings.long_stay_threshold_days),
updated_at = NOW()
RETURNING *`,
[hotelId, is_enabled ?? false, amount ? amount.toFixed(2) : '5000.00', yookassa_shop_id ?? null, yookassa_secret_key ?? null, release_requires_checkout ?? null],
[hotelId, is_enabled ?? false, amount ? amount.toFixed(2) : '5000.00', yookassa_shop_id ?? null, yookassa_secret_key ?? null,
release_requires_checkout ?? null, long_stay_full_payment ?? null, long_stay_threshold_days ?? null],
)
return rows[0]
},
@@ -166,27 +170,43 @@ const deposit: FastifyPluginAsync = async (fastify) => {
return reply.code(400).send({ error: 'YooKassa credentials not configured' })
}
// Get booking for description
// Get booking dates + guest name
const { rows: bRows } = await db.query(
'SELECT id, guest_name FROM bookings WHERE id = $1 AND hotel_id = $2',
'SELECT id, guest_name, check_in, check_out FROM bookings WHERE id = $1 AND hotel_id = $2',
[bookingId, hotelId],
)
if (!bRows[0]) return reply.code(404).send({ error: 'Booking not found' })
const payment = await createHold({
shopId: settings.yookassa_shop_id,
secretKey: settings.yookassa_secret_key,
amount: Number(settings.amount),
description: `Депозит за бронирование — ${bRows[0].guest_name}`,
returnUrl: `${appUrl()}/${slug}/bookings`,
})
const nights = Math.ceil(
(new Date(bRows[0].check_out).getTime() - new Date(bRows[0].check_in).getTime()) / 86400000,
)
const threshold = settings.long_stay_threshold_days ?? 7
const useCharge = settings.long_stay_full_payment && nights > threshold
const payment = useCharge
? await createCharge({
shopId: settings.yookassa_shop_id,
secretKey: settings.yookassa_secret_key,
amount: Number(settings.amount),
description: `Депозит за бронирование — ${bRows[0].guest_name}`,
returnUrl: `${appUrl()}/${slug}/bookings`,
})
: await createHold({
shopId: settings.yookassa_shop_id,
secretKey: settings.yookassa_secret_key,
amount: Number(settings.amount),
description: `Депозит за бронирование — ${bRows[0].guest_name}`,
returnUrl: `${appUrl()}/${slug}/bookings`,
})
const paymentMethod = useCharge ? 'yookassa_charge' : 'yookassa_hold'
const { rows } = await db.query(
`INSERT INTO booking_deposits
(hotel_id, booking_id, amount, status, payment_method, yookassa_payment_id, yookassa_confirmation_url)
VALUES ($1, $2, $3, 'hold_created', 'yookassa_hold', $4, $5)
VALUES ($1, $2, $3, 'hold_created', $4, $5, $6)
RETURNING *`,
[hotelId, bookingId, settings.amount, payment.id,
[hotelId, bookingId, settings.amount, paymentMethod, payment.id,
payment.confirmation?.confirmation_url ?? null],
)
return reply.code(201).send({
@@ -373,6 +393,67 @@ ${refund > 0 && (!items || items.length === 0) ? `<p style="color:#16a34a">Ос
},
)
// ── POST /api/hotels/:slug/bookings/:bookingId/deposit/refund ────────────
// Partial or full refund of a captured (yookassa_charge) deposit
fastify.post<SlugBookingParam & { Body: { amount?: number; reason?: string } }>(
'/api/hotels/:slug/bookings/:bookingId/deposit/refund',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug, bookingId } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) {
return reply.code(403).send({ error: 'Forbidden' })
}
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { amount: reqAmount, reason } = request.body
const { rows: depRows } = await db.query(
`SELECT * FROM booking_deposits
WHERE booking_id = $1 AND hotel_id = $2
AND payment_method = 'yookassa_charge' AND status = 'captured'
ORDER BY created_at DESC LIMIT 1`,
[bookingId, hotelId],
)
if (!depRows[0]) return reply.code(404).send({ error: 'No refundable deposit found' })
const dep = depRows[0]
const { rows: settingsRows } = await db.query(
'SELECT * FROM hotel_deposit_settings WHERE hotel_id = $1', [hotelId],
)
const settings = settingsRows[0]
if (!settings?.yookassa_shop_id || !settings?.yookassa_secret_key) {
return reply.code(400).send({ error: 'YooKassa credentials not configured' })
}
const depositAmount = Number(dep.amount)
const alreadyRefunded = Number(dep.refunded_amount ?? 0)
const remaining = depositAmount - alreadyRefunded
const refundAmount = reqAmount !== undefined ? Math.min(reqAmount, remaining) : remaining
if (refundAmount <= 0) return reply.code(400).send({ error: 'Nothing to refund' })
await createRefund({
shopId: settings.yookassa_shop_id,
secretKey: settings.yookassa_secret_key,
paymentId: dep.yookassa_payment_id,
amount: refundAmount,
})
const newRefunded = alreadyRefunded + refundAmount
const isFullRefund = newRefunded >= depositAmount - 0.01
const newStatus = isFullRefund ? 'refunded' : 'partially_refunded'
const { rows } = await db.query(
`UPDATE booking_deposits SET
status = $1, refunded_amount = $2, retention_reason = $3, released_at = COALESCE(released_at, NOW())
WHERE id = $4 RETURNING *`,
[newStatus, newRefunded.toFixed(2), reason ?? null, dep.id],
)
return rows[0]
},
)
// ── GET /api/pay/:slug — public, no auth ─────────────────────────────────
fastify.get<{ Params: { slug: string } }>(
'/api/pay/:slug',
@@ -632,22 +713,54 @@ ${refund > 0 && (!items || items.length === 0) ? `<p style="color:#16a34a">Ос
canceled: 'cancelled',
}
const newStatus = statusMap[object.status]
if (!newStatus) return { ok: true }
const card = object.payment_method?.card
if (card?.last4) {
await db.query(
`UPDATE booking_deposits
SET status = $1, card_last4 = $2, card_brand = $3
WHERE yookassa_payment_id = $4`,
[newStatus, card.last4, card.card_type ?? null, object.id],
)
} else {
await db.query(
`UPDATE booking_deposits SET status = $1 WHERE yookassa_payment_id = $2`,
[newStatus, object.id],
)
if (newStatus) {
const card = object.payment_method?.card
if (card?.last4) {
await db.query(
`UPDATE booking_deposits
SET status = $1, card_last4 = $2, card_brand = $3
WHERE yookassa_payment_id = $4`,
[newStatus, card.last4, card.card_type ?? null, object.id],
)
} else {
await db.query(
`UPDATE booking_deposits SET status = $1 WHERE yookassa_payment_id = $2`,
[newStatus, object.id],
)
}
}
// Auto-confirm booking on successful online widget payment
if (object.status === 'succeeded') {
const { rows: obRows } = await db.query(
`SELECT ob.booking_id, ob.hotel_id
FROM online_bookings ob
WHERE ob.yookassa_payment_id = $1`,
[object.id],
)
if (obRows[0]?.booking_id) {
await db.query(
`UPDATE online_bookings SET yookassa_status = 'succeeded' WHERE yookassa_payment_id = $1`,
[object.id],
)
// Check gateway auto_confirm_on_payment
const { rows: gwRows } = await db.query(
`SELECT auto_confirm_on_payment FROM hotel_payment_gateways
WHERE hotel_id = $1 AND is_active = true
ORDER BY created_at LIMIT 1`,
[obRows[0].hotel_id],
)
const autoConfirm = gwRows[0]?.auto_confirm_on_payment !== false // default true
if (autoConfirm) {
await db.query(
`UPDATE bookings SET status = 'confirmed' WHERE id = $1 AND status = 'inquiry'`,
[obRows[0].booking_id],
)
}
}
}
return { ok: true }
},
)

View File

@@ -36,6 +36,7 @@ const paymentGateways: FastifyPluginAsync = async (fastify) => {
shopId: r.shop_id, secretKey: r.secret_key,
currency: r.currency, isActive: r.is_active,
modules: r.modules ?? MODULES, createdAt: r.created_at,
autoConfirmOnPayment: r.auto_confirm_on_payment ?? true,
}))
})
@@ -55,35 +56,39 @@ const paymentGateways: FastifyPluginAsync = async (fastify) => {
const r = rows[0]
return { id: r.id, provider: r.provider, label: r.label, shopId: r.shop_id,
secretKey: r.secret_key ? '••••••••' : null,
currency: r.currency, isActive: r.is_active, modules: r.modules }
currency: r.currency, isActive: r.is_active, modules: r.modules,
autoConfirmOnPayment: r.auto_confirm_on_payment ?? true }
}
)
// ── PATCH /api/hotels/:slug/payment-gateways/:id ───────────────────────────
fastify.patch<SlugIdParam & { Body: { label?: string; shopId?: string; secretKey?: string; currency?: string; isActive?: boolean; modules?: string[] } }>(
fastify.patch<SlugIdParam & { Body: { label?: string; shopId?: string; secretKey?: string; currency?: string; isActive?: boolean; modules?: string[]; autoConfirmOnPayment?: boolean } }>(
'/api/hotels/:slug/payment-gateways/:id', { onRequest: [fastify.authenticate] }, async (req, reply) => {
const { slug, id } = req.params
if (!canAccess(req.user.hotelSlug, req.user.role, slug)) return reply.code(403).send({ error: 'Forbidden' })
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Not found' })
const { label, shopId, secretKey, currency, isActive, modules } = req.body
const { label, shopId, secretKey, currency, isActive, modules, autoConfirmOnPayment } = req.body
const { rows } = await db.query(
`UPDATE hotel_payment_gateways SET
label = COALESCE($1, label),
shop_id = COALESCE($2, shop_id),
secret_key = CASE WHEN $3 IS NOT NULL AND $3 != '••••••••' THEN $3 ELSE secret_key END,
currency = COALESCE($4, currency),
is_active = COALESCE($5, is_active),
modules = COALESCE($6::jsonb, modules)
WHERE id = $7 AND hotel_id = $8 RETURNING *`,
label = COALESCE($1, label),
shop_id = COALESCE($2, shop_id),
secret_key = CASE WHEN $3 IS NOT NULL AND $3 != '••••••••' THEN $3 ELSE secret_key END,
currency = COALESCE($4, currency),
is_active = COALESCE($5, is_active),
modules = COALESCE($6::jsonb, modules),
auto_confirm_on_payment = COALESCE($7, auto_confirm_on_payment)
WHERE id = $8 AND hotel_id = $9 RETURNING *`,
[label ?? null, shopId ?? null, secretKey ?? null, currency ?? null,
isActive ?? null, modules ? JSON.stringify(modules) : null, id, hotelId],
isActive ?? null, modules ? JSON.stringify(modules) : null,
autoConfirmOnPayment ?? null, id, hotelId],
)
if (!rows[0]) return reply.code(404).send({ error: 'Not found' })
const r = rows[0]
return { id: r.id, provider: r.provider, label: r.label, shopId: r.shop_id,
secretKey: r.secret_key ? '••••••••' : null,
currency: r.currency, isActive: r.is_active, modules: r.modules }
currency: r.currency, isActive: r.is_active, modules: r.modules,
autoConfirmOnPayment: r.auto_confirm_on_payment ?? true }
}
)