Files
lunarfront-app/packages/backend/src/routes/v1/transactions.ts
ryan 45fd6d34eb
All checks were successful
CI / ci (pull_request) Successful in 24s
CI / e2e (pull_request) Successful in 1m2s
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>
2026-04-05 20:32:52 +00:00

157 lines
7.7 KiB
TypeScript

import type { FastifyPluginAsync } from 'fastify'
import { z } from 'zod'
import {
PaginationSchema,
TransactionCreateSchema,
TransactionLineItemCreateSchema,
ApplyDiscountSchema,
CompleteTransactionSchema,
} from '@lunarfront/shared/schemas'
import { inArray } from 'drizzle-orm'
import { TransactionService } from '../../services/transaction.service.js'
import { appConfig } from '../../db/schema/stores.js'
import { EmailService } from '../../services/email.service.js'
import { renderReceiptEmailHtml, renderReceiptEmailText } from '../../utils/email-templates.js'
const FromRepairBodySchema = z.object({
locationId: z.string().uuid().optional(),
})
export const transactionRoutes: FastifyPluginAsync = async (app) => {
app.post('/transactions', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
const parsed = TransactionCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const txn = await TransactionService.create(app.db, parsed.data, request.user.id)
request.log.info({ transactionId: txn.id, type: parsed.data.transactionType, userId: request.user.id }, 'Transaction created')
return reply.status(201).send(txn)
})
app.post('/transactions/from-repair/:ticketId', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
const { ticketId } = request.params as { ticketId: string }
const parsed = FromRepairBodySchema.safeParse(request.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const txn = await TransactionService.createFromRepairTicket(app.db, ticketId, parsed.data.locationId, request.user.id)
request.log.info({ transactionId: txn.id, ticketId, userId: request.user.id }, 'Repair payment transaction created')
return reply.status(201).send(txn)
})
app.get('/transactions', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const query = request.query as Record<string, string | undefined>
const params = PaginationSchema.parse(query)
const filters = {
status: query.status,
transactionType: query.transactionType,
locationId: query.locationId,
accountId: query.accountId,
itemSearch: query.itemSearch,
}
const result = await TransactionService.list(app.db, params, filters)
return reply.send(result)
})
app.get('/transactions/:id', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const txn = await TransactionService.getById(app.db, id)
if (!txn) return reply.status(404).send({ error: { message: 'Transaction not found', statusCode: 404 } })
return reply.send(txn)
})
app.get('/transactions/:id/receipt', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const receipt = await TransactionService.getReceipt(app.db, id)
return reply.send(receipt)
})
app.post('/transactions/:id/email-receipt', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = z.object({ email: z.string().email() }).safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Valid email is required', statusCode: 400 } })
}
// Rate limit: max 5 emails per transaction per hour
const rateKey = `email-receipt:${id}`
const count = await app.redis.incr(rateKey)
if (count === 1) await app.redis.expire(rateKey, 3600)
if (count > 5) {
return reply.status(429).send({ error: { message: 'Too many emails for this receipt. Try again later.', statusCode: 429 } })
}
const receipt = await TransactionService.getReceipt(app.db, id)
const storeName = receipt.company?.name ?? 'Store'
// Fetch receipt config
const configRows = await app.db.select({ key: appConfig.key, value: appConfig.value })
.from(appConfig)
.where(inArray(appConfig.key, ['receipt_header', 'receipt_footer', 'receipt_return_policy', 'receipt_social']))
const config: Record<string, string> = {}
for (const row of configRows) if (row.value) config[row.key] = row.value
const html = renderReceiptEmailHtml(receipt as any, config)
const text = renderReceiptEmailText(receipt as any, config)
await EmailService.send(app.db, {
to: parsed.data.email,
subject: `Your receipt from ${storeName}${receipt.transaction.transactionNumber}`,
html,
text,
})
request.log.info({ transactionId: id, email: parsed.data.email, userId: request.user.id }, 'Receipt email sent')
return reply.send({ message: 'Receipt sent', sentTo: parsed.data.email })
})
app.post('/transactions/:id/line-items', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = TransactionLineItemCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const lineItem = await TransactionService.addLineItem(app.db, id, parsed.data)
return reply.status(201).send(lineItem)
})
app.delete('/transactions/:id/line-items/:lineItemId', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
const { id, lineItemId } = request.params as { id: string; lineItemId: string }
const deleted = await TransactionService.removeLineItem(app.db, id, lineItemId)
return reply.send(deleted)
})
app.post('/transactions/:id/discounts', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = ApplyDiscountSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
await TransactionService.applyDiscount(app.db, id, parsed.data, request.user.id)
const txn = await TransactionService.getById(app.db, id)
return reply.send(txn)
})
app.post('/transactions/:id/complete', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = CompleteTransactionSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
await TransactionService.complete(app.db, id, parsed.data)
const txn = await TransactionService.getById(app.db, id)
request.log.info({ transactionId: id, paymentMethod: parsed.data.paymentMethod, userId: request.user.id }, 'Transaction completed')
return reply.send(txn)
})
app.post('/transactions/:id/void', { preHandler: [app.authenticate, app.requirePermission('pos.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
await TransactionService.void(app.db, id, request.user.id)
const txn = await TransactionService.getById(app.db, id)
request.log.info({ transactionId: id, voidedBy: request.user.id }, 'Transaction voided')
return reply.send(txn)
})
}