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:
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
@@ -309,6 +309,13 @@
|
||||
"when": 1775680000000,
|
||||
"tag": "0043_repair-pos-consumable",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 44,
|
||||
"version": "7",
|
||||
"when": 1775770000000,
|
||||
"tag": "0044_registers-reports",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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'
|
||||
@@ -120,6 +122,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' })
|
||||
|
||||
50
packages/backend/src/routes/v1/register.ts
Normal file
50
packages/backend/src/routes/v1/register.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
27
packages/backend/src/routes/v1/reports.ts
Normal file
27
packages/backend/src/routes/v1/reports.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export const DrawerService = {
|
||||
.insert(drawerSessions)
|
||||
.values({
|
||||
locationId: input.locationId,
|
||||
registerId: input.registerId,
|
||||
openedBy,
|
||||
openingBalance: input.openingBalance.toString(),
|
||||
})
|
||||
|
||||
84
packages/backend/src/services/register.service.ts
Normal file
84
packages/backend/src/services/register.service.ts
Normal 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
|
||||
},
|
||||
}
|
||||
292
packages/backend/src/services/report.service.ts
Normal file
292
packages/backend/src/services/report.service.ts
Normal 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,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user