feat: email receipts and repair estimates
All checks were successful
CI / ci (pull_request) Successful in 24s
CI / e2e (pull_request) Successful in 1m2s

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>
This commit is contained in:
ryan
2026-04-05 20:32:52 +00:00
parent 30233b430f
commit 45fd6d34eb
11 changed files with 783 additions and 10 deletions

View File

@@ -0,0 +1,319 @@
/**
* 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
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')
}