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:
868
frontend/src/pos/hardware/__tests__/ShiftReportBuilder.test.ts
Normal file
868
frontend/src/pos/hardware/__tests__/ShiftReportBuilder.test.ts
Normal file
@@ -0,0 +1,868 @@
|
||||
/**
|
||||
* Tests for ShiftReportBuilder
|
||||
*
|
||||
* ShiftReportBuilder generates shift summary reports for thermal printers.
|
||||
* It shows opening/closing times, cash drawer balances, sales breakdown,
|
||||
* variance calculations, and optional signature lines.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { ShiftReportBuilder, ShiftReportConfig } from '../ShiftReportBuilder';
|
||||
import type { BusinessInfo, ShiftSummary } from '../../types';
|
||||
|
||||
// ESC/POS command constants for verification
|
||||
const ESC = 0x1b;
|
||||
const GS = 0x1d;
|
||||
const LF = 0x0a;
|
||||
|
||||
/**
|
||||
* Helper to create a minimal business info object
|
||||
*/
|
||||
function createBusinessInfo(overrides: Partial<BusinessInfo> = {}): BusinessInfo {
|
||||
return {
|
||||
name: 'Test Business',
|
||||
address: '123 Main St',
|
||||
city: 'Testville',
|
||||
state: 'TS',
|
||||
zip: '12345',
|
||||
phone: '5551234567',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a minimal shift summary object
|
||||
*/
|
||||
function createShiftSummary(overrides: Partial<ShiftSummary> = {}): ShiftSummary {
|
||||
return {
|
||||
id: 1,
|
||||
openedAt: '2024-12-26T09:00:00Z',
|
||||
closedAt: '2024-12-26T17:00:00Z',
|
||||
openedByName: 'Alice Smith',
|
||||
closedByName: 'Alice Smith',
|
||||
openingBalanceCents: 20000,
|
||||
expectedBalanceCents: 35000,
|
||||
actualBalanceCents: 35000,
|
||||
varianceCents: 0,
|
||||
cashSalesCents: 15000,
|
||||
cardSalesCents: 25000,
|
||||
giftCardSalesCents: 5000,
|
||||
totalSalesCents: 45000,
|
||||
transactionCount: 25,
|
||||
refundsCents: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create shift report config
|
||||
*/
|
||||
function createConfig(overrides: Partial<ShiftReportConfig> = {}): ShiftReportConfig {
|
||||
return {
|
||||
includeSignatureLine: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ShiftReportBuilder', () => {
|
||||
let businessInfo: BusinessInfo;
|
||||
let shift: ShiftSummary;
|
||||
let config: ShiftReportConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
businessInfo = createBusinessInfo();
|
||||
shift = createShiftSummary();
|
||||
config = createConfig();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create instance with default config', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift);
|
||||
expect(builder).toBeInstanceOf(ShiftReportBuilder);
|
||||
});
|
||||
|
||||
it('should accept custom config', () => {
|
||||
const customConfig: ShiftReportConfig = {
|
||||
includeSignatureLine: false,
|
||||
managerName: 'Manager Bob',
|
||||
locationName: 'Downtown Store',
|
||||
printedByName: 'Alice',
|
||||
};
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, customConfig);
|
||||
expect(builder).toBeInstanceOf(ShiftReportBuilder);
|
||||
});
|
||||
|
||||
it('should default includeSignatureLine to true', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, {});
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Signature:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('build()', () => {
|
||||
it('should return Uint8Array', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should start with init command (ESC @)', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
expect(result[0]).toBe(ESC);
|
||||
expect(result[1]).toBe(0x40);
|
||||
});
|
||||
|
||||
it('should end with partial cut command', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
// Partial cut is GS V 1
|
||||
const lastThreeBytes = Array.from(result.slice(-3));
|
||||
expect(lastThreeBytes).toContain(GS);
|
||||
expect(lastThreeBytes).toContain(0x56);
|
||||
});
|
||||
|
||||
it('should contain line feeds', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const lfCount = Array.from(result).filter((b) => b === LF).length;
|
||||
expect(lfCount).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHeader()', () => {
|
||||
it('should include business name in uppercase', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain(businessInfo.name.toUpperCase());
|
||||
});
|
||||
|
||||
it('should include location name when provided', () => {
|
||||
const configWithLocation = createConfig({
|
||||
locationName: 'Main Street Location',
|
||||
});
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, configWithLocation);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Main Street Location');
|
||||
});
|
||||
|
||||
it('should skip location when not provided', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
// Should complete without error
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should include SHIFT REPORT title', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('SHIFT REPORT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildShiftTimes()', () => {
|
||||
it('should include shift ID', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Shift #:');
|
||||
expect(text).toContain('1');
|
||||
});
|
||||
|
||||
it('should include opened time', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Opened:');
|
||||
});
|
||||
|
||||
it('should include opened by name', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Opened By:');
|
||||
expect(text).toContain('Alice Smith');
|
||||
});
|
||||
|
||||
it('should show Unknown when opened by is not provided', () => {
|
||||
const shiftNoOpener = createShiftSummary({ openedByName: '' });
|
||||
const builder = new ShiftReportBuilder(businessInfo, shiftNoOpener, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Opened By:');
|
||||
expect(text).toContain('Unknown');
|
||||
});
|
||||
|
||||
it('should include closed time when shift is closed', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Closed:');
|
||||
expect(text).toContain('Closed By:');
|
||||
});
|
||||
|
||||
it('should include duration for closed shifts', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Duration:');
|
||||
expect(text).toContain('8h'); // 9:00 to 17:00 = 8 hours
|
||||
});
|
||||
|
||||
it('should show SHIFT STILL OPEN for open shifts', () => {
|
||||
const openShift = createShiftSummary({
|
||||
closedAt: null,
|
||||
closedByName: undefined,
|
||||
actualBalanceCents: null,
|
||||
varianceCents: null,
|
||||
});
|
||||
const builder = new ShiftReportBuilder(businessInfo, openShift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('*** SHIFT STILL OPEN ***');
|
||||
});
|
||||
|
||||
it('should format time in business timezone when provided', () => {
|
||||
const shiftWithTz = createShiftSummary({
|
||||
business_timezone: 'America/New_York',
|
||||
});
|
||||
const builder = new ShiftReportBuilder(businessInfo, shiftWithTz, config);
|
||||
const result = builder.build();
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should show Unknown for closed by when not provided', () => {
|
||||
const shiftNoCloser = createShiftSummary({ closedByName: '' });
|
||||
const builder = new ShiftReportBuilder(businessInfo, shiftNoCloser, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Closed By:');
|
||||
expect(text).toContain('Unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildOpeningBalance()', () => {
|
||||
it('should include CASH DRAWER section header', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('CASH DRAWER');
|
||||
});
|
||||
|
||||
it('should show opening balance', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Opening Balance:');
|
||||
expect(text).toContain('$200.00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSalesBreakdown()', () => {
|
||||
it('should include SALES SUMMARY section header', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('SALES SUMMARY');
|
||||
});
|
||||
|
||||
it('should show transaction count', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Transactions:');
|
||||
expect(text).toContain('25');
|
||||
});
|
||||
|
||||
it('should show cash sales', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Cash Sales:');
|
||||
expect(text).toContain('$150.00');
|
||||
});
|
||||
|
||||
it('should show card sales', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Card Sales:');
|
||||
expect(text).toContain('$250.00');
|
||||
});
|
||||
|
||||
it('should show gift card sales', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Gift Card Sales:');
|
||||
expect(text).toContain('$50.00');
|
||||
});
|
||||
|
||||
it('should show total sales', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Total Sales:');
|
||||
expect(text).toContain('$450.00');
|
||||
});
|
||||
|
||||
it('should show refunds when present', () => {
|
||||
const shiftWithRefunds = createShiftSummary({
|
||||
refundsCents: 2500,
|
||||
});
|
||||
const builder = new ShiftReportBuilder(businessInfo, shiftWithRefunds, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Refunds:');
|
||||
expect(text).toContain('-$25.00');
|
||||
});
|
||||
|
||||
it('should skip refunds when zero', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
// Refunds should not appear when zero
|
||||
const refundMatches = text.match(/Refunds:/g) || [];
|
||||
expect(refundMatches.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCashBalance()', () => {
|
||||
it('should include CASH BALANCE section header', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('CASH BALANCE');
|
||||
});
|
||||
|
||||
it('should show opening balance in calculation', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
// Opening balance should appear in cash balance section
|
||||
const openingMatches = text.match(/Opening Balance:/g) || [];
|
||||
expect(openingMatches.length).toBeGreaterThanOrEqual(2); // Once in drawer, once in balance
|
||||
});
|
||||
|
||||
it('should show cash sales in calculation', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('+ Cash Sales:');
|
||||
});
|
||||
|
||||
it('should show expected balance', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Expected Balance:');
|
||||
expect(text).toContain('$350.00');
|
||||
});
|
||||
|
||||
it('should show actual balance when shift is closed', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Actual Balance:');
|
||||
});
|
||||
|
||||
it('should show (Not yet counted) when actual balance is null', () => {
|
||||
const openShift = createShiftSummary({
|
||||
closedAt: null,
|
||||
actualBalanceCents: null,
|
||||
varianceCents: null,
|
||||
});
|
||||
const builder = new ShiftReportBuilder(businessInfo, openShift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('(Not yet counted)');
|
||||
});
|
||||
|
||||
describe('variance display', () => {
|
||||
it('should show zero variance correctly', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Variance:');
|
||||
expect(text).toContain('$0.00');
|
||||
});
|
||||
|
||||
it('should show Over for positive variance', () => {
|
||||
const shiftOver = createShiftSummary({
|
||||
actualBalanceCents: 36000,
|
||||
varianceCents: 1000,
|
||||
});
|
||||
const builder = new ShiftReportBuilder(businessInfo, shiftOver, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Over:');
|
||||
expect(text).toContain('+$10.00');
|
||||
});
|
||||
|
||||
it('should show Short for negative variance', () => {
|
||||
const shiftShort = createShiftSummary({
|
||||
actualBalanceCents: 33000,
|
||||
varianceCents: -2000,
|
||||
});
|
||||
const builder = new ShiftReportBuilder(businessInfo, shiftShort, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Short:');
|
||||
expect(text).toContain('-$20.00');
|
||||
});
|
||||
|
||||
it('should skip variance when null', () => {
|
||||
const openShift = createShiftSummary({
|
||||
closedAt: null,
|
||||
actualBalanceCents: null,
|
||||
varianceCents: null,
|
||||
});
|
||||
const builder = new ShiftReportBuilder(businessInfo, openShift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).not.toContain('Variance:');
|
||||
expect(text).not.toContain('Over:');
|
||||
expect(text).not.toContain('Short:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSignatureLine()', () => {
|
||||
it('should include signature line when enabled', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Signature:');
|
||||
expect(text).toContain('_____'); // Underline
|
||||
});
|
||||
|
||||
it('should skip signature line when disabled', () => {
|
||||
const noSigConfig = createConfig({ includeSignatureLine: false });
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, noSigConfig);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).not.toContain('Signature:');
|
||||
});
|
||||
|
||||
it('should include manager name when provided', () => {
|
||||
const configWithManager = createConfig({
|
||||
managerName: 'Manager Carol',
|
||||
});
|
||||
const builder = new ShiftReportBuilder(
|
||||
businessInfo,
|
||||
shift,
|
||||
configWithManager
|
||||
);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Manager: Manager Carol');
|
||||
});
|
||||
|
||||
it('should skip manager name when not provided', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).not.toContain('Manager:');
|
||||
});
|
||||
|
||||
it('should include date line', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Date:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFooter()', () => {
|
||||
it('should include printed timestamp', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Printed:');
|
||||
});
|
||||
|
||||
it('should include printed by name when provided', () => {
|
||||
const configWithPrinter = createConfig({
|
||||
printedByName: 'Alice',
|
||||
});
|
||||
const builder = new ShiftReportBuilder(
|
||||
businessInfo,
|
||||
shift,
|
||||
configWithPrinter
|
||||
);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('By: Alice');
|
||||
});
|
||||
|
||||
it('should skip printed by when not provided', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
// "By: " as a standalone line (for printed by) should not appear
|
||||
// Note: "Opened By:" and "Closed By:" will still be present
|
||||
const lines = text.split('\n');
|
||||
const printedByLine = lines.find(line => line.startsWith('By:'));
|
||||
expect(printedByLine).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include confidentiality notice', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('CONFIDENTIAL - FOR INTERNAL USE ONLY');
|
||||
});
|
||||
});
|
||||
|
||||
describe('duration calculation', () => {
|
||||
it('should show hours only when no minutes', () => {
|
||||
const exactHours = createShiftSummary({
|
||||
openedAt: '2024-12-26T09:00:00Z',
|
||||
closedAt: '2024-12-26T12:00:00Z',
|
||||
});
|
||||
const builder = new ShiftReportBuilder(businessInfo, exactHours, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Duration:');
|
||||
expect(text).toContain('3h');
|
||||
});
|
||||
|
||||
it('should show minutes only when less than 1 hour', () => {
|
||||
const shortShift = createShiftSummary({
|
||||
openedAt: '2024-12-26T09:00:00Z',
|
||||
closedAt: '2024-12-26T09:45:00Z',
|
||||
});
|
||||
const builder = new ShiftReportBuilder(businessInfo, shortShift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Duration:');
|
||||
expect(text).toContain('45m');
|
||||
});
|
||||
|
||||
it('should show hours and minutes', () => {
|
||||
const mixedShift = createShiftSummary({
|
||||
openedAt: '2024-12-26T09:00:00Z',
|
||||
closedAt: '2024-12-26T14:30:00Z',
|
||||
});
|
||||
const builder = new ShiftReportBuilder(businessInfo, mixedShift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Duration:');
|
||||
expect(text).toContain('5h 30m');
|
||||
});
|
||||
|
||||
it('should handle invalid dates gracefully', () => {
|
||||
const badDates = createShiftSummary({
|
||||
openedAt: 'invalid',
|
||||
closedAt: 'also-invalid',
|
||||
});
|
||||
const builder = new ShiftReportBuilder(businessInfo, badDates, config);
|
||||
const result = builder.build();
|
||||
|
||||
// Should not throw
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should show Invalid for negative duration', () => {
|
||||
const backwards = createShiftSummary({
|
||||
openedAt: '2024-12-26T17:00:00Z',
|
||||
closedAt: '2024-12-26T09:00:00Z',
|
||||
});
|
||||
const builder = new ShiftReportBuilder(businessInfo, backwards, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('Invalid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('currency formatting', () => {
|
||||
it('should format zero correctly', () => {
|
||||
const zeroShift = createShiftSummary({
|
||||
cashSalesCents: 0,
|
||||
cardSalesCents: 0,
|
||||
giftCardSalesCents: 0,
|
||||
totalSalesCents: 0,
|
||||
transactionCount: 0,
|
||||
});
|
||||
const builder = new ShiftReportBuilder(businessInfo, zeroShift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('$0.00');
|
||||
});
|
||||
|
||||
it('should format large amounts correctly', () => {
|
||||
const bigShift = createShiftSummary({
|
||||
totalSalesCents: 1000000,
|
||||
cashSalesCents: 500000,
|
||||
});
|
||||
const builder = new ShiftReportBuilder(businessInfo, bigShift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('$10,000.00');
|
||||
expect(text).toContain('$5,000.00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chaining', () => {
|
||||
it('should support method chaining for all section builders', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
|
||||
// Each method should return `this`
|
||||
const result = builder
|
||||
.buildHeader()
|
||||
.buildShiftTimes()
|
||||
.buildOpeningBalance()
|
||||
.buildSalesBreakdown()
|
||||
.buildCashBalance()
|
||||
.buildSignatureLine()
|
||||
.buildFooter();
|
||||
|
||||
expect(result).toBe(builder);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom width', () => {
|
||||
it('should respect custom width from config', () => {
|
||||
const configWithWidth = createConfig({ width: 32 });
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, configWithWidth);
|
||||
const result = builder.build();
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complete report generation', () => {
|
||||
it('should generate a complete closed shift report', () => {
|
||||
const fullShift = createShiftSummary({
|
||||
id: 42,
|
||||
openedAt: '2024-12-26T08:30:00Z',
|
||||
closedAt: '2024-12-26T17:45:00Z',
|
||||
openedByName: 'Bob Johnson',
|
||||
closedByName: 'Carol Williams',
|
||||
openingBalanceCents: 15000,
|
||||
expectedBalanceCents: 48750,
|
||||
actualBalanceCents: 49000,
|
||||
varianceCents: 250,
|
||||
cashSalesCents: 33750,
|
||||
cardSalesCents: 45000,
|
||||
giftCardSalesCents: 12500,
|
||||
totalSalesCents: 91250,
|
||||
transactionCount: 47,
|
||||
refundsCents: 1500,
|
||||
business_timezone: 'America/Chicago',
|
||||
});
|
||||
|
||||
const fullConfig: ShiftReportConfig = {
|
||||
includeSignatureLine: true,
|
||||
managerName: 'David Manager',
|
||||
locationName: 'Downtown Store',
|
||||
printedByName: 'Carol Williams',
|
||||
};
|
||||
|
||||
const builder = new ShiftReportBuilder(businessInfo, fullShift, fullConfig);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
|
||||
// Verify key sections
|
||||
expect(text).toContain('TEST BUSINESS');
|
||||
expect(text).toContain('Downtown Store');
|
||||
expect(text).toContain('SHIFT REPORT');
|
||||
expect(text).toContain('Shift #:');
|
||||
expect(text).toContain('42');
|
||||
expect(text).toContain('Bob Johnson');
|
||||
expect(text).toContain('Carol Williams');
|
||||
expect(text).toContain('Duration:');
|
||||
expect(text).toContain('CASH DRAWER');
|
||||
expect(text).toContain('$150.00'); // Opening balance
|
||||
expect(text).toContain('SALES SUMMARY');
|
||||
expect(text).toContain('47'); // Transaction count
|
||||
expect(text).toContain('$337.50'); // Cash sales
|
||||
expect(text).toContain('$450.00'); // Card sales
|
||||
expect(text).toContain('$125.00'); // Gift card sales
|
||||
expect(text).toContain('$912.50'); // Total sales
|
||||
expect(text).toContain('Refunds:');
|
||||
expect(text).toContain('-$15.00');
|
||||
expect(text).toContain('CASH BALANCE');
|
||||
expect(text).toContain('Expected Balance:');
|
||||
expect(text).toContain('Actual Balance:');
|
||||
expect(text).toContain('Over:');
|
||||
expect(text).toContain('+$2.50');
|
||||
expect(text).toContain('Manager: David Manager');
|
||||
expect(text).toContain('Signature:');
|
||||
expect(text).toContain('By: Carol Williams');
|
||||
expect(text).toContain('CONFIDENTIAL - FOR INTERNAL USE ONLY');
|
||||
});
|
||||
|
||||
it('should generate a complete open shift report', () => {
|
||||
const openShift = createShiftSummary({
|
||||
id: 43,
|
||||
openedAt: '2024-12-26T08:00:00Z',
|
||||
closedAt: null,
|
||||
openedByName: 'Eve Adams',
|
||||
closedByName: undefined,
|
||||
openingBalanceCents: 20000,
|
||||
expectedBalanceCents: 27500,
|
||||
actualBalanceCents: null,
|
||||
varianceCents: null,
|
||||
cashSalesCents: 7500,
|
||||
cardSalesCents: 15000,
|
||||
giftCardSalesCents: 2500,
|
||||
totalSalesCents: 25000,
|
||||
transactionCount: 12,
|
||||
refundsCents: 0,
|
||||
});
|
||||
|
||||
const openConfig: ShiftReportConfig = {
|
||||
includeSignatureLine: false,
|
||||
locationName: 'Mall Kiosk',
|
||||
};
|
||||
|
||||
const builder = new ShiftReportBuilder(businessInfo, openShift, openConfig);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
|
||||
// Verify open shift specifics
|
||||
expect(text).toContain('*** SHIFT STILL OPEN ***');
|
||||
expect(text).toContain('(Not yet counted)');
|
||||
expect(text).not.toContain('Variance:');
|
||||
expect(text).not.toContain('Over:');
|
||||
expect(text).not.toContain('Short:');
|
||||
expect(text).not.toContain('Signature:');
|
||||
});
|
||||
|
||||
it('should generate report with short variance', () => {
|
||||
const shortShift = createShiftSummary({
|
||||
actualBalanceCents: 30000,
|
||||
expectedBalanceCents: 35000,
|
||||
varianceCents: -5000,
|
||||
});
|
||||
|
||||
const builder = new ShiftReportBuilder(businessInfo, shortShift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
|
||||
expect(text).toContain('Short:');
|
||||
expect(text).toContain('-$50.00');
|
||||
});
|
||||
|
||||
it('should handle minimal shift data', () => {
|
||||
const minimalShift: ShiftSummary = {
|
||||
id: 1,
|
||||
openedAt: '2024-12-26T09:00:00Z',
|
||||
closedAt: null,
|
||||
openedByName: '',
|
||||
openingBalanceCents: 0,
|
||||
expectedBalanceCents: 0,
|
||||
actualBalanceCents: null,
|
||||
varianceCents: null,
|
||||
cashSalesCents: 0,
|
||||
cardSalesCents: 0,
|
||||
giftCardSalesCents: 0,
|
||||
totalSalesCents: 0,
|
||||
transactionCount: 0,
|
||||
refundsCents: 0,
|
||||
};
|
||||
|
||||
const builder = new ShiftReportBuilder(businessInfo, minimalShift, {});
|
||||
const result = builder.build();
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('variance formatting', () => {
|
||||
it('should format positive variance with plus sign', () => {
|
||||
const overShift = createShiftSummary({
|
||||
varianceCents: 100,
|
||||
actualBalanceCents: 35100,
|
||||
});
|
||||
const builder = new ShiftReportBuilder(businessInfo, overShift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('+$1.00');
|
||||
});
|
||||
|
||||
it('should format negative variance with minus sign', () => {
|
||||
const shortShift = createShiftSummary({
|
||||
varianceCents: -100,
|
||||
actualBalanceCents: 34900,
|
||||
});
|
||||
const builder = new ShiftReportBuilder(businessInfo, shortShift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
expect(text).toContain('-$1.00');
|
||||
});
|
||||
|
||||
it('should format exactly zero variance without sign', () => {
|
||||
const builder = new ShiftReportBuilder(businessInfo, shift, config);
|
||||
const result = builder.build();
|
||||
|
||||
const text = new TextDecoder().decode(result);
|
||||
// Zero variance should be displayed as simple $0.00
|
||||
expect(text).toContain('$0.00');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user