Files
lunarfront-app/packages/backend/src/services/transaction.service.ts
ryan 1673e18fe8
Some checks failed
CI / ci (pull_request) Failing after 20s
CI / e2e (pull_request) Has been skipped
fix: require open drawer to complete transactions, fix product price field
- Backend enforces open drawer at location before completing any transaction
- Frontend disables payment buttons when drawer is closed with warning message
- Fix product price field name (price, not sellingPrice) in POS API types
- Fix seed UUIDs to use valid UUID v4 format (version nibble must be 1-8)
- Fix Vite allowedHosts for dev.lunarfront.tech access
- Add e2e test for drawer enforcement (39 POS tests now pass)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:54:07 +00:00

454 lines
14 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 { 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')
// Require an open drawer session at the transaction's location
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')
}
}
// 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()
}
// 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,
roundingAdjustment: roundingAdjustment.toString(),
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')}`
}