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