diff --git a/packages/admin/src/components/pos/pos-payment-dialog.tsx b/packages/admin/src/components/pos/pos-payment-dialog.tsx index 53cabbd..ff0c35d 100644 --- a/packages/admin/src/components/pos/pos-payment-dialog.tsx +++ b/packages/admin/src/components/pos/pos-payment-dialog.tsx @@ -84,6 +84,7 @@ export function POSPaymentDialog({ open, onOpenChange, paymentMethod, transactio queryKey: ['pos', 'receipt', result?.id], queryFn: () => api.get<{ transaction: Transaction & { lineItems: { description: string; qty: number; unitPrice: string; taxAmount: string; lineTotal: string; discountAmount: string }[] } + customerEmail: string | null company: { name: string; phone: string | null; email: string | null; address: { street?: string; city?: string; state?: string; zip?: string } | null } location: { name: string; phone: string | null; email: string | null; address: { street?: string; city?: string; state?: string; zip?: string } | null } }>(`/v1/transactions/${result!.id}/receipt`), @@ -91,6 +92,19 @@ export function POSPaymentDialog({ open, onOpenChange, paymentMethod, transactio }) const [showReceipt, setShowReceipt] = useState(false) + const [emailMode, setEmailMode] = useState(false) + const [emailAddress, setEmailAddress] = useState('') + const [emailSent, setEmailSent] = useState(false) + + const emailReceiptMutation = useMutation({ + mutationFn: () => api.post<{ message: string }>(`/v1/transactions/${result!.id}/email-receipt`, { email: emailAddress }), + onSuccess: () => { + toast.success('Receipt emailed') + setEmailMode(false) + setEmailSent(true) + }, + onError: (err) => toast.error(err.message), + }) if (completed && result) { const changeGiven = parseFloat(result.changeGiven ?? '0') @@ -140,14 +154,47 @@ export function POSPaymentDialog({ open, onOpenChange, paymentMethod, transactio )} -
- - -
+ {emailMode ? ( +
+ +
+ setEmailAddress(e.target.value)} + placeholder="customer@example.com" + className="h-9" + autoFocus + /> + +
+ +
+ ) : ( +
+ + +
+ )} + + + + Email Estimate + +
+
+ + setEstimateEmail(e.target.value)} + placeholder="customer@example.com" + autoFocus + /> +
+ +
+
+ diff --git a/packages/backend/__tests__/utils/email-templates.test.ts b/packages/backend/__tests__/utils/email-templates.test.ts new file mode 100644 index 0000000..a2c815d --- /dev/null +++ b/packages/backend/__tests__/utils/email-templates.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from 'bun:test' +import { + renderReceiptEmailHtml, + renderReceiptEmailText, + renderEstimateEmailHtml, + renderEstimateEmailText, +} from '../../src/utils/email-templates.js' + +const mockReceipt = { + transaction: { + transactionNumber: 'TXN-20260405-001', + subtotal: '100.00', + discountTotal: '10.00', + taxTotal: '7.43', + total: '97.43', + paymentMethod: 'cash', + amountTendered: '100.00', + changeGiven: '2.57', + roundingAdjustment: null, + completedAt: '2026-04-05T12:00:00Z', + lineItems: [ + { description: 'Guitar Strings', qty: 2, unitPrice: '12.99', taxAmount: '2.14', lineTotal: '25.98', discountAmount: null }, + { description: 'Tuner', qty: 1, unitPrice: '74.02', taxAmount: '5.29', lineTotal: '74.02', discountAmount: null }, + ], + }, + company: { name: 'Test Store', phone: '555-1234', email: 'store@test.com', address: { street: '123 Main St', city: 'Austin', state: 'TX', zip: '78701' } }, + location: null, +} + +const mockTicket = { + ticketNumber: 'RPR-001', + customerName: 'Jane Doe', + customerPhone: '555-5678', + itemDescription: 'Acoustic Guitar', + serialNumber: 'AG-12345', + problemDescription: 'Cracked neck joint', + estimatedCost: '250.00', + promisedDate: '2026-04-12', + status: 'pending_approval', +} + +const mockLineItems = [ + { itemType: 'labor', description: 'Neck repair', qty: 1, unitPrice: '150.00', totalPrice: '150.00' }, + { itemType: 'part', description: 'Wood glue & clamps', qty: 1, unitPrice: '25.00', totalPrice: '25.00' }, + { itemType: 'flat_rate', description: 'Setup & restring', qty: 1, unitPrice: '75.00', totalPrice: '75.00' }, +] + +const mockCompany = { name: 'Test Store', phone: '555-1234', email: 'store@test.com', address: null } + +describe('renderReceiptEmailHtml', () => { + it('renders HTML with transaction details', () => { + const html = renderReceiptEmailHtml(mockReceipt as any) + expect(html).toContain('TXN-20260405-001') + expect(html).toContain('Test Store') + expect(html).toContain('Guitar Strings') + expect(html).toContain('Tuner') + expect(html).toContain('$97.43') + expect(html).toContain('$100.00') + expect(html).toContain('Powered by LunarFront') + }) + + it('includes discount when present', () => { + const html = renderReceiptEmailHtml(mockReceipt as any) + expect(html).toContain('Discount') + expect(html).toContain('$10.00') + }) + + it('includes payment details for cash', () => { + const html = renderReceiptEmailHtml(mockReceipt as any) + expect(html).toContain('Cash') + expect(html).toContain('$2.57') + }) + + it('includes receipt config when provided', () => { + const config = { receipt_footer: 'Thank you!', receipt_return_policy: '30-day returns' } + const html = renderReceiptEmailHtml(mockReceipt as any, config) + expect(html).toContain('Thank you!') + expect(html).toContain('30-day returns') + }) + + it('includes company address', () => { + const html = renderReceiptEmailHtml(mockReceipt as any) + expect(html).toContain('123 Main St') + expect(html).toContain('Austin') + }) +}) + +describe('renderReceiptEmailText', () => { + it('renders plain text with transaction details', () => { + const text = renderReceiptEmailText(mockReceipt as any) + expect(text).toContain('TXN-20260405-001') + expect(text).toContain('Guitar Strings') + expect(text).toContain('Total: $97.43') + expect(text).toContain('Cash') + }) +}) + +describe('renderEstimateEmailHtml', () => { + it('renders HTML with ticket details', () => { + const html = renderEstimateEmailHtml(mockTicket as any, mockLineItems as any, mockCompany as any) + expect(html).toContain('RPR-001') + expect(html).toContain('Jane Doe') + expect(html).toContain('Acoustic Guitar') + expect(html).toContain('AG-12345') + expect(html).toContain('Cracked neck joint') + expect(html).toContain('$250.00') + expect(html).toContain('Powered by LunarFront') + }) + + it('renders line items with types', () => { + const html = renderEstimateEmailHtml(mockTicket as any, mockLineItems as any, mockCompany as any) + expect(html).toContain('Labor') + expect(html).toContain('Neck repair') + expect(html).toContain('Part') + expect(html).toContain('Flat Rate') + expect(html).toContain('Setup & restring') + }) + + it('includes promised date', () => { + const html = renderEstimateEmailHtml(mockTicket as any, mockLineItems as any, mockCompany as any) + expect(html).toContain('Estimated completion') + expect(html).toContain('Apr') + }) + + it('renders without line items using estimatedCost', () => { + const html = renderEstimateEmailHtml(mockTicket as any, [], mockCompany as any) + expect(html).toContain('$250.00') + }) + + it('includes company phone', () => { + const html = renderEstimateEmailHtml(mockTicket as any, mockLineItems as any, mockCompany as any) + expect(html).toContain('555-1234') + }) +}) + +describe('renderEstimateEmailText', () => { + it('renders plain text with ticket details', () => { + const text = renderEstimateEmailText(mockTicket as any, mockLineItems as any, mockCompany as any) + expect(text).toContain('RPR-001') + expect(text).toContain('Jane Doe') + expect(text).toContain('Neck repair') + expect(text).toContain('$250.00') + }) +}) diff --git a/packages/backend/api-tests/run.ts b/packages/backend/api-tests/run.ts index c95552a..20065d9 100644 --- a/packages/backend/api-tests/run.ts +++ b/packages/backend/api-tests/run.ts @@ -114,6 +114,10 @@ async function setupDatabase() { await testSql`INSERT INTO module_config (slug, name, description, licensed, enabled) VALUES (${m.slug}, ${m.name}, ${m.description}, true, ${m.enabled}) ON CONFLICT (slug) DO NOTHING` } + // Seed test email provider so email endpoints work without real provider + await testSql`INSERT INTO app_settings (key, value, is_encrypted) VALUES ('email.provider', 'test', false) ON CONFLICT (key) DO NOTHING` + await testSql`INSERT INTO app_settings (key, value, is_encrypted) VALUES ('email.from_address', 'Test Store ', false) ON CONFLICT (key) DO NOTHING` + await testSql.end() console.log(' Database ready') } diff --git a/packages/backend/api-tests/suites/pos.ts b/packages/backend/api-tests/suites/pos.ts index f51be30..d06bcef 100644 --- a/packages/backend/api-tests/suites/pos.ts +++ b/packages/backend/api-tests/suites/pos.ts @@ -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') + }) }) diff --git a/packages/backend/api-tests/suites/repairs.ts b/packages/backend/api-tests/suites/repairs.ts index ed9d2e3..a1bae72 100644 --- a/packages/backend/api-tests/suites/repairs.ts +++ b/packages/backend/api-tests/suites/repairs.ts @@ -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') + }) }) diff --git a/packages/backend/src/routes/v1/repairs.ts b/packages/backend/src/routes/v1/repairs.ts index cedef0b..c6341da 100644 --- a/packages/backend/src/routes/v1/repairs.ts +++ b/packages/backend/src/routes/v1/repairs.ts @@ -13,7 +13,13 @@ import { RepairServiceTemplateCreateSchema, RepairServiceTemplateUpdateSchema, } from '@lunarfront/shared/schemas' +import { eq } from 'drizzle-orm' +import { z } from 'zod' import { RepairTicketService, RepairLineItemService, RepairBatchService, RepairNoteService, RepairServiceTemplateService } from '../../services/repair.service.js' +import { accounts } from '../../db/schema/accounts.js' +import { companies } from '../../db/schema/stores.js' +import { EmailService } from '../../services/email.service.js' +import { renderEstimateEmailHtml, renderEstimateEmailText } from '../../utils/email-templates.js' export const repairRoutes: FastifyPluginAsync = async (app) => { // --- Repair Tickets --- @@ -59,7 +65,14 @@ export const repairRoutes: FastifyPluginAsync = async (app) => { const { id } = request.params as { id: string } const ticket = await RepairTicketService.getById(app.db, id) if (!ticket) return reply.status(404).send({ error: { message: 'Repair ticket not found', statusCode: 404 } }) - return reply.send(ticket) + + let customerEmail: string | null = null + if (ticket.accountId) { + const [acct] = await app.db.select({ email: accounts.email }).from(accounts).where(eq(accounts.id, ticket.accountId)).limit(1) + customerEmail = acct?.email ?? null + } + + return reply.send({ ...ticket, customerEmail }) }) app.patch('/repair-tickets/:id', { preHandler: [app.authenticate, app.requirePermission('repairs.edit')] }, async (request, reply) => { @@ -84,6 +97,42 @@ export const repairRoutes: FastifyPluginAsync = async (app) => { return reply.send(ticket) }) + app.post('/repair-tickets/:id/email-estimate', { preHandler: [app.authenticate, app.requirePermission('repairs.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 ticket per hour + const rateKey = `email-estimate:${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 estimate. Try again later.', statusCode: 429 } }) + } + + const ticket = await RepairTicketService.getById(app.db, id) + if (!ticket) return reply.status(404).send({ error: { message: 'Repair ticket not found', statusCode: 404 } }) + + const lineItemsResult = await RepairLineItemService.listByTicket(app.db, id, { page: 1, limit: 500 }) + const [company] = await app.db.select().from(companies).limit(1) + if (!company) return reply.status(500).send({ error: { message: 'Store not configured', statusCode: 500 } }) + + const html = renderEstimateEmailHtml(ticket as any, lineItemsResult.data as any[], { name: company.name, phone: company.phone, email: company.email, address: company.address as any }) + const text = renderEstimateEmailText(ticket as any, lineItemsResult.data as any[], { name: company.name, phone: company.phone, email: company.email, address: company.address as any }) + + await EmailService.send(app.db, { + to: parsed.data.email, + subject: `Repair Estimate — Ticket #${ticket.ticketNumber}`, + html, + text, + }) + + request.log.info({ ticketId: id, email: parsed.data.email, userId: request.user.id }, 'Estimate email sent') + return reply.send({ message: 'Estimate sent', sentTo: parsed.data.email }) + }) + app.delete('/repair-tickets/:id', { preHandler: [app.authenticate, app.requirePermission('repairs.admin')] }, async (request, reply) => { const { id } = request.params as { id: string } const ticket = await RepairTicketService.delete(app.db, id) diff --git a/packages/backend/src/routes/v1/transactions.ts b/packages/backend/src/routes/v1/transactions.ts index 3535c90..c6e84cd 100644 --- a/packages/backend/src/routes/v1/transactions.ts +++ b/packages/backend/src/routes/v1/transactions.ts @@ -7,7 +7,11 @@ import { 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(), @@ -64,6 +68,45 @@ export const transactionRoutes: FastifyPluginAsync = async (app) => { 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) diff --git a/packages/backend/src/services/email.service.ts b/packages/backend/src/services/email.service.ts index 95ee795..8fcfa1b 100644 --- a/packages/backend/src/services/email.service.ts +++ b/packages/backend/src/services/email.service.ts @@ -87,6 +87,21 @@ class SmtpProvider implements EmailProvider { } } +class TestProvider implements EmailProvider { + static lastEmail: SendOpts | null = null + static emails: SendOpts[] = [] + + async send(opts: SendOpts): Promise { + TestProvider.lastEmail = opts + TestProvider.emails.push(opts) + } + + static reset() { + TestProvider.lastEmail = null + TestProvider.emails = [] + } +} + export const EmailService = { async send(db: PostgresJsDatabase, opts: SendOpts): Promise { const provider = await SettingsService.get(db, 'email.provider') @@ -99,8 +114,14 @@ export const EmailService = { return new SendGridProvider(db).send(opts) case 'smtp': return new SmtpProvider().send(opts) + case 'test': + return new TestProvider().send(opts) default: throw new Error('Email provider not configured. Set email.provider in app_settings.') } }, + + getTestEmails: () => TestProvider.emails, + getLastTestEmail: () => TestProvider.lastEmail, + resetTestEmails: () => TestProvider.reset(), } diff --git a/packages/backend/src/services/transaction.service.ts b/packages/backend/src/services/transaction.service.ts index fc3b9cc..0bbcc0f 100644 --- a/packages/backend/src/services/transaction.service.ts +++ b/packages/backend/src/services/transaction.service.ts @@ -10,6 +10,7 @@ import { import { products, inventoryUnits } from '../db/schema/inventory.js' import { repairTickets, repairLineItems } from '../db/schema/repairs.js' import { companies, locations } from '../db/schema/stores.js' +import { accounts } from '../db/schema/accounts.js' import { NotFoundError, ValidationError, ConflictError } from '../lib/errors.js' import { TaxService } from './tax.service.js' import type { @@ -473,8 +474,16 @@ export const TransactionService = { location = loc ?? null } + // Resolve customer email from linked account + let customerEmail: string | null = null + if (txn.accountId) { + const [acct] = await db.select({ email: accounts.email }).from(accounts).where(eq(accounts.id, txn.accountId)).limit(1) + customerEmail = acct?.email ?? null + } + return { transaction: txn, + customerEmail, company: company ? { name: company.name, diff --git a/packages/backend/src/utils/email-templates.ts b/packages/backend/src/utils/email-templates.ts new file mode 100644 index 0000000..01fcd9a --- /dev/null +++ b/packages/backend/src/utils/email-templates.ts @@ -0,0 +1,319 @@ +/** + * Server-side HTML email template renderers for receipts and repair estimates. + * Uses inline CSS for email-client compatibility. + */ + +interface Address { + street?: string + city?: string + state?: string + zip?: string +} + +interface CompanyInfo { + name: string + phone?: string | null + email?: string | null + address?: Address | null +} + +interface ReceiptLineItem { + description: string + qty: number + unitPrice: string + taxAmount: string + lineTotal: string + discountAmount?: string | null +} + +interface ReceiptTransaction { + transactionNumber: string + subtotal: string + discountTotal: string + taxTotal: string + total: string + paymentMethod: string | null + amountTendered: string | null + changeGiven: string | null + roundingAdjustment: string | null + completedAt: string | null + lineItems: ReceiptLineItem[] +} + +interface ReceiptConfig { + receipt_header?: string + receipt_footer?: string + receipt_return_policy?: string + receipt_social?: string +} + +interface ReceiptData { + transaction: ReceiptTransaction + company: CompanyInfo | null + location: CompanyInfo | null +} + +interface RepairLineItem { + itemType: string + description: string + qty: number + unitPrice: string + totalPrice: string +} + +interface RepairTicket { + ticketNumber: string + customerName: string + customerPhone?: string | null + itemDescription?: string | null + serialNumber?: string | null + problemDescription?: string | null + estimatedCost?: string | null + promisedDate?: string | null + status: string +} + +// ── Shared layout ────────────────────────────────────────────────────────── + +function wrapEmailLayout(storeName: string, bodyHtml: string): string { + return ` +
+

${esc(storeName)}

+ ${bodyHtml} +
+

Powered by LunarFront

+
+ ` +} + +function esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} + +function formatAddress(addr?: Address | null): string { + if (!addr) return '' + const parts = [addr.street, [addr.city, addr.state].filter(Boolean).join(', '), addr.zip].filter(Boolean) + return parts.join('
') +} + +function formatDate(dateStr: string | null): string { + if (!dateStr) return '' + const d = new Date(dateStr) + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }) +} + +function formatDateOnly(dateStr: string | null): string { + if (!dateStr) return '' + const d = new Date(dateStr) + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) +} + +const PAYMENT_LABELS: Record = { + cash: 'Cash', + card_present: 'Card', + card_keyed: 'Card (Keyed)', + check: 'Check', + store_credit: 'Store Credit', + other: 'Other', +} + +const TYPE_LABELS: Record = { + labor: 'Labor', + part: 'Part', + flat_rate: 'Flat Rate', + misc: 'Misc', + consumable: 'Consumable', +} + +// ── Receipt email ────────────────────────────────────────────────────────── + +export function renderReceiptEmailHtml(data: ReceiptData, config?: ReceiptConfig): string { + const txn = data.transaction + const storeName = data.company?.name ?? 'Store' + + let companyBlock = '' + if (data.company) { + const addr = formatAddress(data.company.address) + companyBlock = ` +
+ ${addr ? `
${addr}
` : ''} + ${data.company.phone ? `
${esc(data.company.phone)}
` : ''} +
+ ` + } + + const headerText = config?.receipt_header ? `

${esc(config.receipt_header)}

` : '' + + const lineRows = txn.lineItems.map(item => ` + + ${esc(item.description)} + ${item.qty} + $${item.unitPrice} + $${item.lineTotal} + + `).join('') + + const discountRow = parseFloat(txn.discountTotal) > 0 + ? `Discount-$${txn.discountTotal}` + : '' + + const roundingRow = txn.roundingAdjustment && parseFloat(txn.roundingAdjustment) !== 0 + ? `Rounding$${txn.roundingAdjustment}` + : '' + + const paymentBlock = txn.paymentMethod ? ` +
+
Paid by: ${PAYMENT_LABELS[txn.paymentMethod] ?? txn.paymentMethod}
+ ${txn.amountTendered && txn.paymentMethod === 'cash' ? `
Tendered: $${txn.amountTendered}
` : ''} + ${txn.changeGiven && parseFloat(txn.changeGiven) > 0 ? `
Change: $${txn.changeGiven}
` : ''} +
+ ` : '' + + const footerText = config?.receipt_footer ? `

${esc(config.receipt_footer)}

` : '' + const policyText = config?.receipt_return_policy ? `

${esc(config.receipt_return_policy)}

` : '' + const socialText = config?.receipt_social ? `

${esc(config.receipt_social)}

` : '' + + const body = ` + ${companyBlock} + ${headerText} +
+
+ Receipt #${esc(txn.transactionNumber)}
+ ${formatDate(txn.completedAt)} +
+
+ + + + + + + + + + + ${lineRows} + +
ItemQtyPriceTotal
+ + + ${discountRow} + + ${roundingRow} + +
Subtotal$${txn.subtotal}
Tax$${txn.taxTotal}
Total$${txn.total}
+ ${paymentBlock} + ${footerText} + ${policyText} + ${socialText} + ` + + return wrapEmailLayout(storeName, body) +} + +export function renderReceiptEmailText(data: ReceiptData, config?: ReceiptConfig): string { + const txn = data.transaction + const storeName = data.company?.name ?? 'Store' + const lines = [ + storeName, + `Receipt #${txn.transactionNumber}`, + formatDate(txn.completedAt), + '', + ...txn.lineItems.map(i => `${i.description} x${i.qty} — $${i.lineTotal}`), + '', + `Subtotal: $${txn.subtotal}`, + ...(parseFloat(txn.discountTotal) > 0 ? [`Discount: -$${txn.discountTotal}`] : []), + `Tax: $${txn.taxTotal}`, + `Total: $${txn.total}`, + '', + ...(txn.paymentMethod ? [`Paid by: ${PAYMENT_LABELS[txn.paymentMethod] ?? txn.paymentMethod}`] : []), + ...(config?.receipt_footer ? ['', config.receipt_footer] : []), + ] + return lines.join('\n') +} + +// ── Repair estimate email ────────────────────────────────────────────────── + +export function renderEstimateEmailHtml(ticket: RepairTicket, lineItems: RepairLineItem[], company: CompanyInfo): string { + const storeName = company.name + + const lineRows = lineItems.map(item => ` + + ${TYPE_LABELS[item.itemType] ?? item.itemType} + ${esc(item.description)} + ${item.qty} + $${item.unitPrice} + $${item.totalPrice} + + `).join('') + + const estimatedTotal = ticket.estimatedCost + ? `Estimated Total$${ticket.estimatedCost}` + : '' + + const lineItemTotal = lineItems.reduce((sum, i) => sum + parseFloat(i.totalPrice), 0).toFixed(2) + const lineItemTotalRow = !ticket.estimatedCost && lineItems.length > 0 + ? `Estimated Total$${lineItemTotal}` + : '' + + const body = ` +
+
+ Repair Estimate — Ticket #${esc(ticket.ticketNumber)}
+ ${esc(ticket.customerName)} +
+
+ +
+ ${ticket.itemDescription ? `
Item: ${esc(ticket.itemDescription)}${ticket.serialNumber ? ` (S/N: ${esc(ticket.serialNumber)})` : ''}
` : ''} + ${ticket.problemDescription ? `
Issue: ${esc(ticket.problemDescription)}
` : ''} + ${ticket.promisedDate ? `
Estimated completion: ${formatDateOnly(ticket.promisedDate)}
` : ''} +
+ + ${lineItems.length > 0 ? ` + + + + + + + + + + + + ${lineRows} + ${estimatedTotal} + ${lineItemTotalRow} + +
TypeDescriptionQtyPriceTotal
+ ` : ` + ${ticket.estimatedCost ? `
Estimated Cost: $${ticket.estimatedCost}
` : ''} + `} + +

This is an estimate and the final cost may vary. Please contact us with any questions.

+ ${company.phone ? `

Phone: ${esc(company.phone)}

` : ''} + ` + + return wrapEmailLayout(storeName, body) +} + +export function renderEstimateEmailText(ticket: RepairTicket, lineItems: RepairLineItem[], company: CompanyInfo): string { + const lines = [ + company.name, + `Repair Estimate — Ticket #${ticket.ticketNumber}`, + `Customer: ${ticket.customerName}`, + '', + ...(ticket.itemDescription ? [`Item: ${ticket.itemDescription}${ticket.serialNumber ? ` (S/N: ${ticket.serialNumber})` : ''}`] : []), + ...(ticket.problemDescription ? [`Issue: ${ticket.problemDescription}`] : []), + ...(ticket.promisedDate ? [`Estimated completion: ${formatDateOnly(ticket.promisedDate)}`] : []), + '', + ...lineItems.map(i => `${TYPE_LABELS[i.itemType] ?? i.itemType}: ${i.description} x${i.qty} — $${i.totalPrice}`), + '', + ...(ticket.estimatedCost ? [`Estimated Total: $${ticket.estimatedCost}`] : []), + '', + 'This is an estimate and the final cost may vary.', + ...(company.phone ? [`Phone: ${company.phone}`] : []), + ] + return lines.join('\n') +}