feat: checklists, minibar and deposit modules
- DB migrations 057-059: checklist_templates/items/completions, minibar_items/consumptions, hotel_deposit_settings, booking_deposits - Backend routes: checklists (templates CRUD + task completions), minibar (items + consumptions), deposit (settings, cash/yookassa hold, release/capture) - YooKassa service for hold/capture/cancel payments - Frontend: ChecklistSettingsPage, MinibarSettingsPage, DepositSettingsPage - HousekeepingPage: task cards now show checklist + minibar panel with checkboxes and quantity buttons - BookingModal: minibar charges summary + deposit management (cash/YooKassa/release) for existing bookings - Sidebar + App.tsx: new routes /settings/checklists, /settings/minibar, /settings/deposit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,9 @@ import workstationRoutes from './routes/workstations'
|
||||
import agentReleaseRoutes from './routes/agent-release'
|
||||
import wifiSettingsRoutes, { wifiAuthVerify } from './routes/wifi-settings'
|
||||
import ttlockRoutes from './routes/ttlock'
|
||||
import checklistsRoutes from './routes/checklists'
|
||||
import minibarRoutes from './routes/minibar'
|
||||
import depositRoutes from './routes/deposit'
|
||||
import { setupAgentWsRoute } from './agent-ws'
|
||||
import { startJobs } from './jobs'
|
||||
|
||||
@@ -128,6 +131,9 @@ export async function buildApp() {
|
||||
await fastify.register(wifiSettingsRoutes)
|
||||
await fastify.register(wifiAuthVerify)
|
||||
await fastify.register(ttlockRoutes)
|
||||
await fastify.register(checklistsRoutes)
|
||||
await fastify.register(minibarRoutes)
|
||||
await fastify.register(depositRoutes)
|
||||
await fastify.register(setupAgentWsRoute)
|
||||
|
||||
startJobs()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
export const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST ?? 'smtp.timeweb.ru',
|
||||
port: parseInt(process.env.SMTP_PORT ?? '465', 10),
|
||||
secure: true,
|
||||
|
||||
372
backend/src/routes/checklists.ts
Normal file
372
backend/src/routes/checklists.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import { db } from '../db'
|
||||
|
||||
type SlugParam = { Params: { slug: string } }
|
||||
type SlugTplParam = { Params: { slug: string; templateId: string } }
|
||||
type SlugTplItemParam = { Params: { slug: string; templateId: string; itemId: string } }
|
||||
type SlugTaskParam = { Params: { slug: string; taskId: string } }
|
||||
type SlugTaskItemParam = { Params: { slug: string; taskId: string; itemId: string } }
|
||||
|
||||
const checklists: FastifyPluginAsync = async (fastify) => {
|
||||
const getHotelId = async (slug: string): Promise<string | null> => {
|
||||
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
|
||||
return rows[0]?.id ?? null
|
||||
}
|
||||
|
||||
const canAccess = (userSlug: string | null, role: string, slug: string) =>
|
||||
role === 'super_admin' || userSlug === slug
|
||||
|
||||
const isManager = (role: string) =>
|
||||
['super_admin', 'hotel_admin', 'manager'].includes(role)
|
||||
|
||||
// ── GET /api/hotels/:slug/checklist-templates ─────────────────────────────
|
||||
fastify.get<SlugParam>(
|
||||
'/api/hotels/:slug/checklist-templates',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { rows: templates } = await db.query(
|
||||
`SELECT * FROM checklist_templates WHERE hotel_id = $1 ORDER BY sort_order, created_at`,
|
||||
[hotelId],
|
||||
)
|
||||
const { rows: items } = await db.query(
|
||||
`SELECT ci.* FROM checklist_items ci
|
||||
JOIN checklist_templates ct ON ct.id = ci.template_id
|
||||
WHERE ct.hotel_id = $1
|
||||
ORDER BY ci.sort_order`,
|
||||
[hotelId],
|
||||
)
|
||||
|
||||
return templates.map((t: Record<string, unknown>) => ({
|
||||
...t,
|
||||
items: items.filter((i: Record<string, unknown>) => i.template_id === t.id),
|
||||
}))
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST /api/hotels/:slug/checklist-templates ────────────────────────────
|
||||
fastify.post<SlugParam & { Body: { name: string; task_type?: string } }>(
|
||||
'/api/hotels/:slug/checklist-templates',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug } = 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 { name, task_type } = request.body
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO checklist_templates (hotel_id, name, task_type) VALUES ($1, $2, $3) RETURNING *`,
|
||||
[hotelId, name, task_type ?? null],
|
||||
)
|
||||
return reply.code(201).send({ ...rows[0], items: [] })
|
||||
},
|
||||
)
|
||||
|
||||
// ── PATCH /api/hotels/:slug/checklist-templates/:templateId ──────────────
|
||||
fastify.patch<SlugTplParam & { Body: { name?: string; task_type?: string | null; is_active?: boolean; sort_order?: number } }>(
|
||||
'/api/hotels/:slug/checklist-templates/:templateId',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, templateId } = 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 allowed = ['name', 'task_type', 'is_active', 'sort_order']
|
||||
const updates: string[] = []
|
||||
const values: unknown[] = []
|
||||
let idx = 1
|
||||
|
||||
const body = request.body as Record<string, unknown>
|
||||
for (const key of allowed) {
|
||||
if (body[key] !== undefined) {
|
||||
updates.push(`${key} = $${idx}`)
|
||||
values.push(body[key])
|
||||
idx++
|
||||
}
|
||||
}
|
||||
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
|
||||
values.push(templateId, hotelId)
|
||||
|
||||
const { rows } = await db.query(
|
||||
`UPDATE checklist_templates SET ${updates.join(', ')}
|
||||
WHERE id = $${idx} AND hotel_id = $${idx + 1} RETURNING *`,
|
||||
values,
|
||||
)
|
||||
if (!rows[0]) return reply.code(404).send({ error: 'Template not found' })
|
||||
return rows[0]
|
||||
},
|
||||
)
|
||||
|
||||
// ── DELETE /api/hotels/:slug/checklist-templates/:templateId ─────────────
|
||||
fastify.delete<SlugTplParam>(
|
||||
'/api/hotels/:slug/checklist-templates/:templateId',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, templateId } = 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 { rowCount } = await db.query(
|
||||
'DELETE FROM checklist_templates WHERE id = $1 AND hotel_id = $2',
|
||||
[templateId, hotelId],
|
||||
)
|
||||
if (!rowCount) return reply.code(404).send({ error: 'Template not found' })
|
||||
return reply.code(204).send()
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST /api/hotels/:slug/checklist-templates/:templateId/items ──────────
|
||||
fastify.post<SlugTplParam & { Body: { text: string; sort_order?: number } }>(
|
||||
'/api/hotels/:slug/checklist-templates/:templateId/items',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, templateId } = 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' })
|
||||
|
||||
// Verify template belongs to hotel
|
||||
const { rows: tRows } = await db.query(
|
||||
'SELECT id FROM checklist_templates WHERE id = $1 AND hotel_id = $2',
|
||||
[templateId, hotelId],
|
||||
)
|
||||
if (!tRows[0]) return reply.code(404).send({ error: 'Template not found' })
|
||||
|
||||
const { text, sort_order = 0 } = request.body
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO checklist_items (template_id, text, sort_order) VALUES ($1, $2, $3) RETURNING *`,
|
||||
[templateId, text, sort_order],
|
||||
)
|
||||
return reply.code(201).send(rows[0])
|
||||
},
|
||||
)
|
||||
|
||||
// ── PATCH /api/hotels/:slug/checklist-templates/:templateId/items/:itemId ─
|
||||
fastify.patch<SlugTplItemParam & { Body: { text?: string; sort_order?: number } }>(
|
||||
'/api/hotels/:slug/checklist-templates/:templateId/items/:itemId',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, templateId, itemId } = 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 updates: string[] = []
|
||||
const values: unknown[] = []
|
||||
let idx = 1
|
||||
|
||||
const body = request.body as Record<string, unknown>
|
||||
for (const key of ['text', 'sort_order']) {
|
||||
if (body[key] !== undefined) {
|
||||
updates.push(`${key} = $${idx}`)
|
||||
values.push(body[key])
|
||||
idx++
|
||||
}
|
||||
}
|
||||
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
|
||||
values.push(itemId, templateId)
|
||||
|
||||
// Ensure item belongs to this template (which belongs to hotel)
|
||||
const { rows } = await db.query(
|
||||
`UPDATE checklist_items SET ${updates.join(', ')}
|
||||
WHERE id = $${idx} AND template_id = $${idx + 1} RETURNING *`,
|
||||
values,
|
||||
)
|
||||
if (!rows[0]) return reply.code(404).send({ error: 'Item not found' })
|
||||
return rows[0]
|
||||
},
|
||||
)
|
||||
|
||||
// ── DELETE /api/hotels/:slug/checklist-templates/:templateId/items/:itemId
|
||||
fastify.delete<SlugTplItemParam>(
|
||||
'/api/hotels/:slug/checklist-templates/:templateId/items/:itemId',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, templateId, itemId } = 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 { rowCount } = await db.query(
|
||||
`DELETE FROM checklist_items WHERE id = $1 AND template_id IN (
|
||||
SELECT id FROM checklist_templates WHERE id = $2 AND hotel_id = $3
|
||||
)`,
|
||||
[itemId, templateId, hotelId],
|
||||
)
|
||||
if (!rowCount) return reply.code(404).send({ error: 'Item not found' })
|
||||
return reply.code(204).send()
|
||||
},
|
||||
)
|
||||
|
||||
// ── GET /api/hotels/:slug/housekeeping/:taskId/checklist ──────────────────
|
||||
fastify.get<SlugTaskParam>(
|
||||
'/api/hotels/:slug/housekeeping/:taskId/checklist',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, taskId } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
// Get task to know its type
|
||||
const { rows: taskRows } = await db.query(
|
||||
'SELECT id, type FROM housekeeping_tasks WHERE id = $1 AND hotel_id = $2',
|
||||
[taskId, hotelId],
|
||||
)
|
||||
if (!taskRows[0]) return reply.code(404).send({ error: 'Task not found' })
|
||||
|
||||
const taskType = taskRows[0].type as string
|
||||
|
||||
// Find matching template: exact task_type match first, then NULL (all types) fallback
|
||||
const { rows: templates } = await db.query(
|
||||
`SELECT * FROM checklist_templates
|
||||
WHERE hotel_id = $1 AND is_active = true
|
||||
AND (task_type = $2 OR task_type IS NULL)
|
||||
ORDER BY CASE WHEN task_type = $2 THEN 0 ELSE 1 END, sort_order
|
||||
LIMIT 1`,
|
||||
[hotelId, taskType],
|
||||
)
|
||||
|
||||
if (!templates[0]) {
|
||||
return { templateId: null, templateName: null, items: [] }
|
||||
}
|
||||
|
||||
const template = templates[0]
|
||||
|
||||
// Get items with completion status for this task
|
||||
const { rows: items } = await db.query(
|
||||
`SELECT ci.id, ci.text, ci.sort_order,
|
||||
cc.completed_at, u.name AS completed_by
|
||||
FROM checklist_items ci
|
||||
LEFT JOIN checklist_completions cc ON cc.item_id = ci.id AND cc.task_id = $1
|
||||
LEFT JOIN users u ON u.id = cc.completed_by
|
||||
WHERE ci.template_id = $2
|
||||
ORDER BY ci.sort_order`,
|
||||
[taskId, template.id],
|
||||
)
|
||||
|
||||
return {
|
||||
templateId: template.id,
|
||||
templateName: template.name,
|
||||
items,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST /api/hotels/:slug/housekeeping/:taskId/checklist/:itemId/complete ─
|
||||
fastify.post<SlugTaskItemParam>(
|
||||
'/api/hotels/:slug/housekeeping/:taskId/checklist/:itemId/complete',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, taskId, itemId } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
// Verify task belongs to hotel
|
||||
const { rows: taskRows } = await db.query(
|
||||
'SELECT id FROM housekeeping_tasks WHERE id = $1 AND hotel_id = $2',
|
||||
[taskId, hotelId],
|
||||
)
|
||||
if (!taskRows[0]) return reply.code(404).send({ error: 'Task not found' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO checklist_completions (task_id, item_id, completed_by)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (task_id, item_id) DO UPDATE SET completed_at = NOW(), completed_by = $3
|
||||
RETURNING *`,
|
||||
[taskId, itemId, request.user.sub],
|
||||
)
|
||||
return reply.code(201).send(rows[0])
|
||||
},
|
||||
)
|
||||
|
||||
// ── DELETE /api/hotels/:slug/housekeeping/:taskId/checklist/:itemId/complete
|
||||
fastify.delete<SlugTaskItemParam>(
|
||||
'/api/hotels/:slug/housekeeping/:taskId/checklist/:itemId/complete',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, taskId, itemId } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
await db.query(
|
||||
`DELETE FROM checklist_completions WHERE task_id = $1 AND item_id = $2`,
|
||||
[taskId, itemId],
|
||||
)
|
||||
return reply.code(204).send()
|
||||
},
|
||||
)
|
||||
|
||||
// ── GET /api/hotels/:slug/reports/checklists ──────────────────────────────
|
||||
fastify.get<SlugParam & { Querystring: { from?: string; to?: string } }>(
|
||||
'/api/hotels/:slug/reports/checklists',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug } = 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 { from, to } = request.query
|
||||
const conditions: string[] = ['t.hotel_id = $1', "t.status = 'done'"]
|
||||
const values: unknown[] = [hotelId]
|
||||
let idx = 2
|
||||
|
||||
if (from) { conditions.push(`t.completed_at >= $${idx}`); values.push(from); idx++ }
|
||||
if (to) { conditions.push(`t.completed_at < $${idx}`); values.push(to); idx++ }
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT
|
||||
t.id, t.type, t.completed_at, r.number AS room_number,
|
||||
u.name AS assignee_name,
|
||||
COUNT(ci.id) AS total_items,
|
||||
COUNT(cc.id) AS completed_items
|
||||
FROM housekeeping_tasks t
|
||||
LEFT JOIN rooms r ON r.id = t.room_id
|
||||
LEFT JOIN users u ON u.id = t.assignee_id
|
||||
LEFT JOIN checklist_templates ct ON ct.hotel_id = t.hotel_id AND is_active = true
|
||||
AND (ct.task_type = t.type OR ct.task_type IS NULL)
|
||||
LEFT JOIN checklist_items ci ON ci.template_id = ct.id
|
||||
LEFT JOIN checklist_completions cc ON cc.task_id = t.id AND cc.item_id = ci.id
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
GROUP BY t.id, r.number, u.name
|
||||
ORDER BY t.completed_at DESC`,
|
||||
values,
|
||||
)
|
||||
return rows
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default checklists
|
||||
315
backend/src/routes/deposit.ts
Normal file
315
backend/src/routes/deposit.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import { db } from '../db'
|
||||
import { createHold, capturePayment, cancelPayment } from '../services/yookassa'
|
||||
import { transporter } from '../email'
|
||||
|
||||
type SlugParam = { Params: { slug: string } }
|
||||
type SlugBookingParam = { Params: { slug: string; bookingId: string } }
|
||||
|
||||
const deposit: FastifyPluginAsync = async (fastify) => {
|
||||
const getHotelId = async (slug: string): Promise<string | null> => {
|
||||
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
|
||||
return rows[0]?.id ?? null
|
||||
}
|
||||
|
||||
const canAccess = (userSlug: string | null, role: string, slug: string) =>
|
||||
role === 'super_admin' || userSlug === slug
|
||||
|
||||
const isManager = (role: string) =>
|
||||
['super_admin', 'hotel_admin', 'manager'].includes(role)
|
||||
|
||||
const appUrl = () => process.env.APP_URL ?? 'https://app.hotelsync.ru'
|
||||
|
||||
// ── GET /api/hotels/:slug/deposit/settings ────────────────────────────────
|
||||
fastify.get<SlugParam>(
|
||||
'/api/hotels/:slug/deposit/settings',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
'SELECT * FROM hotel_deposit_settings WHERE hotel_id = $1',
|
||||
[hotelId],
|
||||
)
|
||||
if (!rows[0]) {
|
||||
return { hotelId, isEnabled: false, amount: 5000, yookassaShopId: null, yookassaSecretKey: null }
|
||||
}
|
||||
// Mask secret key
|
||||
const row = rows[0]
|
||||
return {
|
||||
...row,
|
||||
yookassa_secret_key: row.yookassa_secret_key ? '••••••••' : null,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ── PATCH /api/hotels/:slug/deposit/settings ──────────────────────────────
|
||||
fastify.patch<SlugParam & { Body: {
|
||||
is_enabled?: boolean; amount?: number
|
||||
yookassa_shop_id?: string; yookassa_secret_key?: string
|
||||
} }>(
|
||||
'/api/hotels/:slug/deposit/settings',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug } = 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 { is_enabled, amount, yookassa_shop_id, yookassa_secret_key } = 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())
|
||||
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,
|
||||
updated_at = NOW()
|
||||
RETURNING *`,
|
||||
[hotelId, is_enabled ?? false, amount ? amount.toFixed(2) : '5000.00', yookassa_shop_id ?? null, yookassa_secret_key ?? null],
|
||||
)
|
||||
return rows[0]
|
||||
},
|
||||
)
|
||||
|
||||
// ── GET /api/hotels/:slug/bookings/:bookingId/deposit ─────────────────────
|
||||
fastify.get<SlugBookingParam>(
|
||||
'/api/hotels/:slug/bookings/:bookingId/deposit',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, bookingId } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT * FROM booking_deposits WHERE booking_id = $1 AND hotel_id = $2 ORDER BY created_at DESC LIMIT 1`,
|
||||
[bookingId, hotelId],
|
||||
)
|
||||
if (!rows[0]) return reply.code(404).send({ error: 'No deposit found' })
|
||||
// Mask confirmation URL isn't needed — expose it for QR
|
||||
return rows[0]
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST /api/hotels/:slug/bookings/:bookingId/deposit/cash ───────────────
|
||||
fastify.post<SlugBookingParam>(
|
||||
'/api/hotels/:slug/bookings/:bookingId/deposit/cash',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, bookingId } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
// Verify booking
|
||||
const { rows: bRows } = await db.query(
|
||||
'SELECT id FROM bookings WHERE id = $1 AND hotel_id = $2',
|
||||
[bookingId, hotelId],
|
||||
)
|
||||
if (!bRows[0]) return reply.code(404).send({ error: 'Booking not found' })
|
||||
|
||||
// Get deposit settings amount
|
||||
const { rows: settingsRows } = await db.query(
|
||||
'SELECT amount FROM hotel_deposit_settings WHERE hotel_id = $1',
|
||||
[hotelId],
|
||||
)
|
||||
const amount = settingsRows[0]?.amount ?? '5000.00'
|
||||
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO booking_deposits (hotel_id, booking_id, amount, status, payment_method, paid_at)
|
||||
VALUES ($1, $2, $3, 'paid_cash', 'cash', NOW())
|
||||
RETURNING *`,
|
||||
[hotelId, bookingId, amount],
|
||||
)
|
||||
return reply.code(201).send(rows[0])
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST /api/hotels/:slug/bookings/:bookingId/deposit/yookassa ───────────
|
||||
fastify.post<SlugBookingParam>(
|
||||
'/api/hotels/:slug/bookings/:bookingId/deposit/yookassa',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, bookingId } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
// Get settings including YooKassa credentials
|
||||
const { rows: settingsRows } = await db.query(
|
||||
'SELECT * FROM hotel_deposit_settings WHERE hotel_id = $1',
|
||||
[hotelId],
|
||||
)
|
||||
const settings = settingsRows[0]
|
||||
if (!settings) return reply.code(400).send({ error: 'Deposit settings not configured' })
|
||||
if (!settings.yookassa_shop_id || !settings.yookassa_secret_key) {
|
||||
return reply.code(400).send({ error: 'YooKassa credentials not configured' })
|
||||
}
|
||||
|
||||
// Get booking for description
|
||||
const { rows: bRows } = await db.query(
|
||||
'SELECT id, guest_name 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 { 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)
|
||||
RETURNING *`,
|
||||
[hotelId, bookingId, settings.amount, payment.id,
|
||||
payment.confirmation?.confirmation_url ?? null],
|
||||
)
|
||||
return reply.code(201).send({
|
||||
...rows[0],
|
||||
confirmationUrl: payment.confirmation?.confirmation_url ?? null,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST /api/hotels/:slug/bookings/:bookingId/deposit/release ────────────
|
||||
fastify.post<SlugBookingParam & { Body: { capturedAmount: number; reason?: string } }>(
|
||||
'/api/hotels/:slug/bookings/:bookingId/deposit/release',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, bookingId } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { capturedAmount, reason } = request.body
|
||||
|
||||
// Get active deposit
|
||||
const { rows: depRows } = await db.query(
|
||||
`SELECT * FROM booking_deposits
|
||||
WHERE booking_id = $1 AND hotel_id = $2
|
||||
AND status IN ('paid_cash', 'hold_created', 'hold_confirmed')
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[bookingId, hotelId],
|
||||
)
|
||||
if (!depRows[0]) return reply.code(404).send({ error: 'Active deposit not found' })
|
||||
const dep = depRows[0]
|
||||
|
||||
let newStatus: string
|
||||
if (dep.payment_method === 'yookassa_hold' && dep.yookassa_payment_id) {
|
||||
// Get settings for credentials
|
||||
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' })
|
||||
}
|
||||
|
||||
if (capturedAmount === 0) {
|
||||
await cancelPayment({
|
||||
shopId: settings.yookassa_shop_id,
|
||||
secretKey: settings.yookassa_secret_key,
|
||||
paymentId: dep.yookassa_payment_id,
|
||||
})
|
||||
newStatus = 'refunded'
|
||||
} else {
|
||||
await capturePayment({
|
||||
shopId: settings.yookassa_shop_id,
|
||||
secretKey: settings.yookassa_secret_key,
|
||||
paymentId: dep.yookassa_payment_id,
|
||||
amount: capturedAmount,
|
||||
})
|
||||
newStatus = 'captured'
|
||||
}
|
||||
} else {
|
||||
// Cash deposit
|
||||
newStatus = capturedAmount === 0 ? 'refunded' : 'captured'
|
||||
}
|
||||
|
||||
const { rows } = await db.query(
|
||||
`UPDATE booking_deposits SET
|
||||
status = $1,
|
||||
captured_amount = $2,
|
||||
retention_reason = $3,
|
||||
released_at = NOW()
|
||||
WHERE id = $4 RETURNING *`,
|
||||
[newStatus, capturedAmount.toFixed(2), reason ?? null, dep.id],
|
||||
)
|
||||
|
||||
// Send email to guest if email is available
|
||||
const { rows: bRows } = await db.query(
|
||||
'SELECT guest_name, guest_email FROM bookings WHERE id = $1',
|
||||
[bookingId],
|
||||
)
|
||||
if (bRows[0]?.guest_email) {
|
||||
const guest = bRows[0]
|
||||
const actionText = capturedAmount === 0
|
||||
? 'Депозит был полностью возвращён.'
|
||||
: `Из депозита удержана сумма ${capturedAmount.toFixed(2)} руб.${reason ? ` Причина: ${reason}` : ''}`
|
||||
transporter.sendMail({
|
||||
from: `"HotelSync" <${process.env.SMTP_USER ?? 'noreply@hotelsync.ru'}>`,
|
||||
to: guest.guest_email,
|
||||
subject: 'Информация о депозите — HotelSync',
|
||||
text: `Уважаемый(ая) ${guest.guest_name},\n\n${actionText}\n\nСпасибо за проживание.\n\n© 2026 HotelSync`,
|
||||
}).catch(() => {})
|
||||
|
||||
await db.query(
|
||||
'UPDATE booking_deposits SET guest_email_sent = true WHERE id = $1',
|
||||
[dep.id],
|
||||
)
|
||||
}
|
||||
|
||||
return rows[0]
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST /api/webhooks/yookassa ───────────────────────────────────────────
|
||||
fastify.post<{ Body: { event: string; object: { id: string; status: string } } }>(
|
||||
'/api/webhooks/yookassa',
|
||||
async (request, reply) => {
|
||||
const { object } = request.body
|
||||
if (!object?.id) return reply.code(400).send({ error: 'Invalid webhook' })
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
waiting_for_capture: 'hold_confirmed',
|
||||
succeeded: 'captured',
|
||||
canceled: 'cancelled',
|
||||
}
|
||||
const newStatus = statusMap[object.status]
|
||||
if (!newStatus) return { ok: true }
|
||||
|
||||
await db.query(
|
||||
`UPDATE booking_deposits SET status = $1 WHERE yookassa_payment_id = $2`,
|
||||
[newStatus, object.id],
|
||||
)
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default deposit
|
||||
253
backend/src/routes/minibar.ts
Normal file
253
backend/src/routes/minibar.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import { db } from '../db'
|
||||
|
||||
type SlugParam = { Params: { slug: string } }
|
||||
type SlugItemParam = { Params: { slug: string; itemId: string } }
|
||||
type SlugTaskParam = { Params: { slug: string; taskId: string } }
|
||||
type SlugConsumpParam = { Params: { slug: string; consumptionId: string } }
|
||||
type SlugBookingParam = { Params: { slug: string; bookingId: string } }
|
||||
|
||||
const minibar: FastifyPluginAsync = async (fastify) => {
|
||||
const getHotelId = async (slug: string): Promise<string | null> => {
|
||||
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
|
||||
return rows[0]?.id ?? null
|
||||
}
|
||||
|
||||
const canAccess = (userSlug: string | null, role: string, slug: string) =>
|
||||
role === 'super_admin' || userSlug === slug
|
||||
|
||||
const isManager = (role: string) =>
|
||||
['super_admin', 'hotel_admin', 'manager'].includes(role)
|
||||
|
||||
// ── GET /api/hotels/:slug/minibar-items ───────────────────────────────────
|
||||
fastify.get<SlugParam>(
|
||||
'/api/hotels/:slug/minibar-items',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT * FROM minibar_items WHERE hotel_id = $1 AND is_active = true ORDER BY sort_order, name`,
|
||||
[hotelId],
|
||||
)
|
||||
return rows
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST /api/hotels/:slug/minibar-items ──────────────────────────────────
|
||||
fastify.post<SlugParam & { Body: { name: string; price: number; category?: string; sort_order?: number } }>(
|
||||
'/api/hotels/:slug/minibar-items',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug } = 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 { name, price, category, sort_order = 0 } = request.body
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO minibar_items (hotel_id, name, price, category, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[hotelId, name, price.toFixed(2), category ?? null, sort_order],
|
||||
)
|
||||
return reply.code(201).send(rows[0])
|
||||
},
|
||||
)
|
||||
|
||||
// ── PATCH /api/hotels/:slug/minibar-items/:itemId ─────────────────────────
|
||||
fastify.patch<SlugItemParam & { Body: { name?: string; price?: number; category?: string; sort_order?: number; is_active?: boolean } }>(
|
||||
'/api/hotels/:slug/minibar-items/:itemId',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, itemId } = 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 allowed = ['name', 'price', 'category', 'sort_order', 'is_active']
|
||||
const updates: string[] = []
|
||||
const values: unknown[] = []
|
||||
let idx = 1
|
||||
|
||||
const body = request.body as Record<string, unknown>
|
||||
for (const key of allowed) {
|
||||
if (body[key] !== undefined) {
|
||||
updates.push(`${key} = $${idx}`)
|
||||
values.push(key === 'price' ? Number(body[key]).toFixed(2) : body[key])
|
||||
idx++
|
||||
}
|
||||
}
|
||||
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
|
||||
values.push(itemId, hotelId)
|
||||
|
||||
const { rows } = await db.query(
|
||||
`UPDATE minibar_items SET ${updates.join(', ')}
|
||||
WHERE id = $${idx} AND hotel_id = $${idx + 1} RETURNING *`,
|
||||
values,
|
||||
)
|
||||
if (!rows[0]) return reply.code(404).send({ error: 'Item not found' })
|
||||
return rows[0]
|
||||
},
|
||||
)
|
||||
|
||||
// ── DELETE /api/hotels/:slug/minibar-items/:itemId (soft delete) ──────────
|
||||
fastify.delete<SlugItemParam>(
|
||||
'/api/hotels/:slug/minibar-items/:itemId',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, itemId } = 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 { rowCount } = await db.query(
|
||||
'UPDATE minibar_items SET is_active = false WHERE id = $1 AND hotel_id = $2',
|
||||
[itemId, hotelId],
|
||||
)
|
||||
if (!rowCount) return reply.code(404).send({ error: 'Item not found' })
|
||||
return reply.code(204).send()
|
||||
},
|
||||
)
|
||||
|
||||
// ── GET /api/hotels/:slug/housekeeping/:taskId/minibar ────────────────────
|
||||
fastify.get<SlugTaskParam>(
|
||||
'/api/hotels/:slug/housekeeping/:taskId/minibar',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, taskId } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT mc.*, mi.name AS item_name, u.name AS recorded_by_name
|
||||
FROM minibar_consumptions mc
|
||||
JOIN minibar_items mi ON mi.id = mc.item_id
|
||||
LEFT JOIN users u ON u.id = mc.recorded_by
|
||||
WHERE mc.task_id = $1 AND mc.hotel_id = $2
|
||||
ORDER BY mc.recorded_at`,
|
||||
[taskId, hotelId],
|
||||
)
|
||||
return rows
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST /api/hotels/:slug/housekeeping/:taskId/minibar ───────────────────
|
||||
fastify.post<SlugTaskParam & { Body: { item_id: string; quantity: number } }>(
|
||||
'/api/hotels/:slug/housekeeping/:taskId/minibar',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, taskId } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
// Get task to know room_id
|
||||
const { rows: taskRows } = await db.query(
|
||||
'SELECT id, room_id FROM housekeeping_tasks WHERE id = $1 AND hotel_id = $2',
|
||||
[taskId, hotelId],
|
||||
)
|
||||
if (!taskRows[0]) return reply.code(404).send({ error: 'Task not found' })
|
||||
const roomId = taskRows[0].room_id as string | null
|
||||
if (!roomId) return reply.code(400).send({ error: 'Task has no room' })
|
||||
|
||||
// Get minibar item price
|
||||
const { rows: itemRows } = await db.query(
|
||||
'SELECT id, price FROM minibar_items WHERE id = $1 AND hotel_id = $2 AND is_active = true',
|
||||
[request.body.item_id, hotelId],
|
||||
)
|
||||
if (!itemRows[0]) return reply.code(404).send({ error: 'Minibar item not found' })
|
||||
|
||||
// Auto-find current booking for this room
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const { rows: bookingRows } = await db.query(
|
||||
`SELECT id FROM bookings
|
||||
WHERE room_id = $1 AND hotel_id = $2
|
||||
AND status IN ('confirmed', 'checked_in')
|
||||
AND check_in <= $3 AND check_out > $3
|
||||
ORDER BY check_in DESC LIMIT 1`,
|
||||
[roomId, hotelId, today],
|
||||
)
|
||||
const bookingId = bookingRows[0]?.id ?? null
|
||||
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO minibar_consumptions
|
||||
(hotel_id, room_id, booking_id, task_id, item_id, quantity, price_per_unit, recorded_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[hotelId, roomId, bookingId, taskId, request.body.item_id,
|
||||
request.body.quantity, itemRows[0].price, request.user.sub],
|
||||
)
|
||||
return reply.code(201).send(rows[0])
|
||||
},
|
||||
)
|
||||
|
||||
// ── DELETE /api/hotels/:slug/minibar-consumptions/:consumptionId ──────────
|
||||
fastify.delete<SlugConsumpParam>(
|
||||
'/api/hotels/:slug/minibar-consumptions/:consumptionId',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, consumptionId } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { rowCount } = await db.query(
|
||||
'DELETE FROM minibar_consumptions WHERE id = $1 AND hotel_id = $2',
|
||||
[consumptionId, hotelId],
|
||||
)
|
||||
if (!rowCount) return reply.code(404).send({ error: 'Consumption not found' })
|
||||
return reply.code(204).send()
|
||||
},
|
||||
)
|
||||
|
||||
// ── GET /api/hotels/:slug/bookings/:bookingId/minibar ─────────────────────
|
||||
fastify.get<SlugBookingParam>(
|
||||
'/api/hotels/:slug/bookings/:bookingId/minibar',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
const { slug, bookingId } = request.params
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
}
|
||||
const hotelId = await getHotelId(slug)
|
||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT mc.id,
|
||||
mi.name AS item_name,
|
||||
mc.quantity,
|
||||
mc.price_per_unit,
|
||||
(mc.quantity * mc.price_per_unit) AS total,
|
||||
mc.recorded_at,
|
||||
u.name AS recorded_by_name
|
||||
FROM minibar_consumptions mc
|
||||
JOIN minibar_items mi ON mi.id = mc.item_id
|
||||
LEFT JOIN users u ON u.id = mc.recorded_by
|
||||
WHERE mc.booking_id = $1 AND mc.hotel_id = $2
|
||||
ORDER BY mc.recorded_at`,
|
||||
[bookingId, hotelId],
|
||||
)
|
||||
return rows
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default minibar
|
||||
82
backend/src/services/yookassa.ts
Normal file
82
backend/src/services/yookassa.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
interface YooKassaPayment {
|
||||
id: string
|
||||
status: string
|
||||
confirmation?: { confirmation_url: string }
|
||||
}
|
||||
|
||||
export async function createHold(params: {
|
||||
shopId: string
|
||||
secretKey: string
|
||||
amount: number
|
||||
description: string
|
||||
returnUrl: string
|
||||
idempotenceKey?: string
|
||||
}): Promise<YooKassaPayment> {
|
||||
const auth = Buffer.from(`${params.shopId}:${params.secretKey}`).toString('base64')
|
||||
const res = await fetch('https://api.yookassa.ru/v3/payments', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotence-Key': params.idempotenceKey ?? randomUUID(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount: { value: params.amount.toFixed(2), currency: 'RUB' },
|
||||
capture: false,
|
||||
confirmation: { type: 'redirect', return_url: params.returnUrl },
|
||||
description: params.description,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
throw new Error(`YooKassa error ${res.status}: ${err}`)
|
||||
}
|
||||
return res.json() as Promise<YooKassaPayment>
|
||||
}
|
||||
|
||||
export async function capturePayment(params: {
|
||||
shopId: string
|
||||
secretKey: string
|
||||
paymentId: string
|
||||
amount: number
|
||||
}): Promise<void> {
|
||||
const auth = Buffer.from(`${params.shopId}:${params.secretKey}`).toString('base64')
|
||||
const res = await fetch(`https://api.yookassa.ru/v3/payments/${params.paymentId}/capture`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotence-Key': randomUUID(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount: { value: params.amount.toFixed(2), currency: 'RUB' },
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
throw new Error(`YooKassa capture error ${res.status}: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelPayment(params: {
|
||||
shopId: string
|
||||
secretKey: string
|
||||
paymentId: string
|
||||
}): Promise<void> {
|
||||
const auth = Buffer.from(`${params.shopId}:${params.secretKey}`).toString('base64')
|
||||
const res = await fetch(`https://api.yookassa.ru/v3/payments/${params.paymentId}/cancel`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotence-Key': randomUUID(),
|
||||
},
|
||||
body: '{}',
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
throw new Error(`YooKassa cancel error ${res.status}: ${err}`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user