Backend: - Server-side HTML email templates (receipt + estimate) with inline CSS - POST /v1/transactions/:id/email-receipt with per-transaction rate limiting - POST /v1/repair-tickets/:id/email-estimate with per-ticket rate limiting - customerEmail field added to receipt and ticket detail responses - Test email provider for API tests (logs instead of sending) Frontend: - POS payment dialog Email button enabled with inline email input - Pre-fills customer email from linked account - Repair ticket detail page has Email Estimate button with dialog - Pre-fills from account email Tests: - 12 unit tests for email template renderers - 8 API tests for email receipt/estimate endpoints and validation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
320 lines
14 KiB
TypeScript
320 lines
14 KiB
TypeScript
/**
|
|
* Server-side HTML email template renderers for receipts and repair estimates.
|
|
* Uses inline CSS for email-client compatibility.
|
|
*/
|
|
|
|
interface Address {
|
|
street?: string
|
|
city?: string
|
|
state?: string
|
|
zip?: string
|
|
}
|
|
|
|
interface CompanyInfo {
|
|
name: string
|
|
phone?: string | null
|
|
email?: string | null
|
|
address?: Address | null
|
|
}
|
|
|
|
interface ReceiptLineItem {
|
|
description: string
|
|
qty: number
|
|
unitPrice: string
|
|
taxAmount: string
|
|
lineTotal: string
|
|
discountAmount?: string | null
|
|
}
|
|
|
|
interface ReceiptTransaction {
|
|
transactionNumber: string
|
|
subtotal: string
|
|
discountTotal: string
|
|
taxTotal: string
|
|
total: string
|
|
paymentMethod: string | null
|
|
amountTendered: string | null
|
|
changeGiven: string | null
|
|
roundingAdjustment: string | null
|
|
completedAt: string | null
|
|
lineItems: ReceiptLineItem[]
|
|
}
|
|
|
|
interface ReceiptConfig {
|
|
receipt_header?: string
|
|
receipt_footer?: string
|
|
receipt_return_policy?: string
|
|
receipt_social?: string
|
|
}
|
|
|
|
interface ReceiptData {
|
|
transaction: ReceiptTransaction
|
|
company: CompanyInfo | null
|
|
location: CompanyInfo | null
|
|
}
|
|
|
|
interface RepairLineItem {
|
|
itemType: string
|
|
description: string
|
|
qty: number
|
|
unitPrice: string
|
|
totalPrice: string
|
|
}
|
|
|
|
interface RepairTicket {
|
|
ticketNumber: string
|
|
customerName: string
|
|
customerPhone?: string | null
|
|
itemDescription?: string | null
|
|
serialNumber?: string | null
|
|
problemDescription?: string | null
|
|
estimatedCost?: string | null
|
|
promisedDate?: string | null
|
|
status: string
|
|
}
|
|
|
|
// ── Shared layout ──────────────────────────────────────────────────────────
|
|
|
|
function wrapEmailLayout(storeName: string, bodyHtml: string): string {
|
|
return `
|
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px; color: #333;">
|
|
<h2 style="color: #1a1a2e; margin: 0 0 24px 0; font-size: 20px;">${esc(storeName)}</h2>
|
|
${bodyHtml}
|
|
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
|
|
<p style="color: #aaa; font-size: 11px; margin: 0;">Powered by LunarFront</p>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
function esc(s: string): string {
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
}
|
|
|
|
function formatAddress(addr?: Address | null): string {
|
|
if (!addr) return ''
|
|
const parts = [addr.street, [addr.city, addr.state].filter(Boolean).join(', '), addr.zip].filter(Boolean)
|
|
return parts.join('<br />')
|
|
}
|
|
|
|
function formatDate(dateStr: string | null): string {
|
|
if (!dateStr) return ''
|
|
const d = new Date(dateStr)
|
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
|
|
}
|
|
|
|
function formatDateOnly(dateStr: string | null): string {
|
|
if (!dateStr) return ''
|
|
const d = new Date(dateStr)
|
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
|
}
|
|
|
|
const PAYMENT_LABELS: Record<string, string> = {
|
|
cash: 'Cash',
|
|
card_present: 'Card',
|
|
card_keyed: 'Card (Keyed)',
|
|
check: 'Check',
|
|
store_credit: 'Store Credit',
|
|
other: 'Other',
|
|
}
|
|
|
|
const TYPE_LABELS: Record<string, string> = {
|
|
labor: 'Labor',
|
|
part: 'Part',
|
|
flat_rate: 'Flat Rate',
|
|
misc: 'Misc',
|
|
consumable: 'Consumable',
|
|
}
|
|
|
|
// ── Receipt email ──────────────────────────────────────────────────────────
|
|
|
|
export function renderReceiptEmailHtml(data: ReceiptData, config?: ReceiptConfig): string {
|
|
const txn = data.transaction
|
|
const storeName = data.company?.name ?? 'Store'
|
|
|
|
let companyBlock = ''
|
|
if (data.company) {
|
|
const addr = formatAddress(data.company.address)
|
|
companyBlock = `
|
|
<div style="font-size: 13px; color: #666; margin-bottom: 16px;">
|
|
${addr ? `<div>${addr}</div>` : ''}
|
|
${data.company.phone ? `<div>${esc(data.company.phone)}</div>` : ''}
|
|
</div>
|
|
`
|
|
}
|
|
|
|
const headerText = config?.receipt_header ? `<p style="font-size: 13px; color: #555; margin: 0 0 16px 0;">${esc(config.receipt_header)}</p>` : ''
|
|
|
|
const lineRows = txn.lineItems.map(item => `
|
|
<tr>
|
|
<td style="padding: 6px 0; border-bottom: 1px solid #f0f0f0; font-size: 13px;">${esc(item.description)}</td>
|
|
<td style="padding: 6px 8px; border-bottom: 1px solid #f0f0f0; font-size: 13px; text-align: center;">${item.qty}</td>
|
|
<td style="padding: 6px 0; border-bottom: 1px solid #f0f0f0; font-size: 13px; text-align: right;">$${item.unitPrice}</td>
|
|
<td style="padding: 6px 0; border-bottom: 1px solid #f0f0f0; font-size: 13px; text-align: right;">$${item.lineTotal}</td>
|
|
</tr>
|
|
`).join('')
|
|
|
|
const discountRow = parseFloat(txn.discountTotal) > 0
|
|
? `<tr><td style="padding: 4px 0; font-size: 13px;">Discount</td><td style="padding: 4px 0; font-size: 13px; text-align: right; color: #e57373;">-$${txn.discountTotal}</td></tr>`
|
|
: ''
|
|
|
|
const roundingRow = txn.roundingAdjustment && parseFloat(txn.roundingAdjustment) !== 0
|
|
? `<tr><td style="padding: 4px 0; font-size: 13px;">Rounding</td><td style="padding: 4px 0; font-size: 13px; text-align: right;">$${txn.roundingAdjustment}</td></tr>`
|
|
: ''
|
|
|
|
const paymentBlock = txn.paymentMethod ? `
|
|
<div style="margin-top: 16px; font-size: 13px; color: #666;">
|
|
<div>Paid by: ${PAYMENT_LABELS[txn.paymentMethod] ?? txn.paymentMethod}</div>
|
|
${txn.amountTendered && txn.paymentMethod === 'cash' ? `<div>Tendered: $${txn.amountTendered}</div>` : ''}
|
|
${txn.changeGiven && parseFloat(txn.changeGiven) > 0 ? `<div>Change: $${txn.changeGiven}</div>` : ''}
|
|
</div>
|
|
` : ''
|
|
|
|
const footerText = config?.receipt_footer ? `<p style="font-size: 12px; color: #888; margin: 16px 0 0 0; text-align: center;">${esc(config.receipt_footer)}</p>` : ''
|
|
const policyText = config?.receipt_return_policy ? `<p style="font-size: 11px; color: #999; margin: 8px 0 0 0; text-align: center;">${esc(config.receipt_return_policy)}</p>` : ''
|
|
const socialText = config?.receipt_social ? `<p style="font-size: 11px; color: #999; margin: 4px 0 0 0; text-align: center;">${esc(config.receipt_social)}</p>` : ''
|
|
|
|
const body = `
|
|
${companyBlock}
|
|
${headerText}
|
|
<div style="background: #f9f9f9; border-radius: 8px; padding: 16px; margin-bottom: 16px;">
|
|
<div style="font-size: 13px; color: #666;">
|
|
<strong style="color: #333;">Receipt #${esc(txn.transactionNumber)}</strong><br />
|
|
${formatDate(txn.completedAt)}
|
|
</div>
|
|
</div>
|
|
<table style="width: 100%; border-collapse: collapse; margin-bottom: 16px;">
|
|
<thead>
|
|
<tr style="border-bottom: 2px solid #eee;">
|
|
<th style="padding: 6px 0; font-size: 12px; text-align: left; color: #888; font-weight: 600;">Item</th>
|
|
<th style="padding: 6px 8px; font-size: 12px; text-align: center; color: #888; font-weight: 600;">Qty</th>
|
|
<th style="padding: 6px 0; font-size: 12px; text-align: right; color: #888; font-weight: 600;">Price</th>
|
|
<th style="padding: 6px 0; font-size: 12px; text-align: right; color: #888; font-weight: 600;">Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${lineRows}
|
|
</tbody>
|
|
</table>
|
|
<table style="width: 50%; margin-left: auto; border-collapse: collapse;">
|
|
<tr><td style="padding: 4px 0; font-size: 13px;">Subtotal</td><td style="padding: 4px 0; font-size: 13px; text-align: right;">$${txn.subtotal}</td></tr>
|
|
${discountRow}
|
|
<tr><td style="padding: 4px 0; font-size: 13px;">Tax</td><td style="padding: 4px 0; font-size: 13px; text-align: right;">$${txn.taxTotal}</td></tr>
|
|
${roundingRow}
|
|
<tr style="border-top: 2px solid #333;"><td style="padding: 8px 0; font-size: 15px; font-weight: 700;">Total</td><td style="padding: 8px 0; font-size: 15px; font-weight: 700; text-align: right;">$${txn.total}</td></tr>
|
|
</table>
|
|
${paymentBlock}
|
|
${footerText}
|
|
${policyText}
|
|
${socialText}
|
|
`
|
|
|
|
return wrapEmailLayout(storeName, body)
|
|
}
|
|
|
|
export function renderReceiptEmailText(data: ReceiptData, config?: ReceiptConfig): string {
|
|
const txn = data.transaction
|
|
const storeName = data.company?.name ?? 'Store'
|
|
const lines = [
|
|
storeName,
|
|
`Receipt #${txn.transactionNumber}`,
|
|
formatDate(txn.completedAt),
|
|
'',
|
|
...txn.lineItems.map(i => `${i.description} x${i.qty} — $${i.lineTotal}`),
|
|
'',
|
|
`Subtotal: $${txn.subtotal}`,
|
|
...(parseFloat(txn.discountTotal) > 0 ? [`Discount: -$${txn.discountTotal}`] : []),
|
|
`Tax: $${txn.taxTotal}`,
|
|
`Total: $${txn.total}`,
|
|
'',
|
|
...(txn.paymentMethod ? [`Paid by: ${PAYMENT_LABELS[txn.paymentMethod] ?? txn.paymentMethod}`] : []),
|
|
...(config?.receipt_footer ? ['', config.receipt_footer] : []),
|
|
]
|
|
return lines.join('\n')
|
|
}
|
|
|
|
// ── Repair estimate email ──────────────────────────────────────────────────
|
|
|
|
export function renderEstimateEmailHtml(ticket: RepairTicket, lineItems: RepairLineItem[], company: CompanyInfo): string {
|
|
const storeName = company.name
|
|
|
|
const lineRows = lineItems.map(item => `
|
|
<tr>
|
|
<td style="padding: 6px 0; border-bottom: 1px solid #f0f0f0; font-size: 13px;">${TYPE_LABELS[item.itemType] ?? item.itemType}</td>
|
|
<td style="padding: 6px 0; border-bottom: 1px solid #f0f0f0; font-size: 13px;">${esc(item.description)}</td>
|
|
<td style="padding: 6px 8px; border-bottom: 1px solid #f0f0f0; font-size: 13px; text-align: center;">${item.qty}</td>
|
|
<td style="padding: 6px 0; border-bottom: 1px solid #f0f0f0; font-size: 13px; text-align: right;">$${item.unitPrice}</td>
|
|
<td style="padding: 6px 0; border-bottom: 1px solid #f0f0f0; font-size: 13px; text-align: right;">$${item.totalPrice}</td>
|
|
</tr>
|
|
`).join('')
|
|
|
|
const estimatedTotal = ticket.estimatedCost
|
|
? `<tr style="border-top: 2px solid #333;"><td colspan="4" style="padding: 8px 0; font-size: 15px; font-weight: 700;">Estimated Total</td><td style="padding: 8px 0; font-size: 15px; font-weight: 700; text-align: right;">$${ticket.estimatedCost}</td></tr>`
|
|
: ''
|
|
|
|
const lineItemTotal = lineItems.reduce((sum, i) => sum + parseFloat(i.totalPrice), 0).toFixed(2)
|
|
const lineItemTotalRow = !ticket.estimatedCost && lineItems.length > 0
|
|
? `<tr style="border-top: 2px solid #333;"><td colspan="4" style="padding: 8px 0; font-size: 15px; font-weight: 700;">Estimated Total</td><td style="padding: 8px 0; font-size: 15px; font-weight: 700; text-align: right;">$${lineItemTotal}</td></tr>`
|
|
: ''
|
|
|
|
const body = `
|
|
<div style="background: #f9f9f9; border-radius: 8px; padding: 16px; margin-bottom: 16px;">
|
|
<div style="font-size: 13px; color: #666;">
|
|
<strong style="color: #333;">Repair Estimate — Ticket #${esc(ticket.ticketNumber)}</strong><br />
|
|
${esc(ticket.customerName)}
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-bottom: 16px; font-size: 13px; color: #555;">
|
|
${ticket.itemDescription ? `<div><strong>Item:</strong> ${esc(ticket.itemDescription)}${ticket.serialNumber ? ` (S/N: ${esc(ticket.serialNumber)})` : ''}</div>` : ''}
|
|
${ticket.problemDescription ? `<div style="margin-top: 4px;"><strong>Issue:</strong> ${esc(ticket.problemDescription)}</div>` : ''}
|
|
${ticket.promisedDate ? `<div style="margin-top: 4px;"><strong>Estimated completion:</strong> ${formatDateOnly(ticket.promisedDate)}</div>` : ''}
|
|
</div>
|
|
|
|
${lineItems.length > 0 ? `
|
|
<table style="width: 100%; border-collapse: collapse; margin-bottom: 16px;">
|
|
<thead>
|
|
<tr style="border-bottom: 2px solid #eee;">
|
|
<th style="padding: 6px 0; font-size: 12px; text-align: left; color: #888; font-weight: 600;">Type</th>
|
|
<th style="padding: 6px 0; font-size: 12px; text-align: left; color: #888; font-weight: 600;">Description</th>
|
|
<th style="padding: 6px 8px; font-size: 12px; text-align: center; color: #888; font-weight: 600;">Qty</th>
|
|
<th style="padding: 6px 0; font-size: 12px; text-align: right; color: #888; font-weight: 600;">Price</th>
|
|
<th style="padding: 6px 0; font-size: 12px; text-align: right; color: #888; font-weight: 600;">Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${lineRows}
|
|
${estimatedTotal}
|
|
${lineItemTotalRow}
|
|
</tbody>
|
|
</table>
|
|
` : `
|
|
${ticket.estimatedCost ? `<div style="font-size: 15px; font-weight: 700; margin-bottom: 16px;">Estimated Cost: $${ticket.estimatedCost}</div>` : ''}
|
|
`}
|
|
|
|
<p style="font-size: 12px; color: #888;">This is an estimate and the final cost may vary. Please contact us with any questions.</p>
|
|
${company.phone ? `<p style="font-size: 12px; color: #888; margin-top: 4px;">Phone: ${esc(company.phone)}</p>` : ''}
|
|
`
|
|
|
|
return wrapEmailLayout(storeName, body)
|
|
}
|
|
|
|
export function renderEstimateEmailText(ticket: RepairTicket, lineItems: RepairLineItem[], company: CompanyInfo): string {
|
|
const lines = [
|
|
company.name,
|
|
`Repair Estimate — Ticket #${ticket.ticketNumber}`,
|
|
`Customer: ${ticket.customerName}`,
|
|
'',
|
|
...(ticket.itemDescription ? [`Item: ${ticket.itemDescription}${ticket.serialNumber ? ` (S/N: ${ticket.serialNumber})` : ''}`] : []),
|
|
...(ticket.problemDescription ? [`Issue: ${ticket.problemDescription}`] : []),
|
|
...(ticket.promisedDate ? [`Estimated completion: ${formatDateOnly(ticket.promisedDate)}`] : []),
|
|
'',
|
|
...lineItems.map(i => `${TYPE_LABELS[i.itemType] ?? i.itemType}: ${i.description} x${i.qty} — $${i.totalPrice}`),
|
|
'',
|
|
...(ticket.estimatedCost ? [`Estimated Total: $${ticket.estimatedCost}`] : []),
|
|
'',
|
|
'This is an estimate and the final cost may vary.',
|
|
...(company.phone ? [`Phone: ${company.phone}`] : []),
|
|
]
|
|
return lines.join('\n')
|
|
}
|