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>
157 lines
7.7 KiB
TypeScript
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)
|
|
})
|
|
}
|