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,539 @@
/**
* Tests for GiftCardPaymentPanel Component
*
* Features:
* - Gift card code input (manual entry or scan)
* - Look up button to check balance
* - Shows card balance when found
* - Amount to redeem input
* - Apply button to add gift card payment
* - Error handling for invalid/expired cards
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import GiftCardPaymentPanel from '../GiftCardPaymentPanel';
import * as useGiftCardsHooks from '../../hooks/useGiftCards';
import type { GiftCard } from '../../types';
// Mock the useGiftCards hooks
vi.mock('../../hooks/useGiftCards');
describe('GiftCardPaymentPanel', () => {
let queryClient: QueryClient;
const mockOnApply = vi.fn();
const mockOnCancel = vi.fn();
const createWrapper = () => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
// eslint-disable-next-line react/display-name
return ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should render gift card input form', () => {
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isSuccess: false,
isError: false,
data: undefined,
error: null,
reset: vi.fn(),
} as any);
render(
<GiftCardPaymentPanel
amountDueCents={5000}
onApply={mockOnApply}
onCancel={mockOnCancel}
/>,
{ wrapper: createWrapper() }
);
expect(screen.getByPlaceholderText(/enter gift card code/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /lookup/i })).toBeInTheDocument();
});
it('should allow user to enter gift card code', async () => {
const user = userEvent.setup();
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isSuccess: false,
isError: false,
data: undefined,
error: null,
reset: vi.fn(),
} as any);
render(
<GiftCardPaymentPanel
amountDueCents={5000}
onApply={mockOnApply}
onCancel={mockOnCancel}
/>,
{ wrapper: createWrapper() }
);
const input = screen.getByPlaceholderText(/enter gift card code/i);
await user.type(input, 'GC-ABC123');
expect(input).toHaveValue('GC-ABC123');
});
it('should lookup gift card when lookup button is clicked', async () => {
const user = userEvent.setup();
const mockMutate = vi.fn();
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
mutate: mockMutate,
mutateAsync: vi.fn(),
isPending: false,
isSuccess: false,
isError: false,
data: undefined,
error: null,
reset: vi.fn(),
} as any);
render(
<GiftCardPaymentPanel
amountDueCents={5000}
onApply={mockOnApply}
onCancel={mockOnCancel}
/>,
{ wrapper: createWrapper() }
);
const input = screen.getByPlaceholderText(/enter gift card code/i);
const lookupButton = screen.getByRole('button', { name: /lookup/i });
await user.type(input, 'GC-ABC123');
await user.click(lookupButton);
expect(mockMutate).toHaveBeenCalledWith('GC-ABC123');
});
it('should display gift card balance when lookup succeeds', () => {
const mockGiftCard: GiftCard = {
id: 1,
code: 'GC-ABC123',
initial_balance_cents: 10000,
current_balance_cents: 7500,
status: 'active',
purchased_by: null,
recipient_email: '',
recipient_name: '',
created_at: '2024-01-01T00:00:00Z',
expires_at: null,
};
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isSuccess: true,
isError: false,
data: mockGiftCard,
error: null,
reset: vi.fn(),
} as any);
render(
<GiftCardPaymentPanel
amountDueCents={5000}
onApply={mockOnApply}
onCancel={mockOnCancel}
/>,
{ wrapper: createWrapper() }
);
expect(screen.getByText(/GC-ABC123/i)).toBeInTheDocument();
expect(screen.getByText(/\$75\.00/)).toBeInTheDocument();
});
it('should show loading state during lookup', () => {
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: true,
isSuccess: false,
isError: false,
data: undefined,
error: null,
reset: vi.fn(),
} as any);
render(
<GiftCardPaymentPanel
amountDueCents={5000}
onApply={mockOnApply}
onCancel={mockOnCancel}
/>,
{ wrapper: createWrapper() }
);
expect(screen.getByRole('button', { name: /lookup/i })).toBeDisabled();
});
it('should show error for invalid gift card', () => {
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isSuccess: false,
isError: true,
data: undefined,
error: { message: 'Gift card not found' } as any,
reset: vi.fn(),
} as any);
render(
<GiftCardPaymentPanel
amountDueCents={5000}
onApply={mockOnApply}
onCancel={mockOnCancel}
/>,
{ wrapper: createWrapper() }
);
expect(screen.getByText(/gift card not found/i)).toBeInTheDocument();
});
it('should show error for expired gift card', () => {
const expiredGiftCard: GiftCard = {
id: 1,
code: 'GC-EXPIRED',
initial_balance_cents: 5000,
current_balance_cents: 5000,
status: 'expired',
purchased_by: null,
recipient_email: '',
recipient_name: '',
created_at: '2024-01-01T00:00:00Z',
expires_at: '2024-06-01T00:00:00Z',
};
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isSuccess: true,
isError: false,
data: expiredGiftCard,
error: null,
reset: vi.fn(),
} as any);
render(
<GiftCardPaymentPanel
amountDueCents={5000}
onApply={mockOnApply}
onCancel={mockOnCancel}
/>,
{ wrapper: createWrapper() }
);
expect(screen.getByText(/expired/i)).toBeInTheDocument();
});
it('should show error for depleted gift card', () => {
const depletedGiftCard: GiftCard = {
id: 1,
code: 'GC-DEPLETED',
initial_balance_cents: 5000,
current_balance_cents: 0,
status: 'depleted',
purchased_by: null,
recipient_email: '',
recipient_name: '',
created_at: '2024-01-01T00:00:00Z',
expires_at: null,
};
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isSuccess: true,
isError: false,
data: depletedGiftCard,
error: null,
reset: vi.fn(),
} as any);
render(
<GiftCardPaymentPanel
amountDueCents={5000}
onApply={mockOnApply}
onCancel={mockOnCancel}
/>,
{ wrapper: createWrapper() }
);
expect(screen.getByText(/no balance remaining/i)).toBeInTheDocument();
});
it('should default redemption amount to remaining balance or amount due, whichever is less', () => {
const mockGiftCard: GiftCard = {
id: 1,
code: 'GC-ABC123',
initial_balance_cents: 10000,
current_balance_cents: 7500,
status: 'active',
purchased_by: null,
recipient_email: '',
recipient_name: '',
created_at: '2024-01-01T00:00:00Z',
expires_at: null,
};
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isSuccess: true,
isError: false,
data: mockGiftCard,
error: null,
reset: vi.fn(),
} as any);
render(
<GiftCardPaymentPanel
amountDueCents={5000}
onApply={mockOnApply}
onCancel={mockOnCancel}
/>,
{ wrapper: createWrapper() }
);
// FormCurrencyInput displays value in dollars as formatted text
// With amountDue=5000 cents ($50), this should be the default
expect(screen.getByDisplayValue('$50.00')).toBeInTheDocument();
});
it('should allow user to change redemption amount', async () => {
const user = userEvent.setup();
const mockGiftCard: GiftCard = {
id: 1,
code: 'GC-ABC123',
initial_balance_cents: 10000,
current_balance_cents: 10000,
status: 'active',
purchased_by: null,
recipient_email: '',
recipient_name: '',
created_at: '2024-01-01T00:00:00Z',
expires_at: null,
};
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isSuccess: true,
isError: false,
data: mockGiftCard,
error: null,
reset: vi.fn(),
} as any);
render(
<GiftCardPaymentPanel
amountDueCents={5000}
onApply={mockOnApply}
onCancel={mockOnCancel}
/>,
{ wrapper: createWrapper() }
);
// Find the currency input by its placeholder or current value
const amountInput = screen.getByDisplayValue('$50.00');
await user.clear(amountInput);
await user.type(amountInput, '25');
// After typing "25", the display should show "$0.25" (25 cents)
expect(screen.getByDisplayValue('$0.25')).toBeInTheDocument();
});
it('should call onApply with correct payment info when Apply is clicked', async () => {
const user = userEvent.setup();
const mockGiftCard: GiftCard = {
id: 1,
code: 'GC-ABC123',
initial_balance_cents: 10000,
current_balance_cents: 7500,
status: 'active',
purchased_by: null,
recipient_email: '',
recipient_name: '',
created_at: '2024-01-01T00:00:00Z',
expires_at: null,
};
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isSuccess: true,
isError: false,
data: mockGiftCard,
error: null,
reset: vi.fn(),
} as any);
render(
<GiftCardPaymentPanel
amountDueCents={5000}
onApply={mockOnApply}
onCancel={mockOnCancel}
/>,
{ wrapper: createWrapper() }
);
const applyButton = screen.getByRole('button', { name: /apply/i });
await user.click(applyButton);
expect(mockOnApply).toHaveBeenCalledWith({
gift_card_code: 'GC-ABC123',
amount_cents: 5000,
gift_card: mockGiftCard,
});
});
it('should not allow applying more than gift card balance', async () => {
const user = userEvent.setup();
const mockGiftCard: GiftCard = {
id: 1,
code: 'GC-ABC123',
initial_balance_cents: 5000,
current_balance_cents: 2500,
status: 'active',
purchased_by: null,
recipient_email: '',
recipient_name: '',
created_at: '2024-01-01T00:00:00Z',
expires_at: null,
};
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isSuccess: true,
isError: false,
data: mockGiftCard,
error: null,
reset: vi.fn(),
} as any);
render(
<GiftCardPaymentPanel
amountDueCents={5000}
onApply={mockOnApply}
onCancel={mockOnCancel}
/>,
{ wrapper: createWrapper() }
);
// Default amount should be $25 (2500 cents) since that's the card balance
const amountInput = screen.getByDisplayValue('$25.00');
await user.clear(amountInput);
await user.type(amountInput, '5000'); // Try to redeem $50.00 (5000 cents) when balance is $25
const applyButton = screen.getByRole('button', { name: /apply/i });
await user.click(applyButton);
// Should show error, not call onApply
expect(screen.getByText(/exceeds gift card balance/i)).toBeInTheDocument();
expect(mockOnApply).not.toHaveBeenCalled();
});
it('should call onCancel when Cancel button is clicked', async () => {
const user = userEvent.setup();
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isSuccess: false,
isError: false,
data: undefined,
error: null,
reset: vi.fn(),
} as any);
render(
<GiftCardPaymentPanel
amountDueCents={5000}
onApply={mockOnApply}
onCancel={mockOnCancel}
/>,
{ wrapper: createWrapper() }
);
const cancelButton = screen.getByRole('button', { name: /cancel/i });
await user.click(cancelButton);
expect(mockOnCancel).toHaveBeenCalled();
});
it('should reset form when a new lookup is performed', async () => {
const user = userEvent.setup();
const mockMutate = vi.fn();
const mockReset = vi.fn();
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
mutate: mockMutate,
mutateAsync: vi.fn(),
isPending: false,
isSuccess: false,
isError: false,
data: undefined,
error: null,
reset: mockReset,
} as any);
render(
<GiftCardPaymentPanel
amountDueCents={5000}
onApply={mockOnApply}
onCancel={mockOnCancel}
/>,
{ wrapper: createWrapper() }
);
const input = screen.getByPlaceholderText(/enter gift card code/i);
const lookupButton = screen.getByRole('button', { name: /lookup/i });
await user.type(input, 'GC-ABC123');
await user.click(lookupButton);
// Clear and lookup another card
await user.clear(input);
await user.type(input, 'GC-XYZ789');
expect(mockReset).toHaveBeenCalled();
});
});