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:
@@ -1051,4 +1051,48 @@ suite('POS', { tags: ['pos'] }, (t) => {
|
||||
// Cleanup
|
||||
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 100 })
|
||||
})
|
||||
|
||||
// ─── Email Receipt ──────────────────────────────────────────────────────────
|
||||
|
||||
t.test('emails a receipt for a completed transaction', { tags: ['transactions', 'email'] }, async () => {
|
||||
// Create and complete a transaction
|
||||
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, { description: 'Email Test Item', qty: 1, unitPrice: 25 })
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/complete`, { paymentMethod: 'card_present' })
|
||||
|
||||
const res = await t.api.post(`/v1/transactions/${txn.data.id}/email-receipt`, { email: 'customer@test.com' })
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.message, 'Receipt sent')
|
||||
t.assert.equal(res.data.sentTo, 'customer@test.com')
|
||||
})
|
||||
|
||||
t.test('rejects email receipt with invalid email', { tags: ['transactions', 'email', 'validation'] }, async () => {
|
||||
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, { description: 'Bad Email Item', qty: 1, unitPrice: 10 })
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/complete`, { paymentMethod: 'card_present' })
|
||||
|
||||
const res = await t.api.post(`/v1/transactions/${txn.data.id}/email-receipt`, { email: 'not-an-email' })
|
||||
t.assert.status(res, 400)
|
||||
})
|
||||
|
||||
t.test('rejects email receipt with missing body', { tags: ['transactions', 'email', 'validation'] }, async () => {
|
||||
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, { description: 'No Body Item', qty: 1, unitPrice: 10 })
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/complete`, { paymentMethod: 'card_present' })
|
||||
|
||||
const res = await t.api.post(`/v1/transactions/${txn.data.id}/email-receipt`, {})
|
||||
t.assert.status(res, 400)
|
||||
})
|
||||
|
||||
t.test('receipt response includes customerEmail', { tags: ['transactions', 'email'] }, async () => {
|
||||
// Create account with email
|
||||
const acct = await t.api.post('/v1/accounts', { name: 'Email Customer', email: 'acct@test.com', billingMode: 'consolidated' })
|
||||
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID, accountId: acct.data.id })
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, { description: 'Account Item', qty: 1, unitPrice: 50 })
|
||||
await t.api.post(`/v1/transactions/${txn.data.id}/complete`, { paymentMethod: 'card_present' })
|
||||
|
||||
const receipt = await t.api.get(`/v1/transactions/${txn.data.id}/receipt`)
|
||||
t.assert.status(receipt, 200)
|
||||
t.assert.equal(receipt.data.customerEmail, 'acct@test.com')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user