@classytic/payroll 2.0.0 โ 2.3.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 +2599 -253
- package/dist/calculators/index.d.ts +433 -0
- package/dist/calculators/index.js +283 -0
- package/dist/calculators/index.js.map +1 -0
- package/dist/core/index.d.ts +85 -251
- package/dist/core/index.js +286 -91
- package/dist/core/index.js.map +1 -1
- package/dist/employee-identity-DXhgOgXE.d.ts +473 -0
- package/dist/employee.factory-BlZqhiCk.d.ts +189 -0
- package/dist/idempotency-Cw2CWicb.d.ts +52 -0
- package/dist/index.d.ts +618 -683
- package/dist/index.js +8336 -3580
- package/dist/index.js.map +1 -1
- package/dist/jurisdiction/index.d.ts +660 -0
- package/dist/jurisdiction/index.js +533 -0
- package/dist/jurisdiction/index.js.map +1 -0
- package/dist/payroll.d.ts +261 -65
- package/dist/payroll.js +4164 -1075
- package/dist/payroll.js.map +1 -1
- package/dist/schemas/index.d.ts +1176 -783
- package/dist/schemas/index.js +368 -28
- package/dist/schemas/index.js.map +1 -1
- package/dist/services/index.d.ts +582 -3
- package/dist/services/index.js +572 -96
- package/dist/services/index.js.map +1 -1
- package/dist/shift-compliance/index.d.ts +1171 -0
- package/dist/shift-compliance/index.js +1479 -0
- package/dist/shift-compliance/index.js.map +1 -0
- package/dist/types-BN3K_Uhr.d.ts +1842 -0
- package/dist/utils/index.d.ts +22 -2
- package/dist/utils/index.js +470 -1
- package/dist/utils/index.js.map +1 -1
- package/package.json +24 -6
- package/dist/index-CTjHlCzz.d.ts +0 -721
- package/dist/plugin-D9mOr3_d.d.ts +0 -333
- package/dist/types-BSYyX2KJ.d.ts +0 -671
package/README.md
CHANGED
|
@@ -1,253 +1,2599 @@
|
|
|
1
|
-
# @classytic/payroll
|
|
2
|
-
|
|
3
|
-
Enterprise-grade payroll for Mongoose. Simple, powerful, production-ready.
|
|
4
|
-
|
|
5
|
-
[](https://www.npmjs.com/package/@classytic/payroll)
|
|
6
|
-
[](https://www.typescriptlang.org/)
|
|
7
|
-
[](https://opensource.org/licenses/MIT)
|
|
8
|
-
|
|
9
|
-
##
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
```
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
1
|
+
# @classytic/payroll
|
|
2
|
+
|
|
3
|
+
Enterprise-grade payroll for Mongoose. Simple, powerful, production-ready.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@classytic/payroll)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
| Feature | Description | Status |
|
|
12
|
+
|---------|-------------|--------|
|
|
13
|
+
| **Employee Management** | Hire, terminate, re-hire, update employment | โ
Production-ready |
|
|
14
|
+
| **Guest Employee Identity** | Employees without userId, flexible identity lookups | โ
Production-ready |
|
|
15
|
+
| **Compensation** | Base salary, allowances, deductions, bank details | โ
Production-ready |
|
|
16
|
+
| **Payroll Processing** | Monthly salary with automatic calculations | โ
Production-ready |
|
|
17
|
+
| **Shift Compliance** | Late penalties, overtime bonuses, progressive discipline | โ
Production-ready |
|
|
18
|
+
| **Bulk Processing** | Concurrency, progress tracking, cancellation | โ
Production-ready |
|
|
19
|
+
| **Streaming Mode** | Cursor-based processing for millions (auto-detect) | โ
Production-ready |
|
|
20
|
+
| **Attendance Integration** | Native `@classytic/clockin` support for absences | โ
Production-ready |
|
|
21
|
+
| **Leave Management** | Balances, requests, approvals, payroll integration | โ
Production-ready |
|
|
22
|
+
| **Tax Withholding** | Track government tax liability, query pending taxes | โ
Production-ready |
|
|
23
|
+
| **Pro-rating** | Mid-month hires, terminations, attendance | โ
Production-ready |
|
|
24
|
+
| **Tax Calculation** | Progressive tax brackets | โ
Production-ready |
|
|
25
|
+
| **Holidays** | Public holidays, company holidays, paid/unpaid | โ
Production-ready |
|
|
26
|
+
| **Multi-tenant** | Organization isolation, security validated | โ
**HARDENED** Production-ready |
|
|
27
|
+
| **Single-tenant** | Auto-inject org ID, simplified API | โ
Production-ready |
|
|
28
|
+
| **Transactions** | Atomic operations with Mongoose sessions | โ
Production-ready |
|
|
29
|
+
| **Pure Calculators** | Client-side salary previews, no-DB testing | โ
**NEW** Production-ready |
|
|
30
|
+
|
|
31
|
+
## Why This Package?
|
|
32
|
+
|
|
33
|
+
- ๐ฏ **One clear way** - No confusion, single path to success
|
|
34
|
+
- โก **Attendance native** - Built-in `@classytic/clockin` integration
|
|
35
|
+
- ๐ข **Flexible deployment** - Single-tenant or multi-tenant
|
|
36
|
+
- ๐ฐ **Smart calculations** - Pro-rating, tax, deductions, working days
|
|
37
|
+
- ๐ **Complete leave workflow** - Balances, requests, approvals, payroll
|
|
38
|
+
- ๐งช **Pure functions** - Test without database, client-side previews
|
|
39
|
+
- ๐ **Transaction-safe** - Atomic operations, no partial writes
|
|
40
|
+
- ๐ฆ **Zero config** - Works immediately with smart defaults
|
|
41
|
+
|
|
42
|
+
## Scope & Boundaries
|
|
43
|
+
|
|
44
|
+
This package focuses on payroll and core HRM data/calculations. The following remain **app-level** because they are UI, workflow, or company-specific:
|
|
45
|
+
|
|
46
|
+
- Recruiting/ATS
|
|
47
|
+
- Onboarding checklists
|
|
48
|
+
- Performance reviews
|
|
49
|
+
- Training/LMS
|
|
50
|
+
- Org charts
|
|
51
|
+
- Asset tracking
|
|
52
|
+
- Employee self-service UI
|
|
53
|
+
|
|
54
|
+
Jurisdiction rules are also **app-provided**. Use the jurisdiction tools to register your verified data (see `https://github.com/classytic/payroll/tree/main/examples/jurisdiction-data/README.md`).
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npm install @classytic/payroll @classytic/clockin mongoose
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Quick Start (3 steps)
|
|
63
|
+
|
|
64
|
+
### 1. Create Models
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
import mongoose from 'mongoose';
|
|
68
|
+
import { createAttendanceSchema } from '@classytic/clockin';
|
|
69
|
+
import { createEmployeeSchema, createPayrollRecordSchema, employeePlugin, createHolidaySchema } from '@classytic/payroll';
|
|
70
|
+
|
|
71
|
+
// Attendance (from ClockIn - required for payroll)
|
|
72
|
+
const Attendance = mongoose.model('Attendance', createAttendanceSchema());
|
|
73
|
+
|
|
74
|
+
// Employee (create schema + apply payroll plugin)
|
|
75
|
+
const employeeSchema = createEmployeeSchema();
|
|
76
|
+
employeeSchema.plugin(employeePlugin);
|
|
77
|
+
const Employee = mongoose.model('Employee', employeeSchema);
|
|
78
|
+
|
|
79
|
+
// PayrollRecord
|
|
80
|
+
const PayrollRecord = mongoose.model('PayrollRecord', createPayrollRecordSchema());
|
|
81
|
+
|
|
82
|
+
// Transaction (your own model)
|
|
83
|
+
const Transaction = mongoose.model('Transaction', transactionSchema);
|
|
84
|
+
|
|
85
|
+
// Holiday (optional - use our schema or your own)
|
|
86
|
+
const Holiday = mongoose.model('Holiday', createHolidaySchema());
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### 2. Initialize
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
import { createPayrollInstance } from '@classytic/payroll';
|
|
93
|
+
|
|
94
|
+
const payroll = createPayrollInstance()
|
|
95
|
+
.withModels({
|
|
96
|
+
EmployeeModel: Employee,
|
|
97
|
+
PayrollRecordModel: PayrollRecord,
|
|
98
|
+
TransactionModel: Transaction,
|
|
99
|
+
AttendanceModel: Attendance,
|
|
100
|
+
})
|
|
101
|
+
.build();
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### 3. Use It
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
// Hire employee
|
|
108
|
+
const employee = await payroll.hire({
|
|
109
|
+
userId: user._id,
|
|
110
|
+
organizationId: org._id,
|
|
111
|
+
employment: {
|
|
112
|
+
position: 'Software Engineer',
|
|
113
|
+
department: 'it',
|
|
114
|
+
type: 'full_time',
|
|
115
|
+
},
|
|
116
|
+
compensation: {
|
|
117
|
+
baseAmount: 100000,
|
|
118
|
+
currency: 'USD',
|
|
119
|
+
allowances: [
|
|
120
|
+
{ type: 'housing', amount: 20000, taxable: true },
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Process monthly payroll (automatic attendance deductions)
|
|
126
|
+
const result = await payroll.processSalary({
|
|
127
|
+
employeeId: employee._id,
|
|
128
|
+
organizationId: org._id,
|
|
129
|
+
month: 3,
|
|
130
|
+
year: 2024,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
console.log(result.payrollRecord.breakdown);
|
|
134
|
+
// {
|
|
135
|
+
// baseAmount: 100000,
|
|
136
|
+
// allowances: [{ type: 'housing', amount: 20000, taxable: true }],
|
|
137
|
+
// deductions: [{ type: 'absence', amount: 9090, description: 'Unpaid leave deduction' }],
|
|
138
|
+
// taxAmount: 2500,
|
|
139
|
+
// grossSalary: 120000,
|
|
140
|
+
// netSalary: 108410,
|
|
141
|
+
// attendanceDeduction: 9090
|
|
142
|
+
// }
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Guest Employee Identity System
|
|
146
|
+
|
|
147
|
+
Modern HRM needs flexibility in how employees are identified. Not all employees have user accounts โ drivers, contractors, and temporary workers often don't need system access.
|
|
148
|
+
|
|
149
|
+
### Features
|
|
150
|
+
|
|
151
|
+
- **Guest Employees**: Create employees without userId (no user account required)
|
|
152
|
+
- **Flexible Identity Modes**: Lookup by userId, employeeId, email, or any
|
|
153
|
+
- **Smart Fallback Chain**: Automatic fallback if primary lookup fails
|
|
154
|
+
- **Dual Identity Support**: MongoDB ObjectId (_id) and business string IDs (employeeId)
|
|
155
|
+
- **Collision Prevention**: `employeeIdMode` parameter prevents 24-hex business ID collisions
|
|
156
|
+
- **Partial Indexes**: Multiple guest employees per organization (userId field completely absent)
|
|
157
|
+
- **Email Normalization**: Case-insensitive email lookup (lowercase + trim)
|
|
158
|
+
- **Email Reuse**: Terminated employees' emails can be reused for rehiring
|
|
159
|
+
- **Mixed Workforce**: User-linked and guest employees in the same payroll
|
|
160
|
+
|
|
161
|
+
### Configuration
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
import { createPayrollInstance } from '@classytic/payroll';
|
|
165
|
+
|
|
166
|
+
const payroll = createPayrollInstance()
|
|
167
|
+
.withModels({ EmployeeModel, PayrollRecordModel, TransactionModel })
|
|
168
|
+
.withConfig({
|
|
169
|
+
validation: {
|
|
170
|
+
requireUserId: false, // Allow guest employees (default: false)
|
|
171
|
+
identityMode: 'employeeId', // Primary lookup mode (default: 'employeeId')
|
|
172
|
+
identityFallbacks: ['email', 'userId'], // Fallback chain (default: ['email', 'userId'])
|
|
173
|
+
},
|
|
174
|
+
})
|
|
175
|
+
.build();
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Identity Modes
|
|
179
|
+
|
|
180
|
+
| Mode | Description | Use Case |
|
|
181
|
+
|------|-------------|----------|
|
|
182
|
+
| `employeeId` | Human-readable ID (e.g., "EMP-001") | Primary mode for all employees |
|
|
183
|
+
| `email` | Email address | Guest employees without userId |
|
|
184
|
+
| `userId` | MongoDB ObjectId of user account | Traditional user-linked employees |
|
|
185
|
+
| `any` | Try all modes | Flexible lookup when identity type unknown |
|
|
186
|
+
|
|
187
|
+
### Creating Guest Employees
|
|
188
|
+
|
|
189
|
+
#### Using Payroll Instance (Recommended)
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
// Guest employee without user account
|
|
193
|
+
const driver = await payroll.hire({
|
|
194
|
+
organizationId: org._id,
|
|
195
|
+
employment: {
|
|
196
|
+
employeeId: 'DRIVER-001', // Required: human-readable ID
|
|
197
|
+
email: 'john@company.com', // Optional: for guest employee identification
|
|
198
|
+
name: 'John Driver',
|
|
199
|
+
position: 'Delivery Driver',
|
|
200
|
+
department: 'operations',
|
|
201
|
+
type: 'contract',
|
|
202
|
+
joinDate: new Date(),
|
|
203
|
+
},
|
|
204
|
+
compensation: {
|
|
205
|
+
baseAmount: 3000,
|
|
206
|
+
currency: 'USD',
|
|
207
|
+
frequency: 'monthly',
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
console.log(driver.userId); // undefined
|
|
212
|
+
console.log(driver.email); // 'john@company.com' (normalized: lowercase + trimmed)
|
|
213
|
+
console.log(driver.employeeId); // 'DRIVER-001'
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
#### Using Employee Factory (Advanced)
|
|
217
|
+
|
|
218
|
+
For direct data creation without payroll instance:
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
import { EmployeeFactory, type CreateEmployeeParams } from '@classytic/payroll';
|
|
222
|
+
|
|
223
|
+
// Create employee data object
|
|
224
|
+
const employeeData = EmployeeFactory.create({
|
|
225
|
+
organizationId: org._id,
|
|
226
|
+
employment: {
|
|
227
|
+
employeeId: 'DRIVER-001',
|
|
228
|
+
email: 'john@company.com',
|
|
229
|
+
position: 'Delivery Driver',
|
|
230
|
+
department: 'operations',
|
|
231
|
+
type: 'contract',
|
|
232
|
+
hireDate: new Date(),
|
|
233
|
+
},
|
|
234
|
+
compensation: {
|
|
235
|
+
baseAmount: 3000,
|
|
236
|
+
currency: 'USD',
|
|
237
|
+
frequency: 'monthly',
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Use with Mongoose model
|
|
242
|
+
const employee = await Employee.create(employeeData);
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
#### Using Employee Builder (Fluent API)
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
import { createEmployee } from '@classytic/payroll';
|
|
249
|
+
|
|
250
|
+
// Fluent builder pattern
|
|
251
|
+
const employeeData = createEmployee()
|
|
252
|
+
.inOrganization(org._id)
|
|
253
|
+
.withEmployeeId('DRIVER-001')
|
|
254
|
+
.asPosition('Delivery Driver')
|
|
255
|
+
.inDepartment('operations')
|
|
256
|
+
.withEmploymentType('contract')
|
|
257
|
+
.withBaseSalary(3000, 'monthly', 'USD')
|
|
258
|
+
.build();
|
|
259
|
+
|
|
260
|
+
const employee = await Employee.create(employeeData);
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
**Note**: Factory and Builder create data objects only. Use `payroll.hire()` for full hiring workflow with validation and event emission.
|
|
264
|
+
|
|
265
|
+
### Creating User-Linked Employees
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
// Traditional employee with user account
|
|
269
|
+
const manager = await payroll.hire({
|
|
270
|
+
userId: user._id, // User account reference
|
|
271
|
+
organizationId: org._id,
|
|
272
|
+
employment: {
|
|
273
|
+
employeeId: 'MGR-001',
|
|
274
|
+
name: 'Jane Manager',
|
|
275
|
+
position: 'HR Manager',
|
|
276
|
+
department: 'hr',
|
|
277
|
+
type: 'full_time',
|
|
278
|
+
joinDate: new Date(),
|
|
279
|
+
},
|
|
280
|
+
compensation: {
|
|
281
|
+
baseAmount: 8000,
|
|
282
|
+
currency: 'USD',
|
|
283
|
+
frequency: 'monthly',
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### Looking Up Employees
|
|
289
|
+
|
|
290
|
+
```typescript
|
|
291
|
+
// By employeeId (primary mode, works for both guest and user-linked)
|
|
292
|
+
const employee = await payroll.getEmployeeByIdentity({
|
|
293
|
+
identity: 'DRIVER-001',
|
|
294
|
+
organizationId: org._id,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// By email (for guest employees) - case-insensitive!
|
|
298
|
+
const driver = await payroll.getEmployeeByIdentity({
|
|
299
|
+
identity: 'JOHN@COMPANY.COM', // Works! Normalized to lowercase internally
|
|
300
|
+
organizationId: org._id,
|
|
301
|
+
mode: 'email',
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// By userId (for user-linked employees)
|
|
305
|
+
const manager = await payroll.getEmployeeByIdentity({
|
|
306
|
+
identity: user._id,
|
|
307
|
+
organizationId: org._id,
|
|
308
|
+
mode: 'userId',
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Auto mode - tries all identity methods
|
|
312
|
+
const anyEmployee = await payroll.getEmployeeByIdentity({
|
|
313
|
+
identity: 'DRIVER-001', // Could be employeeId, email, or userId
|
|
314
|
+
organizationId: org._id,
|
|
315
|
+
mode: 'any',
|
|
316
|
+
});
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### Fallback Chain Example
|
|
320
|
+
|
|
321
|
+
```typescript
|
|
322
|
+
// With default config: identityMode: 'employeeId', fallbacks: ['email', 'userId']
|
|
323
|
+
const employee = await payroll.getEmployeeByIdentity({
|
|
324
|
+
identity: 'john@company.com', // Not an employeeId
|
|
325
|
+
organizationId: org._id,
|
|
326
|
+
// 1. Tries employeeId lookup โ fails
|
|
327
|
+
// 2. Falls back to email โ success!
|
|
328
|
+
});
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### Payroll Processing for Guest Employees
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
// Process salary for guest employee (same API!)
|
|
335
|
+
const result = await payroll.processSalary({
|
|
336
|
+
employeeId: driver._id,
|
|
337
|
+
organizationId: org._id,
|
|
338
|
+
month: 3,
|
|
339
|
+
year: 2024,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Transaction is created without userId
|
|
343
|
+
console.log(result.transaction.userId); // undefined
|
|
344
|
+
console.log(result.transaction.employeeId); // driver._id
|
|
345
|
+
console.log(result.transaction.metadata.email); // 'john@company.com'
|
|
346
|
+
console.log(result.transaction.sourceId); // payrollRecord._id
|
|
347
|
+
console.log(result.transaction.sourceModel); // 'PayrollRecord'
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### Bulk Payroll with Mixed Employees
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
// Works seamlessly with both guest and user-linked employees
|
|
354
|
+
const result = await payroll.processBulkPayroll({
|
|
355
|
+
organizationId: org._id,
|
|
356
|
+
month: 3,
|
|
357
|
+
year: 2024,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
console.log(result.total); // All employees (guest + user-linked)
|
|
361
|
+
console.log(result.successful); // Successfully processed
|
|
362
|
+
console.log(result.failed); // Failed employees
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
### Database Schema & Implementation
|
|
366
|
+
|
|
367
|
+
The system uses **partial unique indexes** with explicit filter expressions to allow multiple guest employees. This is a critical technical detail:
|
|
368
|
+
|
|
369
|
+
**Why Partial Indexes (Not Sparse)?**
|
|
370
|
+
- Compound sparse indexes don't work correctly when one field (organizationId) is always present
|
|
371
|
+
- MongoDB treats missing fields as `null` in compound sparse indexes, causing duplicate key errors
|
|
372
|
+
- Partial indexes with `partialFilterExpression` explicitly control which documents are included
|
|
373
|
+
|
|
374
|
+
```typescript
|
|
375
|
+
// Partial index - only includes documents WITH userId field
|
|
376
|
+
// Guest employees (no userId field) are completely excluded from this index
|
|
377
|
+
{
|
|
378
|
+
fields: { userId: 1, organizationId: 1 },
|
|
379
|
+
options: {
|
|
380
|
+
unique: true,
|
|
381
|
+
partialFilterExpression: { userId: { $exists: true } }
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Partial index - only includes active employees with email
|
|
386
|
+
// This allows email reuse when employees are terminated
|
|
387
|
+
{
|
|
388
|
+
fields: { email: 1, organizationId: 1 },
|
|
389
|
+
options: {
|
|
390
|
+
unique: true,
|
|
391
|
+
partialFilterExpression: {
|
|
392
|
+
email: { $exists: true },
|
|
393
|
+
status: { $in: ['active', 'on_leave', 'suspended'] }
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Always unique - human-readable IDs (all employees)
|
|
399
|
+
{ fields: { employeeId: 1, organizationId: 1 }, options: { unique: true } }
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
**Implementation Details (Advanced):**
|
|
403
|
+
- Guest employees use MongoDB's `collection.insertOne()` directly instead of Mongoose's `Model.create()`
|
|
404
|
+
- This prevents Mongoose from setting undefined fields to `null` before pre-save hooks run
|
|
405
|
+
- Ensures partial indexes work correctly (they only skip documents where fields are completely absent)
|
|
406
|
+
- Emails are normalized at the EmployeeFactory level (lowercase + trim) for consistent storage and case-insensitive lookup
|
|
407
|
+
|
|
408
|
+
### Validation Rules
|
|
409
|
+
|
|
410
|
+
```typescript
|
|
411
|
+
// At least ONE identity field required
|
|
412
|
+
โ
employeeId only
|
|
413
|
+
โ
email only
|
|
414
|
+
โ
userId only
|
|
415
|
+
โ
employeeId + email
|
|
416
|
+
โ
userId + employeeId
|
|
417
|
+
โ No identity fields
|
|
418
|
+
|
|
419
|
+
// Uniqueness per organization
|
|
420
|
+
โ
Multiple guest employees (no userId field) in same org
|
|
421
|
+
โ
Same userId in different organizations
|
|
422
|
+
โ
Email reuse after termination (status: 'terminated')
|
|
423
|
+
โ Duplicate employeeId in same org
|
|
424
|
+
โ Duplicate email in same org for active employees
|
|
425
|
+
โ Duplicate userId in same org
|
|
426
|
+
|
|
427
|
+
// Email normalization (automatic)
|
|
428
|
+
'John@Company.COM ' โ 'john@company.com'
|
|
429
|
+
' ADMIN@SITE.COM' โ 'admin@site.com'
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
### Strict Mode
|
|
433
|
+
|
|
434
|
+
If you want to require userId for all employees:
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
const payroll = createPayrollInstance()
|
|
438
|
+
.withModels({ EmployeeModel, PayrollRecordModel, TransactionModel })
|
|
439
|
+
.withConfig({
|
|
440
|
+
validation: {
|
|
441
|
+
requireUserId: true, // Enforce user accounts
|
|
442
|
+
},
|
|
443
|
+
})
|
|
444
|
+
.build();
|
|
445
|
+
|
|
446
|
+
// This will throw ValidationError
|
|
447
|
+
await payroll.hire({
|
|
448
|
+
organizationId: org._id,
|
|
449
|
+
employment: {
|
|
450
|
+
employeeId: 'DRIVER-001',
|
|
451
|
+
email: 'driver@company.com',
|
|
452
|
+
// Missing userId - will fail!
|
|
453
|
+
},
|
|
454
|
+
compensation: { baseAmount: 3000, currency: 'USD' },
|
|
455
|
+
});
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
### Dual Identity & Collision Prevention (v2.3.0+)
|
|
459
|
+
|
|
460
|
+
The package supports **dual identity** for employees:
|
|
461
|
+
- **MongoDB ObjectId** (`_id` field) - Internal database ID
|
|
462
|
+
- **Business String ID** (`employeeId` field) - Human-readable like "EMP-001"
|
|
463
|
+
|
|
464
|
+
**Problem: 24-Hex Collision**
|
|
465
|
+
|
|
466
|
+
If your business employeeId is 24 hexadecimal characters (like `"507f1f77bcf86cd799439011"`), it looks exactly like a MongoDB ObjectId. Auto-detection will treat it as `_id` instead of `employeeId`, causing lookup failures.
|
|
467
|
+
|
|
468
|
+
**Solution: `employeeIdMode` Parameter**
|
|
469
|
+
|
|
470
|
+
All payroll methods now support explicit `employeeIdMode` to prevent collision:
|
|
471
|
+
|
|
472
|
+
```typescript
|
|
473
|
+
// Process salary with explicit business ID mode
|
|
474
|
+
const result = await payroll.processSalary({
|
|
475
|
+
employeeId: "507f1f77bcf86cd799439011", // Looks like ObjectId!
|
|
476
|
+
employeeIdMode: 'businessId', // Force treat as string
|
|
477
|
+
organizationId: org._id,
|
|
478
|
+
month: 3,
|
|
479
|
+
year: 2024,
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// Without employeeIdMode (auto-detect)
|
|
483
|
+
// โ Would try to find by _id (wrong!)
|
|
484
|
+
|
|
485
|
+
// With employeeIdMode: 'businessId'
|
|
486
|
+
// โ Correctly finds by employeeId field
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
**Available Modes:**
|
|
490
|
+
|
|
491
|
+
| Mode | Behavior |
|
|
492
|
+
|------|----------|
|
|
493
|
+
| `'auto'` | Auto-detect via ObjectId validation (default) |
|
|
494
|
+
| `'objectId'` | Force treat as MongoDB `_id` |
|
|
495
|
+
| `'businessId'` | Force treat as string `employeeId` |
|
|
496
|
+
|
|
497
|
+
**When to Use:**
|
|
498
|
+
|
|
499
|
+
```typescript
|
|
500
|
+
// โ
Use 'businessId' if your IDs are 24-hex strings
|
|
501
|
+
employeeId: "507f1f77bcf86cd799439011" โ employeeIdMode: 'businessId'
|
|
502
|
+
employeeId: "60d5ec49f1b2c72b8c8e4f3a" โ employeeIdMode: 'businessId'
|
|
503
|
+
|
|
504
|
+
// โ
Auto mode works for normal business IDs
|
|
505
|
+
employeeId: "EMP-001" โ employeeIdMode: 'auto' (default)
|
|
506
|
+
employeeId: "DRIVER-123" โ employeeIdMode: 'auto' (default)
|
|
507
|
+
|
|
508
|
+
// โ
Use 'objectId' when passing MongoDB _id explicitly
|
|
509
|
+
employeeId: employee._id โ employeeIdMode: 'objectId'
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
**Supported Methods (12 total):**
|
|
513
|
+
|
|
514
|
+
All employee operation methods support `employeeIdMode`:
|
|
515
|
+
- `getEmployee()`
|
|
516
|
+
- `updateEmployment()`
|
|
517
|
+
- `terminate()`
|
|
518
|
+
- `reHire()`
|
|
519
|
+
- `updateSalary()`
|
|
520
|
+
- `addAllowance()`
|
|
521
|
+
- `removeAllowance()`
|
|
522
|
+
- `addDeduction()`
|
|
523
|
+
- `removeDeduction()`
|
|
524
|
+
- `updateBankDetails()`
|
|
525
|
+
- `processSalary()`
|
|
526
|
+
- `payrollHistory()`
|
|
527
|
+
|
|
528
|
+
### Real-World Use Cases
|
|
529
|
+
|
|
530
|
+
**1. Delivery Company**
|
|
531
|
+
```typescript
|
|
532
|
+
// Drivers without system access
|
|
533
|
+
const driver = await payroll.hire({
|
|
534
|
+
organizationId: org._id,
|
|
535
|
+
employment: {
|
|
536
|
+
employeeId: 'DRV-' + Date.now(),
|
|
537
|
+
email: 'driver@company.com',
|
|
538
|
+
name: 'John Driver',
|
|
539
|
+
position: 'Delivery Driver',
|
|
540
|
+
department: 'operations',
|
|
541
|
+
type: 'contract',
|
|
542
|
+
},
|
|
543
|
+
compensation: { baseAmount: 3000, currency: 'USD' },
|
|
544
|
+
});
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
**2. Manufacturing Plant**
|
|
548
|
+
```typescript
|
|
549
|
+
// Factory workers with employee badges
|
|
550
|
+
const worker = await payroll.hire({
|
|
551
|
+
organizationId: org._id,
|
|
552
|
+
employment: {
|
|
553
|
+
employeeId: 'BADGE-12345', // Physical badge number
|
|
554
|
+
name: 'Worker Name',
|
|
555
|
+
position: 'Assembly Line Worker',
|
|
556
|
+
department: 'production',
|
|
557
|
+
type: 'full_time',
|
|
558
|
+
},
|
|
559
|
+
compensation: { baseAmount: 4000, currency: 'USD' },
|
|
560
|
+
});
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
**3. Restaurant Chain**
|
|
564
|
+
```typescript
|
|
565
|
+
// Kitchen staff and servers
|
|
566
|
+
const staff = await payroll.hire({
|
|
567
|
+
organizationId: org._id,
|
|
568
|
+
employment: {
|
|
569
|
+
employeeId: 'SERVER-042',
|
|
570
|
+
email: 'server042@restaurant.com',
|
|
571
|
+
name: 'Server Name',
|
|
572
|
+
position: 'Server',
|
|
573
|
+
department: 'operations',
|
|
574
|
+
type: 'part_time',
|
|
575
|
+
},
|
|
576
|
+
compensation: { baseAmount: 2000, currency: 'USD', frequency: 'monthly' },
|
|
577
|
+
});
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
### Migration from User-Only System
|
|
581
|
+
|
|
582
|
+
If you're migrating from a system that requires userId:
|
|
583
|
+
|
|
584
|
+
```typescript
|
|
585
|
+
// Step 1: Update config to allow guest employees
|
|
586
|
+
const payroll = createPayrollInstance()
|
|
587
|
+
.withConfig({ validation: { requireUserId: false } })
|
|
588
|
+
.build();
|
|
589
|
+
|
|
590
|
+
// Step 2: Existing employees still work (they have userId)
|
|
591
|
+
const existing = await payroll.getEmployeeByIdentity({
|
|
592
|
+
identity: user._id,
|
|
593
|
+
organizationId: org._id,
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
// Step 3: Start adding guest employees
|
|
597
|
+
const newGuest = await payroll.hire({
|
|
598
|
+
organizationId: org._id,
|
|
599
|
+
employment: {
|
|
600
|
+
employeeId: 'CONTRACT-001',
|
|
601
|
+
email: 'contractor@company.com', // Automatically normalized
|
|
602
|
+
// No userId needed!
|
|
603
|
+
},
|
|
604
|
+
compensation: { baseAmount: 5000, currency: 'USD' },
|
|
605
|
+
});
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
**Database Migration Notes:**
|
|
609
|
+
1. **Index Migration Required**: If you have existing sparse indexes, you need to drop them and create partial indexes:
|
|
610
|
+
```javascript
|
|
611
|
+
// Drop old sparse index
|
|
612
|
+
await Employee.collection.dropIndex('userId_1_organizationId_1');
|
|
613
|
+
|
|
614
|
+
// Create new partial index
|
|
615
|
+
await Employee.collection.createIndex(
|
|
616
|
+
{ userId: 1, organizationId: 1 },
|
|
617
|
+
{
|
|
618
|
+
unique: true,
|
|
619
|
+
partialFilterExpression: { userId: { $exists: true } }
|
|
620
|
+
}
|
|
621
|
+
);
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
2. **Email Normalization**: Existing emails should be normalized:
|
|
625
|
+
```javascript
|
|
626
|
+
// Normalize all existing emails
|
|
627
|
+
const employees = await Employee.find({ email: { $exists: true } });
|
|
628
|
+
for (const emp of employees) {
|
|
629
|
+
emp.email = emp.email.trim().toLowerCase();
|
|
630
|
+
await emp.save();
|
|
631
|
+
}
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
## Unified Cashflow Model (Transactions)
|
|
635
|
+
|
|
636
|
+
Payroll writes one **unified transaction** per salary run. The same shape is used by revenue, so you can keep a single cashflow table across packages. Shared types are just interfaces โ you define your own schema, enums, and indexes (no required common schema).
|
|
637
|
+
|
|
638
|
+
If you only use payroll, you can import types from `@classytic/payroll`. For shared payroll + revenue, use `@classytic/shared-types`.
|
|
639
|
+
|
|
640
|
+
```typescript
|
|
641
|
+
import { Schema, model } from 'mongoose';
|
|
642
|
+
import type { ITransaction } from '@classytic/shared-types';
|
|
643
|
+
|
|
644
|
+
const transactionSchema = new Schema<ITransaction>({
|
|
645
|
+
organizationId: { type: Schema.Types.ObjectId },
|
|
646
|
+
employeeId: { type: Schema.Types.ObjectId },
|
|
647
|
+
type: { type: String, required: true }, // category, e.g. 'salary'
|
|
648
|
+
flow: { type: String, enum: ['inflow', 'outflow'], required: true },
|
|
649
|
+
amount: { type: Number, required: true },
|
|
650
|
+
currency: { type: String, default: 'USD' },
|
|
651
|
+
sourceId: { type: Schema.Types.ObjectId }, // optional link to your app entity
|
|
652
|
+
sourceModel: { type: String }, // optional link to your app entity
|
|
653
|
+
tax: Number,
|
|
654
|
+
net: Number,
|
|
655
|
+
status: { type: String, default: 'completed' },
|
|
656
|
+
date: { type: Date, default: Date.now },
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
const Transaction = model<ITransaction>('Transaction', transactionSchema);
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
**Under the hood**
|
|
663
|
+
- Payroll creates one transaction per salary run.
|
|
664
|
+
- `flow` is always `outflow` for payroll.
|
|
665
|
+
- `type` is your app-defined category (e.g. `salary`, `bonus`).
|
|
666
|
+
- `sourceId/sourceModel` link the transaction back to `PayrollRecord`.
|
|
667
|
+
|
|
668
|
+
**Type safety**
|
|
669
|
+
- We only share `ITransaction` as the interface.
|
|
670
|
+
- You define your own categories and roles in your app (no hardcoded enums required).
|
|
671
|
+
|
|
672
|
+
## Single-Tenant Setup
|
|
673
|
+
|
|
674
|
+
Building a single-organization HRM? Configure once, forget `organizationId` everywhere else:
|
|
675
|
+
|
|
676
|
+
```typescript
|
|
677
|
+
// Configure with your organization ID once
|
|
678
|
+
const payroll = createPayrollInstance()
|
|
679
|
+
.withModels({ EmployeeModel, PayrollRecordModel, TransactionModel, AttendanceModel })
|
|
680
|
+
.forSingleTenant({
|
|
681
|
+
organizationId: myOrg._id,
|
|
682
|
+
autoInject: true // โ
Enable auto-injection
|
|
683
|
+
})
|
|
684
|
+
.build();
|
|
685
|
+
|
|
686
|
+
// No organizationId needed in operations - auto-injected!
|
|
687
|
+
const employee = await payroll.hire({
|
|
688
|
+
userId: user._id,
|
|
689
|
+
// organizationId auto-injected โจ
|
|
690
|
+
employment: { position: 'Manager', department: 'hr', type: 'full_time' },
|
|
691
|
+
compensation: { baseAmount: 150000, currency: 'USD' },
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
await payroll.processSalary({
|
|
695
|
+
employeeId: employee._id,
|
|
696
|
+
// organizationId auto-injected โจ
|
|
697
|
+
month: 3,
|
|
698
|
+
year: 2024
|
|
699
|
+
});
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
**How it works:**
|
|
703
|
+
- Container automatically injects `organizationId` into ALL operations
|
|
704
|
+
- Security is STILL enforced at database level (same queries with org filter)
|
|
705
|
+
- Perfect for: internal HR systems, database-per-tenant architecture, microservices
|
|
706
|
+
|
|
707
|
+
**โ ๏ธ Important:** You MUST set `autoInject: true` to enable auto-injection. Without it, you'll get "organizationId is required" errors.
|
|
708
|
+
|
|
709
|
+
## Attendance (ClockIn)
|
|
710
|
+
|
|
711
|
+
Attendance is **native**, not an add-on:
|
|
712
|
+
|
|
713
|
+
```typescript
|
|
714
|
+
import { ClockIn } from '@classytic/clockin';
|
|
715
|
+
import { getAttendance } from '@classytic/payroll';
|
|
716
|
+
|
|
717
|
+
// Initialize ClockIn
|
|
718
|
+
const clockin = await ClockIn.create()
|
|
719
|
+
.withModels({ Attendance, Employee })
|
|
720
|
+
.build();
|
|
721
|
+
|
|
722
|
+
// Employees check in
|
|
723
|
+
await clockin.checkIn.record({
|
|
724
|
+
member: employee,
|
|
725
|
+
targetModel: 'Employee',
|
|
726
|
+
data: { method: 'qr_code' },
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
// Payroll automatically uses attendance
|
|
730
|
+
const attendance = await getAttendance(Attendance, {
|
|
731
|
+
organizationId: org._id,
|
|
732
|
+
employeeId: employee._id,
|
|
733
|
+
month: 3,
|
|
734
|
+
year: 2024,
|
|
735
|
+
expectedDays: 22,
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
await payroll.processSalary({
|
|
739
|
+
employeeId: employee._id,
|
|
740
|
+
month: 3,
|
|
741
|
+
year: 2024,
|
|
742
|
+
attendance, // โ Deductions automatically applied
|
|
743
|
+
});
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
## Holidays
|
|
747
|
+
|
|
748
|
+
Simple approach - one way:
|
|
749
|
+
|
|
750
|
+
```typescript
|
|
751
|
+
import { getHolidays } from '@classytic/payroll';
|
|
752
|
+
|
|
753
|
+
// Add sudden off day
|
|
754
|
+
await Holiday.create({
|
|
755
|
+
organizationId: org._id,
|
|
756
|
+
date: new Date('2024-03-17'),
|
|
757
|
+
name: 'Emergency closure',
|
|
758
|
+
type: 'company',
|
|
759
|
+
paid: true,
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
// Get holidays when processing
|
|
763
|
+
const holidays = await getHolidays(Holiday, {
|
|
764
|
+
organizationId: org._id,
|
|
765
|
+
startDate: new Date('2024-03-01'),
|
|
766
|
+
endDate: new Date('2024-03-31'),
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
// Pass to payroll
|
|
770
|
+
await payroll.processSalary({
|
|
771
|
+
employeeId,
|
|
772
|
+
month: 3,
|
|
773
|
+
year: 2024,
|
|
774
|
+
options: { holidays },
|
|
775
|
+
});
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
## Payroll Processing Options
|
|
779
|
+
|
|
780
|
+
Fine-tune calculations per run:
|
|
781
|
+
|
|
782
|
+
```typescript
|
|
783
|
+
await payroll.processSalary({
|
|
784
|
+
employeeId,
|
|
785
|
+
month: 3,
|
|
786
|
+
year: 2024,
|
|
787
|
+
options: {
|
|
788
|
+
holidays: [new Date('2024-03-17')],
|
|
789
|
+
workSchedule: { workingDays: [1, 2, 3, 4, 5], hoursPerDay: 8 },
|
|
790
|
+
skipTax: true,
|
|
791
|
+
skipAttendance: true,
|
|
792
|
+
skipProration: true,
|
|
793
|
+
},
|
|
794
|
+
});
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
## Percentage Allowances & Deductions
|
|
798
|
+
|
|
799
|
+
Percentage-based items are supported and calculated from base salary:
|
|
800
|
+
|
|
801
|
+
```typescript
|
|
802
|
+
await payroll.addAllowance({
|
|
803
|
+
employeeId,
|
|
804
|
+
type: 'housing',
|
|
805
|
+
amount: 0, // ignored when isPercentage is true
|
|
806
|
+
isPercentage: true,
|
|
807
|
+
value: 20, // 20% of base salary
|
|
808
|
+
recurring: true,
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
await payroll.addDeduction({
|
|
812
|
+
employeeId,
|
|
813
|
+
type: 'insurance',
|
|
814
|
+
amount: 0, // ignored when isPercentage is true
|
|
815
|
+
isPercentage: true,
|
|
816
|
+
value: 5, // 5% of base salary
|
|
817
|
+
recurring: true,
|
|
818
|
+
});
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
## Bulk Payroll Processing
|
|
822
|
+
|
|
823
|
+
Process payroll for multiple employees with production-ready features:
|
|
824
|
+
|
|
825
|
+
### Basic Usage (Backward Compatible)
|
|
826
|
+
|
|
827
|
+
```typescript
|
|
828
|
+
// Process all active employees
|
|
829
|
+
const result = await payroll.processBulkPayroll({
|
|
830
|
+
organizationId: org._id,
|
|
831
|
+
month: 3,
|
|
832
|
+
year: 2024,
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
console.log(result);
|
|
836
|
+
// {
|
|
837
|
+
// successful: [{ employeeId: 'EMP-001', amount: 108410, transactionId: ... }, ...],
|
|
838
|
+
// failed: [{ employeeId: 'EMP-042', error: 'Insufficient balance' }],
|
|
839
|
+
// total: 150
|
|
840
|
+
// }
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
### With Progress Tracking
|
|
844
|
+
|
|
845
|
+
Perfect for UI progress bars and job queue updates:
|
|
846
|
+
|
|
847
|
+
```typescript
|
|
848
|
+
await payroll.processBulkPayroll({
|
|
849
|
+
organizationId: org._id,
|
|
850
|
+
month: 3,
|
|
851
|
+
year: 2024,
|
|
852
|
+
onProgress: (progress) => {
|
|
853
|
+
console.log(`${progress.percentage}% - ${progress.successful} ok, ${progress.failed} failed`);
|
|
854
|
+
// 20% - 30 ok, 0 failed
|
|
855
|
+
// 40% - 60 ok, 0 failed
|
|
856
|
+
// ...
|
|
857
|
+
}
|
|
858
|
+
});
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
### Job Queue Integration
|
|
862
|
+
|
|
863
|
+
Update job progress in your database:
|
|
864
|
+
|
|
865
|
+
```typescript
|
|
866
|
+
const job = await jobQueue.add({
|
|
867
|
+
type: 'monthly-payroll',
|
|
868
|
+
month: 3,
|
|
869
|
+
year: 2024,
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
await payroll.processBulkPayroll({
|
|
873
|
+
organizationId: org._id,
|
|
874
|
+
month: 3,
|
|
875
|
+
year: 2024,
|
|
876
|
+
batchSize: 10, // Process 10 employees at a time
|
|
877
|
+
batchDelay: 100, // 100ms pause between batches
|
|
878
|
+
onProgress: async (progress) => {
|
|
879
|
+
// Update job in database
|
|
880
|
+
await Job.findByIdAndUpdate(job._id, {
|
|
881
|
+
'progress.processed': progress.processed,
|
|
882
|
+
'progress.total': progress.total,
|
|
883
|
+
'progress.percentage': progress.percentage,
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
// Emit websocket event for real-time UI updates
|
|
887
|
+
io.to(job.id).emit('payroll:progress', progress);
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
### Cancellation Support
|
|
893
|
+
|
|
894
|
+
Allow users to cancel long-running operations:
|
|
895
|
+
|
|
896
|
+
```typescript
|
|
897
|
+
const controller = new AbortController();
|
|
898
|
+
|
|
899
|
+
// Start processing
|
|
900
|
+
const promise = payroll.processBulkPayroll({
|
|
901
|
+
organizationId: org._id,
|
|
902
|
+
month: 3,
|
|
903
|
+
year: 2024,
|
|
904
|
+
signal: controller.signal,
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
// User clicks "Cancel" button
|
|
908
|
+
cancelButton.onclick = () => {
|
|
909
|
+
controller.abort(); // Gracefully stops after current employee
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
try {
|
|
913
|
+
await promise;
|
|
914
|
+
} catch (error) {
|
|
915
|
+
if (error.message.includes('cancelled')) {
|
|
916
|
+
console.log('Payroll processing was cancelled by user');
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
### Concurrency Control
|
|
922
|
+
|
|
923
|
+
Process employees in parallel for faster execution:
|
|
924
|
+
|
|
925
|
+
```typescript
|
|
926
|
+
// SEQUENTIAL (default, safest)
|
|
927
|
+
await payroll.processBulkPayroll({
|
|
928
|
+
organizationId: org._id,
|
|
929
|
+
month: 3,
|
|
930
|
+
year: 2024,
|
|
931
|
+
concurrency: 1, // One at a time (default)
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
// MODERATE CONCURRENCY (faster, recommended for 100-500 employees)
|
|
935
|
+
await payroll.processBulkPayroll({
|
|
936
|
+
organizationId: org._id,
|
|
937
|
+
month: 3,
|
|
938
|
+
year: 2024,
|
|
939
|
+
concurrency: 5, // 5 employees in parallel
|
|
940
|
+
batchSize: 20, // 20 employees per batch
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
// HIGH CONCURRENCY (fastest, for robust infrastructure)
|
|
944
|
+
await payroll.processBulkPayroll({
|
|
945
|
+
organizationId: org._id,
|
|
946
|
+
month: 3,
|
|
947
|
+
year: 2024,
|
|
948
|
+
concurrency: 10, // 10 employees in parallel
|
|
949
|
+
batchSize: 50, // 50 employees per batch
|
|
950
|
+
});
|
|
951
|
+
```
|
|
952
|
+
|
|
953
|
+
### Streaming Mode (Millions of Employees)
|
|
954
|
+
|
|
955
|
+
For organizations with **10,000+ employees**, the system automatically switches to **cursor-based streaming** to prevent memory exhaustion:
|
|
956
|
+
|
|
957
|
+
```typescript
|
|
958
|
+
// Auto-detected streaming for large datasets
|
|
959
|
+
const result = await payroll.processBulkPayroll({
|
|
960
|
+
organizationId: org._id,
|
|
961
|
+
month: 3,
|
|
962
|
+
year: 2024,
|
|
963
|
+
// โ
Automatically uses streaming if >10,000 employees
|
|
964
|
+
// โ
No memory limits - processes millions efficiently
|
|
965
|
+
// โ
Constant memory usage via MongoDB cursors
|
|
966
|
+
});
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
#### Why Streaming?
|
|
970
|
+
|
|
971
|
+
**Traditional approach** (default for <10k employees):
|
|
972
|
+
- Loads all employees into memory
|
|
973
|
+
- Fast for small-medium datasets (100-10,000 employees)
|
|
974
|
+
- Memory usage grows with employee count
|
|
975
|
+
|
|
976
|
+
**Streaming approach** (auto-enabled for >10k employees):
|
|
977
|
+
- Uses MongoDB cursors (`for await` loops)
|
|
978
|
+
- Processes one employee at a time
|
|
979
|
+
- **Constant memory** - scales to millions
|
|
980
|
+
- No `FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory`
|
|
981
|
+
|
|
982
|
+
#### How It Works
|
|
983
|
+
|
|
984
|
+
```
|
|
985
|
+
MongoDB Cursor โ Worker Pool โ Results
|
|
986
|
+
โ โ โ
|
|
987
|
+
Stream Concurrency Success/
|
|
988
|
+
1M+ docs Control Failed
|
|
989
|
+
(p-limit)
|
|
990
|
+
```
|
|
991
|
+
|
|
992
|
+
#### Manual Control
|
|
993
|
+
|
|
994
|
+
Force streaming mode even for smaller datasets:
|
|
995
|
+
|
|
996
|
+
```typescript
|
|
997
|
+
// Force streaming (useful for testing or low-memory environments)
|
|
998
|
+
await payroll.processBulkPayroll({
|
|
999
|
+
organizationId: org._id,
|
|
1000
|
+
month: 3,
|
|
1001
|
+
year: 2024,
|
|
1002
|
+
useStreaming: true, // โ Force cursor-based streaming
|
|
1003
|
+
concurrency: 10, // Still supports concurrency
|
|
1004
|
+
});
|
|
1005
|
+
```
|
|
1006
|
+
|
|
1007
|
+
Disable streaming (force in-memory mode):
|
|
1008
|
+
|
|
1009
|
+
```typescript
|
|
1010
|
+
// Force in-memory mode (faster for <10k employees)
|
|
1011
|
+
await payroll.processBulkPayroll({
|
|
1012
|
+
organizationId: org._id,
|
|
1013
|
+
month: 3,
|
|
1014
|
+
year: 2024,
|
|
1015
|
+
useStreaming: false, // โ Force in-memory processing
|
|
1016
|
+
});
|
|
1017
|
+
```
|
|
1018
|
+
|
|
1019
|
+
#### Real-World Example (100,000 Employees)
|
|
1020
|
+
|
|
1021
|
+
```typescript
|
|
1022
|
+
// Process 100k employees with streaming
|
|
1023
|
+
const result = await payroll.processBulkPayroll({
|
|
1024
|
+
organizationId: org._id,
|
|
1025
|
+
month: 3,
|
|
1026
|
+
year: 2024,
|
|
1027
|
+
|
|
1028
|
+
// Streaming (auto-detected)
|
|
1029
|
+
// useStreaming: true, // โ Not needed, auto-detected
|
|
1030
|
+
|
|
1031
|
+
// Concurrency for speed
|
|
1032
|
+
concurrency: 10,
|
|
1033
|
+
batchSize: 50,
|
|
1034
|
+
|
|
1035
|
+
// Progress tracking (updates every 50 employees)
|
|
1036
|
+
onProgress: async (progress) => {
|
|
1037
|
+
console.log(`${progress.percentage}% - ${progress.processed}/${progress.total}`);
|
|
1038
|
+
// 0.05% - 50/100000
|
|
1039
|
+
// 0.10% - 100/100000
|
|
1040
|
+
// ...
|
|
1041
|
+
},
|
|
1042
|
+
|
|
1043
|
+
// Cancellation support
|
|
1044
|
+
signal: abortController.signal,
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
// Memory usage: ~50-100MB (constant)
|
|
1048
|
+
// Duration: ~30-60 minutes (depends on concurrency and DB performance)
|
|
1049
|
+
```
|
|
1050
|
+
|
|
1051
|
+
#### Performance Comparison
|
|
1052
|
+
|
|
1053
|
+
| Employee Count | In-Memory | Streaming | Memory Usage |
|
|
1054
|
+
|---------------|-----------|-----------|--------------|
|
|
1055
|
+
| 100 | โ
Fast (5s) | Slower (8s) | 10 MB |
|
|
1056
|
+
| 1,000 | โ
Fast (30s) | Slower (45s) | 50 MB |
|
|
1057
|
+
| 10,000 | โ ๏ธ Slow (5m) | โ
Fast (6m) | 200 MB vs **50 MB** |
|
|
1058
|
+
| 100,000 | โ Crashes | โ
Works (60m) | N/A vs **50 MB** |
|
|
1059
|
+
| 1,000,000 | โ Crashes | โ
Works (10h) | N/A vs **50 MB** |
|
|
1060
|
+
|
|
1061
|
+
**Recommendation**: Let the system auto-detect. It chooses the optimal mode based on your dataset size.
|
|
1062
|
+
|
|
1063
|
+
### Complete Example (Production-Ready)
|
|
1064
|
+
|
|
1065
|
+
Combining all features for a real-world job queue:
|
|
1066
|
+
|
|
1067
|
+
```typescript
|
|
1068
|
+
export async function processMonthlyPayroll(jobId: string) {
|
|
1069
|
+
const job = await Job.findById(jobId);
|
|
1070
|
+
const controller = new AbortController();
|
|
1071
|
+
|
|
1072
|
+
// Allow job cancellation
|
|
1073
|
+
job.on('cancel', () => controller.abort());
|
|
1074
|
+
|
|
1075
|
+
try {
|
|
1076
|
+
const result = await payroll.processBulkPayroll({
|
|
1077
|
+
organizationId: job.data.organizationId,
|
|
1078
|
+
month: job.data.month,
|
|
1079
|
+
year: job.data.year,
|
|
1080
|
+
|
|
1081
|
+
// Cancellation
|
|
1082
|
+
signal: controller.signal,
|
|
1083
|
+
|
|
1084
|
+
// Batching (prevents DB exhaustion)
|
|
1085
|
+
batchSize: 10,
|
|
1086
|
+
batchDelay: 50, // Small delay to let DB breathe
|
|
1087
|
+
|
|
1088
|
+
// Concurrency (3-5x faster)
|
|
1089
|
+
concurrency: 5,
|
|
1090
|
+
|
|
1091
|
+
// Progress tracking
|
|
1092
|
+
onProgress: async (progress) => {
|
|
1093
|
+
await Job.findByIdAndUpdate(jobId, {
|
|
1094
|
+
progress: {
|
|
1095
|
+
processed: progress.processed,
|
|
1096
|
+
total: progress.total,
|
|
1097
|
+
successful: progress.successful,
|
|
1098
|
+
failed: progress.failed,
|
|
1099
|
+
percentage: progress.percentage,
|
|
1100
|
+
},
|
|
1101
|
+
updatedAt: new Date(),
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
// Real-time updates via WebSocket
|
|
1105
|
+
io.to(`job:${jobId}`).emit('progress', progress);
|
|
1106
|
+
},
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
// Mark job as completed
|
|
1110
|
+
await Job.findByIdAndUpdate(jobId, {
|
|
1111
|
+
status: 'completed',
|
|
1112
|
+
result: {
|
|
1113
|
+
total: result.total,
|
|
1114
|
+
successful: result.successful.length,
|
|
1115
|
+
failed: result.failed.length,
|
|
1116
|
+
errors: result.failed,
|
|
1117
|
+
},
|
|
1118
|
+
completedAt: new Date(),
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
// Mark job as failed
|
|
1123
|
+
await Job.findByIdAndUpdate(jobId, {
|
|
1124
|
+
status: error.message.includes('cancelled') ? 'cancelled' : 'failed',
|
|
1125
|
+
error: error.message,
|
|
1126
|
+
failedAt: new Date(),
|
|
1127
|
+
});
|
|
1128
|
+
throw error;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
```
|
|
1132
|
+
|
|
1133
|
+
### Performance Tips
|
|
1134
|
+
|
|
1135
|
+
**Batch Size**:
|
|
1136
|
+
- **Small (5-10)**: Slower, but more stable, frequent progress updates
|
|
1137
|
+
- **Medium (20-50)**: Balanced, good for most use cases
|
|
1138
|
+
- **Large (100+)**: Faster, but infrequent progress updates
|
|
1139
|
+
|
|
1140
|
+
**Batch Delay**:
|
|
1141
|
+
- **0ms**: No delay, fastest (default)
|
|
1142
|
+
- **50-100ms**: Recommended for preventing DB connection pool exhaustion
|
|
1143
|
+
- **500ms+**: Rate limiting for external API calls
|
|
1144
|
+
|
|
1145
|
+
**Concurrency**:
|
|
1146
|
+
- **1**: Sequential, safest, predictable (default)
|
|
1147
|
+
- **3-5**: Sweet spot for most deployments
|
|
1148
|
+
- **10+**: Requires robust infrastructure (DB connection pool, CPU, memory)
|
|
1149
|
+
|
|
1150
|
+
### Why This Matters
|
|
1151
|
+
|
|
1152
|
+
You get predictable, long-running payroll runs with progress updates, cancellation support, and safe batchingโwithout changing your API surface.
|
|
1153
|
+
|
|
1154
|
+
## Leave Management
|
|
1155
|
+
|
|
1156
|
+
Complete leave workflow with balances, requests, and payroll integration.
|
|
1157
|
+
|
|
1158
|
+
### Quick Start (3 Steps)
|
|
1159
|
+
|
|
1160
|
+
```typescript
|
|
1161
|
+
import {
|
|
1162
|
+
employmentFields,
|
|
1163
|
+
leaveBalanceFields,
|
|
1164
|
+
employeePlugin,
|
|
1165
|
+
getLeaveRequestModel,
|
|
1166
|
+
createLeaveService,
|
|
1167
|
+
} from '@classytic/payroll';
|
|
1168
|
+
|
|
1169
|
+
// 1. Setup Employee with leave balances
|
|
1170
|
+
const employeeSchema = new Schema({
|
|
1171
|
+
...employmentFields,
|
|
1172
|
+
...leaveBalanceFields, // Adds leaveBalances: [{ type, allocated, used, pending, year }]
|
|
1173
|
+
});
|
|
1174
|
+
employeeSchema.plugin(employeePlugin, { enableLeave: true });
|
|
1175
|
+
const Employee = mongoose.model('Employee', employeeSchema);
|
|
1176
|
+
|
|
1177
|
+
// 2. Setup LeaveRequest model
|
|
1178
|
+
const LeaveRequest = getLeaveRequestModel();
|
|
1179
|
+
|
|
1180
|
+
// 3. Create leave service (handles all workflows)
|
|
1181
|
+
const leaveService = createLeaveService({
|
|
1182
|
+
EmployeeModel: Employee,
|
|
1183
|
+
LeaveRequestModel: LeaveRequest,
|
|
1184
|
+
config: {
|
|
1185
|
+
enforceBalance: true, // Validate sufficient balance
|
|
1186
|
+
checkOverlap: true, // Prevent conflicting requests
|
|
1187
|
+
},
|
|
1188
|
+
});
|
|
1189
|
+
```
|
|
1190
|
+
|
|
1191
|
+
### Leave Types (8 Built-in)
|
|
1192
|
+
|
|
1193
|
+
| Type | Default Allocation | Description |
|
|
1194
|
+
|------|-------------------|-------------|
|
|
1195
|
+
| `annual` | 20 days | Paid vacation/annual leave |
|
|
1196
|
+
| `sick` | 10 days | Paid sick leave |
|
|
1197
|
+
| `unpaid` | Unlimited | Unpaid leave (affects payroll) |
|
|
1198
|
+
| `maternity` | 90 days | Maternity leave |
|
|
1199
|
+
| `paternity` | 10 days | Paternity leave |
|
|
1200
|
+
| `bereavement` | 5 days | Bereavement leave |
|
|
1201
|
+
| `compensatory` | - | Comp time off |
|
|
1202
|
+
| `other` | - | Custom leave types |
|
|
1203
|
+
|
|
1204
|
+
### Common Use Cases
|
|
1205
|
+
|
|
1206
|
+
#### 1. Request Leave (With Auto-Calculation)
|
|
1207
|
+
|
|
1208
|
+
```typescript
|
|
1209
|
+
// Automatically calculates working days, validates balance, updates employee
|
|
1210
|
+
const { request, days } = await leaveService.requestLeave({
|
|
1211
|
+
organizationId: org._id,
|
|
1212
|
+
employeeId: employee._id,
|
|
1213
|
+
userId: user._id,
|
|
1214
|
+
request: {
|
|
1215
|
+
type: 'annual',
|
|
1216
|
+
startDate: new Date('2024-06-03'),
|
|
1217
|
+
endDate: new Date('2024-06-07'), // Auto-excludes weekends
|
|
1218
|
+
reason: 'Summer vacation',
|
|
1219
|
+
},
|
|
1220
|
+
holidays: [new Date('2024-06-04')], // Exclude public holiday
|
|
1221
|
+
});
|
|
1222
|
+
// โ days = 4 (excluded weekend + holiday)
|
|
1223
|
+
// โ employee.leaveBalances[0].pending += 4
|
|
1224
|
+
```
|
|
1225
|
+
|
|
1226
|
+
#### 2. Approve/Reject Leave
|
|
1227
|
+
|
|
1228
|
+
```typescript
|
|
1229
|
+
// Approve (pending โ used in balance)
|
|
1230
|
+
await leaveService.reviewLeave({
|
|
1231
|
+
requestId: request._id,
|
|
1232
|
+
reviewerId: manager._id,
|
|
1233
|
+
action: 'approve',
|
|
1234
|
+
notes: 'Enjoy your vacation!',
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
// Reject (remove from pending balance)
|
|
1238
|
+
await leaveService.reviewLeave({
|
|
1239
|
+
requestId: request._id,
|
|
1240
|
+
reviewerId: manager._id,
|
|
1241
|
+
action: 'reject',
|
|
1242
|
+
notes: 'Peak season - please reschedule',
|
|
1243
|
+
});
|
|
1244
|
+
```
|
|
1245
|
+
|
|
1246
|
+
#### 3. Cancel Leave
|
|
1247
|
+
|
|
1248
|
+
```typescript
|
|
1249
|
+
// Employee cancels (before or after approval)
|
|
1250
|
+
await leaveService.cancelLeave({
|
|
1251
|
+
requestId: request._id,
|
|
1252
|
+
});
|
|
1253
|
+
// Restores balance automatically
|
|
1254
|
+
```
|
|
1255
|
+
|
|
1256
|
+
#### 4. Check Balance & Overlap
|
|
1257
|
+
|
|
1258
|
+
```typescript
|
|
1259
|
+
import { hasLeaveBalance, getAvailableDays } from '@classytic/payroll';
|
|
1260
|
+
|
|
1261
|
+
// Check if employee can request 5 days
|
|
1262
|
+
if (hasLeaveBalance(employee, 'annual', 5, 2024)) {
|
|
1263
|
+
// Has sufficient balance
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Get available days
|
|
1267
|
+
const available = getAvailableDays(employee, 'annual', 2024); // โ 15
|
|
1268
|
+
|
|
1269
|
+
// Check for conflicts
|
|
1270
|
+
const { hasOverlap } = await leaveService.checkOverlap({
|
|
1271
|
+
employeeId: employee._id,
|
|
1272
|
+
startDate: new Date('2024-06-05'),
|
|
1273
|
+
endDate: new Date('2024-06-10'),
|
|
1274
|
+
});
|
|
1275
|
+
```
|
|
1276
|
+
|
|
1277
|
+
#### 5. Unpaid Leave โ Payroll Deduction
|
|
1278
|
+
|
|
1279
|
+
```typescript
|
|
1280
|
+
// Calculate unpaid leave deduction for the month
|
|
1281
|
+
const { totalDays, deduction } = await leaveService.calculateUnpaidDeduction({
|
|
1282
|
+
organizationId: org._id,
|
|
1283
|
+
employeeId: employee._id,
|
|
1284
|
+
startDate: new Date('2024-06-01'),
|
|
1285
|
+
endDate: new Date('2024-06-30'),
|
|
1286
|
+
baseSalary: 100000,
|
|
1287
|
+
workingDaysInMonth: 22,
|
|
1288
|
+
});
|
|
1289
|
+
// โ totalDays = 3, deduction = 13636
|
|
1290
|
+
|
|
1291
|
+
// Apply deduction to payroll
|
|
1292
|
+
await payroll.addDeduction({
|
|
1293
|
+
employeeId: employee._id,
|
|
1294
|
+
type: 'absence',
|
|
1295
|
+
amount: deduction,
|
|
1296
|
+
auto: true,
|
|
1297
|
+
recurring: false,
|
|
1298
|
+
description: `Unpaid leave: ${totalDays} days`,
|
|
1299
|
+
});
|
|
1300
|
+
```
|
|
1301
|
+
|
|
1302
|
+
#### 6. Year-End Carry Over
|
|
1303
|
+
|
|
1304
|
+
```typescript
|
|
1305
|
+
import { calculateCarryOver } from '@classytic/payroll';
|
|
1306
|
+
|
|
1307
|
+
// Carry over unused leave (with limits)
|
|
1308
|
+
const newBalances = calculateCarryOver(employee.leaveBalances, {
|
|
1309
|
+
annual: 5, // Max 5 days carry-over
|
|
1310
|
+
compensatory: 3, // Max 3 days
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
employee.leaveBalances = newBalances;
|
|
1314
|
+
await employee.save();
|
|
1315
|
+
|
|
1316
|
+
// Or use plugin method
|
|
1317
|
+
employee.processLeaveCarryOver(2024);
|
|
1318
|
+
await employee.save();
|
|
1319
|
+
```
|
|
1320
|
+
|
|
1321
|
+
### Single-Tenant Mode
|
|
1322
|
+
|
|
1323
|
+
Skip `organizationId` in single-organization setups:
|
|
1324
|
+
|
|
1325
|
+
```typescript
|
|
1326
|
+
const leaveService = createLeaveService({
|
|
1327
|
+
EmployeeModel: Employee,
|
|
1328
|
+
LeaveRequestModel: LeaveRequest,
|
|
1329
|
+
config: {
|
|
1330
|
+
singleTenant: true, // organizationId becomes optional everywhere
|
|
1331
|
+
},
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
// Request without organizationId
|
|
1335
|
+
await leaveService.requestLeave({
|
|
1336
|
+
employeeId: employee._id,
|
|
1337
|
+
userId: user._id,
|
|
1338
|
+
request: {
|
|
1339
|
+
type: 'annual',
|
|
1340
|
+
startDate: new Date('2024-06-03'),
|
|
1341
|
+
endDate: new Date('2024-06-07'),
|
|
1342
|
+
},
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
// With default organizationId for storage
|
|
1346
|
+
const leaveService = createLeaveService({
|
|
1347
|
+
EmployeeModel: Employee,
|
|
1348
|
+
LeaveRequestModel: LeaveRequest,
|
|
1349
|
+
config: {
|
|
1350
|
+
singleTenant: true,
|
|
1351
|
+
defaultOrganizationId: myOrg._id,
|
|
1352
|
+
},
|
|
1353
|
+
});
|
|
1354
|
+
```
|
|
1355
|
+
|
|
1356
|
+
### Query Leave Requests
|
|
1357
|
+
|
|
1358
|
+
```typescript
|
|
1359
|
+
// Pending requests
|
|
1360
|
+
const pending = await LeaveRequest.findPendingByOrganization(org._id);
|
|
1361
|
+
|
|
1362
|
+
// Employee history
|
|
1363
|
+
const history = await LeaveRequest.findByEmployee(employee._id, {
|
|
1364
|
+
status: 'approved',
|
|
1365
|
+
year: 2024,
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
// Period query (for reports)
|
|
1369
|
+
const requests = await LeaveRequest.findByPeriod(
|
|
1370
|
+
org._id,
|
|
1371
|
+
new Date('2024-06-01'),
|
|
1372
|
+
new Date('2024-06-30'),
|
|
1373
|
+
{ type: 'unpaid' }
|
|
1374
|
+
);
|
|
1375
|
+
|
|
1376
|
+
// Statistics
|
|
1377
|
+
const stats = await LeaveRequest.getLeaveStats(employee._id, 2024);
|
|
1378
|
+
// โ [{ _id: 'annual', totalDays: 10, count: 2 }, ...]
|
|
1379
|
+
```
|
|
1380
|
+
|
|
1381
|
+
### Balance Utilities
|
|
1382
|
+
|
|
1383
|
+
```typescript
|
|
1384
|
+
import {
|
|
1385
|
+
initializeLeaveBalances,
|
|
1386
|
+
hasLeaveBalance,
|
|
1387
|
+
getAvailableDays,
|
|
1388
|
+
getLeaveSummary,
|
|
1389
|
+
calculateLeaveDays,
|
|
1390
|
+
} from '@classytic/payroll';
|
|
1391
|
+
|
|
1392
|
+
// Initialize for new employee
|
|
1393
|
+
const balances = initializeLeaveBalances(new Date('2024-01-01'), {}, 2024);
|
|
1394
|
+
employee.leaveBalances = balances;
|
|
1395
|
+
|
|
1396
|
+
// Pro-rated for mid-year hire
|
|
1397
|
+
const balances = initializeLeaveBalances(new Date('2024-07-01'), {
|
|
1398
|
+
proRateNewHires: true,
|
|
1399
|
+
}, 2024);
|
|
1400
|
+
|
|
1401
|
+
// Check balance
|
|
1402
|
+
hasLeaveBalance(employee, 'annual', 5, 2024); // โ true/false
|
|
1403
|
+
|
|
1404
|
+
// Get available days
|
|
1405
|
+
getAvailableDays(employee, 'annual', 2024); // โ 15
|
|
1406
|
+
|
|
1407
|
+
// Full summary
|
|
1408
|
+
const summary = getLeaveSummary(employee, 2024);
|
|
1409
|
+
// {
|
|
1410
|
+
// totalAllocated: 30, totalUsed: 7, totalPending: 4, totalAvailable: 19,
|
|
1411
|
+
// byType: { annual: { allocated: 20, used: 5, pending: 2, available: 13 }, ... }
|
|
1412
|
+
// }
|
|
1413
|
+
|
|
1414
|
+
// Calculate working days
|
|
1415
|
+
calculateLeaveDays(
|
|
1416
|
+
new Date('2024-06-03'),
|
|
1417
|
+
new Date('2024-06-07'),
|
|
1418
|
+
{ holidays: [new Date('2024-06-04')] }
|
|
1419
|
+
); // โ 4 (excludes weekend + holiday)
|
|
1420
|
+
```
|
|
1421
|
+
|
|
1422
|
+
### Transactional Workflows
|
|
1423
|
+
|
|
1424
|
+
All `LeaveService` methods support Mongoose sessions for atomic operations:
|
|
1425
|
+
|
|
1426
|
+
```typescript
|
|
1427
|
+
const session = await mongoose.startSession();
|
|
1428
|
+
session.startTransaction();
|
|
1429
|
+
|
|
1430
|
+
try {
|
|
1431
|
+
const result = await leaveService.requestLeave({
|
|
1432
|
+
organizationId: org._id,
|
|
1433
|
+
employeeId: employee._id,
|
|
1434
|
+
userId: user._id,
|
|
1435
|
+
request: { type: 'annual', startDate, endDate },
|
|
1436
|
+
session, // โ Atomic with other operations
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
// Other operations in same transaction
|
|
1440
|
+
await OtherModel.create({ ... }, { session });
|
|
1441
|
+
|
|
1442
|
+
await session.commitTransaction();
|
|
1443
|
+
} catch (error) {
|
|
1444
|
+
await session.abortTransaction();
|
|
1445
|
+
throw error;
|
|
1446
|
+
} finally {
|
|
1447
|
+
session.endSession();
|
|
1448
|
+
}
|
|
1449
|
+
```
|
|
1450
|
+
|
|
1451
|
+
### Configuration Options
|
|
1452
|
+
|
|
1453
|
+
```typescript
|
|
1454
|
+
createLeaveService({
|
|
1455
|
+
EmployeeModel,
|
|
1456
|
+
LeaveRequestModel,
|
|
1457
|
+
config: {
|
|
1458
|
+
// Validation
|
|
1459
|
+
enforceBalance: true, // Validate sufficient balance (default: true)
|
|
1460
|
+
checkOverlap: true, // Prevent overlapping requests (default: true)
|
|
1461
|
+
|
|
1462
|
+
// Working days
|
|
1463
|
+
workingDaysOptions: {
|
|
1464
|
+
workingDays: [1, 2, 3, 4, 5], // Mon-Fri (default)
|
|
1465
|
+
holidays: [new Date('2024-12-25')],
|
|
1466
|
+
},
|
|
1467
|
+
|
|
1468
|
+
// Single/Multi-tenant
|
|
1469
|
+
singleTenant: false, // Enable single-tenant mode (default: false)
|
|
1470
|
+
defaultOrganizationId: null, // Default org for single-tenant
|
|
1471
|
+
|
|
1472
|
+
// Custom fields
|
|
1473
|
+
leaveBalancesField: 'leaveBalances', // Field name on employee (default)
|
|
1474
|
+
},
|
|
1475
|
+
});
|
|
1476
|
+
```
|
|
1477
|
+
|
|
1478
|
+
### Indexes (Opt-in)
|
|
1479
|
+
|
|
1480
|
+
```typescript
|
|
1481
|
+
import { createLeaveRequestSchema } from '@classytic/payroll';
|
|
1482
|
+
|
|
1483
|
+
const leaveRequestSchema = createLeaveRequestSchema({}, {
|
|
1484
|
+
createIndexes: true, // Apply recommended indexes
|
|
1485
|
+
enableTTL: true, // Auto-cleanup old records
|
|
1486
|
+
ttlSeconds: 63072000, // 2 years (default)
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
const LeaveRequest = mongoose.model('LeaveRequest', leaveRequestSchema);
|
|
1490
|
+
```
|
|
1491
|
+
|
|
1492
|
+
**Recommended indexes:**
|
|
1493
|
+
- `{ organizationId: 1, employeeId: 1, startDate: -1 }` - Employee leave history
|
|
1494
|
+
- `{ organizationId: 1, status: 1, createdAt: -1 }` - Pending requests
|
|
1495
|
+
- `{ employeeId: 1, status: 1 }` - Single-tenant queries
|
|
1496
|
+
- `{ organizationId: 1, type: 1, status: 1 }` - Reports by type
|
|
1497
|
+
|
|
1498
|
+
---
|
|
1499
|
+
|
|
1500
|
+
## Shift Compliance
|
|
1501
|
+
|
|
1502
|
+
**Modern shift-based attendance management with late penalties, overtime bonuses, and progressive discipline.**
|
|
1503
|
+
|
|
1504
|
+
Calculate shift compliance adjustments based on attendance data:
|
|
1505
|
+
- **Late arrival penalties** (flat, per-minute, percentage, tiered)
|
|
1506
|
+
- **Early departure penalties** (same modes as late arrival)
|
|
1507
|
+
- **Overtime bonuses** (daily, weekly, monthly with weekend/night premiums)
|
|
1508
|
+
- **Grace periods** (0-60 minutes before penalties apply)
|
|
1509
|
+
- **Progressive discipline** (tiered penalties: 1st warning โ escalating fines)
|
|
1510
|
+
- **Penalty caps** (maximum penalties per period)
|
|
1511
|
+
- **Weekend premiums** (Saturday 1.5x, Sunday 2.0x)
|
|
1512
|
+
- **Night shift differentials** (10pm-6am @ 1.3x)
|
|
1513
|
+
|
|
1514
|
+
### Quick Start
|
|
1515
|
+
|
|
1516
|
+
```typescript
|
|
1517
|
+
import {
|
|
1518
|
+
calculateShiftCompliance,
|
|
1519
|
+
createPolicyFromPreset,
|
|
1520
|
+
AttendancePolicyBuilder,
|
|
1521
|
+
} from '@classytic/payroll';
|
|
1522
|
+
|
|
1523
|
+
// Option 1: Use an industry preset
|
|
1524
|
+
const policy = createPolicyFromPreset('manufacturing', {
|
|
1525
|
+
name: 'Factory Floor Policy',
|
|
1526
|
+
organizationId: org._id,
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
// Option 2: Build a custom policy
|
|
1530
|
+
const customPolicy = AttendancePolicyBuilder.create()
|
|
1531
|
+
.named('Tech Department Policy')
|
|
1532
|
+
.description('Flexible policy for tech workers')
|
|
1533
|
+
.lateArrival()
|
|
1534
|
+
.enable()
|
|
1535
|
+
.gracePeriod(15) // 15 minutes grace
|
|
1536
|
+
.tieredPenalty() // Progressive discipline
|
|
1537
|
+
.tier(1, 3).warning() // 1st-3rd: warning only
|
|
1538
|
+
.tier(4, 5).penalty(20) // 4th-5th: $20
|
|
1539
|
+
.tier(6).penalty(40) // 6th+: $40
|
|
1540
|
+
.end()
|
|
1541
|
+
.maxPenalties(3, 'monthly') // Cap at 3 penalties/month
|
|
1542
|
+
.resetOccurrences('quarterly') // Reset counter quarterly
|
|
1543
|
+
.end()
|
|
1544
|
+
.earlyDeparture()
|
|
1545
|
+
.disable() // Not tracked in office environments
|
|
1546
|
+
.end()
|
|
1547
|
+
.overtime()
|
|
1548
|
+
.enable()
|
|
1549
|
+
.mode('weekly')
|
|
1550
|
+
.weeklyThreshold(40, 1.5) // >40 hours = 1.5x pay
|
|
1551
|
+
.end()
|
|
1552
|
+
.build();
|
|
1553
|
+
|
|
1554
|
+
// Calculate shift compliance
|
|
1555
|
+
const result = calculateShiftCompliance({
|
|
1556
|
+
attendance: {
|
|
1557
|
+
lateArrivals: 3,
|
|
1558
|
+
totalLateMinutes: 45,
|
|
1559
|
+
overtimeHours: 8,
|
|
1560
|
+
},
|
|
1561
|
+
policy,
|
|
1562
|
+
dailyWage: 1500,
|
|
1563
|
+
hourlyRate: 200,
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1566
|
+
console.log(result);
|
|
1567
|
+
// {
|
|
1568
|
+
// latePenalty: { amount: 150, occurrences: 3, breakdown: [...] },
|
|
1569
|
+
// earlyDeparturePenalty: { amount: 0, occurrences: 0, breakdown: [] },
|
|
1570
|
+
// overtimeBonus: { amount: 800, hours: 8, breakdown: [...] },
|
|
1571
|
+
// totalPenalties: 150,
|
|
1572
|
+
// totalBonuses: 800,
|
|
1573
|
+
// netAdjustment: 650, // +800 - 150
|
|
1574
|
+
// complianceScore: 70, // 0-100
|
|
1575
|
+
// occurrenceCount: 3,
|
|
1576
|
+
// isAtRisk: false,
|
|
1577
|
+
// policyName: 'Factory Floor Policy'
|
|
1578
|
+
// }
|
|
1579
|
+
```
|
|
1580
|
+
|
|
1581
|
+
### Industry Presets
|
|
1582
|
+
|
|
1583
|
+
Six practical presets for common operational patterns:
|
|
1584
|
+
|
|
1585
|
+
```typescript
|
|
1586
|
+
import {
|
|
1587
|
+
DEFAULT_ATTENDANCE_POLICY, // Moderate, office-friendly
|
|
1588
|
+
MANUFACTURING_POLICY, // Strict, zero tolerance
|
|
1589
|
+
RETAIL_POLICY, // Flexible with weekend premiums
|
|
1590
|
+
OFFICE_POLICY, // Very flexible, progressive discipline
|
|
1591
|
+
HEALTHCARE_POLICY, // Night shift differential, patient care
|
|
1592
|
+
HOSPITALITY_POLICY, // Percentage-based, night/weekend premiums
|
|
1593
|
+
} from '@classytic/payroll';
|
|
1594
|
+
```
|
|
1595
|
+
|
|
1596
|
+
| Preset | Grace Period | Penalty Mode | Overtime Mode | Special Features |
|
|
1597
|
+
|--------|-------------|--------------|---------------|------------------|
|
|
1598
|
+
| **Default** | 10 min | Tiered (progressive) | Daily | Balanced for office work |
|
|
1599
|
+
| **Manufacturing** | 0 min | Flat ($100) | Daily + Weekly 2x | Clock rounding (down), strict |
|
|
1600
|
+
| **Retail** | 5 min | Flat ($25) | Weekly | Weekend premiums (Sat 1.5x, Sun 2x) |
|
|
1601
|
+
| **Office/Tech** | 15 min | Tiered (lenient) | Weekly | Early departure disabled |
|
|
1602
|
+
| **Healthcare** | 5 min | Flat ($50/$75) | Daily | Night shift 1.3x, weekend premiums |
|
|
1603
|
+
| **Hospitality** | 5 min | Percentage (1-1.5%) | Weekly | Night shift 1.2x, weekend premiums |
|
|
1604
|
+
|
|
1605
|
+
### Penalty Modes
|
|
1606
|
+
|
|
1607
|
+
#### 1. Flat Penalty (Fixed amount per occurrence)
|
|
1608
|
+
|
|
1609
|
+
```typescript
|
|
1610
|
+
lateArrival()
|
|
1611
|
+
.flatPenalty(50) // $50 per late occurrence
|
|
1612
|
+
.build()
|
|
1613
|
+
```
|
|
1614
|
+
|
|
1615
|
+
#### 2. Per-Minute Penalty (Based on minutes late)
|
|
1616
|
+
|
|
1617
|
+
```typescript
|
|
1618
|
+
lateArrival()
|
|
1619
|
+
.perMinutePenalty(2) // $2 per minute late
|
|
1620
|
+
.build()
|
|
1621
|
+
```
|
|
1622
|
+
|
|
1623
|
+
#### 3. Percentage Penalty (Percentage of daily wage)
|
|
1624
|
+
|
|
1625
|
+
```typescript
|
|
1626
|
+
lateArrival()
|
|
1627
|
+
.percentagePenalty(2) // 2% of daily wage per occurrence
|
|
1628
|
+
.build()
|
|
1629
|
+
```
|
|
1630
|
+
|
|
1631
|
+
#### 4. Tiered Penalty (Progressive discipline)
|
|
1632
|
+
|
|
1633
|
+
```typescript
|
|
1634
|
+
lateArrival()
|
|
1635
|
+
.tieredPenalty()
|
|
1636
|
+
.tier(1, 2).warning() // 1st-2nd: warning only ($0)
|
|
1637
|
+
.tier(3, 4).penalty(25) // 3rd-4th: $25
|
|
1638
|
+
.tier(5).penalty(50) // 5th and above: $50
|
|
1639
|
+
.end()
|
|
1640
|
+
.build()
|
|
1641
|
+
```
|
|
1642
|
+
|
|
1643
|
+
### Overtime Modes
|
|
1644
|
+
|
|
1645
|
+
#### Daily Overtime (Per-day threshold)
|
|
1646
|
+
|
|
1647
|
+
```typescript
|
|
1648
|
+
overtime()
|
|
1649
|
+
.mode('daily')
|
|
1650
|
+
.dailyThreshold(8, 1.5) // >8 hours/day = 1.5x pay
|
|
1651
|
+
.build()
|
|
1652
|
+
|
|
1653
|
+
// Employee works 10 hours โ 2 hours overtime @ 1.5x
|
|
1654
|
+
// Bonus = 2 * hourlyRate * 0.5 (only pay the extra 0.5x)
|
|
1655
|
+
```
|
|
1656
|
+
|
|
1657
|
+
#### Weekly Overtime (Per-week threshold)
|
|
1658
|
+
|
|
1659
|
+
```typescript
|
|
1660
|
+
overtime()
|
|
1661
|
+
.mode('weekly')
|
|
1662
|
+
.weeklyThreshold(40, 1.5) // >40 hours/week = 1.5x pay
|
|
1663
|
+
.weekendPremium(1.5, 2.0) // Saturday 1.5x, Sunday 2.0x
|
|
1664
|
+
.build()
|
|
1665
|
+
```
|
|
1666
|
+
|
|
1667
|
+
#### Night Shift Differential
|
|
1668
|
+
|
|
1669
|
+
```typescript
|
|
1670
|
+
overtime()
|
|
1671
|
+
.nightShiftDifferential(22, 6, 1.3) // 10pm-6am @ 1.3x (30% premium)
|
|
1672
|
+
.build()
|
|
1673
|
+
|
|
1674
|
+
// Employee works 8 hours night shift
|
|
1675
|
+
// Bonus = 8 * hourlyRate * 0.3 (the extra 30%)
|
|
1676
|
+
```
|
|
1677
|
+
|
|
1678
|
+
### Working with Detailed Occurrences
|
|
1679
|
+
|
|
1680
|
+
For detailed breakdowns and audit trails:
|
|
1681
|
+
|
|
1682
|
+
```typescript
|
|
1683
|
+
import type {
|
|
1684
|
+
LateOccurrence,
|
|
1685
|
+
OvertimeOccurrence,
|
|
1686
|
+
} from '@classytic/payroll';
|
|
1687
|
+
|
|
1688
|
+
const lateOccurrences: LateOccurrence[] = [
|
|
1689
|
+
{
|
|
1690
|
+
date: new Date('2025-01-15'),
|
|
1691
|
+
scheduledTime: new Date('2025-01-15T09:00:00'),
|
|
1692
|
+
actualTime: new Date('2025-01-15T09:15:00'),
|
|
1693
|
+
minutesLate: 15,
|
|
1694
|
+
},
|
|
1695
|
+
{
|
|
1696
|
+
date: new Date('2025-01-16'),
|
|
1697
|
+
scheduledTime: new Date('2025-01-16T09:00:00'),
|
|
1698
|
+
actualTime: new Date('2025-01-16T09:05:00'),
|
|
1699
|
+
minutesLate: 5, // Within grace period
|
|
1700
|
+
},
|
|
1701
|
+
];
|
|
1702
|
+
|
|
1703
|
+
const overtimeOccurrences: OvertimeOccurrence[] = [
|
|
1704
|
+
{
|
|
1705
|
+
date: new Date('2025-01-18'), // Saturday
|
|
1706
|
+
type: 'weekend-saturday',
|
|
1707
|
+
hours: 8,
|
|
1708
|
+
multiplier: 1.5,
|
|
1709
|
+
},
|
|
1710
|
+
{
|
|
1711
|
+
date: new Date('2025-01-20'),
|
|
1712
|
+
type: 'night-shift',
|
|
1713
|
+
hours: 8,
|
|
1714
|
+
multiplier: 1.3,
|
|
1715
|
+
},
|
|
1716
|
+
];
|
|
1717
|
+
|
|
1718
|
+
const result = calculateShiftCompliance({
|
|
1719
|
+
attendance: {
|
|
1720
|
+
lateOccurrences,
|
|
1721
|
+
overtimeOccurrences,
|
|
1722
|
+
},
|
|
1723
|
+
policy,
|
|
1724
|
+
dailyWage: 1500,
|
|
1725
|
+
hourlyRate: 200,
|
|
1726
|
+
});
|
|
1727
|
+
|
|
1728
|
+
// Access detailed breakdowns
|
|
1729
|
+
result.latePenalty.breakdown.forEach(item => {
|
|
1730
|
+
console.log({
|
|
1731
|
+
date: item.date,
|
|
1732
|
+
minutesLate: item.minutesLate,
|
|
1733
|
+
penalty: item.penaltyAmount,
|
|
1734
|
+
tier: item.tier, // Which tier applied (for tiered mode)
|
|
1735
|
+
waived: item.waived, // true if within grace period
|
|
1736
|
+
});
|
|
1737
|
+
});
|
|
1738
|
+
```
|
|
1739
|
+
|
|
1740
|
+
### Storing Policies in Database (Optional)
|
|
1741
|
+
|
|
1742
|
+
If you want to store policies in MongoDB:
|
|
1743
|
+
|
|
1744
|
+
```typescript
|
|
1745
|
+
import { AttendancePolicySchema } from '@classytic/payroll';
|
|
1746
|
+
import { model } from 'mongoose';
|
|
1747
|
+
|
|
1748
|
+
// Use our schema as-is
|
|
1749
|
+
const AttendancePolicy = model('AttendancePolicy', AttendancePolicySchema);
|
|
1750
|
+
|
|
1751
|
+
// Or extend with your own fields
|
|
1752
|
+
const CustomPolicySchema = new Schema({
|
|
1753
|
+
...AttendancePolicySchema.obj,
|
|
1754
|
+
approvedBy: { type: Schema.Types.ObjectId, ref: 'User' },
|
|
1755
|
+
department: String,
|
|
1756
|
+
tags: [String],
|
|
1757
|
+
});
|
|
1758
|
+
const CustomPolicy = model('CustomPolicy', CustomPolicySchema);
|
|
1759
|
+
|
|
1760
|
+
// Create and save
|
|
1761
|
+
const policy = new AttendancePolicy({
|
|
1762
|
+
name: 'Manufacturing Policy',
|
|
1763
|
+
organizationId: org._id,
|
|
1764
|
+
lateArrival: {
|
|
1765
|
+
enabled: true,
|
|
1766
|
+
gracePeriod: 0,
|
|
1767
|
+
mode: 'flat',
|
|
1768
|
+
flatAmount: 100,
|
|
1769
|
+
},
|
|
1770
|
+
earlyDeparture: {
|
|
1771
|
+
enabled: true,
|
|
1772
|
+
gracePeriod: 0,
|
|
1773
|
+
mode: 'flat',
|
|
1774
|
+
flatAmount: 150,
|
|
1775
|
+
},
|
|
1776
|
+
overtime: {
|
|
1777
|
+
enabled: true,
|
|
1778
|
+
mode: 'daily',
|
|
1779
|
+
dailyThreshold: 8,
|
|
1780
|
+
dailyMultiplier: 1.5,
|
|
1781
|
+
},
|
|
1782
|
+
effectiveFrom: new Date(),
|
|
1783
|
+
active: true,
|
|
1784
|
+
});
|
|
1785
|
+
await policy.save();
|
|
1786
|
+
|
|
1787
|
+
// Query active policy
|
|
1788
|
+
const activePolicy = await AttendancePolicy.findActiveForOrganization(org._id);
|
|
1789
|
+
|
|
1790
|
+
// Check if currently active
|
|
1791
|
+
if (policy.isCurrentlyActive()) {
|
|
1792
|
+
// Use this policy
|
|
1793
|
+
}
|
|
1794
|
+
```
|
|
1795
|
+
|
|
1796
|
+
### Integration Example
|
|
1797
|
+
|
|
1798
|
+
Complete workflow integrating with attendance and payroll:
|
|
1799
|
+
|
|
1800
|
+
```typescript
|
|
1801
|
+
import { calculateShiftCompliance, createPolicyFromPreset } from '@classytic/payroll';
|
|
1802
|
+
|
|
1803
|
+
async function processMonthlyPayroll(employee, month, year) {
|
|
1804
|
+
// 1. Get attendance data (from ClockIn or your system)
|
|
1805
|
+
const attendance = await getAttendanceData(employee._id, month, year);
|
|
1806
|
+
|
|
1807
|
+
// 2. Get applicable policy
|
|
1808
|
+
const policy = await AttendancePolicy.findActiveForOrganization(employee.organizationId);
|
|
1809
|
+
|
|
1810
|
+
// 3. Calculate shift compliance
|
|
1811
|
+
const compliance = calculateShiftCompliance({
|
|
1812
|
+
attendance: {
|
|
1813
|
+
lateArrivals: attendance.lateCount,
|
|
1814
|
+
totalLateMinutes: attendance.totalLateMinutes,
|
|
1815
|
+
earlyDepartures: attendance.earlyCount,
|
|
1816
|
+
totalEarlyMinutes: attendance.totalEarlyMinutes,
|
|
1817
|
+
overtimeHours: attendance.overtimeHours,
|
|
1818
|
+
},
|
|
1819
|
+
policy,
|
|
1820
|
+
dailyWage: employee.compensation.baseAmount / 30,
|
|
1821
|
+
hourlyRate: employee.compensation.baseAmount / 30 / 8,
|
|
1822
|
+
});
|
|
1823
|
+
|
|
1824
|
+
// 4. Process payroll with adjustments
|
|
1825
|
+
const result = await payroll.processSalary({
|
|
1826
|
+
employeeId: employee._id,
|
|
1827
|
+
organizationId: employee.organizationId,
|
|
1828
|
+
month,
|
|
1829
|
+
year,
|
|
1830
|
+
additionalAllowances: [
|
|
1831
|
+
{
|
|
1832
|
+
name: 'Overtime Bonus',
|
|
1833
|
+
amount: compliance.totalBonuses,
|
|
1834
|
+
type: 'overtime',
|
|
1835
|
+
},
|
|
1836
|
+
],
|
|
1837
|
+
additionalDeductions: [
|
|
1838
|
+
{
|
|
1839
|
+
name: 'Shift Compliance Penalties',
|
|
1840
|
+
amount: compliance.totalPenalties,
|
|
1841
|
+
type: 'late_penalty',
|
|
1842
|
+
},
|
|
1843
|
+
],
|
|
1844
|
+
});
|
|
1845
|
+
|
|
1846
|
+
// 5. Log compliance metrics
|
|
1847
|
+
console.log({
|
|
1848
|
+
complianceScore: compliance.complianceScore,
|
|
1849
|
+
isAtRisk: compliance.isAtRisk,
|
|
1850
|
+
netAdjustment: compliance.netAdjustment,
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
return result;
|
|
1854
|
+
}
|
|
1855
|
+
```
|
|
1856
|
+
|
|
1857
|
+
### Pure Calculators (Client-Side Capable!) ๐
|
|
1858
|
+
|
|
1859
|
+
Calculate salaries **without database** - perfect for client-side previews, testing, and microservices!
|
|
1860
|
+
|
|
1861
|
+
### Quick Start
|
|
1862
|
+
|
|
1863
|
+
```typescript
|
|
1864
|
+
import {
|
|
1865
|
+
calculateSalaryBreakdown,
|
|
1866
|
+
calculateProRating,
|
|
1867
|
+
calculateDailyRate,
|
|
1868
|
+
} from '@classytic/payroll';
|
|
1869
|
+
|
|
1870
|
+
// Preview salary calculation (no API call needed!)
|
|
1871
|
+
const preview = calculateSalaryBreakdown({
|
|
1872
|
+
employee: {
|
|
1873
|
+
hireDate: new Date('2024-01-01'),
|
|
1874
|
+
compensation: {
|
|
1875
|
+
baseAmount: 100000,
|
|
1876
|
+
currency: 'USD',
|
|
1877
|
+
allowances: [{ type: 'housing', amount: 20000, taxable: true, recurring: true }],
|
|
1878
|
+
deductions: [{ type: 'insurance', amount: 5000, recurring: true, auto: true }],
|
|
1879
|
+
},
|
|
1880
|
+
},
|
|
1881
|
+
period: {
|
|
1882
|
+
month: 3,
|
|
1883
|
+
year: 2024,
|
|
1884
|
+
startDate: new Date('2024-03-01'),
|
|
1885
|
+
endDate: new Date('2024-03-31'),
|
|
1886
|
+
},
|
|
1887
|
+
config: {
|
|
1888
|
+
allowProRating: true,
|
|
1889
|
+
autoDeductions: true,
|
|
1890
|
+
defaultCurrency: 'USD',
|
|
1891
|
+
attendanceIntegration: false,
|
|
1892
|
+
},
|
|
1893
|
+
taxBrackets: [], // Your tax brackets
|
|
1894
|
+
});
|
|
1895
|
+
|
|
1896
|
+
console.log(preview.netSalary); // Instant preview!
|
|
1897
|
+
```
|
|
1898
|
+
|
|
1899
|
+
### Available Calculators
|
|
1900
|
+
|
|
1901
|
+
```typescript
|
|
1902
|
+
// 1. Pro-Rating Calculator
|
|
1903
|
+
const proRating = calculateProRating({
|
|
1904
|
+
hireDate: new Date('2024-03-15'),
|
|
1905
|
+
terminationDate: null,
|
|
1906
|
+
periodStart: new Date('2024-03-01'),
|
|
1907
|
+
periodEnd: new Date('2024-03-31'),
|
|
1908
|
+
workingDays: [1, 2, 3, 4, 5],
|
|
1909
|
+
});
|
|
1910
|
+
console.log(proRating.ratio); // 0.64 (64% of month worked)
|
|
1911
|
+
|
|
1912
|
+
// 2. Daily Rate Calculator
|
|
1913
|
+
const dailyRate = calculateDailyRate(100000, 22); // 4545
|
|
1914
|
+
const hourlyRate = calculateHourlyRate(100000, 22, 8); // 568
|
|
1915
|
+
|
|
1916
|
+
// 3. Attendance Deduction
|
|
1917
|
+
const deduction = calculateAttendanceDeduction({
|
|
1918
|
+
expectedWorkingDays: 22,
|
|
1919
|
+
actualWorkingDays: 20,
|
|
1920
|
+
dailyRate: 4545,
|
|
1921
|
+
});
|
|
1922
|
+
console.log(deduction.deductionAmount); // 9090
|
|
1923
|
+
```
|
|
1924
|
+
|
|
1925
|
+
### Use Cases
|
|
1926
|
+
|
|
1927
|
+
**1. Client-Side Salary Preview (React/Vue/Angular)**
|
|
1928
|
+
```typescript
|
|
1929
|
+
function SalaryPreview({ baseAmount, allowances }) {
|
|
1930
|
+
const [preview, setPreview] = useState(null);
|
|
1931
|
+
|
|
1932
|
+
useEffect(() => {
|
|
1933
|
+
const result = calculateSalaryBreakdown({
|
|
1934
|
+
employee: { hireDate: new Date(), compensation: { baseAmount, allowances } },
|
|
1935
|
+
period: getCurrentPeriod(),
|
|
1936
|
+
config: appConfig,
|
|
1937
|
+
taxBrackets: appTaxBrackets,
|
|
1938
|
+
});
|
|
1939
|
+
setPreview(result);
|
|
1940
|
+
}, [baseAmount, allowances]);
|
|
1941
|
+
|
|
1942
|
+
return <div>Estimated Net: {preview?.netSalary}</div>;
|
|
1943
|
+
}
|
|
1944
|
+
```
|
|
1945
|
+
|
|
1946
|
+
**2. Testing Without Database**
|
|
1947
|
+
```typescript
|
|
1948
|
+
import { calculateSalaryBreakdown } from '@classytic/payroll';
|
|
1949
|
+
|
|
1950
|
+
describe('Salary Calculations', () => {
|
|
1951
|
+
it('calculates correctly', () => {
|
|
1952
|
+
const result = calculateSalaryBreakdown({...});
|
|
1953
|
+
expect(result.netSalary).toBe(90000);
|
|
1954
|
+
});
|
|
1955
|
+
// No MongoDB, no Mongoose, just pure logic!
|
|
1956
|
+
});
|
|
1957
|
+
```
|
|
1958
|
+
|
|
1959
|
+
**3. Microservices/Serverless**
|
|
1960
|
+
```typescript
|
|
1961
|
+
// Lightweight function - no Payroll instance needed
|
|
1962
|
+
export const handler = async (event) => {
|
|
1963
|
+
const result = calculateSalaryBreakdown(event.input);
|
|
1964
|
+
return { statusCode: 200, body: JSON.stringify(result) };
|
|
1965
|
+
};
|
|
1966
|
+
```
|
|
1967
|
+
|
|
1968
|
+
## Pure Functions (No Database)
|
|
1969
|
+
|
|
1970
|
+
Shift compliance calculations are pure functions - no database required:
|
|
1971
|
+
|
|
1972
|
+
```typescript
|
|
1973
|
+
// Client-side preview
|
|
1974
|
+
function previewShiftAdjustment(lateMinutes: number, overtimeHours: number) {
|
|
1975
|
+
const policy = createPolicyFromPreset('default');
|
|
1976
|
+
|
|
1977
|
+
const result = calculateShiftCompliance({
|
|
1978
|
+
attendance: {
|
|
1979
|
+
lateArrivals: Math.ceil(lateMinutes / 10),
|
|
1980
|
+
totalLateMinutes: lateMinutes,
|
|
1981
|
+
overtimeHours,
|
|
1982
|
+
},
|
|
1983
|
+
policy,
|
|
1984
|
+
dailyWage: 1500,
|
|
1985
|
+
hourlyRate: 200,
|
|
1986
|
+
});
|
|
1987
|
+
|
|
1988
|
+
return {
|
|
1989
|
+
penalty: result.totalPenalties,
|
|
1990
|
+
bonus: result.totalBonuses,
|
|
1991
|
+
net: result.netAdjustment,
|
|
1992
|
+
};
|
|
1993
|
+
}
|
|
1994
|
+
```
|
|
1995
|
+
|
|
1996
|
+
### API Reference
|
|
1997
|
+
|
|
1998
|
+
#### Main Calculator
|
|
1999
|
+
|
|
2000
|
+
```typescript
|
|
2001
|
+
calculateShiftCompliance(input: {
|
|
2002
|
+
attendance: ShiftComplianceData;
|
|
2003
|
+
policy: AttendancePolicy;
|
|
2004
|
+
dailyWage: number;
|
|
2005
|
+
hourlyRate: number;
|
|
2006
|
+
currentOccurrenceCount?: number;
|
|
2007
|
+
}): ShiftComplianceResult
|
|
2008
|
+
```
|
|
2009
|
+
|
|
2010
|
+
#### Factory Function
|
|
2011
|
+
|
|
2012
|
+
```typescript
|
|
2013
|
+
createPolicyFromPreset(
|
|
2014
|
+
preset: 'default' | 'manufacturing' | 'retail' | 'office' | 'healthcare' | 'hospitality',
|
|
2015
|
+
overrides?: Partial<AttendancePolicy>
|
|
2016
|
+
): AttendancePolicy
|
|
2017
|
+
```
|
|
2018
|
+
|
|
2019
|
+
#### Builder API
|
|
2020
|
+
|
|
2021
|
+
```typescript
|
|
2022
|
+
AttendancePolicyBuilder.create()
|
|
2023
|
+
.named(string)
|
|
2024
|
+
.description(string)
|
|
2025
|
+
.organizationId(ObjectId)
|
|
2026
|
+
.lateArrival() ...
|
|
2027
|
+
.earlyDeparture() ...
|
|
2028
|
+
.overtime() ...
|
|
2029
|
+
.clockRounding() ...
|
|
2030
|
+
.build()
|
|
2031
|
+
```
|
|
2032
|
+
|
|
2033
|
+
---
|
|
2034
|
+
|
|
2035
|
+
## Logging
|
|
2036
|
+
|
|
2037
|
+
Control logging in production:
|
|
2038
|
+
|
|
2039
|
+
```typescript
|
|
2040
|
+
import { createPayrollInstance } from '@classytic/payroll';
|
|
2041
|
+
import { disableLogging, enableLogging } from '@classytic/payroll/utils';
|
|
2042
|
+
|
|
2043
|
+
// Disable in production
|
|
2044
|
+
if (process.env.NODE_ENV === 'production') {
|
|
2045
|
+
disableLogging();
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
// Or use custom logger
|
|
2049
|
+
const payroll = createPayrollInstance()
|
|
2050
|
+
.withModels({ EmployeeModel, PayrollRecordModel, TransactionModel, AttendanceModel })
|
|
2051
|
+
.withLogger({
|
|
2052
|
+
info: (msg, meta) => pino.info(meta, msg),
|
|
2053
|
+
error: (msg, meta) => pino.error(meta, msg),
|
|
2054
|
+
warn: (msg, meta) => pino.warn(meta, msg),
|
|
2055
|
+
debug: (msg, meta) => pino.debug(meta, msg),
|
|
2056
|
+
})
|
|
2057
|
+
.build();
|
|
2058
|
+
```
|
|
2059
|
+
|
|
2060
|
+
## Indexes
|
|
2061
|
+
|
|
2062
|
+
This package does **not** create indexes automatically. This gives you full control over your database indexes based on your actual query patterns.
|
|
2063
|
+
|
|
2064
|
+
### Opt-in Index Creation
|
|
2065
|
+
|
|
2066
|
+
If you want the library to create indexes for you, explicitly opt-in:
|
|
2067
|
+
|
|
2068
|
+
```typescript
|
|
2069
|
+
// Employee plugin with indexes
|
|
2070
|
+
employeeSchema.plugin(employeePlugin, { createIndexes: true });
|
|
2071
|
+
```
|
|
2072
|
+
|
|
2073
|
+
### Manual Index Creation
|
|
2074
|
+
|
|
2075
|
+
For more control, use the exported index helpers:
|
|
2076
|
+
|
|
2077
|
+
```typescript
|
|
2078
|
+
import {
|
|
2079
|
+
applyEmployeeIndexes,
|
|
2080
|
+
applyPayrollRecordIndexes,
|
|
2081
|
+
employeeIndexes,
|
|
2082
|
+
payrollRecordIndexes
|
|
2083
|
+
} from '@classytic/payroll';
|
|
2084
|
+
|
|
2085
|
+
// Apply all recommended indexes
|
|
2086
|
+
applyEmployeeIndexes(employeeSchema);
|
|
2087
|
+
applyPayrollRecordIndexes(payrollRecordSchema);
|
|
2088
|
+
|
|
2089
|
+
// Or inspect and apply selectively
|
|
2090
|
+
console.log(employeeIndexes);
|
|
2091
|
+
// [
|
|
2092
|
+
// { fields: { organizationId: 1, employeeId: 1 }, options: { unique: true } },
|
|
2093
|
+
// { fields: { userId: 1, organizationId: 1 }, options: { unique: true } },
|
|
2094
|
+
// { fields: { organizationId: 1, status: 1 } },
|
|
2095
|
+
// { fields: { organizationId: 1, department: 1 } },
|
|
2096
|
+
// { fields: { organizationId: 1, 'compensation.netSalary': -1 } },
|
|
2097
|
+
// ]
|
|
2098
|
+
```
|
|
2099
|
+
|
|
2100
|
+
### Why No Auto-Indexes?
|
|
2101
|
+
|
|
2102
|
+
Unused indexes waste memory and slow down writes. By making indexes opt-in:
|
|
2103
|
+
- You only create indexes you actually need
|
|
2104
|
+
- You can analyze your query patterns first
|
|
2105
|
+
- No surprise index creation on production databases
|
|
2106
|
+
|
|
2107
|
+
## API Reference
|
|
2108
|
+
|
|
2109
|
+
### Payroll Instance
|
|
2110
|
+
|
|
2111
|
+
```typescript
|
|
2112
|
+
// Employee Lifecycle
|
|
2113
|
+
payroll.hire(params) // Hire new employee with compensation
|
|
2114
|
+
payroll.updateEmployment(params) // Update position, department, type
|
|
2115
|
+
payroll.terminate(params) // Terminate with reason and date
|
|
2116
|
+
payroll.reHire(params) // Re-hire terminated employee
|
|
2117
|
+
|
|
2118
|
+
// Compensation Management
|
|
2119
|
+
payroll.updateSalary(params) // Update base salary and compensation
|
|
2120
|
+
payroll.addAllowance(params) // Add one-time or recurring allowance
|
|
2121
|
+
payroll.removeAllowance(params) // Remove allowance by type
|
|
2122
|
+
payroll.addDeduction(params) // Add deduction (tax, insurance, etc.)
|
|
2123
|
+
payroll.removeDeduction(params) // Remove deduction by type
|
|
2124
|
+
payroll.updateBankDetails(params) // Update payment information
|
|
2125
|
+
|
|
2126
|
+
// Payroll Processing
|
|
2127
|
+
payroll.processSalary(params) // Process monthly salary for one employee
|
|
2128
|
+
payroll.processBulkPayroll(params) // Process for multiple employees
|
|
2129
|
+
payroll.payrollHistory(params) // Query payroll records
|
|
2130
|
+
payroll.payrollSummary(params) // Aggregate statistics
|
|
2131
|
+
```
|
|
2132
|
+
|
|
2133
|
+
### Leave Service
|
|
2134
|
+
|
|
2135
|
+
```typescript
|
|
2136
|
+
// Request & Review
|
|
2137
|
+
leaveService.requestLeave(params) // Create request, validate, update balance
|
|
2138
|
+
leaveService.reviewLeave(params) // Approve/reject with balance updates
|
|
2139
|
+
leaveService.cancelLeave(params) // Cancel and restore balance
|
|
2140
|
+
|
|
2141
|
+
// Queries
|
|
2142
|
+
leaveService.getLeaveForPayroll(params) // Get approved leaves for period
|
|
2143
|
+
leaveService.checkOverlap(params) // Check for conflicts
|
|
2144
|
+
leaveService.calculateUnpaidDeduction(params) // Calculate payroll deduction
|
|
2145
|
+
```
|
|
2146
|
+
|
|
2147
|
+
### LeaveRequest Model (Statics)
|
|
2148
|
+
|
|
2149
|
+
```typescript
|
|
2150
|
+
LeaveRequest.findByEmployee(employeeId, options)
|
|
2151
|
+
LeaveRequest.findPendingByOrganization(orgId?)
|
|
2152
|
+
LeaveRequest.findByPeriod(orgId?, startDate, endDate, options)
|
|
2153
|
+
LeaveRequest.getLeaveStats(employeeId, year)
|
|
2154
|
+
LeaveRequest.getOrganizationSummary(orgId?, year)
|
|
2155
|
+
LeaveRequest.findOverlapping(employeeId, startDate, endDate)
|
|
2156
|
+
LeaveRequest.hasOverlap(employeeId, startDate, endDate)
|
|
2157
|
+
```
|
|
2158
|
+
|
|
2159
|
+
## Tax Withholding
|
|
2160
|
+
|
|
2161
|
+
Track tax liability separately from payroll transactions for government payment reconciliation.
|
|
2162
|
+
|
|
2163
|
+
### Overview
|
|
2164
|
+
|
|
2165
|
+
When processing payroll, taxes are deducted from employee salaries. The **Tax Withholding** feature creates separate records to track government tax liability, making it easy to query pending taxes and record payments to tax authorities.
|
|
2166
|
+
|
|
2167
|
+
**Key Benefits:**
|
|
2168
|
+
- ๐ Query pending taxes by type, period, or employee
|
|
2169
|
+
- ๐ฐ Track tax payments to government with reference numbers
|
|
2170
|
+
- ๐ Generate tax summaries for compliance reporting
|
|
2171
|
+
- ๐ฏ One record per tax type for clean reporting
|
|
2172
|
+
|
|
2173
|
+
### Quick Start (3 Steps)
|
|
2174
|
+
|
|
2175
|
+
```typescript
|
|
2176
|
+
import {
|
|
2177
|
+
getTaxWithholdingModel,
|
|
2178
|
+
createPayrollInstance,
|
|
2179
|
+
createTaxWithholdingService,
|
|
2180
|
+
} from '@classytic/payroll';
|
|
2181
|
+
|
|
2182
|
+
// 1. Setup TaxWithholding model (optional)
|
|
2183
|
+
const TaxWithholding = getTaxWithholdingModel();
|
|
2184
|
+
|
|
2185
|
+
// 2. Initialize Payroll with TaxWithholding support
|
|
2186
|
+
const payroll = createPayrollInstance()
|
|
2187
|
+
.withModels({
|
|
2188
|
+
EmployeeModel: Employee,
|
|
2189
|
+
PayrollRecordModel: PayrollRecord,
|
|
2190
|
+
TransactionModel: Transaction,
|
|
2191
|
+
TaxWithholdingModel: TaxWithholding, // Optional - graceful degradation
|
|
2192
|
+
})
|
|
2193
|
+
.build();
|
|
2194
|
+
|
|
2195
|
+
// 3. Tax withholdings are automatically created during salary processing
|
|
2196
|
+
const result = await payroll.processSalary({
|
|
2197
|
+
employeeId: employee._id,
|
|
2198
|
+
month: 3,
|
|
2199
|
+
year: 2024,
|
|
2200
|
+
});
|
|
2201
|
+
// โ Creates TaxWithholding records for each tax type in breakdown
|
|
2202
|
+
```
|
|
2203
|
+
|
|
2204
|
+
### Transaction Amount Semantic (IMPORTANT)
|
|
2205
|
+
|
|
2206
|
+
**New in v2.2.1**: `Transaction.amount` now represents net salary (actual payment), not gross:
|
|
2207
|
+
|
|
2208
|
+
```typescript
|
|
2209
|
+
const transaction = {
|
|
2210
|
+
grossAmount: 110000, // NEW: Gross salary (before tax)
|
|
2211
|
+
amount: 67091, // CHANGED: Net salary (actual payment)
|
|
2212
|
+
tax: 42909, // Income tax withheld
|
|
2213
|
+
};
|
|
2214
|
+
```
|
|
2215
|
+
|
|
2216
|
+
This aligns with industry standards (Stripe, Square, banking) where `amount` = actual cash movement.
|
|
2217
|
+
|
|
2218
|
+
### Tax Types (7 Built-in)
|
|
2219
|
+
|
|
2220
|
+
| Type | Description | Common Use |
|
|
2221
|
+
|------|-------------|------------|
|
|
2222
|
+
| `income_tax` | Income tax withholding | Federal/state income tax |
|
|
2223
|
+
| `social_security` | Social security contributions | FICA, national insurance |
|
|
2224
|
+
| `health_insurance` | Health insurance premiums | Government health schemes |
|
|
2225
|
+
| `pension` | Pension/retirement contributions | 401k, state pension |
|
|
2226
|
+
| `employment_insurance` | Unemployment insurance | State unemployment tax |
|
|
2227
|
+
| `local_tax` | Local/municipal taxes | City or county taxes |
|
|
2228
|
+
| `other` | Custom tax types | Jurisdiction-specific taxes |
|
|
2229
|
+
|
|
2230
|
+
### Common Use Cases
|
|
2231
|
+
|
|
2232
|
+
#### 1. Process Salary (Auto-Create Tax Withholdings)
|
|
2233
|
+
|
|
2234
|
+
```typescript
|
|
2235
|
+
// Tax withholdings are created automatically
|
|
2236
|
+
const result = await payroll.processSalary({
|
|
2237
|
+
employeeId: employee._id,
|
|
2238
|
+
month: 3,
|
|
2239
|
+
year: 2024,
|
|
2240
|
+
});
|
|
2241
|
+
|
|
2242
|
+
// Access tax info
|
|
2243
|
+
console.log(result.payrollRecord.breakdown.taxAmount); // 42909
|
|
2244
|
+
console.log(result.transaction.tax); // 42909
|
|
2245
|
+
console.log(result.transaction.amount); // 67091 (net)
|
|
2246
|
+
console.log(result.transaction.grossAmount); // 110000 (gross)
|
|
2247
|
+
```
|
|
2248
|
+
|
|
2249
|
+
#### 2. Query Pending Taxes
|
|
2250
|
+
|
|
2251
|
+
```typescript
|
|
2252
|
+
// Get all pending taxes for an organization
|
|
2253
|
+
const pending = await payroll.getPendingTaxWithholdings({
|
|
2254
|
+
organizationId: org._id,
|
|
2255
|
+
});
|
|
2256
|
+
|
|
2257
|
+
// Filter by tax type
|
|
2258
|
+
const pendingIncome = await payroll.getPendingTaxWithholdings({
|
|
2259
|
+
organizationId: org._id,
|
|
2260
|
+
taxType: 'income_tax',
|
|
2261
|
+
});
|
|
2262
|
+
|
|
2263
|
+
// Filter by period
|
|
2264
|
+
const q1Pending = await payroll.getPendingTaxWithholdings({
|
|
2265
|
+
organizationId: org._id,
|
|
2266
|
+
fromPeriod: { month: 1, year: 2024 },
|
|
2267
|
+
toPeriod: { month: 3, year: 2024 },
|
|
2268
|
+
});
|
|
2269
|
+
|
|
2270
|
+
// Filter by employee
|
|
2271
|
+
const employeeTaxes = await payroll.getPendingTaxWithholdings({
|
|
2272
|
+
organizationId: org._id,
|
|
2273
|
+
employeeId: employee._id,
|
|
2274
|
+
});
|
|
2275
|
+
```
|
|
2276
|
+
|
|
2277
|
+
#### 3. Generate Tax Summary
|
|
2278
|
+
|
|
2279
|
+
```typescript
|
|
2280
|
+
// Summary by tax type (default)
|
|
2281
|
+
const summary = await payroll.getTaxSummary({
|
|
2282
|
+
organizationId: org._id,
|
|
2283
|
+
fromPeriod: { month: 1, year: 2024 },
|
|
2284
|
+
toPeriod: { month: 12, year: 2024 },
|
|
2285
|
+
});
|
|
2286
|
+
|
|
2287
|
+
console.log(summary);
|
|
2288
|
+
// {
|
|
2289
|
+
// totalAmount: 514908,
|
|
2290
|
+
// count: 12,
|
|
2291
|
+
// byType: [
|
|
2292
|
+
// { taxType: 'income_tax', totalAmount: 514908, count: 12, withholdingIds: [...] },
|
|
2293
|
+
// { taxType: 'social_security', totalAmount: 82385, count: 12, withholdingIds: [...] },
|
|
2294
|
+
// ],
|
|
2295
|
+
// period: { fromMonth: 1, fromYear: 2024, toMonth: 12, toYear: 2024 }
|
|
2296
|
+
// }
|
|
2297
|
+
```
|
|
2298
|
+
|
|
2299
|
+
#### 4. Mark Taxes as Paid
|
|
2300
|
+
|
|
2301
|
+
```typescript
|
|
2302
|
+
// Mark specific withholdings as paid
|
|
2303
|
+
const result = await payroll.markTaxWithholdingsPaid({
|
|
2304
|
+
organizationId: org._id,
|
|
2305
|
+
withholdingIds: [id1, id2, id3],
|
|
2306
|
+
referenceNumber: 'GOV-2024-Q1-12345', // Government payment reference
|
|
2307
|
+
paidAt: new Date(),
|
|
2308
|
+
notes: 'Q1 2024 income tax payment',
|
|
2309
|
+
|
|
2310
|
+
// Optional: Create transaction for government payment
|
|
2311
|
+
createTransaction: true,
|
|
2312
|
+
});
|
|
2313
|
+
|
|
2314
|
+
console.log(result);
|
|
2315
|
+
// {
|
|
2316
|
+
// withholdings: [/* updated withholding docs */],
|
|
2317
|
+
// transaction: {
|
|
2318
|
+
// type: 'tax_payment',
|
|
2319
|
+
// flow: 'outflow',
|
|
2320
|
+
// amount: 514908,
|
|
2321
|
+
// description: 'Tax payment to government - GOV-2024-Q1-12345',
|
|
2322
|
+
// metadata: { withholdingIds: [...], referenceNumber: '...' }
|
|
2323
|
+
// }
|
|
2324
|
+
// }
|
|
2325
|
+
```
|
|
2326
|
+
|
|
2327
|
+
### Service Usage (Advanced)
|
|
2328
|
+
|
|
2329
|
+
For more control, use `TaxWithholdingService` directly:
|
|
2330
|
+
|
|
2331
|
+
```typescript
|
|
2332
|
+
import { createTaxWithholdingService } from '@classytic/payroll';
|
|
2333
|
+
|
|
2334
|
+
const taxService = createTaxWithholdingService({
|
|
2335
|
+
TaxWithholdingModel: TaxWithholding,
|
|
2336
|
+
TransactionModel: Transaction,
|
|
2337
|
+
events: eventBus, // Optional event emitter
|
|
2338
|
+
});
|
|
2339
|
+
|
|
2340
|
+
// Get pending taxes
|
|
2341
|
+
const pending = await taxService.getPending({
|
|
2342
|
+
organizationId: org._id,
|
|
2343
|
+
taxType: 'income_tax',
|
|
2344
|
+
});
|
|
2345
|
+
|
|
2346
|
+
// Generate summary
|
|
2347
|
+
const summary = await taxService.getSummary({
|
|
2348
|
+
organizationId: org._id,
|
|
2349
|
+
fromPeriod: { month: 1, year: 2024 },
|
|
2350
|
+
toPeriod: { month: 3, year: 2024 },
|
|
2351
|
+
});
|
|
2352
|
+
|
|
2353
|
+
// Mark as paid
|
|
2354
|
+
await taxService.markPaid({
|
|
2355
|
+
organizationId: org._id,
|
|
2356
|
+
withholdingIds: pending.map(p => p._id),
|
|
2357
|
+
referenceNumber: 'GOV-REF-123',
|
|
2358
|
+
createTransaction: true,
|
|
2359
|
+
});
|
|
2360
|
+
```
|
|
2361
|
+
|
|
2362
|
+
### Schema & Model
|
|
2363
|
+
|
|
2364
|
+
#### TaxWithholding Document Structure
|
|
2365
|
+
|
|
2366
|
+
```typescript
|
|
2367
|
+
interface TaxWithholdingDocument {
|
|
2368
|
+
_id: ObjectId;
|
|
2369
|
+
organizationId: ObjectId;
|
|
2370
|
+
employeeId: ObjectId;
|
|
2371
|
+
userId?: ObjectId;
|
|
2372
|
+
payrollRecordId: ObjectId;
|
|
2373
|
+
transactionId: ObjectId;
|
|
2374
|
+
|
|
2375
|
+
period: {
|
|
2376
|
+
month: number;
|
|
2377
|
+
year: number;
|
|
2378
|
+
startDate: Date;
|
|
2379
|
+
endDate: Date;
|
|
2380
|
+
payDate: Date;
|
|
2381
|
+
};
|
|
2382
|
+
|
|
2383
|
+
amount: number;
|
|
2384
|
+
currency: string;
|
|
2385
|
+
|
|
2386
|
+
taxType: TaxType;
|
|
2387
|
+
taxRate: number;
|
|
2388
|
+
taxableAmount: number;
|
|
2389
|
+
|
|
2390
|
+
status: 'pending' | 'submitted' | 'paid';
|
|
2391
|
+
|
|
2392
|
+
submittedAt?: Date;
|
|
2393
|
+
paidAt?: Date;
|
|
2394
|
+
governmentTransactionId?: ObjectId;
|
|
2395
|
+
referenceNumber?: string;
|
|
2396
|
+
|
|
2397
|
+
notes?: string;
|
|
2398
|
+
metadata?: Record<string, unknown>;
|
|
2399
|
+
|
|
2400
|
+
createdAt: Date;
|
|
2401
|
+
updatedAt: Date;
|
|
2402
|
+
}
|
|
2403
|
+
```
|
|
2404
|
+
|
|
2405
|
+
#### Custom Schema (Optional)
|
|
2406
|
+
|
|
2407
|
+
```typescript
|
|
2408
|
+
import { createTaxWithholdingSchema, taxWithholdingFields } from '@classytic/payroll';
|
|
2409
|
+
|
|
2410
|
+
// Use schema creator
|
|
2411
|
+
const customSchema = createTaxWithholdingSchema({
|
|
2412
|
+
customField: { type: String },
|
|
2413
|
+
});
|
|
2414
|
+
|
|
2415
|
+
// Or spread fields into your schema
|
|
2416
|
+
const schema = new Schema({
|
|
2417
|
+
...taxWithholdingFields,
|
|
2418
|
+
customField: { type: String },
|
|
2419
|
+
});
|
|
2420
|
+
```
|
|
2421
|
+
|
|
2422
|
+
### Events
|
|
2423
|
+
|
|
2424
|
+
Tax withholding emits events for workflow integration:
|
|
2425
|
+
|
|
2426
|
+
```typescript
|
|
2427
|
+
payroll.on('tax:withheld', (payload) => {
|
|
2428
|
+
// Fired when tax withholding is created
|
|
2429
|
+
console.log(payload.withholding);
|
|
2430
|
+
console.log(payload.employee);
|
|
2431
|
+
console.log(payload.period);
|
|
2432
|
+
});
|
|
2433
|
+
|
|
2434
|
+
payroll.on('tax:paid', (payload) => {
|
|
2435
|
+
// Fired when taxes are marked as paid
|
|
2436
|
+
console.log(payload.withholdings);
|
|
2437
|
+
console.log(payload.totalAmount);
|
|
2438
|
+
console.log(payload.referenceNumber);
|
|
2439
|
+
console.log(payload.transaction); // If createTransaction: true
|
|
2440
|
+
});
|
|
2441
|
+
```
|
|
2442
|
+
|
|
2443
|
+
### Optional Feature
|
|
2444
|
+
|
|
2445
|
+
Tax withholding is **optional** - the package works perfectly without it:
|
|
2446
|
+
|
|
2447
|
+
```typescript
|
|
2448
|
+
// Without TaxWithholding model
|
|
2449
|
+
const payroll = createPayrollInstance()
|
|
2450
|
+
.withModels({
|
|
2451
|
+
EmployeeModel: Employee,
|
|
2452
|
+
PayrollRecordModel: PayrollRecord,
|
|
2453
|
+
TransactionModel: Transaction,
|
|
2454
|
+
// No TaxWithholdingModel
|
|
2455
|
+
})
|
|
2456
|
+
.build();
|
|
2457
|
+
|
|
2458
|
+
// Salary processing still works, just no separate tax tracking
|
|
2459
|
+
const result = await payroll.processSalary({
|
|
2460
|
+
employeeId: employee._id,
|
|
2461
|
+
month: 3,
|
|
2462
|
+
year: 2024,
|
|
2463
|
+
});
|
|
2464
|
+
// โ Tax still calculated in breakdown, but no TaxWithholding records created
|
|
2465
|
+
```
|
|
2466
|
+
|
|
2467
|
+
### Pure Functions (No DB)
|
|
2468
|
+
|
|
2469
|
+
```typescript
|
|
2470
|
+
// Salary Calculations
|
|
2471
|
+
import {
|
|
2472
|
+
calculateSalaryBreakdown,
|
|
2473
|
+
calculateTax,
|
|
2474
|
+
countWorkingDays,
|
|
2475
|
+
} from '@classytic/payroll/core';
|
|
2476
|
+
|
|
2477
|
+
// Leave Calculations
|
|
2478
|
+
import {
|
|
2479
|
+
calculateLeaveDays,
|
|
2480
|
+
hasLeaveBalance,
|
|
2481
|
+
getAvailableDays,
|
|
2482
|
+
getLeaveSummary,
|
|
2483
|
+
initializeLeaveBalances,
|
|
2484
|
+
calculateCarryOver,
|
|
2485
|
+
calculateUnpaidLeaveDeduction,
|
|
2486
|
+
} from '@classytic/payroll/utils';
|
|
2487
|
+
|
|
2488
|
+
// Use for previews, testing, or client-side calculations
|
|
2489
|
+
const breakdown = calculateSalaryBreakdown({
|
|
2490
|
+
baseSalary: 100000,
|
|
2491
|
+
currency: 'USD',
|
|
2492
|
+
hireDate: new Date('2024-01-01'),
|
|
2493
|
+
periodStart: new Date('2024-03-01'),
|
|
2494
|
+
periodEnd: new Date('2024-03-31'),
|
|
2495
|
+
allowances: [{ type: 'housing', amount: 20000, taxable: true }],
|
|
2496
|
+
deductions: [{ type: 'insurance', amount: 5000 }],
|
|
2497
|
+
options: { holidays: [new Date('2024-03-26')] },
|
|
2498
|
+
attendance: { expectedDays: 22, actualDays: 20 },
|
|
2499
|
+
});
|
|
2500
|
+
```
|
|
2501
|
+
|
|
2502
|
+
## Package Exports & Architecture
|
|
2503
|
+
|
|
2504
|
+
### Public API
|
|
2505
|
+
|
|
2506
|
+
The package follows a **single entry point** architecture for simplicity and safety:
|
|
2507
|
+
|
|
2508
|
+
#### Main Barrel (`@classytic/payroll`)
|
|
2509
|
+
- **Payroll class** - Primary API with full org isolation
|
|
2510
|
+
- **Types & Interfaces** - TypeScript definitions
|
|
2511
|
+
- **Enums & Constants** - Status, departments, leave types, etc.
|
|
2512
|
+
- **Models** - Schema creators and model getters
|
|
2513
|
+
- **Factories** - Employee, payroll, compensation factories
|
|
2514
|
+
|
|
2515
|
+
#### Specialized Exports
|
|
2516
|
+
|
|
2517
|
+
```typescript
|
|
2518
|
+
// Pure calculators (NEW in v2.3.0) - No database dependencies
|
|
2519
|
+
import {
|
|
2520
|
+
calculateSalaryBreakdown,
|
|
2521
|
+
calculateProRating,
|
|
2522
|
+
calculateAttendanceDeduction,
|
|
2523
|
+
} from '@classytic/payroll/calculators';
|
|
2524
|
+
|
|
2525
|
+
// Schemas - For custom model creation
|
|
2526
|
+
import {
|
|
2527
|
+
createEmployeeSchema,
|
|
2528
|
+
createPayrollRecordSchema,
|
|
2529
|
+
createLeaveRequestSchema,
|
|
2530
|
+
createTaxWithholdingSchema,
|
|
2531
|
+
} from '@classytic/payroll/schemas';
|
|
2532
|
+
|
|
2533
|
+
// Core - Event bus, plugins, configuration
|
|
2534
|
+
import {
|
|
2535
|
+
createEventBus,
|
|
2536
|
+
PluginManager,
|
|
2537
|
+
HRM_CONFIG,
|
|
2538
|
+
} from '@classytic/payroll/core';
|
|
2539
|
+
|
|
2540
|
+
// Utils - Query builders, validation, calculations
|
|
2541
|
+
import {
|
|
2542
|
+
findEmployeeSecure,
|
|
2543
|
+
resolveOrganizationId,
|
|
2544
|
+
calculateGross,
|
|
2545
|
+
calculateNet,
|
|
2546
|
+
employee as employeeQuery,
|
|
2547
|
+
payroll as payrollQuery,
|
|
2548
|
+
} from '@classytic/payroll/utils';
|
|
2549
|
+
|
|
2550
|
+
// Shift Compliance - Late penalties, overtime
|
|
2551
|
+
import {
|
|
2552
|
+
LateComplianceManager,
|
|
2553
|
+
ShiftCompliancePlugin,
|
|
2554
|
+
} from '@classytic/payroll/shift-compliance';
|
|
2555
|
+
|
|
2556
|
+
// Jurisdiction - Tax brackets, holidays
|
|
2557
|
+
import {
|
|
2558
|
+
JurisdictionRegistry,
|
|
2559
|
+
registerTaxBrackets,
|
|
2560
|
+
} from '@classytic/payroll/jurisdiction';
|
|
2561
|
+
```
|
|
2562
|
+
|
|
2563
|
+
### Internal Services (NOT Exported)
|
|
2564
|
+
|
|
2565
|
+
The following services are **internal only** and not accessible from the public API:
|
|
2566
|
+
|
|
2567
|
+
- `EmployeeService`
|
|
2568
|
+
- `PayrollService`
|
|
2569
|
+
- `CompensationService`
|
|
2570
|
+
- `TaxWithholdingService`
|
|
2571
|
+
|
|
2572
|
+
**Why internal?**
|
|
2573
|
+
- All functionality is available through `Payroll` class methods
|
|
2574
|
+
- Enforces consistent security (organizationId isolation)
|
|
2575
|
+
- Prevents accidental misuse
|
|
2576
|
+
- Single clear way to do things
|
|
2577
|
+
|
|
2578
|
+
**If you need direct service access** (advanced use cases), you can import them internally:
|
|
2579
|
+
|
|
2580
|
+
```typescript
|
|
2581
|
+
// โ ๏ธ NOT RECOMMENDED - Services are internal
|
|
2582
|
+
import { EmployeeService } from '@classytic/payroll/services';
|
|
2583
|
+
|
|
2584
|
+
// โ
RECOMMENDED - Use Payroll class instead
|
|
2585
|
+
const payroll = createPayrollInstance()
|
|
2586
|
+
.withModels({ ... })
|
|
2587
|
+
.build();
|
|
2588
|
+
|
|
2589
|
+
await payroll.hire({ ... });
|
|
2590
|
+
await payroll.processSalary({ ... });
|
|
2591
|
+
```
|
|
2592
|
+
|
|
2593
|
+
## Related Packages
|
|
2594
|
+
|
|
2595
|
+
- **[@classytic/clockin](https://npmjs.com/package/@classytic/clockin)** - Attendance management (optional peer dependency for attendance-based deductions)
|
|
2596
|
+
|
|
2597
|
+
## License
|
|
2598
|
+
|
|
2599
|
+
MIT ยฉ [Sadman Chowdhury](https://github.com/classytic)
|