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:
@@ -114,6 +114,81 @@ suite('POS', { tags: ['pos'] }, (t) => {
|
||||
t.assert.ok(res.data.pagination)
|
||||
})
|
||||
|
||||
// ─── Drawer Adjustments ─────────────────────────────────────────────────────
|
||||
|
||||
t.test('adds cash out adjustment to open drawer', { tags: ['drawer', 'adjustments'] }, async () => {
|
||||
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 200 })
|
||||
t.assert.status(drawer, 201)
|
||||
|
||||
const res = await t.api.post(`/v1/drawer/${drawer.data.id}/adjustments`, {
|
||||
type: 'cash_out',
|
||||
amount: 50,
|
||||
reason: 'Bank deposit',
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.type, 'cash_out')
|
||||
t.assert.equal(parseFloat(res.data.amount), 50)
|
||||
|
||||
// Cleanup
|
||||
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 150 })
|
||||
})
|
||||
|
||||
t.test('adds cash in adjustment to open drawer', { tags: ['drawer', 'adjustments'] }, async () => {
|
||||
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
|
||||
t.assert.status(drawer, 201)
|
||||
|
||||
const res = await t.api.post(`/v1/drawer/${drawer.data.id}/adjustments`, {
|
||||
type: 'cash_in',
|
||||
amount: 25,
|
||||
reason: 'Change from petty cash',
|
||||
})
|
||||
t.assert.status(res, 201)
|
||||
t.assert.equal(res.data.type, 'cash_in')
|
||||
|
||||
// Cleanup
|
||||
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 125 })
|
||||
})
|
||||
|
||||
t.test('lists drawer adjustments', { tags: ['drawer', 'adjustments'] }, async () => {
|
||||
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
|
||||
await t.api.post(`/v1/drawer/${drawer.data.id}/adjustments`, { type: 'cash_out', amount: 30, reason: 'Test out' })
|
||||
await t.api.post(`/v1/drawer/${drawer.data.id}/adjustments`, { type: 'cash_in', amount: 10, reason: 'Test in' })
|
||||
|
||||
const res = await t.api.get(`/v1/drawer/${drawer.data.id}/adjustments`)
|
||||
t.assert.status(res, 200)
|
||||
t.assert.equal(res.data.data.length, 2)
|
||||
|
||||
// Cleanup
|
||||
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 80 })
|
||||
})
|
||||
|
||||
t.test('drawer close includes adjustments in expected balance', { tags: ['drawer', 'adjustments', 'close'] }, async () => {
|
||||
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 200 })
|
||||
t.assert.status(drawer, 201)
|
||||
|
||||
// Cash out $50, cash in $20 → net adjustment = -$30
|
||||
await t.api.post(`/v1/drawer/${drawer.data.id}/adjustments`, { type: 'cash_out', amount: 50, reason: 'Bank drop' })
|
||||
await t.api.post(`/v1/drawer/${drawer.data.id}/adjustments`, { type: 'cash_in', amount: 20, reason: 'Extra change' })
|
||||
|
||||
// Close — expected = 200 (opening) + 0 (no sales) + 20 (in) - 50 (out) = 170
|
||||
const closed = await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 170 })
|
||||
t.assert.status(closed, 200)
|
||||
t.assert.equal(parseFloat(closed.data.expectedBalance), 170)
|
||||
t.assert.equal(parseFloat(closed.data.overShort), 0)
|
||||
})
|
||||
|
||||
t.test('rejects adjustment on closed drawer', { tags: ['drawer', 'adjustments', 'validation'] }, async () => {
|
||||
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
|
||||
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 100 })
|
||||
|
||||
const res = await t.api.post(`/v1/drawer/${drawer.data.id}/adjustments`, {
|
||||
type: 'cash_out',
|
||||
amount: 10,
|
||||
reason: 'Should fail',
|
||||
})
|
||||
t.assert.status(res, 409)
|
||||
})
|
||||
|
||||
// ─── Transactions ──────────────────────────────────────────────────────────
|
||||
|
||||
t.test('creates a sale transaction', { tags: ['transactions', 'create'] }, async () => {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -288,6 +288,13 @@
|
||||
"when": 1775494000000,
|
||||
"tag": "0040_app-config",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 41,
|
||||
"version": "7",
|
||||
"when": 1775580000000,
|
||||
"tag": "0041_drawer-adjustments",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -180,6 +180,7 @@ export {
|
||||
DiscountUpdateSchema,
|
||||
DrawerOpenSchema,
|
||||
DrawerCloseSchema,
|
||||
DrawerAdjustmentSchema,
|
||||
} from './pos.schema.js'
|
||||
export type {
|
||||
TransactionCreateInput,
|
||||
@@ -190,6 +191,7 @@ export type {
|
||||
DiscountUpdateInput,
|
||||
DrawerOpenInput,
|
||||
DrawerCloseInput,
|
||||
DrawerAdjustmentInput,
|
||||
} from './pos.schema.js'
|
||||
|
||||
export { LogLevel, AppConfigUpdateSchema } from './config.schema.js'
|
||||
|
||||
@@ -104,6 +104,13 @@ export const DrawerOpenSchema = z.object({
|
||||
})
|
||||
export type DrawerOpenInput = z.infer<typeof DrawerOpenSchema>
|
||||
|
||||
export const DrawerAdjustmentSchema = z.object({
|
||||
type: z.enum(['cash_in', 'cash_out']),
|
||||
amount: z.coerce.number().min(0.01),
|
||||
reason: z.string().min(1),
|
||||
})
|
||||
export type DrawerAdjustmentInput = z.infer<typeof DrawerAdjustmentSchema>
|
||||
|
||||
export const DrawerCloseSchema = z.object({
|
||||
closingBalance: z.coerce.number().min(0),
|
||||
denominations: z
|
||||
|
||||
Reference in New Issue
Block a user