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>
This commit is contained in:
ryan
2026-04-05 02:21:55 +00:00
parent be8cc0ad8b
commit 7d9aeaf188
17 changed files with 1062 additions and 6 deletions

View File

@@ -902,4 +902,153 @@ suite('POS', { tags: ['pos'] }, (t) => {
t.assert.status(res2, 200)
t.assert.ok(res2.data.data.some((p: any) => p.id === consumable.data.id))
})
// ─── Registers ────────────────────────────────────────────────────────────
t.test('creates a register', { tags: ['registers', 'create'] }, async () => {
const res = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Register 1' })
t.assert.status(res, 201)
t.assert.equal(res.data.name, 'Register 1')
t.assert.equal(res.data.locationId, LOCATION_ID)
t.assert.equal(res.data.isActive, true)
})
t.test('lists registers for a location', { tags: ['registers', 'list'] }, async () => {
await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Register 2' })
const res = await t.api.get('/v1/registers', { locationId: LOCATION_ID })
t.assert.status(res, 200)
t.assert.ok(res.data.data.length >= 2)
t.assert.ok(res.data.pagination)
})
t.test('lists all registers (lookup)', { tags: ['registers', 'list'] }, async () => {
const res = await t.api.get('/v1/registers/all', { locationId: LOCATION_ID })
t.assert.status(res, 200)
t.assert.ok(res.data.data.length >= 2)
})
t.test('updates a register name', { tags: ['registers', 'update'] }, async () => {
const created = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Old Name' })
const res = await t.api.patch(`/v1/registers/${created.data.id}`, { name: 'New Name' })
t.assert.status(res, 200)
t.assert.equal(res.data.name, 'New Name')
})
t.test('deactivates a register', { tags: ['registers', 'delete'] }, async () => {
const created = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Delete Me' })
const res = await t.api.del(`/v1/registers/${created.data.id}`)
t.assert.status(res, 200)
t.assert.equal(res.data.isActive, false)
})
// ─── Drawer Reports (X/Z) ────────────────────────────────────────────────
t.test('cleanup: close any open drawers for report tests', { tags: ['reports', 'setup'] }, async () => {
const current = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID })
if (current.status === 200 && current.data?.id) {
await t.api.post(`/v1/drawer/${current.data.id}/close`, { closingBalance: 0 })
}
})
t.test('drawer report returns correct data for a session with transactions', { tags: ['reports', 'drawer'] }, async () => {
// Open drawer
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
t.assert.status(drawer, 201)
// Make a cash sale
const txn1 = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
await t.api.post(`/v1/transactions/${txn1.data.id}/line-items`, { description: 'Report Item 1', qty: 1, unitPrice: 50 })
await t.api.post(`/v1/transactions/${txn1.data.id}/complete`, { paymentMethod: 'cash', amountTendered: 60 })
// Make a card sale
const txn2 = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
await t.api.post(`/v1/transactions/${txn2.data.id}/line-items`, { description: 'Report Item 2', qty: 1, unitPrice: 30 })
await t.api.post(`/v1/transactions/${txn2.data.id}/complete`, { paymentMethod: 'card_present' })
// Void a transaction
const txn3 = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
await t.api.post(`/v1/transactions/${txn3.data.id}/line-items`, { description: 'Voided Item', qty: 1, unitPrice: 10 })
await t.api.post(`/v1/transactions/${txn3.data.id}/void`)
// Get X report (drawer still open)
const xReport = await t.api.get(`/v1/reports/drawer/${drawer.data.id}`)
t.assert.status(xReport, 200)
t.assert.equal(xReport.data.sales.transactionCount, 2)
t.assert.greaterThan(xReport.data.sales.grossSales, 0)
// Voided transactions don't go through complete() so drawerSessionId isn't set
// They won't appear in the drawer report — this is correct behavior
t.assert.ok(xReport.data.payments.cash)
t.assert.ok(xReport.data.payments.card_present)
t.assert.equal(xReport.data.cash.actualBalance, null) // not closed yet
// Close drawer
const closingAmount = 100 + xReport.data.cash.cashSales
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: closingAmount })
// Get Z report (drawer closed)
const zReport = await t.api.get(`/v1/reports/drawer/${drawer.data.id}`)
t.assert.status(zReport, 200)
t.assert.ok(zReport.data.session.closedAt)
t.assert.ok(zReport.data.cash.actualBalance !== null)
t.assert.ok(typeof zReport.data.cash.overShort === 'number')
})
t.test('drawerSessionId is populated on completed transactions', { tags: ['reports', 'drawer-session-id'] }, async () => {
// Cleanup any open drawer
const cur = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID })
if (cur.status === 200 && cur.data?.id) await t.api.post(`/v1/drawer/${cur.data.id}/close`, { closingBalance: 0 })
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
t.assert.status(drawer, 201)
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, { description: 'Session ID Test', qty: 1, unitPrice: 20 })
await t.api.post(`/v1/transactions/${txn.data.id}/complete`, { paymentMethod: 'card_present' })
const completed = await t.api.get(`/v1/transactions/${txn.data.id}`)
t.assert.status(completed, 200)
t.assert.equal(completed.data.drawerSessionId, drawer.data.id)
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 100 })
})
// ─── Daily Report ─────────────────────────────────────────────────────────
t.test('daily report aggregates across sessions', { tags: ['reports', 'daily'] }, async () => {
const today = new Date().toISOString().slice(0, 10)
const res = await t.api.get('/v1/reports/daily', { locationId: LOCATION_ID, date: today })
t.assert.status(res, 200)
t.assert.equal(res.data.date, today)
t.assert.ok(res.data.location)
t.assert.ok(Array.isArray(res.data.sessions))
t.assert.ok(typeof res.data.sales.grossSales === 'number')
t.assert.ok(typeof res.data.payments === 'object')
t.assert.ok(typeof res.data.cash.totalExpected === 'number')
})
t.test('daily report rejects missing params', { tags: ['reports', 'daily', 'validation'] }, async () => {
const res = await t.api.get('/v1/reports/daily', {})
t.assert.status(res, 400)
})
t.test('opens drawer with register', { tags: ['registers', 'drawer'] }, async () => {
// Cleanup any open drawer
const cur = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID })
if (cur.status === 200 && cur.data?.id) await t.api.post(`/v1/drawer/${cur.data.id}/close`, { closingBalance: 0 })
const reg = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Report Register' })
t.assert.status(reg, 201)
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, registerId: reg.data.id, openingBalance: 100 })
t.assert.status(drawer, 201)
// Get report to check register info
const report = await t.api.get(`/v1/reports/drawer/${drawer.data.id}`)
t.assert.status(report, 200)
t.assert.ok(report.data.session.register)
t.assert.equal(report.data.session.register.name, 'Report Register')
// Cleanup
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 100 })
})
})

View File

@@ -0,0 +1,12 @@
-- Named registers for POS terminals
CREATE TABLE IF NOT EXISTS register (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
location_id UUID NOT NULL REFERENCES location(id),
name VARCHAR(100) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Link drawer sessions to registers
ALTER TABLE drawer_session ADD COLUMN IF NOT EXISTS register_id UUID REFERENCES register(id);

View File

@@ -316,6 +316,13 @@
"when": 1775680000000,
"tag": "0043_repair-pos-consumable",
"breakpoints": true
},
{
"idx": 44,
"version": "7",
"when": 1775770000000,
"tag": "0044_registers-reports",
"breakpoints": true
}
]
}

View File

@@ -70,9 +70,21 @@ export const discounts = pgTable('discount', {
export const adjustmentTypeEnum = pgEnum('adjustment_type', ['cash_in', 'cash_out'])
export const registers = pgTable('register', {
id: uuid('id').primaryKey().defaultRandom(),
locationId: uuid('location_id')
.notNull()
.references(() => locations.id),
name: varchar('name', { length: 100 }).notNull(),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export const drawerSessions = pgTable('drawer_session', {
id: uuid('id').primaryKey().defaultRandom(),
locationId: uuid('location_id').references(() => locations.id),
registerId: uuid('register_id').references(() => registers.id),
openedBy: uuid('opened_by')
.notNull()
.references(() => users.id),

View File

@@ -23,6 +23,8 @@ import { repairRoutes } from './routes/v1/repairs.js'
import { lessonRoutes } from './routes/v1/lessons.js'
import { transactionRoutes } from './routes/v1/transactions.js'
import { drawerRoutes } from './routes/v1/drawer.js'
import { registerRoutes } from './routes/v1/register.js'
import { reportRoutes } from './routes/v1/reports.js'
import { discountRoutes } from './routes/v1/discounts.js'
import { taxRoutes } from './routes/v1/tax.js'
import { storageRoutes } from './routes/v1/storage.js'
@@ -156,6 +158,8 @@ export async function buildApp() {
await app.register(withModule('lessons', lessonRoutes), { prefix: '/v1' })
await app.register(withModule('pos', transactionRoutes), { prefix: '/v1' })
await app.register(withModule('pos', drawerRoutes), { prefix: '/v1' })
await app.register(withModule('pos', registerRoutes), { prefix: '/v1' })
await app.register(withModule('pos', reportRoutes), { prefix: '/v1' })
await app.register(withModule('pos', discountRoutes), { prefix: '/v1' })
await app.register(withModule('pos', taxRoutes), { prefix: '/v1' })
await app.register(withModule('vault', vaultRoutes), { prefix: '/v1' })

View File

@@ -0,0 +1,50 @@
import type { FastifyPluginAsync } from 'fastify'
import { PaginationSchema, RegisterCreateSchema, RegisterUpdateSchema } from '@lunarfront/shared/schemas'
import { RegisterService } from '../../services/register.service.js'
export const registerRoutes: FastifyPluginAsync = async (app) => {
app.post('/registers', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
const parsed = RegisterCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const register = await RegisterService.create(app.db, parsed.data)
return reply.status(201).send(register)
})
app.get('/registers', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const query = request.query as Record<string, string | undefined>
const params = PaginationSchema.parse(query)
const result = await RegisterService.list(app.db, params, { locationId: query.locationId })
return reply.send(result)
})
app.get('/registers/all', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const query = request.query as Record<string, string | undefined>
const data = await RegisterService.listAll(app.db, query.locationId)
return reply.send({ data })
})
app.get('/registers/:id', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const register = await RegisterService.getById(app.db, id)
if (!register) return reply.status(404).send({ error: { message: 'Register not found', statusCode: 404 } })
return reply.send(register)
})
app.patch('/registers/:id', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = RegisterUpdateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const register = await RegisterService.update(app.db, id, parsed.data)
return reply.send(register)
})
app.delete('/registers/:id', { preHandler: [app.authenticate, app.requirePermission('pos.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const register = await RegisterService.delete(app.db, id)
return reply.send(register)
})
}

View File

@@ -0,0 +1,27 @@
import type { FastifyPluginAsync } from 'fastify'
import { z } from 'zod'
import { ReportService } from '../../services/report.service.js'
const DailyReportQuerySchema = z.object({
locationId: z.string().uuid(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
})
export const reportRoutes: FastifyPluginAsync = async (app) => {
// X or Z report for a drawer session
app.get('/reports/drawer/:id', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const report = await ReportService.getDrawerReport(app.db, id)
return reply.send(report)
})
// Daily rollup for a location
app.get('/reports/daily', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const parsed = DailyReportQuerySchema.safeParse(request.query)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed — locationId and date (YYYY-MM-DD) are required', details: parsed.error.flatten(), statusCode: 400 } })
}
const report = await ReportService.getDailyReport(app.db, parsed.data.locationId, parsed.data.date)
return reply.send(report)
})
}

View File

@@ -19,6 +19,7 @@ export const DrawerService = {
.insert(drawerSessions)
.values({
locationId: input.locationId,
registerId: input.registerId,
openedBy,
openingBalance: input.openingBalance.toString(),
})

View File

@@ -0,0 +1,84 @@
import { eq, and, count, type Column } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { registers } from '../db/schema/pos.js'
import { NotFoundError } from '../lib/errors.js'
import type { RegisterCreateInput, RegisterUpdateInput, PaginationInput } from '@lunarfront/shared/schemas'
import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../utils/pagination.js'
export const RegisterService = {
async create(db: PostgresJsDatabase<any>, input: RegisterCreateInput) {
const [register] = await db
.insert(registers)
.values({
locationId: input.locationId,
name: input.name,
})
.returning()
return register
},
async getById(db: PostgresJsDatabase<any>, id: string) {
const [register] = await db
.select()
.from(registers)
.where(eq(registers.id, id))
.limit(1)
return register ?? null
},
async list(db: PostgresJsDatabase<any>, params: PaginationInput, filters?: { locationId?: string }) {
const conditions = [eq(registers.isActive, true)]
if (params.q) {
conditions.push(buildSearchCondition(params.q, [registers.name])!)
}
if (filters?.locationId) {
conditions.push(eq(registers.locationId, filters.locationId))
}
const where = conditions.length === 1 ? conditions[0] : and(...conditions)
const sortableColumns: Record<string, Column> = {
name: registers.name,
created_at: registers.createdAt,
}
let query = db.select().from(registers).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, registers.name)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(registers).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async listAll(db: PostgresJsDatabase<any>, locationId?: string) {
const conditions = [eq(registers.isActive, true)]
if (locationId) conditions.push(eq(registers.locationId, locationId))
const where = conditions.length === 1 ? conditions[0] : and(...conditions)
return db.select().from(registers).where(where)
},
async update(db: PostgresJsDatabase<any>, id: string, input: RegisterUpdateInput) {
const [updated] = await db
.update(registers)
.set({ ...input, updatedAt: new Date() })
.where(eq(registers.id, id))
.returning()
if (!updated) throw new NotFoundError('Register')
return updated
},
async delete(db: PostgresJsDatabase<any>, id: string) {
const [deleted] = await db
.update(registers)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(registers.id, id))
.returning()
if (!deleted) throw new NotFoundError('Register')
return deleted
},
}

View File

@@ -0,0 +1,292 @@
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,
}
},
}

View File

@@ -329,6 +329,7 @@ export const TransactionService = {
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 })
@@ -338,6 +339,7 @@ export const TransactionService = {
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)
@@ -399,6 +401,7 @@ export const TransactionService = {
changeGiven,
roundingAdjustment: roundingAdjustment.toString(),
checkNumber: input.checkNumber,
drawerSessionId,
completedAt: new Date(),
updatedAt: new Date(),
})