feat: email receipts and repair estimates
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:
144
packages/backend/__tests__/utils/email-templates.test.ts
Normal file
144
packages/backend/__tests__/utils/email-templates.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect } from 'bun:test'
|
||||
import {
|
||||
renderReceiptEmailHtml,
|
||||
renderReceiptEmailText,
|
||||
renderEstimateEmailHtml,
|
||||
renderEstimateEmailText,
|
||||
} from '../../src/utils/email-templates.js'
|
||||
|
||||
const mockReceipt = {
|
||||
transaction: {
|
||||
transactionNumber: 'TXN-20260405-001',
|
||||
subtotal: '100.00',
|
||||
discountTotal: '10.00',
|
||||
taxTotal: '7.43',
|
||||
total: '97.43',
|
||||
paymentMethod: 'cash',
|
||||
amountTendered: '100.00',
|
||||
changeGiven: '2.57',
|
||||
roundingAdjustment: null,
|
||||
completedAt: '2026-04-05T12:00:00Z',
|
||||
lineItems: [
|
||||
{ description: 'Guitar Strings', qty: 2, unitPrice: '12.99', taxAmount: '2.14', lineTotal: '25.98', discountAmount: null },
|
||||
{ description: 'Tuner', qty: 1, unitPrice: '74.02', taxAmount: '5.29', lineTotal: '74.02', discountAmount: null },
|
||||
],
|
||||
},
|
||||
company: { name: 'Test Store', phone: '555-1234', email: 'store@test.com', address: { street: '123 Main St', city: 'Austin', state: 'TX', zip: '78701' } },
|
||||
location: null,
|
||||
}
|
||||
|
||||
const mockTicket = {
|
||||
ticketNumber: 'RPR-001',
|
||||
customerName: 'Jane Doe',
|
||||
customerPhone: '555-5678',
|
||||
itemDescription: 'Acoustic Guitar',
|
||||
serialNumber: 'AG-12345',
|
||||
problemDescription: 'Cracked neck joint',
|
||||
estimatedCost: '250.00',
|
||||
promisedDate: '2026-04-12',
|
||||
status: 'pending_approval',
|
||||
}
|
||||
|
||||
const mockLineItems = [
|
||||
{ itemType: 'labor', description: 'Neck repair', qty: 1, unitPrice: '150.00', totalPrice: '150.00' },
|
||||
{ itemType: 'part', description: 'Wood glue & clamps', qty: 1, unitPrice: '25.00', totalPrice: '25.00' },
|
||||
{ itemType: 'flat_rate', description: 'Setup & restring', qty: 1, unitPrice: '75.00', totalPrice: '75.00' },
|
||||
]
|
||||
|
||||
const mockCompany = { name: 'Test Store', phone: '555-1234', email: 'store@test.com', address: null }
|
||||
|
||||
describe('renderReceiptEmailHtml', () => {
|
||||
it('renders HTML with transaction details', () => {
|
||||
const html = renderReceiptEmailHtml(mockReceipt as any)
|
||||
expect(html).toContain('TXN-20260405-001')
|
||||
expect(html).toContain('Test Store')
|
||||
expect(html).toContain('Guitar Strings')
|
||||
expect(html).toContain('Tuner')
|
||||
expect(html).toContain('$97.43')
|
||||
expect(html).toContain('$100.00')
|
||||
expect(html).toContain('Powered by LunarFront')
|
||||
})
|
||||
|
||||
it('includes discount when present', () => {
|
||||
const html = renderReceiptEmailHtml(mockReceipt as any)
|
||||
expect(html).toContain('Discount')
|
||||
expect(html).toContain('$10.00')
|
||||
})
|
||||
|
||||
it('includes payment details for cash', () => {
|
||||
const html = renderReceiptEmailHtml(mockReceipt as any)
|
||||
expect(html).toContain('Cash')
|
||||
expect(html).toContain('$2.57')
|
||||
})
|
||||
|
||||
it('includes receipt config when provided', () => {
|
||||
const config = { receipt_footer: 'Thank you!', receipt_return_policy: '30-day returns' }
|
||||
const html = renderReceiptEmailHtml(mockReceipt as any, config)
|
||||
expect(html).toContain('Thank you!')
|
||||
expect(html).toContain('30-day returns')
|
||||
})
|
||||
|
||||
it('includes company address', () => {
|
||||
const html = renderReceiptEmailHtml(mockReceipt as any)
|
||||
expect(html).toContain('123 Main St')
|
||||
expect(html).toContain('Austin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderReceiptEmailText', () => {
|
||||
it('renders plain text with transaction details', () => {
|
||||
const text = renderReceiptEmailText(mockReceipt as any)
|
||||
expect(text).toContain('TXN-20260405-001')
|
||||
expect(text).toContain('Guitar Strings')
|
||||
expect(text).toContain('Total: $97.43')
|
||||
expect(text).toContain('Cash')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderEstimateEmailHtml', () => {
|
||||
it('renders HTML with ticket details', () => {
|
||||
const html = renderEstimateEmailHtml(mockTicket as any, mockLineItems as any, mockCompany as any)
|
||||
expect(html).toContain('RPR-001')
|
||||
expect(html).toContain('Jane Doe')
|
||||
expect(html).toContain('Acoustic Guitar')
|
||||
expect(html).toContain('AG-12345')
|
||||
expect(html).toContain('Cracked neck joint')
|
||||
expect(html).toContain('$250.00')
|
||||
expect(html).toContain('Powered by LunarFront')
|
||||
})
|
||||
|
||||
it('renders line items with types', () => {
|
||||
const html = renderEstimateEmailHtml(mockTicket as any, mockLineItems as any, mockCompany as any)
|
||||
expect(html).toContain('Labor')
|
||||
expect(html).toContain('Neck repair')
|
||||
expect(html).toContain('Part')
|
||||
expect(html).toContain('Flat Rate')
|
||||
expect(html).toContain('Setup & restring')
|
||||
})
|
||||
|
||||
it('includes promised date', () => {
|
||||
const html = renderEstimateEmailHtml(mockTicket as any, mockLineItems as any, mockCompany as any)
|
||||
expect(html).toContain('Estimated completion')
|
||||
expect(html).toContain('Apr')
|
||||
})
|
||||
|
||||
it('renders without line items using estimatedCost', () => {
|
||||
const html = renderEstimateEmailHtml(mockTicket as any, [], mockCompany as any)
|
||||
expect(html).toContain('$250.00')
|
||||
})
|
||||
|
||||
it('includes company phone', () => {
|
||||
const html = renderEstimateEmailHtml(mockTicket as any, mockLineItems as any, mockCompany as any)
|
||||
expect(html).toContain('555-1234')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderEstimateEmailText', () => {
|
||||
it('renders plain text with ticket details', () => {
|
||||
const text = renderEstimateEmailText(mockTicket as any, mockLineItems as any, mockCompany as any)
|
||||
expect(text).toContain('RPR-001')
|
||||
expect(text).toContain('Jane Doe')
|
||||
expect(text).toContain('Neck repair')
|
||||
expect(text).toContain('$250.00')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user