feat: add core POS module — transactions, discounts, drawer, tax
Phase 3a backend API for point-of-sale. Includes:
Schema (packages/backend/src/db/schema/pos.ts):
- pgEnums: transaction_type, transaction_status, payment_method,
discount_type, discount_applies_to, drawer_status
- Tables: transaction, transaction_line_item, discount,
discount_audit, drawer_session
- Transaction links to accounts, repair_tickets, repair_batches
- Line items link to products and inventory_units
Tax system:
- tax_rate + service_tax_rate columns on location
- tax_category enum (goods/service/exempt) on product
- Tax resolves per line item: goods→tax_rate, service→service_tax_rate,
exempt→0. Repair line items map: part→goods, labor→service
- GET /tax/lookup/:zip stubbed for future API integration (TAX_API_KEY)
Services (export const pattern, matching existing codebase):
- TransactionService: create, addLineItem, removeLineItem, applyDiscount,
recalculateTotals, complete (decrements inventory), void, getReceipt
- DiscountService: CRUD + listAll for dropdowns
- DrawerService: open/close with expected balance + over/short calc
- TaxService: getRateForLocation (by tax category), calculateTax
Routes:
- POST/GET /transactions, GET /transactions/:id, GET /transactions/:id/receipt
- POST /transactions/:id/line-items, DELETE /transactions/:id/line-items/:id
- POST /transactions/:id/discounts, /complete, /void
- POST /drawer/open, POST /drawer/:id/close, GET /drawer/current, GET /drawer
- CRUD /discounts + GET /discounts/all
- GET /products/lookup/upc/:upc (barcode scanner support)
All routes gated by pos.view/pos.edit/pos.admin + withModule('pos').
POS module already seeded in migration 0026.
Still needed: bun install, drizzle-kit generate + migrate, tests, lint.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
424
packages/backend/src/services/transaction.service.ts
Normal file
424
packages/backend/src/services/transaction.service.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import { eq, and, count, sql, desc, type Column } from 'drizzle-orm'
|
||||
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||
import {
|
||||
transactions,
|
||||
transactionLineItems,
|
||||
discountAudits,
|
||||
discounts,
|
||||
} from '../db/schema/pos.js'
|
||||
import { products, inventoryUnits } from '../db/schema/inventory.js'
|
||||
import { companies, locations } from '../db/schema/stores.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 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')
|
||||
|
||||
// Validate cash payment
|
||||
let changeGiven: string | undefined
|
||||
if (input.paymentMethod === 'cash') {
|
||||
const total = parseFloat(txn.total)
|
||||
if (!input.amountTendered || input.amountTendered < total) {
|
||||
throw new ValidationError('Amount tendered must be >= transaction total for cash payments')
|
||||
}
|
||||
changeGiven = (input.amountTendered - total).toString()
|
||||
}
|
||||
|
||||
// Update inventory for each line item
|
||||
const lineItems = await db
|
||||
.select()
|
||||
.from(transactionLineItems)
|
||||
.where(eq(transactionLineItems.transactionId, transactionId))
|
||||
|
||||
for (const item of lineItems) {
|
||||
if (item.inventoryUnitId) {
|
||||
// Serialized item — mark as sold
|
||||
await db
|
||||
.update(inventoryUnits)
|
||||
.set({ status: 'sold' })
|
||||
.where(eq(inventoryUnits.id, item.inventoryUnitId))
|
||||
} else if (item.productId) {
|
||||
// Non-serialized — decrement qty_on_hand
|
||||
await db
|
||||
.update(products)
|
||||
.set({
|
||||
qtyOnHand: sql`${products.qtyOnHand} - ${item.qty}`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(products.id, item.productId))
|
||||
}
|
||||
}
|
||||
|
||||
const [completed] = await db
|
||||
.update(transactions)
|
||||
.set({
|
||||
status: 'completed',
|
||||
paymentMethod: input.paymentMethod,
|
||||
amountTendered: input.amountTendered?.toString(),
|
||||
changeGiven,
|
||||
checkNumber: input.checkNumber,
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(transactions.id, transactionId))
|
||||
.returning()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return {
|
||||
transaction: txn,
|
||||
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
|
||||
}) {
|
||||
const conditions: ReturnType<typeof eq>[] = []
|
||||
|
||||
if (params.q) {
|
||||
conditions.push(buildSearchCondition(params.q, [transactions.transactionNumber])!)
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
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')}`
|
||||
}
|
||||
Reference in New Issue
Block a user