diff --git a/packages/backend/api-tests/suites/pos.ts b/packages/backend/api-tests/suites/pos.ts index dd0d5b7..4f1c48e 100644 --- a/packages/backend/api-tests/suites/pos.ts +++ b/packages/backend/api-tests/suites/pos.ts @@ -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 () => { diff --git a/packages/backend/src/db/migrations/0042_drawer-adjustments.sql b/packages/backend/src/db/migrations/0042_drawer-adjustments.sql new file mode 100644 index 0000000..5fc4958 --- /dev/null +++ b/packages/backend/src/db/migrations/0042_drawer-adjustments.sql @@ -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 +); diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index fb835fd..76c67bb 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -295,6 +295,13 @@ "when": 1775580000000, "tag": "0041_app_settings", "breakpoints": true + }, + { + "idx": 42, + "version": "7", + "when": 1775590000000, + "tag": "0042_drawer-adjustments", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/pos.ts b/packages/backend/src/db/schema/pos.ts index ae86504..af595c5 100644 --- a/packages/backend/src/db/schema/pos.ts +++ b/packages/backend/src/db/schema/pos.ts @@ -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), diff --git a/packages/backend/src/routes/v1/drawer.ts b/packages/backend/src/routes/v1/drawer.ts index 569e9c3..6212b6d 100644 --- a/packages/backend/src/routes/v1/drawer.ts +++ b/packages/backend/src/routes/v1/drawer.ts @@ -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) diff --git a/packages/backend/src/services/drawer.service.ts b/packages/backend/src/services/drawer.service.ts index bc17496..dd992de 100644 --- a/packages/backend/src/services/drawer.service.ts +++ b/packages/backend/src/services/drawer.service.ts @@ -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`coalesce(sum(case when ${drawerAdjustments.type} = 'cash_in' then ${drawerAdjustments.amount} else 0 end), 0)`, + cashOut: sql`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, 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, sessionId: string) { + return db + .select() + .from(drawerAdjustments) + .where(eq(drawerAdjustments.drawerSessionId, sessionId)) + }, + async list(db: PostgresJsDatabase, params: PaginationInput) { const sortableColumns: Record = { opened_at: drawerSessions.openedAt, diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 7416413..89f72c4 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -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' diff --git a/packages/shared/src/schemas/pos.schema.ts b/packages/shared/src/schemas/pos.schema.ts index d826247..b8b94dd 100644 --- a/packages/shared/src/schemas/pos.schema.ts +++ b/packages/shared/src/schemas/pos.schema.ts @@ -104,6 +104,13 @@ export const DrawerOpenSchema = z.object({ }) export type DrawerOpenInput = z.infer +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 + export const DrawerCloseSchema = z.object({ closingBalance: z.coerce.number().min(0), denominations: z