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