diff --git a/packages/admin/src/api/pos.ts b/packages/admin/src/api/pos.ts index d881a18..cccfef7 100644 --- a/packages/admin/src/api/pos.ts +++ b/packages/admin/src/api/pos.ts @@ -88,6 +88,15 @@ export interface Product { isActive: boolean } +export interface Register { + id: string + locationId: string + name: string + isActive: boolean + createdAt: string + updatedAt: string +} + // --- Query Keys --- export interface DrawerAdjustment { @@ -104,6 +113,9 @@ export const posKeys = { transaction: (id: string) => ['pos', 'transaction', id] as const, drawer: (locationId: string) => ['pos', 'drawer', locationId] as const, drawerAdjustments: (id: string) => ['pos', 'drawer-adjustments', id] as const, + drawerReport: (id: string) => ['pos', 'drawer-report', id] as const, + dailyReport: (locationId: string, date: string) => ['pos', 'daily-report', locationId, date] as const, + registers: (locationId: string) => ['pos', 'registers', locationId] as const, products: (search: string) => ['pos', 'products', search] as const, discounts: ['pos', 'discounts'] as const, } @@ -148,6 +160,30 @@ export function discountListOptions() { }) } +export function registerListOptions(locationId: string | null) { + return queryOptions({ + queryKey: posKeys.registers(locationId ?? ''), + queryFn: () => api.get<{ data: Register[] }>('/v1/registers/all', { locationId }), + enabled: !!locationId, + }) +} + +export function drawerReportOptions(drawerSessionId: string | null) { + return queryOptions({ + queryKey: posKeys.drawerReport(drawerSessionId ?? ''), + queryFn: () => api.get(`/v1/reports/drawer/${drawerSessionId}`), + enabled: !!drawerSessionId, + }) +} + +export function dailyReportOptions(locationId: string | null, date: string) { + return queryOptions({ + queryKey: posKeys.dailyReport(locationId ?? '', date), + queryFn: () => api.get('/v1/reports/daily', { locationId, date }), + enabled: !!locationId && !!date, + }) +} + // --- Mutations --- export const posMutations = { @@ -169,7 +205,7 @@ export const posMutations = { void: (txnId: string) => api.post(`/v1/transactions/${txnId}/void`, {}), - openDrawer: (data: { locationId?: string; openingBalance: number }) => + openDrawer: (data: { locationId?: string; registerId?: string; openingBalance: number }) => api.post('/v1/drawer/open', data), closeDrawer: (id: string, data: { closingBalance: number; denominations?: Record; notes?: string }) => diff --git a/packages/admin/src/components/pos/pos-drawer-dialog.tsx b/packages/admin/src/components/pos/pos-drawer-dialog.tsx index 0e0f6cf..37c59bb 100644 --- a/packages/admin/src/components/pos/pos-drawer-dialog.tsx +++ b/packages/admin/src/components/pos/pos-drawer-dialog.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { usePOSStore } from '@/stores/pos.store' -import { posMutations, posKeys, type DrawerSession } from '@/api/pos' +import { posMutations, posKeys, drawerReportOptions, type DrawerSession } from '@/api/pos' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -20,7 +20,7 @@ interface POSDrawerDialogProps { export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogProps) { const queryClient = useQueryClient() - const { locationId, setDrawerSession } = usePOSStore() + const { locationId, registerId, setDrawerSession } = usePOSStore() const isOpen = drawer?.status === 'open' const [openingBalance, setOpeningBalance] = useState('200') @@ -31,6 +31,15 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP const [adjReason, setAdjReason] = useState('') const [overrideOpen, setOverrideOpen] = useState(false) const [pendingAdjustView, setPendingAdjustView] = useState<'cash_in' | 'cash_out' | null>(null) + const [showZReport, setShowZReport] = useState(false) + const [closedDrawerId, setClosedDrawerId] = useState(null) + const [showXReport, setShowXReport] = useState(false) + + // Z Report data (after close) + const { data: reportData } = useQuery(drawerReportOptions(closedDrawerId)) + + // X Report data (live, for open drawer) + const { data: xReportData } = useQuery(drawerReportOptions(showXReport ? drawer?.id ?? null : null)) // Fetch adjustments for open drawer const { data: adjData } = useQuery({ @@ -44,6 +53,7 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP mutationFn: () => posMutations.openDrawer({ locationId: locationId ?? undefined, + registerId: registerId ?? undefined, openingBalance: parseFloat(openingBalance) || 0, }), onSuccess: async (session) => { @@ -69,7 +79,9 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP } else { toast.warning(`Drawer closed - ${overShort > 0 ? 'over' : 'short'} $${Math.abs(overShort).toFixed(2)}`) } - onOpenChange(false) + // Show Z report + setClosedDrawerId(session.id) + setShowZReport(true) await queryClient.invalidateQueries({ queryKey: posKeys.drawer(locationId ?? '') }) }, onError: (err) => toast.error(err.message), @@ -92,6 +104,45 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP onError: (err) => toast.error(err.message), }) + // Z Report view (shown after drawer close) + if (showZReport && reportData) { + const r = reportData + return ( + <> + { onOpenChange(o); if (!o) { setShowZReport(false); setClosedDrawerId(null) } }}> + + + Z Report — Drawer Closed + + + + + + + ) + } + + // X Report view (mid-shift snapshot) + if (showXReport && xReportData) { + return ( + <> + + + + X Report — Current Shift + + + + + + + ) + } + // Adjustment entry view if (adjustView && isOpen) { const isCashIn = adjustView === 'cash_in' @@ -199,6 +250,11 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP + {/* X Report button */} + + {/* Adjustment history */} {adjustments.length > 0 && ( <> @@ -293,3 +349,123 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP ) } + +// --- Shared report view used by both X and Z reports --- + +const PAYMENT_LABELS: Record = { + cash: 'Cash', + card_present: 'Card (Present)', + card_keyed: 'Card (Keyed)', + check: 'Check', + account_charge: 'Account', + unknown: 'Other', +} + +function DrawerReportView({ report }: { report: any }) { + const { session, sales, payments, discounts, cash, adjustments } = report + + return ( +
+ {/* Session info */} +
+ {session.register &&
Register{session.register.name}
} + {session.openedBy &&
Opened by{session.openedBy.firstName} {session.openedBy.lastName}
} +
Opened{new Date(session.openedAt).toLocaleString()}
+ {session.closedAt && ( + <> + {session.closedBy &&
Closed by{session.closedBy.firstName} {session.closedBy.lastName}
} +
Closed{new Date(session.closedAt).toLocaleString()}
+ + )} +
+ + + + {/* Sales */} +
+

Sales

+
+
Transactions{sales.transactionCount}
+
Gross Sales${sales.grossSales.toFixed(2)}
+ {sales.refundTotal > 0 &&
Refunds-${sales.refundTotal.toFixed(2)}
} +
Net Sales${sales.netSales.toFixed(2)}
+ {sales.voidCount > 0 &&
Voided{sales.voidCount}
} +
+
+ + + + {/* Payment breakdown */} +
+

Payments

+
+ {Object.entries(payments as Record).map(([method, data]) => ( +
+ {PAYMENT_LABELS[method] ?? method} ({data.count}) + ${data.total.toFixed(2)} +
+ ))} + {Object.keys(payments).length === 0 &&

No payments

} +
+
+ + {/* Discounts */} + {discounts.count > 0 && ( + <> + +
+

Discounts

+
Total ({discounts.count})-${discounts.total.toFixed(2)}
+
+ + )} + + + + {/* Cash accountability */} +
+

Cash

+
+
Opening Balance${cash.openingBalance.toFixed(2)}
+
Cash Sales${cash.cashSales.toFixed(2)}
+ {cash.cashIn > 0 &&
Cash In+${cash.cashIn.toFixed(2)}
} + {cash.cashOut > 0 &&
Cash Out-${cash.cashOut.toFixed(2)}
} + +
Expected${cash.expectedBalance.toFixed(2)}
+ {cash.actualBalance !== null && ( + <> +
Actual Count${cash.actualBalance.toFixed(2)}
+
+ {cash.overShort! >= 0 ? 'Over' : 'Short'} + ${Math.abs(cash.overShort!).toFixed(2)} +
+ + )} +
+
+ + {/* Adjustments */} + {adjustments.length > 0 && ( + <> + +
+

Adjustments

+
+ {adjustments.map((adj: any) => ( +
+
+ + {adj.type === 'cash_in' ? 'IN' : 'OUT'} + + {adj.reason} +
+ ${parseFloat(adj.amount).toFixed(2)} +
+ ))} +
+
+ + )} +
+ ) +} diff --git a/packages/admin/src/routes/_authenticated/reports/daily.tsx b/packages/admin/src/routes/_authenticated/reports/daily.tsx new file mode 100644 index 0000000..84abd3a --- /dev/null +++ b/packages/admin/src/routes/_authenticated/reports/daily.tsx @@ -0,0 +1,180 @@ +import { useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { useQuery } from '@tanstack/react-query' +import { dailyReportOptions } from '@/api/pos' +import { api } from '@/lib/api-client' +import { queryOptions } from '@tanstack/react-query' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' +import { Skeleton } from '@/components/ui/skeleton' + +interface Location { + id: string + name: string +} + +function locationsOptions() { + return queryOptions({ + queryKey: ['locations'], + queryFn: () => api.get<{ data: Location[] }>('/v1/locations'), + }) +} + +export const Route = createFileRoute('/_authenticated/reports/daily')({ + component: DailyReportPage, +}) + +const PAYMENT_LABELS: Record = { + cash: 'Cash', + card_present: 'Card (Present)', + card_keyed: 'Card (Keyed)', + check: 'Check', + account_charge: 'Account', +} + +function DailyReportPage() { + const today = new Date().toISOString().slice(0, 10) + const [date, setDate] = useState(today) + const [locationId, setLocationId] = useState(null) + + const { data: locationsData } = useQuery(locationsOptions()) + const locations = locationsData?.data ?? [] + + // Auto-select first location + if (!locationId && locations.length > 0) { + setLocationId(locations[0].id) + } + + const { data: report, isLoading } = useQuery(dailyReportOptions(locationId, date)) + + return ( +
+

Daily Report

+ +
+
+ + setDate(e.target.value)} className="w-44" /> +
+
+ + +
+
+ + {isLoading ? ( +
+ + +
+ ) : !report ? ( +

Select a location and date to view the report.

+ ) : ( +
+ {/* Sessions */} + + + Drawer Sessions ({report.sessions.length}) + + + {report.sessions.length === 0 ? ( +

No drawer sessions on this date.

+ ) : ( +
+ {report.sessions.map((s: any) => ( +
+
+ {s.register?.name ?? 'Unassigned'} + + {new Date(s.openedAt).toLocaleTimeString()} — {s.closedAt ? new Date(s.closedAt).toLocaleTimeString() : 'Open'} + + {s.openedBy && ({s.openedBy.firstName})} +
+
+ ${s.grossSales.toFixed(2)} + {s.overShort !== null && ( + + {s.overShort === 0 ? 'Balanced' : `${s.overShort > 0 ? '+' : ''}$${s.overShort.toFixed(2)}`} + + )} + {s.status === 'open' && Open} +
+
+ ))} +
+ )} +
+
+ + {/* Sales Summary */} + + Sales + +
Transactions{report.sales.transactionCount}
+
Gross Sales${report.sales.grossSales.toFixed(2)}
+ {report.sales.refundTotal > 0 &&
Refunds-${report.sales.refundTotal.toFixed(2)}
} + +
Net Sales${report.sales.netSales.toFixed(2)}
+ {report.sales.voidCount > 0 &&
Voided{report.sales.voidCount}
} +
+
+ + {/* Payment Breakdown */} + + Payments + + {Object.entries(report.payments as Record).map(([method, data]) => ( +
+ {PAYMENT_LABELS[method] ?? method} ({data.count}) + ${data.total.toFixed(2)} +
+ ))} + {Object.keys(report.payments).length === 0 &&

No payments

} +
+
+ + {/* Discounts */} + {report.discounts.count > 0 && ( + + Discounts + +
Total ({report.discounts.count} transactions)-${report.discounts.total.toFixed(2)}
+
+
+ )} + + {/* Cash Summary */} + + Cash + +
Total Opening${report.cash.totalOpening.toFixed(2)}
+
Cash Sales${report.cash.totalCashSales.toFixed(2)}
+ {report.cash.totalCashIn > 0 &&
Cash In+${report.cash.totalCashIn.toFixed(2)}
} + {report.cash.totalCashOut > 0 &&
Cash Out-${report.cash.totalCashOut.toFixed(2)}
} + +
Expected Total${report.cash.totalExpected.toFixed(2)}
+
Actual Total${report.cash.totalActual.toFixed(2)}
+
+ {report.cash.totalOverShort >= 0 ? 'Over' : 'Short'} + ${Math.abs(report.cash.totalOverShort).toFixed(2)} +
+
+
+
+ )} +
+ ) +} diff --git a/packages/admin/src/stores/pos.store.ts b/packages/admin/src/stores/pos.store.ts index 86a622a..16bc122 100644 --- a/packages/admin/src/stores/pos.store.ts +++ b/packages/admin/src/stores/pos.store.ts @@ -13,6 +13,7 @@ type ReceiptFormat = 'thermal' | 'full' interface POSState { currentTransactionId: string | null locationId: string | null + registerId: string | null drawerSessionId: string | null locked: boolean cashier: POSUser | null @@ -25,6 +26,7 @@ interface POSState { receiptFormat: ReceiptFormat setTransaction: (id: string | null) => void setLocation: (id: string) => void + setRegister: (id: string | null) => void setDrawerSession: (id: string | null) => void unlock: (user: POSUser, token: string) => void lock: () => void @@ -45,7 +47,8 @@ function getStoredReceiptFormat(): ReceiptFormat { export const usePOSStore = create((set) => ({ currentTransactionId: null, locationId: null, - drawerSessionId: null, + registerId: localStorage.getItem('pos_register_id') ?? null, + drawerSessionId: localStorage.getItem('pos_drawer_session_id') ?? null, locked: true, cashier: null, token: null, @@ -57,7 +60,8 @@ export const usePOSStore = create((set) => ({ receiptFormat: getStoredReceiptFormat(), setTransaction: (id) => set({ currentTransactionId: id }), setLocation: (id) => set({ locationId: id }), - setDrawerSession: (id) => set({ drawerSessionId: id }), + setRegister: (id) => { if (id) localStorage.setItem('pos_register_id', id); else localStorage.removeItem('pos_register_id'); set({ registerId: id }) }, + setDrawerSession: (id) => { if (id) localStorage.setItem('pos_drawer_session_id', id); else localStorage.removeItem('pos_drawer_session_id'); set({ drawerSessionId: id }) }, unlock: (user, token) => set({ locked: false, cashier: user, token, lastActivity: Date.now() }), lock: () => set({ locked: true, currentTransactionId: null }), touchActivity: () => set({ lastActivity: Date.now() }), diff --git a/packages/backend/api-tests/suites/pos.ts b/packages/backend/api-tests/suites/pos.ts index 7086cf2..f51be30 100644 --- a/packages/backend/api-tests/suites/pos.ts +++ b/packages/backend/api-tests/suites/pos.ts @@ -902,4 +902,153 @@ suite('POS', { tags: ['pos'] }, (t) => { t.assert.status(res2, 200) t.assert.ok(res2.data.data.some((p: any) => p.id === consumable.data.id)) }) + + // ─── Registers ──────────────────────────────────────────────────────────── + + t.test('creates a register', { tags: ['registers', 'create'] }, async () => { + const res = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Register 1' }) + t.assert.status(res, 201) + t.assert.equal(res.data.name, 'Register 1') + t.assert.equal(res.data.locationId, LOCATION_ID) + t.assert.equal(res.data.isActive, true) + }) + + t.test('lists registers for a location', { tags: ['registers', 'list'] }, async () => { + await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Register 2' }) + const res = await t.api.get('/v1/registers', { locationId: LOCATION_ID }) + t.assert.status(res, 200) + t.assert.ok(res.data.data.length >= 2) + t.assert.ok(res.data.pagination) + }) + + t.test('lists all registers (lookup)', { tags: ['registers', 'list'] }, async () => { + const res = await t.api.get('/v1/registers/all', { locationId: LOCATION_ID }) + t.assert.status(res, 200) + t.assert.ok(res.data.data.length >= 2) + }) + + t.test('updates a register name', { tags: ['registers', 'update'] }, async () => { + const created = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Old Name' }) + const res = await t.api.patch(`/v1/registers/${created.data.id}`, { name: 'New Name' }) + t.assert.status(res, 200) + t.assert.equal(res.data.name, 'New Name') + }) + + t.test('deactivates a register', { tags: ['registers', 'delete'] }, async () => { + const created = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Delete Me' }) + const res = await t.api.del(`/v1/registers/${created.data.id}`) + t.assert.status(res, 200) + t.assert.equal(res.data.isActive, false) + }) + + // ─── Drawer Reports (X/Z) ──────────────────────────────────────────────── + + t.test('cleanup: close any open drawers for report tests', { tags: ['reports', 'setup'] }, async () => { + 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 }) + } + }) + + t.test('drawer report returns correct data for a session with transactions', { tags: ['reports', 'drawer'] }, async () => { + // Open drawer + const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 }) + t.assert.status(drawer, 201) + + // Make a cash sale + const txn1 = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID }) + await t.api.post(`/v1/transactions/${txn1.data.id}/line-items`, { description: 'Report Item 1', qty: 1, unitPrice: 50 }) + await t.api.post(`/v1/transactions/${txn1.data.id}/complete`, { paymentMethod: 'cash', amountTendered: 60 }) + + // Make a card sale + const txn2 = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID }) + await t.api.post(`/v1/transactions/${txn2.data.id}/line-items`, { description: 'Report Item 2', qty: 1, unitPrice: 30 }) + await t.api.post(`/v1/transactions/${txn2.data.id}/complete`, { paymentMethod: 'card_present' }) + + // Void a transaction + const txn3 = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID }) + await t.api.post(`/v1/transactions/${txn3.data.id}/line-items`, { description: 'Voided Item', qty: 1, unitPrice: 10 }) + await t.api.post(`/v1/transactions/${txn3.data.id}/void`) + + // Get X report (drawer still open) + const xReport = await t.api.get(`/v1/reports/drawer/${drawer.data.id}`) + t.assert.status(xReport, 200) + t.assert.equal(xReport.data.sales.transactionCount, 2) + t.assert.greaterThan(xReport.data.sales.grossSales, 0) + // Voided transactions don't go through complete() so drawerSessionId isn't set + // They won't appear in the drawer report — this is correct behavior + t.assert.ok(xReport.data.payments.cash) + t.assert.ok(xReport.data.payments.card_present) + t.assert.equal(xReport.data.cash.actualBalance, null) // not closed yet + + // Close drawer + const closingAmount = 100 + xReport.data.cash.cashSales + await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: closingAmount }) + + // Get Z report (drawer closed) + const zReport = await t.api.get(`/v1/reports/drawer/${drawer.data.id}`) + t.assert.status(zReport, 200) + t.assert.ok(zReport.data.session.closedAt) + t.assert.ok(zReport.data.cash.actualBalance !== null) + t.assert.ok(typeof zReport.data.cash.overShort === 'number') + }) + + t.test('drawerSessionId is populated on completed transactions', { tags: ['reports', 'drawer-session-id'] }, async () => { + // Cleanup any open drawer + const cur = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID }) + if (cur.status === 200 && cur.data?.id) await t.api.post(`/v1/drawer/${cur.data.id}/close`, { closingBalance: 0 }) + + 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 }) + await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, { description: 'Session ID Test', qty: 1, unitPrice: 20 }) + await t.api.post(`/v1/transactions/${txn.data.id}/complete`, { paymentMethod: 'card_present' }) + + const completed = await t.api.get(`/v1/transactions/${txn.data.id}`) + t.assert.status(completed, 200) + t.assert.equal(completed.data.drawerSessionId, drawer.data.id) + + await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 100 }) + }) + + // ─── Daily Report ───────────────────────────────────────────────────────── + + t.test('daily report aggregates across sessions', { tags: ['reports', 'daily'] }, async () => { + const today = new Date().toISOString().slice(0, 10) + const res = await t.api.get('/v1/reports/daily', { locationId: LOCATION_ID, date: today }) + t.assert.status(res, 200) + t.assert.equal(res.data.date, today) + t.assert.ok(res.data.location) + t.assert.ok(Array.isArray(res.data.sessions)) + t.assert.ok(typeof res.data.sales.grossSales === 'number') + t.assert.ok(typeof res.data.payments === 'object') + t.assert.ok(typeof res.data.cash.totalExpected === 'number') + }) + + t.test('daily report rejects missing params', { tags: ['reports', 'daily', 'validation'] }, async () => { + const res = await t.api.get('/v1/reports/daily', {}) + t.assert.status(res, 400) + }) + + t.test('opens drawer with register', { tags: ['registers', 'drawer'] }, async () => { + // Cleanup any open drawer + const cur = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID }) + if (cur.status === 200 && cur.data?.id) await t.api.post(`/v1/drawer/${cur.data.id}/close`, { closingBalance: 0 }) + + const reg = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Report Register' }) + t.assert.status(reg, 201) + + const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, registerId: reg.data.id, openingBalance: 100 }) + t.assert.status(drawer, 201) + + // Get report to check register info + const report = await t.api.get(`/v1/reports/drawer/${drawer.data.id}`) + t.assert.status(report, 200) + t.assert.ok(report.data.session.register) + t.assert.equal(report.data.session.register.name, 'Report Register') + + // Cleanup + await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 100 }) + }) }) diff --git a/packages/backend/src/db/migrations/0044_registers-reports.sql b/packages/backend/src/db/migrations/0044_registers-reports.sql new file mode 100644 index 0000000..aad959e --- /dev/null +++ b/packages/backend/src/db/migrations/0044_registers-reports.sql @@ -0,0 +1,12 @@ +-- Named registers for POS terminals +CREATE TABLE IF NOT EXISTS register ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + location_id UUID NOT NULL REFERENCES location(id), + name VARCHAR(100) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Link drawer sessions to registers +ALTER TABLE drawer_session ADD COLUMN IF NOT EXISTS register_id UUID REFERENCES register(id); diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index e2f2f39..09330e0 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -316,6 +316,13 @@ "when": 1775680000000, "tag": "0043_repair-pos-consumable", "breakpoints": true + }, + { + "idx": 44, + "version": "7", + "when": 1775770000000, + "tag": "0044_registers-reports", + "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 af595c5..059cadb 100644 --- a/packages/backend/src/db/schema/pos.ts +++ b/packages/backend/src/db/schema/pos.ts @@ -70,9 +70,21 @@ export const discounts = pgTable('discount', { export const adjustmentTypeEnum = pgEnum('adjustment_type', ['cash_in', 'cash_out']) +export const registers = pgTable('register', { + id: uuid('id').primaryKey().defaultRandom(), + locationId: uuid('location_id') + .notNull() + .references(() => locations.id), + name: varchar('name', { length: 100 }).notNull(), + isActive: boolean('is_active').notNull().default(true), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}) + export const drawerSessions = pgTable('drawer_session', { id: uuid('id').primaryKey().defaultRandom(), locationId: uuid('location_id').references(() => locations.id), + registerId: uuid('register_id').references(() => registers.id), openedBy: uuid('opened_by') .notNull() .references(() => users.id), diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 3849e19..3e363b0 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -23,6 +23,8 @@ import { repairRoutes } from './routes/v1/repairs.js' import { lessonRoutes } from './routes/v1/lessons.js' import { transactionRoutes } from './routes/v1/transactions.js' import { drawerRoutes } from './routes/v1/drawer.js' +import { registerRoutes } from './routes/v1/register.js' +import { reportRoutes } from './routes/v1/reports.js' import { discountRoutes } from './routes/v1/discounts.js' import { taxRoutes } from './routes/v1/tax.js' import { storageRoutes } from './routes/v1/storage.js' @@ -156,6 +158,8 @@ export async function buildApp() { await app.register(withModule('lessons', lessonRoutes), { prefix: '/v1' }) await app.register(withModule('pos', transactionRoutes), { prefix: '/v1' }) await app.register(withModule('pos', drawerRoutes), { prefix: '/v1' }) + await app.register(withModule('pos', registerRoutes), { prefix: '/v1' }) + await app.register(withModule('pos', reportRoutes), { prefix: '/v1' }) await app.register(withModule('pos', discountRoutes), { prefix: '/v1' }) await app.register(withModule('pos', taxRoutes), { prefix: '/v1' }) await app.register(withModule('vault', vaultRoutes), { prefix: '/v1' }) diff --git a/packages/backend/src/routes/v1/register.ts b/packages/backend/src/routes/v1/register.ts new file mode 100644 index 0000000..606bc1e --- /dev/null +++ b/packages/backend/src/routes/v1/register.ts @@ -0,0 +1,50 @@ +import type { FastifyPluginAsync } from 'fastify' +import { PaginationSchema, RegisterCreateSchema, RegisterUpdateSchema } from '@lunarfront/shared/schemas' +import { RegisterService } from '../../services/register.service.js' + +export const registerRoutes: FastifyPluginAsync = async (app) => { + app.post('/registers', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => { + const parsed = RegisterCreateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const register = await RegisterService.create(app.db, parsed.data) + return reply.status(201).send(register) + }) + + app.get('/registers', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => { + const query = request.query as Record + const params = PaginationSchema.parse(query) + const result = await RegisterService.list(app.db, params, { locationId: query.locationId }) + return reply.send(result) + }) + + app.get('/registers/all', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => { + const query = request.query as Record + const data = await RegisterService.listAll(app.db, query.locationId) + return reply.send({ data }) + }) + + app.get('/registers/:id', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const register = await RegisterService.getById(app.db, id) + if (!register) return reply.status(404).send({ error: { message: 'Register not found', statusCode: 404 } }) + return reply.send(register) + }) + + app.patch('/registers/:id', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const parsed = RegisterUpdateSchema.safeParse(request.body) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } }) + } + const register = await RegisterService.update(app.db, id, parsed.data) + return reply.send(register) + }) + + app.delete('/registers/:id', { preHandler: [app.authenticate, app.requirePermission('pos.admin')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const register = await RegisterService.delete(app.db, id) + return reply.send(register) + }) +} diff --git a/packages/backend/src/routes/v1/reports.ts b/packages/backend/src/routes/v1/reports.ts new file mode 100644 index 0000000..8db10ad --- /dev/null +++ b/packages/backend/src/routes/v1/reports.ts @@ -0,0 +1,27 @@ +import type { FastifyPluginAsync } from 'fastify' +import { z } from 'zod' +import { ReportService } from '../../services/report.service.js' + +const DailyReportQuerySchema = z.object({ + locationId: z.string().uuid(), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), +}) + +export const reportRoutes: FastifyPluginAsync = async (app) => { + // X or Z report for a drawer session + app.get('/reports/drawer/:id', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => { + const { id } = request.params as { id: string } + const report = await ReportService.getDrawerReport(app.db, id) + return reply.send(report) + }) + + // Daily rollup for a location + app.get('/reports/daily', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => { + const parsed = DailyReportQuerySchema.safeParse(request.query) + if (!parsed.success) { + return reply.status(400).send({ error: { message: 'Validation failed — locationId and date (YYYY-MM-DD) are required', details: parsed.error.flatten(), statusCode: 400 } }) + } + const report = await ReportService.getDailyReport(app.db, parsed.data.locationId, parsed.data.date) + return reply.send(report) + }) +} diff --git a/packages/backend/src/services/drawer.service.ts b/packages/backend/src/services/drawer.service.ts index 55fe966..33ec79a 100644 --- a/packages/backend/src/services/drawer.service.ts +++ b/packages/backend/src/services/drawer.service.ts @@ -19,6 +19,7 @@ export const DrawerService = { .insert(drawerSessions) .values({ locationId: input.locationId, + registerId: input.registerId, openedBy, openingBalance: input.openingBalance.toString(), }) diff --git a/packages/backend/src/services/register.service.ts b/packages/backend/src/services/register.service.ts new file mode 100644 index 0000000..6e9fdde --- /dev/null +++ b/packages/backend/src/services/register.service.ts @@ -0,0 +1,84 @@ +import { eq, and, count, type Column } from 'drizzle-orm' +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import { registers } from '../db/schema/pos.js' +import { NotFoundError } from '../lib/errors.js' +import type { RegisterCreateInput, RegisterUpdateInput, PaginationInput } from '@lunarfront/shared/schemas' +import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../utils/pagination.js' + +export const RegisterService = { + async create(db: PostgresJsDatabase, input: RegisterCreateInput) { + const [register] = await db + .insert(registers) + .values({ + locationId: input.locationId, + name: input.name, + }) + .returning() + return register + }, + + async getById(db: PostgresJsDatabase, id: string) { + const [register] = await db + .select() + .from(registers) + .where(eq(registers.id, id)) + .limit(1) + return register ?? null + }, + + async list(db: PostgresJsDatabase, params: PaginationInput, filters?: { locationId?: string }) { + const conditions = [eq(registers.isActive, true)] + + if (params.q) { + conditions.push(buildSearchCondition(params.q, [registers.name])!) + } + if (filters?.locationId) { + conditions.push(eq(registers.locationId, filters.locationId)) + } + + const where = conditions.length === 1 ? conditions[0] : and(...conditions) + + const sortableColumns: Record = { + name: registers.name, + created_at: registers.createdAt, + } + + let query = db.select().from(registers).where(where).$dynamic() + query = withSort(query, params.sort, params.order, sortableColumns, registers.name) + query = withPagination(query, params.page, params.limit) + + const [data, [{ total }]] = await Promise.all([ + query, + db.select({ total: count() }).from(registers).where(where), + ]) + + return paginatedResponse(data, total, params.page, params.limit) + }, + + async listAll(db: PostgresJsDatabase, locationId?: string) { + const conditions = [eq(registers.isActive, true)] + if (locationId) conditions.push(eq(registers.locationId, locationId)) + const where = conditions.length === 1 ? conditions[0] : and(...conditions) + return db.select().from(registers).where(where) + }, + + async update(db: PostgresJsDatabase, id: string, input: RegisterUpdateInput) { + const [updated] = await db + .update(registers) + .set({ ...input, updatedAt: new Date() }) + .where(eq(registers.id, id)) + .returning() + if (!updated) throw new NotFoundError('Register') + return updated + }, + + async delete(db: PostgresJsDatabase, id: string) { + const [deleted] = await db + .update(registers) + .set({ isActive: false, updatedAt: new Date() }) + .where(eq(registers.id, id)) + .returning() + if (!deleted) throw new NotFoundError('Register') + return deleted + }, +} diff --git a/packages/backend/src/services/report.service.ts b/packages/backend/src/services/report.service.ts new file mode 100644 index 0000000..1fde1dd --- /dev/null +++ b/packages/backend/src/services/report.service.ts @@ -0,0 +1,292 @@ +import { eq, and, sql, gte, lt } from 'drizzle-orm' +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import { transactions, drawerSessions, drawerAdjustments, registers } from '../db/schema/pos.js' +import { locations } from '../db/schema/stores.js' +import { users } from '../db/schema/users.js' +import { NotFoundError } from '../lib/errors.js' + +interface PaymentBreakdown { + count: number + total: number +} + +interface DrawerReport { + session: { + id: string + openedAt: string + closedAt: string | null + openingBalance: string + closingBalance: string | null + expectedBalance: string | null + overShort: string | null + status: string + notes: string | null + denominations: Record | null + register: { id: string; name: string } | null + openedBy: { id: string; firstName: string; lastName: string } | null + closedBy: { id: string; firstName: string; lastName: string } | null + } + sales: { + grossSales: number + netSales: number + transactionCount: number + voidCount: number + refundTotal: number + } + payments: Record + discounts: { + total: number + count: number + } + cash: { + openingBalance: number + cashSales: number + cashIn: number + cashOut: number + expectedBalance: number + actualBalance: number | null + overShort: number | null + } + adjustments: { id: string; type: string; amount: string; reason: string; createdAt: string }[] +} + +export const ReportService = { + async getDrawerReport(db: PostgresJsDatabase, drawerSessionId: string): Promise { + // Fetch session with register and user info + const [session] = await db + .select() + .from(drawerSessions) + .where(eq(drawerSessions.id, drawerSessionId)) + .limit(1) + + if (!session) throw new NotFoundError('Drawer session') + + // Fetch register info + let register: { id: string; name: string } | null = null + if (session.registerId) { + const [reg] = await db.select({ id: registers.id, name: registers.name }).from(registers).where(eq(registers.id, session.registerId)).limit(1) + register = reg ?? null + } + + // Fetch user info + const [openedByUser] = await db.select({ id: users.id, firstName: users.firstName, lastName: users.lastName }).from(users).where(eq(users.id, session.openedBy)).limit(1) + let closedByUser = null + if (session.closedBy) { + const [u] = await db.select({ id: users.id, firstName: users.firstName, lastName: users.lastName }).from(users).where(eq(users.id, session.closedBy)).limit(1) + closedByUser = u ?? null + } + + // Aggregate transaction data for this drawer session + const txns = await db + .select({ + status: transactions.status, + transactionType: transactions.transactionType, + paymentMethod: transactions.paymentMethod, + total: transactions.total, + discountTotal: transactions.discountTotal, + roundingAdjustment: transactions.roundingAdjustment, + }) + .from(transactions) + .where(eq(transactions.drawerSessionId, drawerSessionId)) + + // Calculate sales + let grossSales = 0 + let refundTotal = 0 + let transactionCount = 0 + let voidCount = 0 + let discountTotalSum = 0 + let discountCount = 0 + const payments: Record = {} + + for (const txn of txns) { + if (txn.status === 'voided') { + voidCount++ + continue + } + if (txn.status !== 'completed') continue + + const total = parseFloat(txn.total ?? '0') + const discAmt = parseFloat(txn.discountTotal ?? '0') + + if (txn.transactionType === 'refund') { + refundTotal += total + } else { + grossSales += total + transactionCount++ + } + + if (discAmt > 0) { + discountTotalSum += discAmt + discountCount++ + } + + const method = txn.paymentMethod ?? 'unknown' + if (!payments[method]) payments[method] = { count: 0, total: 0 } + payments[method].count++ + payments[method].total += total + } + + // Cash accountability + const cashPayment = payments['cash'] ?? { count: 0, total: 0 } + const cashRounding = txns + .filter((t) => t.status === 'completed' && t.paymentMethod === 'cash') + .reduce((sum, t) => sum + parseFloat(t.roundingAdjustment ?? '0'), 0) + const cashSales = cashPayment.total + cashRounding + + // Adjustments + const adjRows = await db + .select() + .from(drawerAdjustments) + .where(eq(drawerAdjustments.drawerSessionId, drawerSessionId)) + + let cashIn = 0 + let cashOut = 0 + for (const adj of adjRows) { + if (adj.type === 'cash_in') cashIn += parseFloat(adj.amount) + else cashOut += parseFloat(adj.amount) + } + + const openingBalance = parseFloat(session.openingBalance) + const expectedBalance = openingBalance + cashSales + cashIn - cashOut + const actualBalance = session.closingBalance ? parseFloat(session.closingBalance) : null + const overShort = actualBalance !== null ? Math.round((actualBalance - expectedBalance) * 100) / 100 : null + + return { + session: { + id: session.id, + openedAt: session.openedAt.toISOString(), + closedAt: session.closedAt?.toISOString() ?? null, + openingBalance: session.openingBalance, + closingBalance: session.closingBalance, + expectedBalance: session.expectedBalance, + overShort: session.overShort, + status: session.status, + notes: session.notes, + denominations: session.denominations, + register, + openedBy: openedByUser ?? null, + closedBy: closedByUser, + }, + sales: { + grossSales: Math.round(grossSales * 100) / 100, + netSales: Math.round((grossSales - refundTotal) * 100) / 100, + transactionCount, + voidCount, + refundTotal: Math.round(refundTotal * 100) / 100, + }, + payments, + discounts: { + total: Math.round(discountTotalSum * 100) / 100, + count: discountCount, + }, + cash: { + openingBalance, + cashSales: Math.round(cashSales * 100) / 100, + cashIn: Math.round(cashIn * 100) / 100, + cashOut: Math.round(cashOut * 100) / 100, + expectedBalance: Math.round(expectedBalance * 100) / 100, + actualBalance, + overShort, + }, + adjustments: adjRows.map((a) => ({ + id: a.id, + type: a.type, + amount: a.amount, + reason: a.reason, + createdAt: a.createdAt.toISOString(), + })), + } + }, + + async getDailyReport(db: PostgresJsDatabase, locationId: string, date: string) { + // Get location info + const [location] = await db + .select({ id: locations.id, name: locations.name, timezone: locations.timezone }) + .from(locations) + .where(eq(locations.id, locationId)) + .limit(1) + + if (!location) throw new NotFoundError('Location') + + // Find all drawer sessions opened at this location on the given date + const dayStart = new Date(`${date}T00:00:00`) + const dayEnd = new Date(`${date}T00:00:00`) + dayEnd.setDate(dayEnd.getDate() + 1) + + const sessions = await db + .select() + .from(drawerSessions) + .where(and( + eq(drawerSessions.locationId, locationId), + gte(drawerSessions.openedAt, dayStart), + lt(drawerSessions.openedAt, dayEnd), + )) + + // Get individual reports for each session + const sessionReports = await Promise.all( + sessions.map((s) => this.getDrawerReport(db, s.id)) + ) + + // Aggregate + const sales = { grossSales: 0, netSales: 0, transactionCount: 0, voidCount: 0, refundTotal: 0 } + const payments: Record = {} + const discounts = { total: 0, count: 0 } + const cash = { totalOpening: 0, totalCashSales: 0, totalCashIn: 0, totalCashOut: 0, totalExpected: 0, totalActual: 0, totalOverShort: 0 } + + for (const report of sessionReports) { + sales.grossSales += report.sales.grossSales + sales.netSales += report.sales.netSales + sales.transactionCount += report.sales.transactionCount + sales.voidCount += report.sales.voidCount + sales.refundTotal += report.sales.refundTotal + + for (const [method, data] of Object.entries(report.payments)) { + if (!payments[method]) payments[method] = { count: 0, total: 0 } + payments[method].count += data.count + payments[method].total += data.total + } + + discounts.total += report.discounts.total + discounts.count += report.discounts.count + + cash.totalOpening += report.cash.openingBalance + cash.totalCashSales += report.cash.cashSales + cash.totalCashIn += report.cash.cashIn + cash.totalCashOut += report.cash.cashOut + cash.totalExpected += report.cash.expectedBalance + if (report.cash.actualBalance !== null) cash.totalActual += report.cash.actualBalance + if (report.cash.overShort !== null) cash.totalOverShort += report.cash.overShort + } + + // Round all aggregated values + for (const key of Object.keys(sales) as (keyof typeof sales)[]) { + sales[key] = Math.round(sales[key] * 100) / 100 + } + for (const data of Object.values(payments)) { + data.total = Math.round(data.total * 100) / 100 + } + discounts.total = Math.round(discounts.total * 100) / 100 + for (const key of Object.keys(cash) as (keyof typeof cash)[]) { + cash[key] = Math.round(cash[key] * 100) / 100 + } + + return { + date, + location: { id: location.id, name: location.name }, + sessions: sessionReports.map((r) => ({ + id: r.session.id, + register: r.session.register, + openedBy: r.session.openedBy, + openedAt: r.session.openedAt, + closedAt: r.session.closedAt, + status: r.session.status, + overShort: r.cash.overShort, + grossSales: r.sales.grossSales, + })), + sales, + payments, + discounts, + cash, + } + }, +} diff --git a/packages/backend/src/services/transaction.service.ts b/packages/backend/src/services/transaction.service.ts index 0193f27..fc3b9cc 100644 --- a/packages/backend/src/services/transaction.service.ts +++ b/packages/backend/src/services/transaction.service.ts @@ -329,6 +329,7 @@ export const TransactionService = { if (txn.status !== 'pending') throw new ConflictError('Transaction is not pending') // Require an open drawer session at the transaction's location + let drawerSessionId: string | null = null if (txn.locationId) { const [openDrawer] = await db .select({ id: drawerSessions.id }) @@ -338,6 +339,7 @@ export const TransactionService = { if (!openDrawer) { throw new ValidationError('Cannot complete transaction without an open drawer at this location') } + drawerSessionId = openDrawer.id } // Validate cash payment (with optional nickel rounding) @@ -399,6 +401,7 @@ export const TransactionService = { changeGiven, roundingAdjustment: roundingAdjustment.toString(), checkNumber: input.checkNumber, + drawerSessionId, completedAt: new Date(), updatedAt: new Date(), }) diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 6407df1..5f14409 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -181,6 +181,8 @@ export { DrawerOpenSchema, DrawerCloseSchema, DrawerAdjustmentSchema, + RegisterCreateSchema, + RegisterUpdateSchema, } from './pos.schema.js' export type { TransactionCreateInput, @@ -192,6 +194,8 @@ export type { DrawerOpenInput, DrawerCloseInput, DrawerAdjustmentInput, + RegisterCreateInput, + RegisterUpdateInput, } 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 b8b94dd..63b446e 100644 --- a/packages/shared/src/schemas/pos.schema.ts +++ b/packages/shared/src/schemas/pos.schema.ts @@ -100,6 +100,7 @@ export type DiscountUpdateInput = z.infer export const DrawerOpenSchema = z.object({ locationId: opt(z.string().uuid()), + registerId: opt(z.string().uuid()), openingBalance: z.coerce.number().min(0), }) export type DrawerOpenInput = z.infer @@ -119,3 +120,17 @@ export const DrawerCloseSchema = z.object({ notes: opt(z.string()), }) export type DrawerCloseInput = z.infer + +// --- Register schemas --- + +export const RegisterCreateSchema = z.object({ + locationId: z.string().uuid(), + name: z.string().min(1).max(100), +}) +export type RegisterCreateInput = z.infer + +export const RegisterUpdateSchema = z.object({ + name: z.string().min(1).max(100).optional(), + isActive: z.boolean().optional(), +}) +export type RegisterUpdateInput = z.infer