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:
@@ -510,4 +510,55 @@ suite('Repairs', { tags: ['repairs'] }, (t) => {
|
||||
const fileRes = await fetch(`${t.baseUrl}${signedRes.data.url}`)
|
||||
t.assert.equal(fileRes.status, 200)
|
||||
})
|
||||
|
||||
// ─── Email Estimate ─────────────────────────────────────────────────────────
|
||||
|
||||
t.test('emails a repair estimate', { tags: ['tickets', 'email'] }, async () => {
|
||||
const ticket = await t.api.post('/v1/repair-tickets', {
|
||||
customerName: 'Email Estimate Customer',
|
||||
itemDescription: 'Broken Laptop',
|
||||
problemDescription: 'Won\'t power on',
|
||||
conditionIn: 'poor',
|
||||
estimatedCost: 200,
|
||||
})
|
||||
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/line-items`, {
|
||||
itemType: 'labor',
|
||||
description: 'Diagnostic fee',
|
||||
qty: 1,
|
||||
unitPrice: 50,
|
||||
})
|
||||
|
||||
const res = await t.api.post(`/v1/repair-tickets/${ticket.data.id}/email-estimate`, { email: 'customer@test.com' })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.message, 'Estimate sent')
|
||||
t.assert.equal(res.data.sentTo, 'customer@test.com')
|
||||
})
|
||||
|
||||
t.test('rejects estimate email with invalid email', { tags: ['tickets', 'email', 'validation'] }, async () => {
|
||||
const ticket = await t.api.post('/v1/repair-tickets', {
|
||||
customerName: 'Bad Email Customer',
|
||||
problemDescription: 'Test',
|
||||
conditionIn: 'good',
|
||||
})
|
||||
const res = await t.api.post(`/v1/repair-tickets/${ticket.data.id}/email-estimate`, { email: 'bad' })
|
||||
t.assert.status(res, 400)
|
||||
})
|
||||
|
||||
t.test('returns 404 for estimate email on nonexistent ticket', { tags: ['tickets', 'email', 'validation'] }, async () => {
|
||||
const res = await t.api.post('/v1/repair-tickets/00000000-0000-0000-0000-000000000000/email-estimate', { email: 'test@test.com' })
|
||||
t.assert.status(res, 404)
|
||||
})
|
||||
|
||||
t.test('ticket detail includes customerEmail from account', { tags: ['tickets', 'email'] }, async () => {
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Repair Email Acct', email: 'repair@test.com', billingMode: 'consolidated' })
|
||||
const ticket = await t.api.post('/v1/repair-tickets', {
|
||||
customerName: 'Repair Email Acct',
|
||||
accountId: acct.data.id,
|
||||
problemDescription: 'Email test',
|
||||
conditionIn: 'good',
|
||||
})
|
||||
const detail = await t.api.get(`/v1/repair-tickets/${ticket.data.id}`)
|
||||
t.assert.status(detail, 200)
|
||||
t.assert.equal(detail.data.customerEmail, 'repair@test.com')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user