feat: add drawer cash in/out adjustments with balance reconciliation

- New drawer_adjustment table (type: cash_in/cash_out, amount, reason)
- POST/GET /drawer/:id/adjustments endpoints
- Drawer close calculation now includes adjustments: expected = opening + sales + cash_in - cash_out
- DrawerAdjustmentSchema for input validation
- 5 new tests (44 total POS tests passing)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ryan
2026-04-04 20:24:55 +00:00
parent 24ddb17ca8
commit 3ed2707a66
8 changed files with 180 additions and 6 deletions

View File

@@ -0,0 +1,14 @@
DO $$ BEGIN
CREATE TYPE "adjustment_type" AS ENUM ('cash_in', 'cash_out');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
CREATE TABLE IF NOT EXISTS "drawer_adjustment" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"drawer_session_id" uuid NOT NULL REFERENCES "drawer_session"("id"),
"type" "adjustment_type" NOT NULL,
"amount" numeric(10, 2) NOT NULL,
"reason" text NOT NULL,
"created_by" uuid NOT NULL REFERENCES "user"("id"),
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);

View File

@@ -295,6 +295,13 @@
"when": 1775580000000,
"tag": "0041_app_settings",
"breakpoints": true
},
{
"idx": 42,
"version": "7",
"when": 1775590000000,
"tag": "0042_drawer-adjustments",
"breakpoints": true
}
]
}

View File

@@ -68,6 +68,8 @@ export const discounts = pgTable('discount', {
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export const adjustmentTypeEnum = pgEnum('adjustment_type', ['cash_in', 'cash_out'])
export const drawerSessions = pgTable('drawer_session', {
id: uuid('id').primaryKey().defaultRandom(),
locationId: uuid('location_id').references(() => locations.id),
@@ -86,6 +88,20 @@ export const drawerSessions = pgTable('drawer_session', {
closedAt: timestamp('closed_at', { withTimezone: true }),
})
export const drawerAdjustments = pgTable('drawer_adjustment', {
id: uuid('id').primaryKey().defaultRandom(),
drawerSessionId: uuid('drawer_session_id')
.notNull()
.references(() => drawerSessions.id),
type: adjustmentTypeEnum('type').notNull(),
amount: numeric('amount', { precision: 10, scale: 2 }).notNull(),
reason: text('reason').notNull(),
createdBy: uuid('created_by')
.notNull()
.references(() => users.id),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export const transactions = pgTable('transaction', {
id: uuid('id').primaryKey().defaultRandom(),
locationId: uuid('location_id').references(() => locations.id),

View File

@@ -1,5 +1,5 @@
import type { FastifyPluginAsync } from 'fastify'
import { PaginationSchema, DrawerOpenSchema, DrawerCloseSchema } from '@lunarfront/shared/schemas'
import { PaginationSchema, DrawerOpenSchema, DrawerCloseSchema, DrawerAdjustmentSchema } from '@lunarfront/shared/schemas'
import { DrawerService } from '../../services/drawer.service.js'
export const drawerRoutes: FastifyPluginAsync = async (app) => {
@@ -35,6 +35,23 @@ export const drawerRoutes: FastifyPluginAsync = async (app) => {
return reply.send(session)
})
app.post('/drawer/:id/adjustments', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = DrawerAdjustmentSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const adjustment = await DrawerService.addAdjustment(app.db, id, parsed.data, request.user.id)
request.log.info({ drawerSessionId: id, type: parsed.data.type, amount: parsed.data.amount, userId: request.user.id }, 'Drawer adjustment')
return reply.status(201).send(adjustment)
})
app.get('/drawer/:id/adjustments', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const adjustments = await DrawerService.getAdjustments(app.db, id)
return reply.send({ data: adjustments })
})
app.get('/drawer/:id', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const session = await DrawerService.getById(app.db, id)

View File

@@ -1,8 +1,8 @@
import { eq, and, count, sum, type Column } from 'drizzle-orm'
import { eq, and, count, sum, sql, type Column } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { drawerSessions, transactions } from '../db/schema/pos.js'
import { drawerSessions, drawerAdjustments, transactions } from '../db/schema/pos.js'
import { ConflictError, NotFoundError } from '../lib/errors.js'
import type { DrawerOpenInput, DrawerCloseInput, PaginationInput } from '@lunarfront/shared/schemas'
import type { DrawerOpenInput, DrawerCloseInput, DrawerAdjustmentInput, PaginationInput } from '@lunarfront/shared/schemas'
import { withPagination, withSort, paginatedResponse } from '../utils/pagination.js'
export const DrawerService = {
@@ -47,9 +47,20 @@ export const DrawerService = {
)
)
const cashIn = parseFloat(cashTotals?.total ?? '0') + parseFloat(cashTotals?.rounding ?? '0')
// 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 + cashIn
const expectedBalance = openingBalance + salesCashIn + adjCashIn - adjCashOut
const closingBalance = input.closingBalance
const overShort = closingBalance - expectedBalance
@@ -93,6 +104,31 @@ export const DrawerService = {
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,