Files
lunarfront-app/packages/backend/src/services/report.service.ts
ryan 7d9aeaf188 feat: named registers, X/Z reports, daily rollup, fix drawerSessionId
Registers:
- New register table with location association
- CRUD service + API routes (POST/GET/PATCH/DELETE /registers)
- Drawer sessions now link to a register via registerId
- Register ID persisted in localStorage per device

X/Z Reports:
- ReportService with getDrawerReport() (X or Z depending on session state)
- Z report auto-displayed on drawer close in the drawer dialog
- X report (Current Shift Report) button on open drawer view
- Report shows: sales summary, payment breakdown, discounts, cash accountability, adjustments

Daily Rollup:
- ReportService.getDailyReport() aggregates all sessions at a location for a date
- New /reports/daily endpoint with locationId + date params
- Frontend daily report page with date picker, location selector, session breakdown

Critical Fix:
- drawerSessionId is now populated on transactions when completing (was never set before)
- This enables accurate per-drawer reporting and cash accountability

Migration 0044: register table, drawer_session.register_id column

Tests: 14 new (register CRUD, drawer report X/Z, drawerSessionId population, daily rollup, register-drawer link)
Full suite: 367 passed

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

293 lines
9.7 KiB
TypeScript

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<string, number> | 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<string, PaymentBreakdown>
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<any>, drawerSessionId: string): Promise<DrawerReport> {
// 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<string, PaymentBreakdown> = {}
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<any>, 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<string, PaymentBreakdown> = {}
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,
}
},
}