fix: require open drawer to complete transactions, fix product price field
Some checks failed
CI / ci (pull_request) Failing after 20s
CI / e2e (pull_request) Has been skipped

- 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) <noreply@anthropic.com>
This commit is contained in:
ryan
2026-04-04 19:54:07 +00:00
parent bd3a25aa1c
commit 1673e18fe8
8 changed files with 125 additions and 44 deletions

View File

@@ -80,7 +80,7 @@ export interface Product {
sku: string | null sku: string | null
upc: string | null upc: string | null
description: string | null description: string | null
sellingPrice: string | null price: string | null
costPrice: string | null costPrice: string | null
qtyOnHand: number | null qtyOnHand: number | null
taxCategory: string taxCategory: string

View File

@@ -18,6 +18,9 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
const [paymentMethod, setPaymentMethod] = useState<string | null>(null) const [paymentMethod, setPaymentMethod] = useState<string | null>(null)
const lineItems = transaction?.lineItems ?? [] const lineItems = transaction?.lineItems ?? []
const drawerSessionId = usePOSStore((s) => s.drawerSessionId)
const drawerOpen = !!drawerSessionId
const removeItemMutation = useMutation({ const removeItemMutation = useMutation({
mutationFn: (lineItemId: string) => mutationFn: (lineItemId: string) =>
posMutations.removeLineItem(currentTransactionId!, lineItemId), posMutations.removeLineItem(currentTransactionId!, lineItemId),
@@ -126,10 +129,14 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
</div> </div>
{/* Payment buttons */} {/* Payment buttons */}
<div className="p-3 grid grid-cols-2 gap-2"> <div className="p-3 space-y-2">
{!drawerOpen && hasItems && (
<p className="text-xs text-destructive text-center">Open the drawer before accepting payment</p>
)}
<div className="grid grid-cols-2 gap-2">
<Button <Button
className="h-12 text-sm gap-2" className="h-12 text-sm gap-2"
disabled={!hasItems || !isPending} disabled={!hasItems || !isPending || !drawerOpen}
onClick={() => setPaymentMethod('cash')} onClick={() => setPaymentMethod('cash')}
> >
<Banknote className="h-4 w-4" /> <Banknote className="h-4 w-4" />
@@ -137,7 +144,7 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
</Button> </Button>
<Button <Button
className="h-12 text-sm gap-2" className="h-12 text-sm gap-2"
disabled={!hasItems || !isPending} disabled={!hasItems || !isPending || !drawerOpen}
onClick={() => setPaymentMethod('card_present')} onClick={() => setPaymentMethod('card_present')}
> >
<CreditCard className="h-4 w-4" /> <CreditCard className="h-4 w-4" />
@@ -146,7 +153,7 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
<Button <Button
variant="outline" variant="outline"
className="h-12 text-sm gap-2" className="h-12 text-sm gap-2"
disabled={!hasItems || !isPending} disabled={!hasItems || !isPending || !drawerOpen}
onClick={() => setPaymentMethod('check')} onClick={() => setPaymentMethod('check')}
> >
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
@@ -163,6 +170,7 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
</Button> </Button>
</div> </div>
</div> </div>
</div>
{/* Payment dialog */} {/* Payment dialog */}
{paymentMethod && transaction && ( {paymentMethod && transaction && (

View File

@@ -48,11 +48,12 @@ export function POSItemPanel({ transaction }: POSItemPanelProps) {
productId: product.id, productId: product.id,
description: product.name, description: product.name,
qty: 1, qty: 1,
unitPrice: parseFloat(product.sellingPrice ?? '0'), unitPrice: parseFloat(product.price ?? '0'),
}) })
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId ?? '') }) const txnId = usePOSStore.getState().currentTransactionId
queryClient.invalidateQueries({ queryKey: posKeys.transaction(txnId ?? '') })
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
@@ -76,7 +77,8 @@ export function POSItemPanel({ transaction }: POSItemPanelProps) {
}) })
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId ?? '') }) const txnId = usePOSStore.getState().currentTransactionId
queryClient.invalidateQueries({ queryKey: posKeys.transaction(txnId ?? '') })
setCustomOpen(false) setCustomOpen(false)
setCustomDesc('') setCustomDesc('')
setCustomPrice('') setCustomPrice('')
@@ -102,7 +104,7 @@ export function POSItemPanel({ transaction }: POSItemPanelProps) {
productId: product.id, productId: product.id,
description: product.name, description: product.name,
qty: 1, qty: 1,
unitPrice: parseFloat(product.sellingPrice ?? '0'), unitPrice: parseFloat(product.price ?? '0'),
}) })
}, },
onSuccess: () => { onSuccess: () => {
@@ -158,7 +160,7 @@ export function POSItemPanel({ transaction }: POSItemPanelProps) {
> >
<span className="font-medium text-sm line-clamp-2">{product.name}</span> <span className="font-medium text-sm line-clamp-2">{product.name}</span>
<div className="mt-auto flex items-center justify-between w-full pt-1"> <div className="mt-auto flex items-center justify-between w-full pt-1">
<span className="text-base font-semibold">${parseFloat(product.sellingPrice ?? '0').toFixed(2)}</span> <span className="text-base font-semibold">${parseFloat(product.price ?? '0').toFixed(2)}</span>
{product.sku && <span className="text-xs text-muted-foreground">{product.sku}</span>} {product.sku && <span className="text-xs text-muted-foreground">{product.sku}</span>}
</div> </div>
{product.isSerialized ? ( {product.isSerialized ? (

View File

@@ -17,6 +17,7 @@ export default defineConfig({
}, },
server: { server: {
port: 5173, port: 5173,
allowedHosts: ['dev.lunarfront.tech'],
proxy: { proxy: {
'/v1': { '/v1': {
target: 'http://localhost:8000', target: 'http://localhost:8000',

View File

@@ -273,6 +273,36 @@ suite('POS', { tags: ['pos'] }, (t) => {
// ─── Complete Transaction ────────────────────────────────────────────────── // ─── 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 () => { 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 }) const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, { await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
@@ -427,6 +457,18 @@ suite('POS', { tags: ['pos'] }, (t) => {
// ─── Cash Rounding ───────────────────────────────────────────────────────── // ─── 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 () => { t.test('cash rounding adjusts total to nearest nickel', { tags: ['transactions', 'rounding'] }, async () => {
// Create transaction at the rounding-enabled location // Create transaction at the rounding-enabled location
const txn = await t.api.post('/v1/transactions', { 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 () => { 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', { const txn = await t.api.post('/v1/transactions', {
transactionType: 'sale', transactionType: 'sale',
locationId: LOCATION_ID, locationId: LOCATION_ID,
@@ -500,6 +546,17 @@ suite('POS', { tags: ['pos'] }, (t) => {
}) })
t.assert.status(res, 200) t.assert.status(res, 200)
t.assert.equal(parseFloat(res.data.roundingAdjustment), 0) 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 ──────────────────────────────────────────────────────── // ─── Full POS Flow ────────────────────────────────────────────────────────

View File

@@ -7,7 +7,7 @@
import postgres from 'postgres' import postgres from 'postgres'
const DB_URL = process.env.DATABASE_URL ?? 'postgresql://lunarfront:lunarfront@localhost:5432/lunarfront' 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) const sql = postgres(DB_URL)
@@ -18,7 +18,7 @@ async function seed() {
const [company] = await sql`SELECT id FROM company WHERE id = ${COMPANY_ID}` const [company] = await sql`SELECT id FROM company WHERE id = ${COMPANY_ID}`
if (!company) { if (!company) {
await sql`INSERT INTO company (id, name, timezone) VALUES (${COMPANY_ID}, 'Demo Store', 'America/Chicago')` 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') console.log(' Created company and location')
// Seed RBAC // Seed RBAC

View File

@@ -8,7 +8,7 @@
import postgres from 'postgres' import postgres from 'postgres'
const DB_URL = process.env.DATABASE_URL ?? 'postgresql://lunarfront:lunarfront@localhost:5432/lunarfront' 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) const sql = postgres(DB_URL)

View File

@@ -5,6 +5,7 @@ import {
transactionLineItems, transactionLineItems,
discountAudits, discountAudits,
discounts, discounts,
drawerSessions,
} from '../db/schema/pos.js' } from '../db/schema/pos.js'
import { products, inventoryUnits } from '../db/schema/inventory.js' import { products, inventoryUnits } from '../db/schema/inventory.js'
import { companies, locations } from '../db/schema/stores.js' import { companies, locations } from '../db/schema/stores.js'
@@ -231,6 +232,18 @@ export const TransactionService = {
if (!txn) throw new NotFoundError('Transaction') if (!txn) throw new NotFoundError('Transaction')
if (txn.status !== 'pending') throw new ConflictError('Transaction is not pending') 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) // Validate cash payment (with optional nickel rounding)
let changeGiven: string | undefined let changeGiven: string | undefined
let roundingAdjustment = 0 let roundingAdjustment = 0