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>
582 lines
19 KiB
TypeScript
582 lines
19 KiB
TypeScript
import { eq, and, count, sql, desc, type Column } from 'drizzle-orm'
|
|
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
|
import {
|
|
transactions,
|
|
transactionLineItems,
|
|
discountAudits,
|
|
discounts,
|
|
drawerSessions,
|
|
} from '../db/schema/pos.js'
|
|
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 {
|
|
TransactionCreateInput,
|
|
TransactionLineItemCreateInput,
|
|
ApplyDiscountInput,
|
|
CompleteTransactionInput,
|
|
PaginationInput,
|
|
} from '@lunarfront/shared/schemas'
|
|
import {
|
|
withPagination,
|
|
withSort,
|
|
buildSearchCondition,
|
|
paginatedResponse,
|
|
} from '../utils/pagination.js'
|
|
|
|
export const TransactionService = {
|
|
async create(db: PostgresJsDatabase<any>, input: TransactionCreateInput, processedBy: string) {
|
|
const transactionNumber = await generateTransactionNumber(db)
|
|
|
|
const [txn] = await db
|
|
.insert(transactions)
|
|
.values({
|
|
transactionNumber,
|
|
transactionType: input.transactionType,
|
|
locationId: input.locationId,
|
|
accountId: input.accountId,
|
|
repairTicketId: input.repairTicketId,
|
|
repairBatchId: input.repairBatchId,
|
|
notes: input.notes,
|
|
taxExempt: input.taxExempt,
|
|
taxExemptReason: input.taxExemptReason,
|
|
processedBy,
|
|
})
|
|
.returning()
|
|
return txn
|
|
},
|
|
|
|
async createFromRepairTicket(db: PostgresJsDatabase<any>, ticketId: string, locationId: string | undefined, processedBy: string) {
|
|
// Validate ticket exists and is eligible before entering transaction
|
|
const [ticket] = await db
|
|
.select()
|
|
.from(repairTickets)
|
|
.where(eq(repairTickets.id, ticketId))
|
|
.limit(1)
|
|
|
|
if (!ticket) throw new NotFoundError('Repair ticket')
|
|
if (!['ready', 'approved', 'in_progress'].includes(ticket.status)) {
|
|
throw new ValidationError('Ticket must be in ready, approved, or in_progress status to check out')
|
|
}
|
|
|
|
// Check for existing pending repair_payment for this ticket
|
|
const [existing] = await db
|
|
.select({ id: transactions.id })
|
|
.from(transactions)
|
|
.where(and(
|
|
eq(transactions.repairTicketId, ticketId),
|
|
eq(transactions.status, 'pending'),
|
|
))
|
|
.limit(1)
|
|
if (existing) throw new ConflictError('A pending transaction already exists for this ticket')
|
|
|
|
// Fetch non-consumable line items
|
|
const items = await db
|
|
.select()
|
|
.from(repairLineItems)
|
|
.where(eq(repairLineItems.repairTicketId, ticketId))
|
|
|
|
const billableItems = items.filter((i) => i.itemType !== 'consumable')
|
|
if (billableItems.length === 0) throw new ValidationError('No billable line items on this ticket')
|
|
|
|
const resolvedLocationId = locationId ?? ticket.locationId
|
|
|
|
// Wrap creation + line items in a DB transaction for atomicity
|
|
return db.transaction(async (tx) => {
|
|
const transactionNumber = await generateTransactionNumber(tx)
|
|
|
|
const [txn] = await tx
|
|
.insert(transactions)
|
|
.values({
|
|
transactionNumber,
|
|
transactionType: 'repair_payment',
|
|
locationId: resolvedLocationId,
|
|
accountId: ticket.accountId,
|
|
repairTicketId: ticketId,
|
|
processedBy,
|
|
})
|
|
.returning()
|
|
|
|
for (const item of billableItems) {
|
|
const taxCategory = TaxService.repairItemTypeToTaxCategory(item.itemType)
|
|
let taxRate = 0
|
|
if (resolvedLocationId) {
|
|
taxRate = await TaxService.getRateForLocation(tx, resolvedLocationId, taxCategory)
|
|
}
|
|
|
|
const unitPrice = parseFloat(item.unitPrice) || 0
|
|
const qty = Math.max(1, Math.round(parseFloat(item.qty) || 1))
|
|
const lineSubtotal = unitPrice * qty
|
|
const taxAmount = TaxService.calculateTax(lineSubtotal, taxRate)
|
|
const lineTotal = lineSubtotal + taxAmount
|
|
|
|
await tx.insert(transactionLineItems).values({
|
|
transactionId: txn.id,
|
|
productId: item.productId,
|
|
description: item.description,
|
|
qty,
|
|
unitPrice: unitPrice.toString(),
|
|
taxRate: taxRate.toString(),
|
|
taxAmount: taxAmount.toString(),
|
|
lineTotal: lineTotal.toString(),
|
|
})
|
|
}
|
|
|
|
await this.recalculateTotals(tx, txn.id)
|
|
|
|
// Return full transaction with line items
|
|
const lineItemRows = await tx
|
|
.select()
|
|
.from(transactionLineItems)
|
|
.where(eq(transactionLineItems.transactionId, txn.id))
|
|
|
|
// Re-read the transaction to get updated totals
|
|
const [updated] = await tx
|
|
.select()
|
|
.from(transactions)
|
|
.where(eq(transactions.id, txn.id))
|
|
.limit(1)
|
|
|
|
return { ...updated, lineItems: lineItemRows }
|
|
})
|
|
},
|
|
|
|
async addLineItem(db: PostgresJsDatabase<any>, transactionId: string, input: TransactionLineItemCreateInput) {
|
|
const txn = await this.getById(db, transactionId)
|
|
if (!txn) throw new NotFoundError('Transaction')
|
|
if (txn.status !== 'pending') throw new ConflictError('Can only add items to pending transactions')
|
|
|
|
// Resolve tax category from the product (defaults to "goods")
|
|
let taxCategory: 'goods' | 'service' | 'exempt' = 'goods'
|
|
if (input.productId) {
|
|
const [product] = await db
|
|
.select({ taxCategory: products.taxCategory })
|
|
.from(products)
|
|
.where(eq(products.id, input.productId))
|
|
.limit(1)
|
|
if (product?.taxCategory) {
|
|
taxCategory = product.taxCategory as 'goods' | 'service' | 'exempt'
|
|
}
|
|
}
|
|
|
|
// Snapshot the tax rate at time of sale
|
|
let taxRate = 0
|
|
if (!txn.taxExempt && txn.locationId) {
|
|
taxRate = await TaxService.getRateForLocation(db, txn.locationId, taxCategory)
|
|
}
|
|
|
|
const lineSubtotal = input.unitPrice * input.qty
|
|
const taxAmount = TaxService.calculateTax(lineSubtotal, taxRate)
|
|
const lineTotal = lineSubtotal + taxAmount
|
|
|
|
const [lineItem] = await db
|
|
.insert(transactionLineItems)
|
|
.values({
|
|
transactionId,
|
|
productId: input.productId,
|
|
inventoryUnitId: input.inventoryUnitId,
|
|
description: input.description,
|
|
qty: input.qty,
|
|
unitPrice: input.unitPrice.toString(),
|
|
taxRate: taxRate.toString(),
|
|
taxAmount: taxAmount.toString(),
|
|
lineTotal: lineTotal.toString(),
|
|
})
|
|
.returning()
|
|
|
|
await this.recalculateTotals(db, transactionId)
|
|
return lineItem
|
|
},
|
|
|
|
async removeLineItem(db: PostgresJsDatabase<any>, transactionId: string, lineItemId: string) {
|
|
const txn = await this.getById(db, transactionId)
|
|
if (!txn) throw new NotFoundError('Transaction')
|
|
if (txn.status !== 'pending') throw new ConflictError('Can only remove items from pending transactions')
|
|
|
|
const [deleted] = await db
|
|
.delete(transactionLineItems)
|
|
.where(
|
|
and(
|
|
eq(transactionLineItems.id, lineItemId),
|
|
eq(transactionLineItems.transactionId, transactionId)
|
|
)
|
|
)
|
|
.returning()
|
|
|
|
if (!deleted) throw new NotFoundError('Line item')
|
|
|
|
await this.recalculateTotals(db, transactionId)
|
|
return deleted
|
|
},
|
|
|
|
async applyDiscount(
|
|
db: PostgresJsDatabase<any>,
|
|
transactionId: string,
|
|
input: ApplyDiscountInput,
|
|
appliedBy: string,
|
|
) {
|
|
const txn = await this.getById(db, transactionId)
|
|
if (!txn) throw new NotFoundError('Transaction')
|
|
if (txn.status !== 'pending') throw new ConflictError('Can only apply discounts to pending transactions')
|
|
|
|
// If applying a predefined discount, check approval threshold
|
|
if (input.discountId) {
|
|
const [discount] = await db
|
|
.select()
|
|
.from(discounts)
|
|
.where(eq(discounts.id, input.discountId))
|
|
.limit(1)
|
|
|
|
if (!discount) throw new NotFoundError('Discount')
|
|
|
|
if (
|
|
discount.requiresApprovalAbove &&
|
|
input.amount > parseFloat(discount.requiresApprovalAbove)
|
|
) {
|
|
throw new ValidationError('Discount amount exceeds approval threshold — manager approval required')
|
|
}
|
|
}
|
|
|
|
// Apply to specific line item or order level
|
|
if (input.lineItemId) {
|
|
const [lineItem] = await db
|
|
.select()
|
|
.from(transactionLineItems)
|
|
.where(
|
|
and(
|
|
eq(transactionLineItems.id, input.lineItemId),
|
|
eq(transactionLineItems.transactionId, transactionId)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
if (!lineItem) throw new NotFoundError('Line item')
|
|
|
|
const originalAmount = parseFloat(lineItem.unitPrice) * lineItem.qty
|
|
|
|
await db
|
|
.update(transactionLineItems)
|
|
.set({
|
|
discountAmount: input.amount.toString(),
|
|
discountReason: input.reason,
|
|
})
|
|
.where(eq(transactionLineItems.id, input.lineItemId))
|
|
|
|
// Recalculate line total (subtotal - discount + tax)
|
|
const lineSubtotal = originalAmount - input.amount
|
|
const taxAmount = TaxService.calculateTax(lineSubtotal, parseFloat(lineItem.taxRate))
|
|
const lineTotal = lineSubtotal + taxAmount
|
|
|
|
await db
|
|
.update(transactionLineItems)
|
|
.set({
|
|
taxAmount: taxAmount.toString(),
|
|
lineTotal: lineTotal.toString(),
|
|
})
|
|
.where(eq(transactionLineItems.id, input.lineItemId))
|
|
|
|
// Create audit record
|
|
await db.insert(discountAudits).values({
|
|
transactionId,
|
|
transactionLineItemId: input.lineItemId,
|
|
discountId: input.discountId,
|
|
appliedBy,
|
|
originalAmount: originalAmount.toString(),
|
|
discountedAmount: input.amount.toString(),
|
|
reason: input.reason,
|
|
})
|
|
}
|
|
|
|
await this.recalculateTotals(db, transactionId)
|
|
},
|
|
|
|
async recalculateTotals(db: PostgresJsDatabase<any>, transactionId: string) {
|
|
const lineItems = await db
|
|
.select()
|
|
.from(transactionLineItems)
|
|
.where(eq(transactionLineItems.transactionId, transactionId))
|
|
|
|
let subtotal = 0
|
|
let discountTotal = 0
|
|
let taxTotal = 0
|
|
|
|
for (const item of lineItems) {
|
|
const itemSubtotal = parseFloat(item.unitPrice) * item.qty
|
|
subtotal += itemSubtotal
|
|
discountTotal += parseFloat(item.discountAmount)
|
|
taxTotal += parseFloat(item.taxAmount)
|
|
}
|
|
|
|
const total = subtotal - discountTotal + taxTotal
|
|
|
|
await db
|
|
.update(transactions)
|
|
.set({
|
|
subtotal: subtotal.toString(),
|
|
discountTotal: discountTotal.toString(),
|
|
taxTotal: taxTotal.toString(),
|
|
total: total.toString(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(transactions.id, transactionId))
|
|
},
|
|
|
|
async complete(db: PostgresJsDatabase<any>, transactionId: string, input: CompleteTransactionInput) {
|
|
const txn = await this.getById(db, transactionId)
|
|
if (!txn) throw new NotFoundError('Transaction')
|
|
if (txn.status !== 'pending') throw new ConflictError('Transaction is not pending')
|
|
|
|
// Require an open drawer session at the transaction's location
|
|
let drawerSessionId: string | null = null
|
|
if (txn.locationId) {
|
|
const [openDrawer] = await db
|
|
.select({ id: drawerSessions.id })
|
|
.from(drawerSessions)
|
|
.where(and(eq(drawerSessions.locationId, txn.locationId), eq(drawerSessions.status, 'open')))
|
|
.limit(1)
|
|
if (!openDrawer) {
|
|
throw new ValidationError('Cannot complete transaction without an open drawer at this location')
|
|
}
|
|
drawerSessionId = openDrawer.id
|
|
}
|
|
|
|
// Validate cash payment (with optional nickel rounding)
|
|
let changeGiven: string | undefined
|
|
let roundingAdjustment = 0
|
|
if (input.paymentMethod === 'cash') {
|
|
let total = parseFloat(txn.total)
|
|
|
|
// Apply Swedish rounding if location has cash_rounding enabled
|
|
if (txn.locationId) {
|
|
const [loc] = await db
|
|
.select({ cashRounding: locations.cashRounding })
|
|
.from(locations)
|
|
.where(eq(locations.id, txn.locationId))
|
|
.limit(1)
|
|
if (loc?.cashRounding) {
|
|
const rounded = TaxService.roundToNickel(total)
|
|
roundingAdjustment = Math.round((rounded - total) * 100) / 100
|
|
total = rounded
|
|
}
|
|
}
|
|
|
|
if (!input.amountTendered || input.amountTendered < total) {
|
|
throw new ValidationError('Amount tendered must be >= transaction total for cash payments')
|
|
}
|
|
changeGiven = (input.amountTendered - total).toString()
|
|
}
|
|
|
|
// Wrap inventory updates, transaction completion, and repair status in a DB transaction
|
|
return db.transaction(async (tx) => {
|
|
const lineItems = await tx
|
|
.select()
|
|
.from(transactionLineItems)
|
|
.where(eq(transactionLineItems.transactionId, transactionId))
|
|
|
|
for (const item of lineItems) {
|
|
if (item.inventoryUnitId) {
|
|
await tx
|
|
.update(inventoryUnits)
|
|
.set({ status: 'sold' })
|
|
.where(eq(inventoryUnits.id, item.inventoryUnitId))
|
|
} else if (item.productId) {
|
|
await tx
|
|
.update(products)
|
|
.set({
|
|
qtyOnHand: sql`${products.qtyOnHand} - ${item.qty}`,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(products.id, item.productId))
|
|
}
|
|
}
|
|
|
|
const [completed] = await tx
|
|
.update(transactions)
|
|
.set({
|
|
status: 'completed',
|
|
paymentMethod: input.paymentMethod,
|
|
amountTendered: input.amountTendered?.toString(),
|
|
changeGiven,
|
|
roundingAdjustment: roundingAdjustment.toString(),
|
|
checkNumber: input.checkNumber,
|
|
drawerSessionId,
|
|
completedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(transactions.id, transactionId))
|
|
.returning()
|
|
|
|
// If this is a repair payment, update ticket status to picked_up
|
|
if (completed.transactionType === 'repair_payment' && completed.repairTicketId) {
|
|
await tx
|
|
.update(repairTickets)
|
|
.set({ status: 'picked_up', completedDate: new Date(), updatedAt: new Date() })
|
|
.where(eq(repairTickets.id, completed.repairTicketId))
|
|
}
|
|
|
|
return completed
|
|
})
|
|
},
|
|
|
|
async void(db: PostgresJsDatabase<any>, transactionId: string, _voidedBy: string) {
|
|
const txn = await this.getById(db, transactionId)
|
|
if (!txn) throw new NotFoundError('Transaction')
|
|
if (txn.status !== 'pending') throw new ConflictError('Can only void pending transactions')
|
|
|
|
// Restore inventory (in case items were reserved, though we only decrement on complete)
|
|
const [voided] = await db
|
|
.update(transactions)
|
|
.set({
|
|
status: 'voided',
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(transactions.id, transactionId))
|
|
.returning()
|
|
|
|
return voided
|
|
},
|
|
|
|
async getById(db: PostgresJsDatabase<any>, id: string) {
|
|
const [txn] = await db
|
|
.select()
|
|
.from(transactions)
|
|
.where(eq(transactions.id, id))
|
|
.limit(1)
|
|
|
|
if (!txn) return null
|
|
|
|
const lineItems = await db
|
|
.select()
|
|
.from(transactionLineItems)
|
|
.where(eq(transactionLineItems.transactionId, id))
|
|
|
|
return { ...txn, lineItems }
|
|
},
|
|
|
|
async getReceipt(db: PostgresJsDatabase<any>, id: string) {
|
|
const txn = await this.getById(db, id)
|
|
if (!txn) throw new NotFoundError('Transaction')
|
|
|
|
// Get company info
|
|
const [company] = await db.select().from(companies).limit(1)
|
|
|
|
// Get location info if available
|
|
let location = null
|
|
if (txn.locationId) {
|
|
const [loc] = await db
|
|
.select()
|
|
.from(locations)
|
|
.where(eq(locations.id, txn.locationId))
|
|
.limit(1)
|
|
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,
|
|
phone: company.phone,
|
|
email: company.email,
|
|
address: company.address,
|
|
}
|
|
: null,
|
|
location: location
|
|
? {
|
|
name: location.name,
|
|
phone: location.phone,
|
|
email: location.email,
|
|
address: location.address,
|
|
}
|
|
: null,
|
|
}
|
|
},
|
|
|
|
async list(db: PostgresJsDatabase<any>, params: PaginationInput, filters?: {
|
|
status?: string
|
|
transactionType?: string
|
|
locationId?: string
|
|
accountId?: string
|
|
itemSearch?: string
|
|
}) {
|
|
const conditions: ReturnType<typeof eq>[] = []
|
|
|
|
if (params.q) {
|
|
conditions.push(buildSearchCondition(params.q, [transactions.transactionNumber])!)
|
|
}
|
|
if (filters?.itemSearch) {
|
|
const term = `%${filters.itemSearch}%`
|
|
conditions.push(
|
|
sql`EXISTS (SELECT 1 FROM ${transactionLineItems} WHERE ${transactionLineItems.transactionId} = ${transactions.id} AND ${transactionLineItems.description} ILIKE ${term})`
|
|
)
|
|
}
|
|
if (filters?.status) {
|
|
conditions.push(eq(transactions.status, filters.status as any))
|
|
}
|
|
if (filters?.transactionType) {
|
|
conditions.push(eq(transactions.transactionType, filters.transactionType as any))
|
|
}
|
|
if (filters?.locationId) {
|
|
conditions.push(eq(transactions.locationId, filters.locationId))
|
|
}
|
|
if (filters?.accountId) {
|
|
conditions.push(eq(transactions.accountId, filters.accountId))
|
|
}
|
|
|
|
const where = conditions.length === 0 ? undefined : conditions.length === 1 ? conditions[0] : and(...conditions)
|
|
|
|
const sortableColumns: Record<string, Column> = {
|
|
transaction_number: transactions.transactionNumber,
|
|
total: transactions.total,
|
|
status: transactions.status,
|
|
created_at: transactions.createdAt,
|
|
completed_at: transactions.completedAt,
|
|
}
|
|
|
|
let query = db.select().from(transactions).where(where).$dynamic()
|
|
query = withSort(query, params.sort, params.order, sortableColumns, transactions.createdAt)
|
|
query = withPagination(query, params.page, params.limit)
|
|
|
|
const countQuery = where
|
|
? db.select({ total: count() }).from(transactions).where(where)
|
|
: db.select({ total: count() }).from(transactions)
|
|
|
|
const [data, [{ total }]] = await Promise.all([query, countQuery])
|
|
|
|
return paginatedResponse(data, total, params.page, params.limit)
|
|
},
|
|
}
|
|
|
|
async function generateTransactionNumber(db: PostgresJsDatabase<any>): Promise<string> {
|
|
const today = new Date()
|
|
const dateStr = today.toISOString().slice(0, 10).replace(/-/g, '')
|
|
const prefix = `TXN-${dateStr}-`
|
|
|
|
// Find the highest sequence number for today
|
|
const [latest] = await db
|
|
.select({ transactionNumber: transactions.transactionNumber })
|
|
.from(transactions)
|
|
.where(sql`${transactions.transactionNumber} LIKE ${prefix + '%'}`)
|
|
.orderBy(desc(transactions.transactionNumber))
|
|
.limit(1)
|
|
|
|
let seq = 1
|
|
if (latest?.transactionNumber) {
|
|
const lastSeq = parseInt(latest.transactionNumber.replace(prefix, ''), 10)
|
|
if (!isNaN(lastSeq)) seq = lastSeq + 1
|
|
}
|
|
|
|
return `${prefix}${seq.toString().padStart(4, '0')}`
|
|
}
|