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>
869 lines
28 KiB
TypeScript
869 lines
28 KiB
TypeScript
/**
|
|
* 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');
|
|
});
|
|
});
|
|
});
|