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 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 = {} 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) }) }