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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user