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, 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, 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, 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, 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, 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, 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, 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, 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, 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, params: PaginationInput, filters?: { status?: string transactionType?: string locationId?: string }) { const conditions: ReturnType[] = [] 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 = { 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): Promise { 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')}` }