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:
poduck
2025-12-27 11:31:19 -05:00
parent da508da398
commit 1aa5b76e3b
156 changed files with 61604 additions and 4 deletions

View 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);
});
});