@markwharton/eh-payroll 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # @markwharton/eh-payroll
2
+
3
+ Employment Hero Payroll API client for employee data, roster shifts, and time & attendance.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @markwharton/eh-payroll
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import { EHClient } from '@markwharton/eh-payroll';
15
+
16
+ const client = new EHClient({
17
+ apiKey: 'xxx',
18
+ businessId: 123,
19
+ region: 'au', // 'au' | 'nz' | 'uk' | 'sg' | 'my' (default: 'au')
20
+ });
21
+
22
+ // Validate credentials
23
+ await client.validateApiKey();
24
+
25
+ // Get all employees
26
+ const { employees } = await client.getEmployees();
27
+
28
+ // Get employees filtered by location
29
+ const { employees: filtered } = await client.getEmployees({ locationId: 5 });
30
+
31
+ // Get roster shifts for a date range (auto-paginates)
32
+ const { shifts } = await client.getRosterShifts('2026-02-03', '2026-02-09');
33
+
34
+ // Get roster shifts with filters
35
+ const { shifts: published } = await client.getRosterShifts('2026-02-03', '2026-02-09', {
36
+ employeeId: 42,
37
+ shiftStatus: 'Published',
38
+ selectAllRoles: true
39
+ });
40
+
41
+ // Get kiosk staff (time and attendance)
42
+ const { staff } = await client.getKioskStaff(kioskId);
43
+
44
+ // Get employee groups
45
+ const { groups } = await client.getEmployeeGroups();
46
+
47
+ // Get standard hours for an employee (includes FTE value)
48
+ const { standardHours } = await client.getStandardHours(employeeId);
49
+
50
+ // Discover available report columns
51
+ const { fields } = await client.getReportFields();
52
+
53
+ // Run Employee Details Report with selected columns
54
+ const { records } = await client.getEmployeeDetailsReport({
55
+ selectedColumns: ['FirstName', 'Surname', 'ExternalId']
56
+ });
57
+ ```
58
+
59
+ ## API Reference
60
+
61
+ All methods return `{ data?, error? }` result objects rather than throwing exceptions.
62
+
63
+ | Method | Parameters | Returns |
64
+ |--------|-----------|---------|
65
+ | `validateApiKey()` | — | `{ valid, error? }` |
66
+ | `getEmployees(options?)` | `EHEmployeeOptions?` | `{ employees?, error? }` |
67
+ | `getEmployee(employeeId)` | `number` | `{ employee?, error? }` |
68
+ | `getStandardHours(employeeId)` | `number` | `{ standardHours?, error? }` |
69
+ | `getEmployeeGroups()` | — | `{ groups?, error? }` |
70
+ | `getRosterShifts(from, to, options?)` | `string, string, EHRosterShiftOptions?` | `{ shifts?, error? }` |
71
+ | `getKioskStaff(kioskId, options?)` | `number, EHKioskStaffOptions?` | `{ staff?, error? }` |
72
+ | `getReportFields()` | — | `{ fields?, error? }` |
73
+ | `getEmployeeDetailsReport(options?)` | `EHEmployeeDetailsReportOptions?` | `{ records?, error? }` |
74
+
75
+ ### `getRosterShifts()`
76
+
77
+ Automatically paginates through all results (100 per page). Returns the complete set of shifts for the requested date range. All filter options are applied server-side, reducing the number of results and pages fetched.
78
+
79
+ ### Query Parameter Casing
80
+
81
+ The [KeyPay Swagger spec](https://api.keypay.com.au/swagger-au.json) uses inconsistent query parameter casing across endpoints. Roster shifts use PascalCase (`EmployeeId`, `ShiftStatus`), while all other endpoints use camelCase (`selectedColumns`, `filter.locationId`). The library accepts camelCase options throughout and converts to PascalCase for roster shifts internally.
82
+
83
+ ## Configuration
84
+
85
+ ```typescript
86
+ const client = new EHClient({
87
+ apiKey: 'your-api-key', // Required: used as Basic Auth username
88
+ businessId: 12345, // Required: business ID
89
+ region: 'au', // Optional: au, nz, uk, sg, my (default: au)
90
+ baseUrl: '...', // Optional: override base URL (takes priority over region)
91
+ rateLimitPerSecond: 5, // Optional: rate limit (default 5, set 0 to disable)
92
+ onRequest: ({ method, url, description }) => { ... }, // Optional: debug callback
93
+ cache: {}, // Optional: enable TTL caching (defaults below)
94
+ retry: { // Optional: retry on 429/503
95
+ maxRetries: 3,
96
+ initialDelayMs: 1000,
97
+ maxDelayMs: 10000,
98
+ },
99
+ });
100
+ ```
101
+
102
+ ### Cache TTLs
103
+
104
+ | Cache Key | Default TTL |
105
+ |-----------|-------------|
106
+ | Employees | 5 min |
107
+ | Employee groups | 5 min |
108
+ | Standard hours | 5 min |
109
+ | Roster shifts | 2 min |
110
+ | Report fields | 10 min |
111
+
112
+ ### Rate Limiting
113
+
114
+ 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).
115
+
116
+ ### Retry
117
+
118
+ Automatically retries on HTTP 429 (Too Many Requests) and 503 (Service Unavailable) with exponential backoff. Respects the `Retry-After` header when present.
119
+
120
+ ## Authentication
121
+
122
+ 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/).
123
+
124
+ ## License
125
+
126
+ MIT
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Simple in-memory TTL cache with request coalescing
3
+ *
4
+ * Provides per-instance memoization for EHClient API responses.
5
+ * In serverless environments (Azure Functions, Static Web Apps),
6
+ * module-level state persists across warm invocations within the
7
+ * same instance — this cache leverages that behavior.
8
+ *
9
+ * Request coalescing: when multiple concurrent callers request the
10
+ * same expired key, only one factory call is made. All callers
11
+ * receive the same resolved value (or the same rejection).
12
+ *
13
+ * Not a distributed cache: each instance has its own cache.
14
+ * Cold starts and instance recycling naturally clear stale data.
15
+ */
16
+ export declare class TTLCache {
17
+ private store;
18
+ private inflight;
19
+ /**
20
+ * Get a cached value, or call the factory to populate it.
21
+ *
22
+ * If a factory call is already in progress for this key,
23
+ * returns the existing promise instead of starting a duplicate.
24
+ *
25
+ * @param key - Cache key
26
+ * @param ttlMs - Time-to-live in milliseconds
27
+ * @param factory - Async function to produce the value on cache miss
28
+ */
29
+ get<T>(key: string, ttlMs: number, factory: () => Promise<T>): Promise<T>;
30
+ /**
31
+ * Invalidate cache entries matching a key prefix.
32
+ */
33
+ invalidate(prefix: string): void;
34
+ /**
35
+ * Clear all cached data and in-flight requests.
36
+ */
37
+ clear(): void;
38
+ }
package/dist/cache.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Simple in-memory TTL cache with request coalescing
3
+ *
4
+ * Provides per-instance memoization for EHClient API responses.
5
+ * In serverless environments (Azure Functions, Static Web Apps),
6
+ * module-level state persists across warm invocations within the
7
+ * same instance — this cache leverages that behavior.
8
+ *
9
+ * Request coalescing: when multiple concurrent callers request the
10
+ * same expired key, only one factory call is made. All callers
11
+ * receive the same resolved value (or the same rejection).
12
+ *
13
+ * Not a distributed cache: each instance has its own cache.
14
+ * Cold starts and instance recycling naturally clear stale data.
15
+ */
16
+ export class TTLCache {
17
+ constructor() {
18
+ this.store = new Map();
19
+ this.inflight = new Map();
20
+ }
21
+ /**
22
+ * Get a cached value, or call the factory to populate it.
23
+ *
24
+ * If a factory call is already in progress for this key,
25
+ * returns the existing promise instead of starting a duplicate.
26
+ *
27
+ * @param key - Cache key
28
+ * @param ttlMs - Time-to-live in milliseconds
29
+ * @param factory - Async function to produce the value on cache miss
30
+ */
31
+ async get(key, ttlMs, factory) {
32
+ const existing = this.store.get(key);
33
+ if (existing && existing.expiresAt > Date.now()) {
34
+ return existing.data;
35
+ }
36
+ const pending = this.inflight.get(key);
37
+ if (pending) {
38
+ return pending;
39
+ }
40
+ const promise = factory().then((data) => {
41
+ this.store.set(key, { data, expiresAt: Date.now() + ttlMs });
42
+ this.inflight.delete(key);
43
+ return data;
44
+ }, (err) => {
45
+ this.inflight.delete(key);
46
+ throw err;
47
+ });
48
+ this.inflight.set(key, promise);
49
+ return promise;
50
+ }
51
+ /**
52
+ * Invalidate cache entries matching a key prefix.
53
+ */
54
+ invalidate(prefix) {
55
+ for (const key of this.store.keys()) {
56
+ if (key.startsWith(prefix)) {
57
+ this.store.delete(key);
58
+ }
59
+ }
60
+ for (const key of this.inflight.keys()) {
61
+ if (key.startsWith(prefix)) {
62
+ this.inflight.delete(key);
63
+ }
64
+ }
65
+ }
66
+ /**
67
+ * Clear all cached data and in-flight requests.
68
+ */
69
+ clear() {
70
+ this.store.clear();
71
+ this.inflight.clear();
72
+ }
73
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Employment Hero Payroll API Client
3
+ *
4
+ * Provides methods for interacting with the Employment Hero Payroll (KeyPay) API.
5
+ * Uses HTTP Basic Authentication (API key as username, empty password).
6
+ *
7
+ * @see https://api.keypay.com.au/
8
+ */
9
+ import type { EHConfig, EHEmployeeOptions, EHStandardHours, EHEmployeeGroup, EHRosterShift, EHRosterShiftOptions, EHKioskEmployee, EHKioskStaffOptions, EHErrorInfo, EHReportField, EHEmployeeDetailsReportOptions } from './types.js';
10
+ import type { EHAuEmployee } from './employee-types.generated.js';
11
+ /**
12
+ * Employment Hero Payroll API Client
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * const client = new EHClient({ apiKey: 'xxx', businessId: 123 });
17
+ *
18
+ * // Validate credentials
19
+ * await client.validateApiKey();
20
+ *
21
+ * // Get employees
22
+ * const { employees } = await client.getEmployees();
23
+ * ```
24
+ */
25
+ export declare class EHClient {
26
+ private readonly apiKey;
27
+ private readonly businessId;
28
+ private readonly baseUrl;
29
+ private readonly onRequest?;
30
+ private readonly cache?;
31
+ private readonly cacheTtl;
32
+ private readonly retryConfig?;
33
+ private readonly rateLimiter?;
34
+ constructor(config: EHConfig);
35
+ /**
36
+ * Route through cache if enabled, otherwise call factory directly.
37
+ */
38
+ private cached;
39
+ /**
40
+ * Clear all cached API responses.
41
+ */
42
+ clearCache(): void;
43
+ /**
44
+ * Make an authenticated request to the EH API
45
+ *
46
+ * When retry is configured, automatically retries on HTTP 429 and 503
47
+ * with exponential backoff. Respects the Retry-After header when present.
48
+ */
49
+ private fetch;
50
+ /**
51
+ * Fetch a URL and parse the response, with standardized error handling.
52
+ */
53
+ private fetchAndParse;
54
+ /**
55
+ * Validate the API key by calling GET /user
56
+ */
57
+ validateApiKey(): Promise<{
58
+ valid: boolean;
59
+ error?: string;
60
+ }>;
61
+ /**
62
+ * Get all employees (unstructured format)
63
+ */
64
+ getEmployees(options?: EHEmployeeOptions): Promise<{
65
+ employees?: EHAuEmployee[];
66
+ error?: EHErrorInfo;
67
+ }>;
68
+ /**
69
+ * Get a single employee by ID
70
+ */
71
+ getEmployee(employeeId: number): Promise<{
72
+ employee?: EHAuEmployee;
73
+ error?: EHErrorInfo;
74
+ }>;
75
+ /**
76
+ * Get standard hours for an employee (includes FTE value)
77
+ */
78
+ getStandardHours(employeeId: number): Promise<{
79
+ standardHours?: EHStandardHours;
80
+ error?: EHErrorInfo;
81
+ }>;
82
+ /**
83
+ * Get all employee groups
84
+ */
85
+ getEmployeeGroups(): Promise<{
86
+ groups?: EHEmployeeGroup[];
87
+ error?: EHErrorInfo;
88
+ }>;
89
+ /**
90
+ * Get roster shifts for a date range
91
+ *
92
+ * @param fromDate - Start date (YYYY-MM-DD)
93
+ * @param toDate - End date (YYYY-MM-DD)
94
+ * @param options - Optional filters
95
+ */
96
+ getRosterShifts(fromDate: string, toDate: string, options?: EHRosterShiftOptions): Promise<{
97
+ shifts?: EHRosterShift[];
98
+ error?: EHErrorInfo;
99
+ }>;
100
+ /**
101
+ * Get kiosk staff for time and attendance
102
+ *
103
+ * @param kioskId - Kiosk identifier
104
+ * @param options - Optional query parameters
105
+ */
106
+ getKioskStaff(kioskId: number, options?: EHKioskStaffOptions): Promise<{
107
+ staff?: EHKioskEmployee[];
108
+ error?: EHErrorInfo;
109
+ }>;
110
+ /**
111
+ * Get available fields for the Employee Details Report
112
+ *
113
+ * Returns the list of columns that can be requested via getEmployeeDetailsReport().
114
+ * Use this to discover what data is available for the business.
115
+ */
116
+ getReportFields(): Promise<{
117
+ fields?: EHReportField[];
118
+ error?: EHErrorInfo;
119
+ }>;
120
+ /**
121
+ * Get Employee Details Report
122
+ *
123
+ * Returns all employees with the requested columns in a single API call.
124
+ * The response is dynamic (JObject[]) — field keys depend on selectedColumns.
125
+ */
126
+ getEmployeeDetailsReport(options?: EHEmployeeDetailsReportOptions): Promise<{
127
+ records?: Record<string, unknown>[];
128
+ error?: EHErrorInfo;
129
+ }>;
130
+ }