From 1673e18fe803b2a49d0f879c9f3855f8c391ef01 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 4 Apr 2026 19:54:07 +0000 Subject: [PATCH] fix: require open drawer to complete transactions, fix product price field - Backend enforces open drawer at location before completing any transaction - Frontend disables payment buttons when drawer is closed with warning message - Fix product price field name (price, not sellingPrice) in POS API types - Fix seed UUIDs to use valid UUID v4 format (version nibble must be 1-8) - Fix Vite allowedHosts for dev.lunarfront.tech access - Add e2e test for drawer enforcement (39 POS tests now pass) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/admin/src/api/pos.ts | 2 +- .../src/components/pos/pos-cart-panel.tsx | 78 ++++++++++--------- .../src/components/pos/pos-item-panel.tsx | 12 +-- packages/admin/vite.config.ts | 1 + packages/backend/api-tests/suites/pos.ts | 57 ++++++++++++++ packages/backend/src/db/seeds/dev-seed.ts | 4 +- .../backend/src/db/seeds/music-store-seed.ts | 2 +- .../src/services/transaction.service.ts | 13 ++++ 8 files changed, 125 insertions(+), 44 deletions(-) diff --git a/packages/admin/src/api/pos.ts b/packages/admin/src/api/pos.ts index 3c525aa..9a48b95 100644 --- a/packages/admin/src/api/pos.ts +++ b/packages/admin/src/api/pos.ts @@ -80,7 +80,7 @@ export interface Product { sku: string | null upc: string | null description: string | null - sellingPrice: string | null + price: string | null costPrice: string | null qtyOnHand: number | null taxCategory: string diff --git a/packages/admin/src/components/pos/pos-cart-panel.tsx b/packages/admin/src/components/pos/pos-cart-panel.tsx index 3f530da..36caad4 100644 --- a/packages/admin/src/components/pos/pos-cart-panel.tsx +++ b/packages/admin/src/components/pos/pos-cart-panel.tsx @@ -18,6 +18,9 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) { const [paymentMethod, setPaymentMethod] = useState(null) const lineItems = transaction?.lineItems ?? [] + const drawerSessionId = usePOSStore((s) => s.drawerSessionId) + const drawerOpen = !!drawerSessionId + const removeItemMutation = useMutation({ mutationFn: (lineItemId: string) => posMutations.removeLineItem(currentTransactionId!, lineItemId), @@ -126,41 +129,46 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) { {/* Payment buttons */} -
- - - - +
+ {!drawerOpen && hasItems && ( +

Open the drawer before accepting payment

+ )} +
+ + + + +
diff --git a/packages/admin/src/components/pos/pos-item-panel.tsx b/packages/admin/src/components/pos/pos-item-panel.tsx index bf4bb7f..6b1381b 100644 --- a/packages/admin/src/components/pos/pos-item-panel.tsx +++ b/packages/admin/src/components/pos/pos-item-panel.tsx @@ -48,11 +48,12 @@ export function POSItemPanel({ transaction }: POSItemPanelProps) { productId: product.id, description: product.name, qty: 1, - unitPrice: parseFloat(product.sellingPrice ?? '0'), + unitPrice: parseFloat(product.price ?? '0'), }) }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId ?? '') }) + const txnId = usePOSStore.getState().currentTransactionId + queryClient.invalidateQueries({ queryKey: posKeys.transaction(txnId ?? '') }) }, onError: (err) => toast.error(err.message), }) @@ -76,7 +77,8 @@ export function POSItemPanel({ transaction }: POSItemPanelProps) { }) }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId ?? '') }) + const txnId = usePOSStore.getState().currentTransactionId + queryClient.invalidateQueries({ queryKey: posKeys.transaction(txnId ?? '') }) setCustomOpen(false) setCustomDesc('') setCustomPrice('') @@ -102,7 +104,7 @@ export function POSItemPanel({ transaction }: POSItemPanelProps) { productId: product.id, description: product.name, qty: 1, - unitPrice: parseFloat(product.sellingPrice ?? '0'), + unitPrice: parseFloat(product.price ?? '0'), }) }, onSuccess: () => { @@ -158,7 +160,7 @@ export function POSItemPanel({ transaction }: POSItemPanelProps) { > {product.name}
- ${parseFloat(product.sellingPrice ?? '0').toFixed(2)} + ${parseFloat(product.price ?? '0').toFixed(2)} {product.sku && {product.sku}}
{product.isSerialized ? ( diff --git a/packages/admin/vite.config.ts b/packages/admin/vite.config.ts index b705dae..a67d2af 100644 --- a/packages/admin/vite.config.ts +++ b/packages/admin/vite.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ }, server: { port: 5173, + allowedHosts: ['dev.lunarfront.tech'], proxy: { '/v1': { target: 'http://localhost:8000', diff --git a/packages/backend/api-tests/suites/pos.ts b/packages/backend/api-tests/suites/pos.ts index 6822d39..dd0d5b7 100644 --- a/packages/backend/api-tests/suites/pos.ts +++ b/packages/backend/api-tests/suites/pos.ts @@ -273,6 +273,36 @@ suite('POS', { tags: ['pos'] }, (t) => { // ─── Complete Transaction ────────────────────────────────────────────────── + t.test('rejects completing transaction without open drawer', { tags: ['transactions', 'complete', 'validation', 'drawer'] }, async () => { + // Ensure no drawer is open at LOCATION_ID + 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 }) + } + + 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: 'No Drawer Item', + qty: 1, + unitPrice: 10, + }) + + const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, { + paymentMethod: 'cash', + amountTendered: 20, + }) + t.assert.status(res, 400) + + // Void to clean up + await t.api.post(`/v1/transactions/${txn.data.id}/void`) + }) + + // Open a drawer for the remaining complete tests + t.test('opens drawer for complete tests', { tags: ['transactions', 'complete', 'setup'] }, async () => { + const res = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 200 }) + t.assert.status(res, 201) + }) + t.test('completes a cash transaction with change', { tags: ['transactions', 'complete', 'cash'] }, async () => { const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID }) await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, { @@ -427,6 +457,18 @@ suite('POS', { tags: ['pos'] }, (t) => { // ─── Cash Rounding ───────────────────────────────────────────────────────── + // Close the LOCATION_ID drawer and open one at ROUNDING_LOCATION_ID + t.test('setup drawer for rounding tests', { tags: ['transactions', 'rounding', 'setup'] }, async () => { + // Close drawer at LOCATION_ID + 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: 200 }) + } + // Open drawer at ROUNDING_LOCATION_ID + const res = await t.api.post('/v1/drawer/open', { locationId: ROUNDING_LOCATION_ID, openingBalance: 200 }) + t.assert.status(res, 201) + }) + t.test('cash rounding adjusts total to nearest nickel', { tags: ['transactions', 'rounding'] }, async () => { // Create transaction at the rounding-enabled location const txn = await t.api.post('/v1/transactions', { @@ -484,6 +526,10 @@ suite('POS', { tags: ['pos'] }, (t) => { }) t.test('no rounding at non-rounding location', { tags: ['transactions', 'rounding'] }, async () => { + // Open drawer at LOCATION_ID for this test + 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, @@ -500,6 +546,17 @@ suite('POS', { tags: ['pos'] }, (t) => { }) t.assert.status(res, 200) t.assert.equal(parseFloat(res.data.roundingAdjustment), 0) + + // Cleanup + await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 100 }) + }) + + // Close rounding location drawer + t.test('cleanup rounding drawer', { tags: ['transactions', 'rounding', 'setup'] }, async () => { + const current = await t.api.get('/v1/drawer/current', { locationId: ROUNDING_LOCATION_ID }) + if (current.status === 200 && current.data.id) { + await t.api.post(`/v1/drawer/${current.data.id}/close`, { closingBalance: 200 }) + } }) // ─── Full POS Flow ──────────────────────────────────────────────────────── diff --git a/packages/backend/src/db/seeds/dev-seed.ts b/packages/backend/src/db/seeds/dev-seed.ts index 1fb15ef..8a49f49 100644 --- a/packages/backend/src/db/seeds/dev-seed.ts +++ b/packages/backend/src/db/seeds/dev-seed.ts @@ -7,7 +7,7 @@ import postgres from 'postgres' const DB_URL = process.env.DATABASE_URL ?? 'postgresql://lunarfront:lunarfront@localhost:5432/lunarfront' -const COMPANY_ID = 'a0000000-0000-0000-0000-000000000001' +const COMPANY_ID = 'a0000000-0000-4000-8000-000000000001' const sql = postgres(DB_URL) @@ -18,7 +18,7 @@ async function seed() { const [company] = await sql`SELECT id FROM company WHERE id = ${COMPANY_ID}` if (!company) { await sql`INSERT INTO company (id, name, timezone) VALUES (${COMPANY_ID}, 'Demo Store', 'America/Chicago')` - await sql`INSERT INTO location (id, name) VALUES ('a0000000-0000-0000-0000-000000000002', 'Main Store')` + await sql`INSERT INTO location (id, name, tax_rate, service_tax_rate) VALUES ('a0000000-0000-4000-8000-000000000002', 'Main Store', '0.0825', '0.0825')` console.log(' Created company and location') // Seed RBAC diff --git a/packages/backend/src/db/seeds/music-store-seed.ts b/packages/backend/src/db/seeds/music-store-seed.ts index 4b58f81..e8a3692 100644 --- a/packages/backend/src/db/seeds/music-store-seed.ts +++ b/packages/backend/src/db/seeds/music-store-seed.ts @@ -8,7 +8,7 @@ import postgres from 'postgres' const DB_URL = process.env.DATABASE_URL ?? 'postgresql://lunarfront:lunarfront@localhost:5432/lunarfront' -const COMPANY_ID = 'a0000000-0000-0000-0000-000000000001' +const COMPANY_ID = 'a0000000-0000-4000-8000-000000000001' const sql = postgres(DB_URL) diff --git a/packages/backend/src/services/transaction.service.ts b/packages/backend/src/services/transaction.service.ts index 7e53298..959666c 100644 --- a/packages/backend/src/services/transaction.service.ts +++ b/packages/backend/src/services/transaction.service.ts @@ -5,6 +5,7 @@ import { transactionLineItems, discountAudits, discounts, + drawerSessions, } from '../db/schema/pos.js' import { products, inventoryUnits } from '../db/schema/inventory.js' import { companies, locations } from '../db/schema/stores.js' @@ -231,6 +232,18 @@ export const TransactionService = { if (!txn) throw new NotFoundError('Transaction') if (txn.status !== 'pending') throw new ConflictError('Transaction is not pending') + // Require an open drawer session at the transaction's location + if (txn.locationId) { + const [openDrawer] = await db + .select({ id: drawerSessions.id }) + .from(drawerSessions) + .where(and(eq(drawerSessions.locationId, txn.locationId), eq(drawerSessions.status, 'open'))) + .limit(1) + if (!openDrawer) { + throw new ValidationError('Cannot complete transaction without an open drawer at this location') + } + } + // Validate cash payment (with optional nickel rounding) let changeGiven: string | undefined let roundingAdjustment = 0