import { eq, and, sql, gte, lt } from 'drizzle-orm' import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' import { transactions, drawerSessions, drawerAdjustments, registers } from '../db/schema/pos.js' import { locations } from '../db/schema/stores.js' import { users } from '../db/schema/users.js' import { NotFoundError } from '../lib/errors.js' interface PaymentBreakdown { count: number total: number } interface DrawerReport { session: { id: string openedAt: string closedAt: string | null openingBalance: string closingBalance: string | null expectedBalance: string | null overShort: string | null status: string notes: string | null denominations: Record | null register: { id: string; name: string } | null openedBy: { id: string; firstName: string; lastName: string } | null closedBy: { id: string; firstName: string; lastName: string } | null } sales: { grossSales: number netSales: number transactionCount: number voidCount: number refundTotal: number } payments: Record discounts: { total: number count: number } cash: { openingBalance: number cashSales: number cashIn: number cashOut: number expectedBalance: number actualBalance: number | null overShort: number | null } adjustments: { id: string; type: string; amount: string; reason: string; createdAt: string }[] } export const ReportService = { async getDrawerReport(db: PostgresJsDatabase, drawerSessionId: string): Promise { // Fetch session with register and user info const [session] = await db .select() .from(drawerSessions) .where(eq(drawerSessions.id, drawerSessionId)) .limit(1) if (!session) throw new NotFoundError('Drawer session') // Fetch register info let register: { id: string; name: string } | null = null if (session.registerId) { const [reg] = await db.select({ id: registers.id, name: registers.name }).from(registers).where(eq(registers.id, session.registerId)).limit(1) register = reg ?? null } // Fetch user info const [openedByUser] = await db.select({ id: users.id, firstName: users.firstName, lastName: users.lastName }).from(users).where(eq(users.id, session.openedBy)).limit(1) let closedByUser = null if (session.closedBy) { const [u] = await db.select({ id: users.id, firstName: users.firstName, lastName: users.lastName }).from(users).where(eq(users.id, session.closedBy)).limit(1) closedByUser = u ?? null } // Aggregate transaction data for this drawer session const txns = await db .select({ status: transactions.status, transactionType: transactions.transactionType, paymentMethod: transactions.paymentMethod, total: transactions.total, discountTotal: transactions.discountTotal, roundingAdjustment: transactions.roundingAdjustment, }) .from(transactions) .where(eq(transactions.drawerSessionId, drawerSessionId)) // Calculate sales let grossSales = 0 let refundTotal = 0 let transactionCount = 0 let voidCount = 0 let discountTotalSum = 0 let discountCount = 0 const payments: Record = {} for (const txn of txns) { if (txn.status === 'voided') { voidCount++ continue } if (txn.status !== 'completed') continue const total = parseFloat(txn.total ?? '0') const discAmt = parseFloat(txn.discountTotal ?? '0') if (txn.transactionType === 'refund') { refundTotal += total } else { grossSales += total transactionCount++ } if (discAmt > 0) { discountTotalSum += discAmt discountCount++ } const method = txn.paymentMethod ?? 'unknown' if (!payments[method]) payments[method] = { count: 0, total: 0 } payments[method].count++ payments[method].total += total } // Cash accountability const cashPayment = payments['cash'] ?? { count: 0, total: 0 } const cashRounding = txns .filter((t) => t.status === 'completed' && t.paymentMethod === 'cash') .reduce((sum, t) => sum + parseFloat(t.roundingAdjustment ?? '0'), 0) const cashSales = cashPayment.total + cashRounding // Adjustments const adjRows = await db .select() .from(drawerAdjustments) .where(eq(drawerAdjustments.drawerSessionId, drawerSessionId)) let cashIn = 0 let cashOut = 0 for (const adj of adjRows) { if (adj.type === 'cash_in') cashIn += parseFloat(adj.amount) else cashOut += parseFloat(adj.amount) } const openingBalance = parseFloat(session.openingBalance) const expectedBalance = openingBalance + cashSales + cashIn - cashOut const actualBalance = session.closingBalance ? parseFloat(session.closingBalance) : null const overShort = actualBalance !== null ? Math.round((actualBalance - expectedBalance) * 100) / 100 : null return { session: { id: session.id, openedAt: session.openedAt.toISOString(), closedAt: session.closedAt?.toISOString() ?? null, openingBalance: session.openingBalance, closingBalance: session.closingBalance, expectedBalance: session.expectedBalance, overShort: session.overShort, status: session.status, notes: session.notes, denominations: session.denominations, register, openedBy: openedByUser ?? null, closedBy: closedByUser, }, sales: { grossSales: Math.round(grossSales * 100) / 100, netSales: Math.round((grossSales - refundTotal) * 100) / 100, transactionCount, voidCount, refundTotal: Math.round(refundTotal * 100) / 100, }, payments, discounts: { total: Math.round(discountTotalSum * 100) / 100, count: discountCount, }, cash: { openingBalance, cashSales: Math.round(cashSales * 100) / 100, cashIn: Math.round(cashIn * 100) / 100, cashOut: Math.round(cashOut * 100) / 100, expectedBalance: Math.round(expectedBalance * 100) / 100, actualBalance, overShort, }, adjustments: adjRows.map((a) => ({ id: a.id, type: a.type, amount: a.amount, reason: a.reason, createdAt: a.createdAt.toISOString(), })), } }, async getDailyReport(db: PostgresJsDatabase, locationId: string, date: string) { // Get location info const [location] = await db .select({ id: locations.id, name: locations.name, timezone: locations.timezone }) .from(locations) .where(eq(locations.id, locationId)) .limit(1) if (!location) throw new NotFoundError('Location') // Find all drawer sessions opened at this location on the given date const dayStart = new Date(`${date}T00:00:00`) const dayEnd = new Date(`${date}T00:00:00`) dayEnd.setDate(dayEnd.getDate() + 1) const sessions = await db .select() .from(drawerSessions) .where(and( eq(drawerSessions.locationId, locationId), gte(drawerSessions.openedAt, dayStart), lt(drawerSessions.openedAt, dayEnd), )) // Get individual reports for each session const sessionReports = await Promise.all( sessions.map((s) => this.getDrawerReport(db, s.id)) ) // Aggregate const sales = { grossSales: 0, netSales: 0, transactionCount: 0, voidCount: 0, refundTotal: 0 } const payments: Record = {} const discounts = { total: 0, count: 0 } const cash = { totalOpening: 0, totalCashSales: 0, totalCashIn: 0, totalCashOut: 0, totalExpected: 0, totalActual: 0, totalOverShort: 0 } for (const report of sessionReports) { sales.grossSales += report.sales.grossSales sales.netSales += report.sales.netSales sales.transactionCount += report.sales.transactionCount sales.voidCount += report.sales.voidCount sales.refundTotal += report.sales.refundTotal for (const [method, data] of Object.entries(report.payments)) { if (!payments[method]) payments[method] = { count: 0, total: 0 } payments[method].count += data.count payments[method].total += data.total } discounts.total += report.discounts.total discounts.count += report.discounts.count cash.totalOpening += report.cash.openingBalance cash.totalCashSales += report.cash.cashSales cash.totalCashIn += report.cash.cashIn cash.totalCashOut += report.cash.cashOut cash.totalExpected += report.cash.expectedBalance if (report.cash.actualBalance !== null) cash.totalActual += report.cash.actualBalance if (report.cash.overShort !== null) cash.totalOverShort += report.cash.overShort } // Round all aggregated values for (const key of Object.keys(sales) as (keyof typeof sales)[]) { sales[key] = Math.round(sales[key] * 100) / 100 } for (const data of Object.values(payments)) { data.total = Math.round(data.total * 100) / 100 } discounts.total = Math.round(discounts.total * 100) / 100 for (const key of Object.keys(cash) as (keyof typeof cash)[]) { cash[key] = Math.round(cash[key] * 100) / 100 } return { date, location: { id: location.id, name: location.name }, sessions: sessionReports.map((r) => ({ id: r.session.id, register: r.session.register, openedBy: r.session.openedBy, openedAt: r.session.openedAt, closedAt: r.session.closedAt, status: r.session.status, overShort: r.cash.overShort, grossSales: r.sales.grossSales, })), sales, payments, discounts, cash, } }, }