Add Point of Sale system and tax rate lookup integration
POS System: - Full POS interface with product grid, cart panel, and payment flow - Product and category management with barcode scanning support - Cash drawer operations and shift management - Order history and receipt generation - Thermal printer integration (ESC/POS protocol) - Gift card support with purchase and redemption - Inventory tracking with low stock alerts - Customer selection and walk-in support Tax Rate Integration: - ZIP-to-state mapping for automatic state detection - SST boundary data import for 24 member states - Static rates for uniform-rate states (IN, MA, CT, etc.) - Statewide jurisdiction fallback for simple lookups - Tax rate suggestion in location editor with auto-apply - Multiple data sources: SST, CDTFA, TX Comptroller, Avalara UI Improvements: - POS renders full-screen outside BusinessLayout - Clear cart button prominently in cart header - Tax rate limited to 2 decimal places - Location tax rate field with suggestion UI 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
389
frontend/src/pos/components/__tests__/CloseShiftModal.test.tsx
Normal file
389
frontend/src/pos/components/__tests__/CloseShiftModal.test.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* Tests for CloseShiftModal component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import CloseShiftModal from '../CloseShiftModal';
|
||||
import { useCloseShift } from '../../hooks/useCashDrawer';
|
||||
import type { CashShift } from '../../types';
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('../../hooks/useCashDrawer');
|
||||
|
||||
const mockUseCloseShift = useCloseShift as any;
|
||||
|
||||
const mockShift: CashShift = {
|
||||
id: 1,
|
||||
location: 1,
|
||||
status: 'open',
|
||||
opening_balance_cents: 10000,
|
||||
expected_balance_cents: 15000,
|
||||
opened_at: '2024-12-26T09:00:00Z',
|
||||
opened_by: 1,
|
||||
closed_by: null,
|
||||
actual_balance_cents: null,
|
||||
variance_cents: null,
|
||||
cash_breakdown: {},
|
||||
closing_notes: '',
|
||||
opening_notes: '',
|
||||
closed_at: null,
|
||||
};
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('CloseShiftModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render when open', () => {
|
||||
mockUseCloseShift.mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<CloseShiftModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
shift={mockShift}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText(/close cash drawer/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/count cash/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display expected balance prominently', () => {
|
||||
mockUseCloseShift.mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<CloseShiftModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
shift={mockShift}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText(/expected balance/i)).toBeInTheDocument();
|
||||
// Check for the expected balance in the blue box
|
||||
const expectedBalanceElements = screen.getAllByText(/\$150\.00/);
|
||||
expect(expectedBalanceElements.length).toBeGreaterThan(0);
|
||||
expect(expectedBalanceElements[0]).toHaveClass('text-blue-900');
|
||||
});
|
||||
|
||||
it('should have denomination counter inputs', () => {
|
||||
mockUseCloseShift.mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<CloseShiftModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
shift={mockShift}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Bills
|
||||
expect(screen.getByLabelText(/\$100 bills/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/\$50 bills/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/\$20 bills/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/\$10 bills/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/\$5 bills/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/\$1 bills/i)).toBeInTheDocument();
|
||||
|
||||
// Coins
|
||||
expect(screen.getByLabelText(/quarters/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/dimes/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/nickels/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/pennies/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should calculate total from denominations', () => {
|
||||
mockUseCloseShift.mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<CloseShiftModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
shift={mockShift}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Enter: 1x $100, 2x $20, 1x $5
|
||||
fireEvent.change(screen.getByLabelText(/\$100 bills/i), { target: { value: '1' } });
|
||||
fireEvent.change(screen.getByLabelText(/\$20 bills/i), { target: { value: '2' } });
|
||||
fireEvent.change(screen.getByLabelText(/\$5 bills/i), { target: { value: '1' } });
|
||||
|
||||
// Total should be $145.00 - look for it in the "Actual Balance" section
|
||||
expect(screen.getByText('Actual Balance')).toBeInTheDocument();
|
||||
const actualBalanceElements = screen.getAllByText(/\$145\.00/);
|
||||
expect(actualBalanceElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should show variance in green when actual matches expected', () => {
|
||||
mockUseCloseShift.mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<CloseShiftModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
shift={mockShift}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Enter exactly $150.00 (expected balance)
|
||||
fireEvent.change(screen.getByLabelText(/\$100 bills/i), { target: { value: '1' } });
|
||||
fireEvent.change(screen.getByLabelText(/\$50 bills/i), { target: { value: '1' } });
|
||||
|
||||
// Find variance section - get parent container with background
|
||||
const varianceLabel = screen.getByText('Variance');
|
||||
const parentDiv = varianceLabel.parentElement;
|
||||
expect(parentDiv).toHaveClass('bg-green-50');
|
||||
|
||||
// Variance amount should be green
|
||||
const varianceAmounts = screen.getAllByText(/\$0\.00/);
|
||||
const varianceAmount = varianceAmounts.find(el => el.classList.contains('text-green-600'));
|
||||
expect(varianceAmount).toBeDefined();
|
||||
});
|
||||
|
||||
it('should show variance in red when actual is short', () => {
|
||||
mockUseCloseShift.mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<CloseShiftModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
shift={mockShift}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Enter $100.00 (short by $50)
|
||||
fireEvent.change(screen.getByLabelText(/\$100 bills/i), { target: { value: '1' } });
|
||||
|
||||
// Find variance section - should be red
|
||||
const varianceLabel = screen.getByText('Variance');
|
||||
const parentDiv = varianceLabel.parentElement;
|
||||
expect(parentDiv).toHaveClass('bg-red-50');
|
||||
|
||||
// Variance amount should be red
|
||||
const varianceText = screen.getByText(/-\$50\.00/);
|
||||
expect(varianceText).toHaveClass('text-red-600');
|
||||
});
|
||||
|
||||
it('should show variance in green when actual is over', () => {
|
||||
mockUseCloseShift.mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<CloseShiftModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
shift={mockShift}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Enter $200.00 (over by $50)
|
||||
fireEvent.change(screen.getByLabelText(/\$100 bills/i), { target: { value: '2' } });
|
||||
|
||||
// Find variance section - should be green
|
||||
const varianceLabel = screen.getByText('Variance');
|
||||
const parentDiv = varianceLabel.parentElement;
|
||||
expect(parentDiv).toHaveClass('bg-green-50');
|
||||
|
||||
// Variance amount should be green and positive
|
||||
const varianceText = screen.getByText(/\+\$50\.00/);
|
||||
expect(varianceText).toHaveClass('text-green-600');
|
||||
});
|
||||
|
||||
it('should allow adding closing notes', () => {
|
||||
mockUseCloseShift.mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<CloseShiftModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
shift={mockShift}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const notesInput = screen.getByPlaceholderText(/notes about the shift/i);
|
||||
fireEvent.change(notesInput, { target: { value: 'Short due to refund' } });
|
||||
|
||||
expect(notesInput).toHaveValue('Short due to refund');
|
||||
});
|
||||
|
||||
it('should call onClose when Cancel clicked', () => {
|
||||
mockUseCloseShift.mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<CloseShiftModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
shift={mockShift}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should close shift with correct data when Close Shift clicked', async () => {
|
||||
const mockMutateAsync = vi.fn().mockResolvedValue({});
|
||||
mockUseCloseShift.mockReturnValue({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
const onSuccess = vi.fn();
|
||||
|
||||
render(
|
||||
<CloseShiftModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
shift={mockShift}
|
||||
onSuccess={onSuccess}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Enter cash count
|
||||
fireEvent.change(screen.getByLabelText(/\$100 bills/i), { target: { value: '1' } });
|
||||
fireEvent.change(screen.getByLabelText(/\$20 bills/i), { target: { value: '2' } });
|
||||
fireEvent.change(screen.getByLabelText(/\$5 bills/i), { target: { value: '1' } });
|
||||
|
||||
// Add notes
|
||||
const notesInput = screen.getByPlaceholderText(/notes about the shift/i);
|
||||
fireEvent.change(notesInput, { target: { value: 'End of day' } });
|
||||
|
||||
// Submit
|
||||
fireEvent.click(screen.getByRole('button', { name: /close shift/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
shiftId: 1,
|
||||
actual_balance_cents: 14500, // $145.00
|
||||
cash_breakdown: {
|
||||
'10000': 1, // 1x $100
|
||||
'2000': 2, // 2x $20
|
||||
'500': 1, // 1x $5
|
||||
},
|
||||
closing_notes: 'End of day',
|
||||
});
|
||||
});
|
||||
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should disable Close Shift button when amount is zero', () => {
|
||||
mockUseCloseShift.mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<CloseShiftModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
shift={mockShift}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: /close shift/i });
|
||||
expect(closeButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should show loading state during submission', () => {
|
||||
mockUseCloseShift.mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<CloseShiftModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
shift={mockShift}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText(/closing\.\.\./i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle coin denominations correctly', () => {
|
||||
mockUseCloseShift.mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<CloseShiftModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
shift={mockShift}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Enter: 4 quarters, 10 dimes, 2 nickels, 5 pennies
|
||||
// = $1.00 + $1.00 + $0.10 + $0.05 = $2.15
|
||||
fireEvent.change(screen.getByLabelText(/quarters/i), { target: { value: '4' } });
|
||||
fireEvent.change(screen.getByLabelText(/dimes/i), { target: { value: '10' } });
|
||||
fireEvent.change(screen.getByLabelText(/nickels/i), { target: { value: '2' } });
|
||||
fireEvent.change(screen.getByLabelText(/pennies/i), { target: { value: '5' } });
|
||||
|
||||
// Should show $2.15 in the Actual Balance section
|
||||
expect(screen.getByText('Actual Balance')).toBeInTheDocument();
|
||||
const actualBalanceElements = screen.getAllByText(/\$2\.15/);
|
||||
expect(actualBalanceElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user