feat: deposit release — детальный список позиций + HTML-чек на email гостю
- Форма удержания: позиции мини-бара расписаны по отдельности (товар × кол-во, цена/шт.) с кнопками «+ Добавить» для каждой и «+ Добавить всё» - Email гостю: HTML-шаблон с таблицей-чеком (позиция / сумма), итоговые строки, остаток возврата, брендинг отеля; текстовый fallback сохранён Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -263,31 +263,101 @@ const deposit: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
// Send email to guest if email is available
|
||||
const { rows: bRows } = await db.query(
|
||||
'SELECT guest_name, guest_email FROM bookings WHERE id = $1',
|
||||
`SELECT b.guest_name, b.guest_email, h.name AS hotel_name
|
||||
FROM bookings b
|
||||
JOIN hotels h ON h.id = b.hotel_id
|
||||
WHERE b.id = $1`,
|
||||
[bookingId],
|
||||
)
|
||||
if (bRows[0]?.guest_email) {
|
||||
const guest = bRows[0]
|
||||
let bodyLines: string[]
|
||||
const hotelName: string = guest.hotel_name ?? 'HotelSync'
|
||||
const fmt = (n: number) => n.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ₽'
|
||||
const refund = Number(dep.amount) - capturedAmount
|
||||
|
||||
let html: string
|
||||
if (capturedAmount === 0) {
|
||||
bodyLines = ['Депозит был полностью возвращён. Средства поступят на карту в течение нескольких рабочих дней.']
|
||||
html = `
|
||||
<p>Уважаемый(ая) <strong>${guest.guest_name}</strong>,</p>
|
||||
<p>Страховой депозит был полностью возвращён.</p>
|
||||
<p style="color:#6b7280;font-size:13px">Средства поступят на вашу карту в течение нескольких рабочих дней.</p>`
|
||||
} else {
|
||||
bodyLines = [`Из страхового депозита удержана сумма ${capturedAmount.toFixed(2)} ₽.`]
|
||||
if (items && items.length > 0) {
|
||||
bodyLines.push('', 'Позиции удержания:')
|
||||
for (const item of items) {
|
||||
bodyLines.push(` • ${item.name} — ${Number(item.amount).toFixed(2)} ₽`)
|
||||
}
|
||||
}
|
||||
if (reason) bodyLines.push('', `Комментарий: ${reason}`)
|
||||
const refund = Number(dep.amount) - capturedAmount
|
||||
if (refund > 0) bodyLines.push('', `Остаток депозита ${refund.toFixed(2)} ₽ возвращён.`)
|
||||
const itemsHtml = items && items.length > 0
|
||||
? `<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;margin:16px 0;font-size:14px">
|
||||
<thead>
|
||||
<tr style="background:#f1f5f9">
|
||||
<th align="left" style="padding:8px 12px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569">Позиция</th>
|
||||
<th align="right" style="padding:8px 12px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569">Сумма</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${items.map((it, i) => `
|
||||
<tr style="background:${i % 2 === 0 ? '#fff' : '#f8fafc'}">
|
||||
<td style="padding:8px 12px;border-bottom:1px solid #f1f5f9;color:#1e293b">${it.name}</td>
|
||||
<td align="right" style="padding:8px 12px;border-bottom:1px solid #f1f5f9;color:#1e293b;white-space:nowrap">${fmt(Number(it.amount))}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style="background:#f1f5f9">
|
||||
<td style="padding:8px 12px;font-weight:700;color:#0f172a">Итого удержано</td>
|
||||
<td align="right" style="padding:8px 12px;font-weight:700;color:#0f172a;white-space:nowrap">${fmt(capturedAmount)}</td>
|
||||
</tr>
|
||||
${refund > 0 ? `<tr><td style="padding:8px 12px;color:#16a34a">Возврат остатка</td><td align="right" style="padding:8px 12px;color:#16a34a;white-space:nowrap">${fmt(refund)}</td></tr>` : ''}
|
||||
</tfoot>
|
||||
</table>`
|
||||
: `<p>Удержана сумма: <strong>${fmt(capturedAmount)}</strong></p>${refund > 0 ? `<p style="color:#16a34a">Остаток <strong>${fmt(refund)}</strong> возвращён.</p>` : ''}`
|
||||
|
||||
html = `
|
||||
<p>Уважаемый(ая) <strong>${guest.guest_name}</strong>,</p>
|
||||
<p>Из страхового депозита удержана сумма за следующие позиции:</p>
|
||||
${itemsHtml}
|
||||
${reason ? `<p style="color:#6b7280;font-size:13px"><em>Комментарий: ${reason}</em></p>` : ''}
|
||||
${refund > 0 && (!items || items.length === 0) ? `<p style="color:#16a34a">Остаток депозита <strong>${fmt(refund)}</strong> возвращён.</p>` : ''}`
|
||||
}
|
||||
|
||||
const emailHtml = `<!DOCTYPE html>
|
||||
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||
<body style="margin:0;padding:0;background:#f8fafc;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;padding:32px 0">
|
||||
<tr><td align="center">
|
||||
<table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1)">
|
||||
<tr><td style="background:#0f172a;padding:20px 32px">
|
||||
<p style="margin:0;color:#fff;font-size:18px;font-weight:700">${hotelName}</p>
|
||||
<p style="margin:4px 0 0;color:#94a3b8;font-size:13px">Информация о страховом депозите</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:28px 32px;color:#334155;font-size:15px;line-height:1.6">
|
||||
${html}
|
||||
<p style="margin-top:24px">Спасибо за проживание.</p>
|
||||
</td></tr>
|
||||
<tr><td style="background:#f1f5f9;padding:16px 32px;text-align:center">
|
||||
<p style="margin:0;color:#94a3b8;font-size:12px">© 2026 ${hotelName} · Powered by HotelSync</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>`
|
||||
|
||||
const textFallback = capturedAmount === 0
|
||||
? `Уважаемый(ая) ${guest.guest_name},\n\nСтраховой депозит полностью возвращён.\n\nСпасибо за проживание.\n\n© 2026 ${hotelName}`
|
||||
: [
|
||||
`Уважаемый(ая) ${guest.guest_name},`,
|
||||
'',
|
||||
`Из страхового депозита удержана сумма ${fmt(capturedAmount)}.`,
|
||||
...(items && items.length > 0 ? ['', 'Позиции удержания:', ...items.map(it => ` • ${it.name} — ${fmt(Number(it.amount))}`)] : []),
|
||||
...(reason ? ['', `Комментарий: ${reason}`] : []),
|
||||
...(refund > 0 ? ['', `Остаток депозита ${fmt(refund)} возвращён.`] : []),
|
||||
'',
|
||||
'Спасибо за проживание.',
|
||||
'',
|
||||
`© 2026 ${hotelName}`,
|
||||
].join('\n')
|
||||
|
||||
transporter.sendMail({
|
||||
from: `"HotelSync" <${process.env.SMTP_USER ?? 'noreply@hotelsync.ru'}>`,
|
||||
from: `"${hotelName}" <${process.env.SMTP_USER ?? 'noreply@hotelsync.ru'}>`,
|
||||
to: guest.guest_email,
|
||||
subject: 'Информация о депозите — HotelSync',
|
||||
text: [`Уважаемый(ая) ${guest.guest_name},`, '', ...bodyLines, '', 'Спасибо за проживание.', '', '© 2026 HotelSync'].join('\n'),
|
||||
subject: `Информация о депозите — ${hotelName}`,
|
||||
text: textFallback,
|
||||
html: emailHtml,
|
||||
}).catch(() => {})
|
||||
|
||||
await db.query(
|
||||
|
||||
Reference in New Issue
Block a user