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>
152 lines
5.4 KiB
TypeScript
152 lines
5.4 KiB
TypeScript
import { eq, and, count, sum, sql, type Column } from 'drizzle-orm'
|
|
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
|
import { drawerSessions, drawerAdjustments, transactions } from '../db/schema/pos.js'
|
|
import { ConflictError, NotFoundError } from '../lib/errors.js'
|
|
import type { DrawerOpenInput, DrawerCloseInput, DrawerAdjustmentInput, PaginationInput } from '@lunarfront/shared/schemas'
|
|
import { withPagination, withSort, paginatedResponse } from '../utils/pagination.js'
|
|
|
|
export const DrawerService = {
|
|
async open(db: PostgresJsDatabase<any>, input: DrawerOpenInput, openedBy: string) {
|
|
// Ensure no other open session at this location
|
|
if (input.locationId) {
|
|
const existing = await this.getOpen(db, input.locationId)
|
|
if (existing) {
|
|
throw new ConflictError('A drawer session is already open at this location')
|
|
}
|
|
}
|
|
|
|
const [session] = await db
|
|
.insert(drawerSessions)
|
|
.values({
|
|
locationId: input.locationId,
|
|
registerId: input.registerId,
|
|
openedBy,
|
|
openingBalance: input.openingBalance.toString(),
|
|
})
|
|
.returning()
|
|
return session
|
|
},
|
|
|
|
async close(db: PostgresJsDatabase<any>, sessionId: string, input: DrawerCloseInput, closedBy: string) {
|
|
const session = await this.getById(db, sessionId)
|
|
if (!session) throw new NotFoundError('Drawer session')
|
|
if (session.status === 'closed') throw new ConflictError('Drawer session is already closed')
|
|
|
|
// Calculate expected balance from cash transactions in this drawer session
|
|
// Net cash kept = total + rounding_adjustment (change is already accounted for)
|
|
const [cashTotals] = await db
|
|
.select({
|
|
total: sum(transactions.total),
|
|
rounding: sum(transactions.roundingAdjustment),
|
|
})
|
|
.from(transactions)
|
|
.where(
|
|
and(
|
|
eq(transactions.drawerSessionId, sessionId),
|
|
eq(transactions.status, 'completed'),
|
|
eq(transactions.paymentMethod, 'cash')
|
|
)
|
|
)
|
|
|
|
// Calculate net drawer adjustments (cash_in adds, cash_out subtracts)
|
|
const [adjTotals] = await db
|
|
.select({
|
|
cashIn: sql<string>`coalesce(sum(case when ${drawerAdjustments.type} = 'cash_in' then ${drawerAdjustments.amount} else 0 end), 0)`,
|
|
cashOut: sql<string>`coalesce(sum(case when ${drawerAdjustments.type} = 'cash_out' then ${drawerAdjustments.amount} else 0 end), 0)`,
|
|
})
|
|
.from(drawerAdjustments)
|
|
.where(eq(drawerAdjustments.drawerSessionId, sessionId))
|
|
|
|
const salesCashIn = parseFloat(cashTotals?.total ?? '0') + parseFloat(cashTotals?.rounding ?? '0')
|
|
const adjCashIn = parseFloat(adjTotals?.cashIn ?? '0')
|
|
const adjCashOut = parseFloat(adjTotals?.cashOut ?? '0')
|
|
const openingBalance = parseFloat(session.openingBalance)
|
|
const expectedBalance = openingBalance + salesCashIn + adjCashIn - adjCashOut
|
|
const closingBalance = input.closingBalance
|
|
const overShort = closingBalance - expectedBalance
|
|
|
|
const [updated] = await db
|
|
.update(drawerSessions)
|
|
.set({
|
|
closedBy,
|
|
closingBalance: closingBalance.toString(),
|
|
expectedBalance: expectedBalance.toString(),
|
|
overShort: overShort.toString(),
|
|
denominations: input.denominations,
|
|
notes: input.notes,
|
|
status: 'closed',
|
|
closedAt: new Date(),
|
|
})
|
|
.where(eq(drawerSessions.id, sessionId))
|
|
.returning()
|
|
return updated
|
|
},
|
|
|
|
async getOpen(db: PostgresJsDatabase<any>, locationId: string) {
|
|
const [session] = await db
|
|
.select()
|
|
.from(drawerSessions)
|
|
.where(
|
|
and(
|
|
eq(drawerSessions.locationId, locationId),
|
|
eq(drawerSessions.status, 'open')
|
|
)
|
|
)
|
|
.limit(1)
|
|
return session ?? null
|
|
},
|
|
|
|
async getById(db: PostgresJsDatabase<any>, id: string) {
|
|
const [session] = await db
|
|
.select()
|
|
.from(drawerSessions)
|
|
.where(eq(drawerSessions.id, id))
|
|
.limit(1)
|
|
return session ?? null
|
|
},
|
|
|
|
async addAdjustment(db: PostgresJsDatabase<any>, sessionId: string, input: DrawerAdjustmentInput, createdBy: string, _approvedBy?: string) {
|
|
const session = await this.getById(db, sessionId)
|
|
if (!session) throw new NotFoundError('Drawer session')
|
|
if (session.status === 'closed') throw new ConflictError('Cannot adjust a closed drawer')
|
|
|
|
const [adjustment] = await db
|
|
.insert(drawerAdjustments)
|
|
.values({
|
|
drawerSessionId: sessionId,
|
|
type: input.type,
|
|
amount: input.amount.toString(),
|
|
reason: input.reason,
|
|
createdBy,
|
|
})
|
|
.returning()
|
|
return adjustment
|
|
},
|
|
|
|
async getAdjustments(db: PostgresJsDatabase<any>, sessionId: string) {
|
|
return db
|
|
.select()
|
|
.from(drawerAdjustments)
|
|
.where(eq(drawerAdjustments.drawerSessionId, sessionId))
|
|
},
|
|
|
|
async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
|
|
const sortableColumns: Record<string, Column> = {
|
|
opened_at: drawerSessions.openedAt,
|
|
closed_at: drawerSessions.closedAt,
|
|
status: drawerSessions.status,
|
|
}
|
|
|
|
let query = db.select().from(drawerSessions).$dynamic()
|
|
query = withSort(query, params.sort, params.order, sortableColumns, drawerSessions.openedAt)
|
|
query = withPagination(query, params.page, params.limit)
|
|
|
|
const [data, [{ total }]] = await Promise.all([
|
|
query,
|
|
db.select({ total: count() }).from(drawerSessions),
|
|
])
|
|
|
|
return paginatedResponse(data, total, params.page, params.limit)
|
|
},
|
|
}
|