npm package: @markwharton/eh-payroll
Description: Employment Hero Payroll API client
Author: Mark Wharton
Latest version: 3.4.0

# @markwharton/eh-payroll

Employment Hero Payroll API client for employee data, roster shifts, and time & attendance.

## Install

```bash
npm install @markwharton/eh-payroll
```

## Quick Start

```typescript
import { PayrollClient } from '@markwharton/eh-payroll';

const client = new PayrollClient({
  apiKey: 'xxx',
  businessId: 123,
  region: 'au',  // 'au' | 'nz' | 'uk' | 'sg' | 'my' (default: 'au')
});

// Validate credentials
const validation = await client.validateApiKey();
if (!validation.ok) throw new Error(validation.error);

// Get all employees
const empResult = await client.getEmployees();
if (empResult.ok) console.log(empResult.data); // PayrollAuEmployee[]

// Get employees filtered by location
const filteredResult = await client.getEmployees({ locationId: 5 });
if (filteredResult.ok) console.log(filteredResult.data); // PayrollAuEmployee[]

// Get roster shifts for a date range (auto-paginates)
const rosterResult = await client.getRosterShifts('2026-02-03', '2026-02-09');
if (rosterResult.ok) console.log(rosterResult.data); // PayrollRosterShift[]

// Get roster shifts with filters
const pubResult = await client.getRosterShifts('2026-02-03', '2026-02-09', {
  employeeId: 42,
  shiftStatus: 'Published',
  selectAllRoles: true
});
if (pubResult.ok) console.log(pubResult.data); // PayrollRosterShift[]

// Get leave requests for a date range (all filters server-side)
const leaveResult = await client.getLeaveRequests({
  fromDate: '2026-01-01',
  toDate: '2026-03-31',
  status: 'Approved',
});
if (leaveResult.ok) console.log(leaveResult.data); // PayrollLeaveRequest[]

// Get business locations
const locResult = await client.getLocations();
if (locResult.ok) console.log(locResult.data); // PayrollLocation[]

// List kiosks
const kioskResult = await client.getKiosks();
if (kioskResult.ok) console.log(kioskResult.data); // PayrollKiosk[]

// Get kiosk staff (time and attendance)
const staffResult = await client.getKioskStaff(kioskId);
if (staffResult.ok) console.log(staffResult.data); // PayrollKioskEmployee[]

// Get employee groups
const groupResult = await client.getEmployeeGroups();
if (groupResult.ok) console.log(groupResult.data); // PayrollEmployeeGroup[]

// Get standard hours for an employee (includes FTE value)
const hoursResult = await client.getStandardHours(employeeId);
if (hoursResult.ok) console.log(hoursResult.data); // PayrollStandardHours

// Get leave balances for an employee
const balanceResult = await client.getEmployeeLeaveBalances(employeeId);
if (balanceResult.ok) console.log(balanceResult.data); // PayrollLeaveBalance[]

```

## Result Pattern

All methods return `Result<T>` — see [api-core Result Pattern](../../README.md#result-pattern). Always check `ok` before accessing `data`.

## API Reference

| Method | Parameters | Returns |
|--------|-----------|---------|
| `validateApiKey()` | — | `Result<void>` |
| `getEmployees(options?)` | `PayrollEmployeeOptions?` | `Result<PayrollAuEmployee[]>` |
| `getEmployee(employeeId)` | `number` | `Result<PayrollAuEmployee>` |
| `getEmployeeImage(employeeId)` | `number` | `Result<PayrollEmployeeImage>` |
| `getStandardHours(employeeId)` | `number` | `Result<PayrollStandardHours>` |
| `getEmployeeLeaveBalances(employeeId, options?)` | `number, PayrollLeaveBalanceOptions?` | `Result<PayrollLeaveBalance[]>` |
| `getUnavailabilities(options?)` | `PayrollUnavailabilityOptions?` | `Result<PayrollUnavailability[]>` |
| `getLocations()` | — | `Result<PayrollLocation[]>` |
| `getEmployeeGroups()` | — | `Result<PayrollEmployeeGroup[]>` |
| `getPublicHolidays(options?)` | `PayrollPublicHolidayOptions?` | `Result<PayrollPublicHoliday[]>` |
| `getWorkTypes()` | — | `Result<PayrollWorkType[]>` |
| `getLeaveCategories()` | — | `Result<PayrollLeaveCategory[]>` |
| `getRosterShifts(from, to, options?)` | `string, string, PayrollRosterShiftOptions?` | `Result<PayrollRosterShift[]>` |
| `getKiosks()` | — | `Result<PayrollKiosk[]>` |
| `getKioskStaff(kioskId, options?)` | `number, PayrollKioskStaffOptions?` | `Result<PayrollKioskEmployee[]>` |
| `getLeaveRequests(options?)` | `PayrollLeaveRequestOptions?` | `Result<PayrollLeaveRequest[]>` |
| `getEmployeeLeaveRequests(employeeId, options?)` | `number, PayrollEmployeeLeaveRequestOptions?` | `Result<PayrollLeaveRequest[]>` |
| `clearCache()` | — | `void` |
| `invalidateEmployeeCache()` | — | `void` |
| `invalidateLeaveRequestCache()` | — | `void` |
| `invalidateRosterShiftCache()` | — | `void` |
| `invalidateLocationCache()` | — | `void` |
| `invalidateKioskCache()` | — | `void` |

### `getLeaveRequests()`

Returns leave requests with all filters applied server-side. The business-level endpoint returns a flat array — no pagination.

**Leave requests are time-series data** — unlike employees and locations which remain relatively stable, leave requests grow continuously. Always use `fromDate`/`toDate` filters to bound the result set.

**`PayrollLeaveRequestOptions`:**

| Option | Type | Description |
|--------|------|-------------|
| `fromDate` | `string` | Start date filter (YYYY-MM-DD) |
| `toDate` | `string` | End date filter (YYYY-MM-DD) |
| `status` | `PayrollLeaveRequestStatus` | `'Approved'`, `'Pending'`, `'Rejected'`, or `'Cancelled'` |
| `employeeId` | `number` | Filter by employee ID |
| `leaveCategoryId` | `number` | Filter by leave category ID |
| `locationId` | `number` | Filter by location ID |
| `groupBy` | `'Employee' \| 'LeaveType'` | Group results by employee or leave type |
| `restrictOverlappingLeave` | `boolean` | Restrict results to leave overlapping the date range |

### `getEmployeeLeaveRequests()`

Returns leave requests for a specific employee using the per-employee endpoint with OData pagination.

**`PayrollEmployeeLeaveRequestOptions`:**

| Option | Type | Description |
|--------|------|-------------|
| `status` | `PayrollLeaveRequestStatus` | `'Approved'`, `'Pending'`, `'Rejected'`, or `'Cancelled'` |
| `fromDate` | `string` | Start date filter (ISO 8601) |
| `toDate` | `string` | End date filter (ISO 8601) |

### `getEmployeeLeaveBalances()`

Returns the accrued leave balance per category for an employee. The balance is the "live" value calculated from pay runs — updated after each pay run is processed. Supports an optional `asAtDate` parameter for point-in-time balance queries.

**`PayrollLeaveBalanceOptions`:**

| Option | Type | Description |
|--------|------|-------------|
| `asAtDate` | `string` | Point-in-time date for balance query (ISO 8601 date-time) |

### `getUnavailabilities()`

Returns unavailability records for the business. The endpoint returns a flat array (not paginated).

**Unavailabilities are time-series data** — always use `fromDate`/`toDate` filters to bound the result set.

**`PayrollUnavailabilityOptions`:**

| Option | Type | Description |
|--------|------|-------------|
| `fromDate` | `string` | Start date filter (YYYY-MM-DD) |
| `toDate` | `string` | End date filter (YYYY-MM-DD) |
| `employeeId` | `number` | Filter by employee ID |
| `defaultLocationId` | `number` | Filter by default location ID |

### `getPublicHolidays()`

Returns public holidays for the business.

**`PayrollPublicHolidayOptions`:**

| Option | Type | Description |
|--------|------|-------------|
| `year` | `number` | Filter by year |

### `getWorkTypes()`

Returns all work types for the business. Automatically paginates through all results.

### `getLeaveCategories()`

Returns all leave categories for the business. Automatically paginates through all results.

### `getRosterShifts()`

Automatically paginates through all results (100 per page). Returns the complete set of roster shifts for the requested date range. All filter options are applied server-side, reducing the number of results and pages fetched.

### Query Parameter Casing

The [KeyPay Swagger spec](https://api.keypay.com.au/swagger-au.json) uses inconsistent query parameter casing across endpoints. Roster shifts and leave requests use PascalCase (`EmployeeId`, `ShiftStatus`, `FromDate`), while other endpoints use camelCase (`selectedColumns`, `filter.locationId`). The library accepts camelCase options throughout and converts to PascalCase internally where needed.

## Configuration

```typescript
const client = new PayrollClient({
  apiKey: 'your-api-key',       // Required: used as Basic Auth username
  businessId: 12345,            // Required: business ID
  region: 'au',                 // Optional: au, nz, uk, sg, my (default: au)
  baseUrl: '...',               // Optional: override base URL (takes priority over region)
  rateLimitPerSecond: 5,        // Optional: rate limit (default 5, set 0 to disable)
  onRequest: ({ method, url, description }) => { ... },  // Optional: debug callback
  cache: {},                    // Optional: enable TTL caching (defaults below)
  cacheInstance: cache,         // Optional: custom cache backend (e.g., LayeredCache)
  retry: {                      // Optional: retry on 429/503
    maxRetries: 3,
    initialDelayMs: 1000,
    maxDelayMs: 10000,
  },
});
```

`PayrollConfig` extends `ClientConfig` from api-core, which provides the `baseUrl`, `onRequest`, `retry`, and `cacheInstance` fields. `apiKey`, `businessId`, `region`, `cache`, and `rateLimitPerSecond` are payroll-specific. Pass `cacheInstance` to use a custom cache backend (e.g., `LayeredCache` with persistent stores); otherwise `cache: {}` creates an in-memory `TTLCache`. Set `persistRestricted: false` to keep restricted-tier data in memory only.

### Cache TTLs

| Cache Key | Default TTL |
|-----------|-------------|
| Employees | 5 min |
| Locations | 5 min |
| Employee groups | 5 min |
| Standard hours | 5 min |
| Roster shifts | 2 min |
| Leave requests | 2 min |
| Kiosks | 5 min |
| Kiosk staff | 1 min |

Failed API results (`ok: false`) are never cached — transient errors won't persist for the full TTL. See the [root README Cache System section](../../README.md#cache-system) for the full cache architecture (layered stores, restricted data handling, request coalescing).

### Rate Limiting

The client enforces a sliding-window rate limit of 5 requests per second (the [API limit](https://api.keypay.com.au/australia/guides/Usage.html)). All outbound requests pass through the rate limiter automatically. Set `rateLimitPerSecond: 0` in config to disable (useful for tests). The `RateLimiter` class is provided by api-core and operates per-instance (see Known Limitations in the root README).

### Retry

Automatically retries on HTTP 429 (Too Many Requests) and 503 (Service Unavailable) with exponential backoff. Respects the `Retry-After` header when present.

## Authentication

Uses HTTP Basic Authentication with the API key as the username and an empty password, per the [KeyPay API reference](https://api.keypay.com.au/).

## License

MIT