Compare commits
2 Commits
feature/ac
...
8aed3e8f88
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aed3e8f88 | ||
|
|
c66554f932 |
@@ -90,9 +90,20 @@ export interface Product {
|
|||||||
|
|
||||||
// --- Query Keys ---
|
// --- Query Keys ---
|
||||||
|
|
||||||
|
export interface DrawerAdjustment {
|
||||||
|
id: string
|
||||||
|
drawerSessionId: string
|
||||||
|
type: string
|
||||||
|
amount: string
|
||||||
|
reason: string
|
||||||
|
createdBy: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export const posKeys = {
|
export const posKeys = {
|
||||||
transaction: (id: string) => ['pos', 'transaction', id] as const,
|
transaction: (id: string) => ['pos', 'transaction', id] as const,
|
||||||
drawer: (locationId: string) => ['pos', 'drawer', locationId] as const,
|
drawer: (locationId: string) => ['pos', 'drawer', locationId] as const,
|
||||||
|
drawerAdjustments: (id: string) => ['pos', 'drawer-adjustments', id] as const,
|
||||||
products: (search: string) => ['pos', 'products', search] as const,
|
products: (search: string) => ['pos', 'products', search] as const,
|
||||||
discounts: ['pos', 'discounts'] as const,
|
discounts: ['pos', 'discounts'] as const,
|
||||||
}
|
}
|
||||||
@@ -160,4 +171,10 @@ export const posMutations = {
|
|||||||
|
|
||||||
lookupUpc: (upc: string) =>
|
lookupUpc: (upc: string) =>
|
||||||
api.get<Product>(`/v1/products/lookup/upc/${upc}`),
|
api.get<Product>(`/v1/products/lookup/upc/${upc}`),
|
||||||
|
|
||||||
|
addAdjustment: (drawerId: string, data: { type: string; amount: number; reason: string }) =>
|
||||||
|
api.post<DrawerAdjustment>(`/v1/drawer/${drawerId}/adjustments`, data),
|
||||||
|
|
||||||
|
getAdjustments: (drawerId: string) =>
|
||||||
|
api.get<{ data: DrawerAdjustment[] }>(`/v1/drawer/${drawerId}/adjustments`),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { usePOSStore } from '@/stores/pos.store'
|
import { usePOSStore } from '@/stores/pos.store'
|
||||||
import { posMutations, posKeys, type DrawerSession } from '@/api/pos'
|
import { posMutations, posKeys, type DrawerSession } from '@/api/pos'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
@@ -7,6 +7,8 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { ArrowDownToLine, ArrowUpFromLine } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
interface POSDrawerDialogProps {
|
interface POSDrawerDialogProps {
|
||||||
@@ -23,6 +25,17 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP
|
|||||||
const [openingBalance, setOpeningBalance] = useState('200')
|
const [openingBalance, setOpeningBalance] = useState('200')
|
||||||
const [closingBalance, setClosingBalance] = useState('')
|
const [closingBalance, setClosingBalance] = useState('')
|
||||||
const [notes, setNotes] = useState('')
|
const [notes, setNotes] = useState('')
|
||||||
|
const [adjustView, setAdjustView] = useState<'cash_in' | 'cash_out' | null>(null)
|
||||||
|
const [adjAmount, setAdjAmount] = useState('')
|
||||||
|
const [adjReason, setAdjReason] = useState('')
|
||||||
|
|
||||||
|
// Fetch adjustments for open drawer
|
||||||
|
const { data: adjData } = useQuery({
|
||||||
|
queryKey: posKeys.drawerAdjustments(drawer?.id ?? ''),
|
||||||
|
queryFn: () => posMutations.getAdjustments(drawer!.id),
|
||||||
|
enabled: !!drawer?.id && isOpen,
|
||||||
|
})
|
||||||
|
const adjustments = adjData?.data ?? []
|
||||||
|
|
||||||
const openMutation = useMutation({
|
const openMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
@@ -59,11 +72,78 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP
|
|||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const adjustMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
posMutations.addAdjustment(drawer!.id, {
|
||||||
|
type: adjustView!,
|
||||||
|
amount: parseFloat(adjAmount) || 0,
|
||||||
|
reason: adjReason,
|
||||||
|
}),
|
||||||
|
onSuccess: (adj) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: posKeys.drawerAdjustments(drawer!.id) })
|
||||||
|
toast.success(`${adj.type === 'cash_in' ? 'Cash added' : 'Cash removed'}: $${parseFloat(adj.amount).toFixed(2)}`)
|
||||||
|
setAdjustView(null)
|
||||||
|
setAdjAmount('')
|
||||||
|
setAdjReason('')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Adjustment entry view
|
||||||
|
if (adjustView && isOpen) {
|
||||||
|
const isCashIn = adjustView === 'cash_in'
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isCashIn ? 'Cash In' : 'Cash Out'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Amount *</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
value={adjAmount}
|
||||||
|
onChange={(e) => setAdjAmount(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
className="h-11 text-lg"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Reason *</Label>
|
||||||
|
<Input
|
||||||
|
value={adjReason}
|
||||||
|
onChange={(e) => setAdjReason(e.target.value)}
|
||||||
|
placeholder={isCashIn ? 'e.g. Extra change' : 'e.g. Bank deposit'}
|
||||||
|
className="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
className="flex-1 h-12"
|
||||||
|
onClick={() => adjustMutation.mutate()}
|
||||||
|
disabled={!adjAmount || !adjReason || adjustMutation.isPending}
|
||||||
|
>
|
||||||
|
{adjustMutation.isPending ? 'Saving...' : isCashIn ? 'Add Cash' : 'Remove Cash'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="h-12" onClick={() => setAdjustView(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-sm">
|
<DialogContent className="max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{isOpen ? 'Close Drawer' : 'Open Drawer'}</DialogTitle>
|
<DialogTitle>{isOpen ? 'Drawer' : 'Open Drawer'}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
@@ -78,7 +158,53 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP
|
|||||||
<span>{new Date(drawer!.openedAt).toLocaleTimeString()}</span>
|
<span>{new Date(drawer!.openedAt).toLocaleTimeString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Cash In / Cash Out buttons */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-11 gap-2"
|
||||||
|
onClick={() => setAdjustView('cash_in')}
|
||||||
|
>
|
||||||
|
<ArrowDownToLine className="h-4 w-4 text-green-600" />
|
||||||
|
Cash In
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-11 gap-2"
|
||||||
|
onClick={() => setAdjustView('cash_out')}
|
||||||
|
>
|
||||||
|
<ArrowUpFromLine className="h-4 w-4 text-red-600" />
|
||||||
|
Cash Out
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Adjustment history */}
|
||||||
|
{adjustments.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Adjustments</span>
|
||||||
|
{adjustments.map((adj) => (
|
||||||
|
<div key={adj.id} className="flex items-center justify-between text-sm py-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={adj.type === 'cash_in' ? 'default' : 'destructive'} className="text-[10px]">
|
||||||
|
{adj.type === 'cash_in' ? 'IN' : 'OUT'}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground truncate max-w-[140px]">{adj.reason}</span>
|
||||||
|
</div>
|
||||||
|
<span className={adj.type === 'cash_in' ? 'text-green-600' : 'text-red-600'}>
|
||||||
|
{adj.type === 'cash_in' ? '+' : '-'}${parseFloat(adj.amount).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* Close drawer */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Closing Balance *</Label>
|
<Label>Closing Balance *</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -89,7 +215,6 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP
|
|||||||
onChange={(e) => setClosingBalance(e.target.value)}
|
onChange={(e) => setClosingBalance(e.target.value)}
|
||||||
placeholder="Count the cash in the drawer"
|
placeholder="Count the cash in the drawer"
|
||||||
className="h-11 text-lg"
|
className="h-11 text-lg"
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -64,9 +64,7 @@ export function POSTopBar({ locations, locationId, onLocationChange, drawer }: P
|
|||||||
>
|
>
|
||||||
<DollarSign className="h-4 w-4" />
|
<DollarSign className="h-4 w-4" />
|
||||||
{drawerOpen ? (
|
{drawerOpen ? (
|
||||||
<Badge variant="default" className="text-xs">
|
<Badge variant="default" className="text-xs">Drawer Open</Badge>
|
||||||
Drawer Open — ${parseFloat(drawer!.openingBalance).toFixed(2)}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="text-xs">Drawer Closed</Badge>
|
<Badge variant="outline" className="text-xs">Drawer Closed</Badge>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -114,6 +114,81 @@ suite('POS', { tags: ['pos'] }, (t) => {
|
|||||||
t.assert.ok(res.data.pagination)
|
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 ──────────────────────────────────────────────────────────
|
// ─── Transactions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
t.test('creates a sale transaction', { tags: ['transactions', 'create'] }, async () => {
|
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,
|
"when": 1775494000000,
|
||||||
"tag": "0040_app-config",
|
"tag": "0040_app-config",
|
||||||
"breakpoints": true
|
"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(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const adjustmentTypeEnum = pgEnum('adjustment_type', ['cash_in', 'cash_out'])
|
||||||
|
|
||||||
export const drawerSessions = pgTable('drawer_session', {
|
export const drawerSessions = pgTable('drawer_session', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
locationId: uuid('location_id').references(() => locations.id),
|
locationId: uuid('location_id').references(() => locations.id),
|
||||||
@@ -86,6 +88,20 @@ export const drawerSessions = pgTable('drawer_session', {
|
|||||||
closedAt: timestamp('closed_at', { withTimezone: true }),
|
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', {
|
export const transactions = pgTable('transaction', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
locationId: uuid('location_id').references(() => locations.id),
|
locationId: uuid('location_id').references(() => locations.id),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyPluginAsync } from 'fastify'
|
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'
|
import { DrawerService } from '../../services/drawer.service.js'
|
||||||
|
|
||||||
export const drawerRoutes: FastifyPluginAsync = async (app) => {
|
export const drawerRoutes: FastifyPluginAsync = async (app) => {
|
||||||
@@ -35,6 +35,23 @@ export const drawerRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return reply.send(session)
|
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) => {
|
app.get('/drawer/:id', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
|
||||||
const { id } = request.params as { id: string }
|
const { id } = request.params as { id: string }
|
||||||
const session = await DrawerService.getById(app.db, id)
|
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 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 { 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'
|
import { withPagination, withSort, paginatedResponse } from '../utils/pagination.js'
|
||||||
|
|
||||||
export const DrawerService = {
|
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 openingBalance = parseFloat(session.openingBalance)
|
||||||
const expectedBalance = openingBalance + cashIn
|
const expectedBalance = openingBalance + salesCashIn + adjCashIn - adjCashOut
|
||||||
const closingBalance = input.closingBalance
|
const closingBalance = input.closingBalance
|
||||||
const overShort = closingBalance - expectedBalance
|
const overShort = closingBalance - expectedBalance
|
||||||
|
|
||||||
@@ -93,6 +104,31 @@ export const DrawerService = {
|
|||||||
return session ?? null
|
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) {
|
async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
|
||||||
const sortableColumns: Record<string, Column> = {
|
const sortableColumns: Record<string, Column> = {
|
||||||
opened_at: drawerSessions.openedAt,
|
opened_at: drawerSessions.openedAt,
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ export {
|
|||||||
DiscountUpdateSchema,
|
DiscountUpdateSchema,
|
||||||
DrawerOpenSchema,
|
DrawerOpenSchema,
|
||||||
DrawerCloseSchema,
|
DrawerCloseSchema,
|
||||||
|
DrawerAdjustmentSchema,
|
||||||
} from './pos.schema.js'
|
} from './pos.schema.js'
|
||||||
export type {
|
export type {
|
||||||
TransactionCreateInput,
|
TransactionCreateInput,
|
||||||
@@ -190,6 +191,7 @@ export type {
|
|||||||
DiscountUpdateInput,
|
DiscountUpdateInput,
|
||||||
DrawerOpenInput,
|
DrawerOpenInput,
|
||||||
DrawerCloseInput,
|
DrawerCloseInput,
|
||||||
|
DrawerAdjustmentInput,
|
||||||
} from './pos.schema.js'
|
} from './pos.schema.js'
|
||||||
|
|
||||||
export { LogLevel, AppConfigUpdateSchema } from './config.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 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({
|
export const DrawerCloseSchema = z.object({
|
||||||
closingBalance: z.coerce.number().min(0),
|
closingBalance: z.coerce.number().min(0),
|
||||||
denominations: z
|
denominations: z
|
||||||
|
|||||||
Reference in New Issue
Block a user