- 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>
454 lines
14 KiB
TypeScript
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')}`
|
|
}
|