@classytic/payroll 1.0.2 → 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.
Files changed (78) hide show
  1. package/README.md +2599 -574
  2. package/dist/calculators/index.d.ts +433 -0
  3. package/dist/calculators/index.js +283 -0
  4. package/dist/calculators/index.js.map +1 -0
  5. package/dist/core/index.d.ts +314 -0
  6. package/dist/core/index.js +1166 -0
  7. package/dist/core/index.js.map +1 -0
  8. package/dist/employee-identity-DXhgOgXE.d.ts +473 -0
  9. package/dist/employee.factory-BlZqhiCk.d.ts +189 -0
  10. package/dist/idempotency-Cw2CWicb.d.ts +52 -0
  11. package/dist/index.d.ts +902 -0
  12. package/dist/index.js +9108 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/jurisdiction/index.d.ts +660 -0
  15. package/dist/jurisdiction/index.js +533 -0
  16. package/dist/jurisdiction/index.js.map +1 -0
  17. package/dist/payroll.d.ts +429 -0
  18. package/dist/payroll.js +5192 -0
  19. package/dist/payroll.js.map +1 -0
  20. package/dist/schemas/index.d.ts +3262 -0
  21. package/dist/schemas/index.js +780 -0
  22. package/dist/schemas/index.js.map +1 -0
  23. package/dist/services/index.d.ts +582 -0
  24. package/dist/services/index.js +2172 -0
  25. package/dist/services/index.js.map +1 -0
  26. package/dist/shift-compliance/index.d.ts +1171 -0
  27. package/dist/shift-compliance/index.js +1479 -0
  28. package/dist/shift-compliance/index.js.map +1 -0
  29. package/dist/types-BN3K_Uhr.d.ts +1842 -0
  30. package/dist/utils/index.d.ts +893 -0
  31. package/dist/utils/index.js +1515 -0
  32. package/dist/utils/index.js.map +1 -0
  33. package/package.json +72 -37
  34. package/dist/types/config.d.ts +0 -162
  35. package/dist/types/core/compensation.manager.d.ts +0 -54
  36. package/dist/types/core/employment.manager.d.ts +0 -49
  37. package/dist/types/core/payroll.manager.d.ts +0 -60
  38. package/dist/types/enums.d.ts +0 -117
  39. package/dist/types/factories/compensation.factory.d.ts +0 -196
  40. package/dist/types/factories/employee.factory.d.ts +0 -149
  41. package/dist/types/factories/payroll.factory.d.ts +0 -319
  42. package/dist/types/hrm.orchestrator.d.ts +0 -47
  43. package/dist/types/index.d.ts +0 -20
  44. package/dist/types/init.d.ts +0 -30
  45. package/dist/types/models/payroll-record.model.d.ts +0 -3
  46. package/dist/types/plugins/employee.plugin.d.ts +0 -2
  47. package/dist/types/schemas/employment.schema.d.ts +0 -959
  48. package/dist/types/services/compensation.service.d.ts +0 -94
  49. package/dist/types/services/employee.service.d.ts +0 -28
  50. package/dist/types/services/payroll.service.d.ts +0 -30
  51. package/dist/types/utils/calculation.utils.d.ts +0 -26
  52. package/dist/types/utils/date.utils.d.ts +0 -35
  53. package/dist/types/utils/logger.d.ts +0 -12
  54. package/dist/types/utils/query-builders.d.ts +0 -83
  55. package/dist/types/utils/validation.utils.d.ts +0 -33
  56. package/payroll.d.ts +0 -241
  57. package/src/config.js +0 -177
  58. package/src/core/compensation.manager.js +0 -242
  59. package/src/core/employment.manager.js +0 -224
  60. package/src/core/payroll.manager.js +0 -499
  61. package/src/enums.js +0 -141
  62. package/src/factories/compensation.factory.js +0 -198
  63. package/src/factories/employee.factory.js +0 -173
  64. package/src/factories/payroll.factory.js +0 -413
  65. package/src/hrm.orchestrator.js +0 -139
  66. package/src/index.js +0 -172
  67. package/src/init.js +0 -62
  68. package/src/models/payroll-record.model.js +0 -126
  69. package/src/plugins/employee.plugin.js +0 -164
  70. package/src/schemas/employment.schema.js +0 -126
  71. package/src/services/compensation.service.js +0 -231
  72. package/src/services/employee.service.js +0 -162
  73. package/src/services/payroll.service.js +0 -213
  74. package/src/utils/calculation.utils.js +0 -91
  75. package/src/utils/date.utils.js +0 -120
  76. package/src/utils/logger.js +0 -36
  77. package/src/utils/query-builders.js +0 -185
  78. package/src/utils/validation.utils.js +0 -122
package/README.md CHANGED
@@ -1,574 +1,2599 @@
1
- # 🎯 HRM Library - Human Resource Management
2
-
3
- Modern, flexible, production-ready HRM system following Stripe/Passport.js architecture patterns.
4
-
5
- ## 🌟 Key Features
6
-
7
- ### Multi-Tenant Support
8
- - Same user can be employee in multiple organizations
9
- - Complete data isolation per tenant
10
- - Re-hiring support with full employment history
11
-
12
- ### Smart Payroll
13
- - **Pro-rated calculations** (mid-month hires)
14
- - **Attendance integration** (unpaid leave auto-deduction)
15
- - **Automatic deductions** (loans, advances, tax)
16
- - **Bulk payroll processing**
17
- - **Transaction integration** (seamless with existing system)
18
-
19
- ### Data Retention
20
- - **Auto-deletion**: PayrollRecords expire after 2 years (MongoDB TTL)
21
- - **Export before deletion**: Required export for compliance
22
- - **Configurable retention**: Adjust via `HRM_CONFIG`
23
-
24
- ### Flexible Architecture
25
- - **Reusable schemas**: Merge with your custom fields
26
- - **Plugin system**: Adds methods, virtuals, indexes
27
- - **Clean DSL**: `hrm.hire()`, `hrm.processSalary()`, `hrm.terminate()`
28
- - **Dependency injection**: Models injected at bootstrap
29
-
30
- ## 📁 Structure
31
-
32
- ```
33
- lib/hrm/
34
- ├── index.js # Public exports
35
- ├── init.js # Bootstrap initialization
36
- ├── hrm.orchestrator.js # Clean API (Stripe-like)
37
- ├── enums.js # Single source of truth
38
- ├── config.js # Configurable settings
39
-
40
- ├── models/
41
- │ └── payroll-record.model.js # Universal payroll ledger
42
-
43
- ├── schemas/
44
- │ └── employment.schema.js # Reusable mongoose schemas
45
-
46
- ├── plugins/
47
- │ └── employee.plugin.js # Mongoose plugin (methods/virtuals)
48
-
49
- ├── core/ # Domain business logic
50
- │ ├── employment.manager.js # Hire/terminate operations
51
- │ ├── compensation.manager.js # Salary/allowance operations
52
- │ └── payroll.manager.js # Payroll processing
53
-
54
- ├── factories/ # Clean object creation
55
- │ ├── employee.factory.js # Employee creation with defaults
56
- │ ├── payroll.factory.js # Payroll generation
57
- │ └── compensation.factory.js # Compensation breakdown
58
-
59
- ├── services/ # High-level operations
60
- │ ├── employee.service.js # Employee CRUD + queries
61
- │ ├── payroll.service.js # Batch payroll processing
62
- │ └── compensation.service.js # Compensation calculations
63
-
64
- └── utils/ # Pure, reusable functions
65
- ├── date.utils.js # Date calculations
66
- ├── calculation.utils.js # Salary calculations
67
- ├── validation.utils.js # Validators
68
- └── query-builders.js # Fluent query API
69
- ```
70
-
71
- ## 🚀 Quick Start
72
-
73
- ### 1. Create Your Employee Model
74
-
75
- ```javascript
76
- // modules/employee/employee.model.js
77
- import mongoose from 'mongoose';
78
- import { employmentFields, employeePlugin } from '#lib/hrm/index.js';
79
-
80
- const employeeSchema = new mongoose.Schema({
81
- // Core HRM fields (required)
82
- ...employmentFields,
83
-
84
- // Your custom fields
85
- certifications: [{ name: String, issuedDate: Date }],
86
- specializations: [String],
87
- emergencyContact: { name: String, phone: String },
88
- // ... any other fields you need
89
- });
90
-
91
- // Apply HRM plugin (adds methods, virtuals, indexes)
92
- employeeSchema.plugin(employeePlugin);
93
-
94
- export default mongoose.model('Employee', employeeSchema);
95
- ```
96
-
97
- ### 2. Bootstrap Integration
98
-
99
- ```javascript
100
- // bootstrap/hrm.js
101
- import { initializeHRM } from '#lib/hrm/index.js';
102
- import Employee from '../modules/employee/employee.model.js';
103
- import PayrollRecord from '#lib/hrm/models/payroll-record.model.js';
104
- import Transaction from '../modules/transaction/transaction.model.js';
105
- import Attendance from '#lib/attendance/models/attendance.model.js';
106
-
107
- export async function loadHRM() {
108
- initializeHRM({
109
- EmployeeModel: Employee,
110
- PayrollRecordModel: PayrollRecord,
111
- TransactionModel: Transaction,
112
- AttendanceModel: Attendance, // Optional
113
- });
114
- }
115
- ```
116
-
117
- ### 3. Use the HRM API
118
-
119
- ```javascript
120
- import { hrm } from '#lib/hrm/index.js';
121
-
122
- // Hire employee
123
- const employee = await hrm.hire({
124
- organizationId,
125
- userId,
126
- employment: {
127
- employeeId: 'EMP-001',
128
- type: 'full_time',
129
- department: 'training',
130
- position: 'Senior Trainer',
131
- hireDate: new Date(),
132
- },
133
- compensation: {
134
- baseAmount: 50000,
135
- frequency: 'monthly',
136
- allowances: [
137
- { type: 'housing', amount: 10000 },
138
- { type: 'transport', amount: 5000 }
139
- ]
140
- },
141
- bankDetails: {
142
- accountName: 'John Doe',
143
- accountNumber: '1234567890',
144
- bankName: 'Example Bank'
145
- },
146
- context: { userId: hrManagerId }
147
- });
148
-
149
- // Process salary (creates Transaction automatically)
150
- const result = await hrm.processSalary({
151
- employeeId: employee._id,
152
- month: 11,
153
- year: 2025,
154
- paymentDate: new Date(),
155
- paymentMethod: 'bank',
156
- context: { userId: hrManagerId }
157
- });
158
-
159
- // Bulk payroll (all active employees)
160
- const results = await hrm.processBulkPayroll({
161
- organizationId,
162
- month: 11,
163
- year: 2025,
164
- context: { userId: hrManagerId }
165
- });
166
- ```
167
-
168
-
169
- ## 🎨 Complete API Reference
170
-
171
- ### Employment Lifecycle
172
-
173
- ```javascript
174
- // Hire
175
- await hrm.hire({ organizationId, userId, employment, compensation, bankDetails, context });
176
-
177
- // Update employment details
178
- await hrm.updateEmployment({ employeeId, updates: { department: 'management' }, context });
179
-
180
- // Terminate
181
- await hrm.terminate({ employeeId, terminationDate, reason: 'resignation', notes, context });
182
-
183
- // Re-hire (same employee, new stint)
184
- await hrm.reHire({ employeeId, hireDate, position, compensation, context });
185
-
186
- // List employees
187
- await hrm.listEmployees({
188
- organizationId,
189
- filters: { status: 'active', department: 'training', minSalary: 40000 },
190
- pagination: { page: 1, limit: 20 }
191
- });
192
-
193
- // Get single employee
194
- await hrm.getEmployee({ employeeId, populateUser: true });
195
- ```
196
-
197
- ### Compensation Management
198
-
199
- ```javascript
200
- // Update salary
201
- await hrm.updateSalary({
202
- employeeId,
203
- compensation: { baseAmount: 60000 },
204
- effectiveFrom: new Date(),
205
- context
206
- });
207
-
208
- // Add allowance
209
- await hrm.addAllowance({
210
- employeeId,
211
- type: 'meal',
212
- amount: 3000,
213
- taxable: true,
214
- recurring: true,
215
- context
216
- });
217
-
218
- // Remove allowance
219
- await hrm.removeAllowance({ employeeId, type: 'meal', context });
220
-
221
- // Add deduction
222
- await hrm.addDeduction({
223
- employeeId,
224
- type: 'loan',
225
- amount: 5000,
226
- auto: true, // Auto-deduct from salary
227
- description: 'Personal loan repayment',
228
- context
229
- });
230
-
231
- // Remove deduction
232
- await hrm.removeDeduction({ employeeId, type: 'loan', context });
233
-
234
- // Update bank details
235
- await hrm.updateBankDetails({
236
- employeeId,
237
- bankDetails: { accountNumber: '9876543210', bankName: 'New Bank' },
238
- context
239
- });
240
- ```
241
-
242
- ### Payroll Processing
243
-
244
- ```javascript
245
- // Process single salary
246
- await hrm.processSalary({
247
- employeeId,
248
- month: 11,
249
- year: 2025,
250
- paymentDate: new Date(),
251
- paymentMethod: 'bank',
252
- context
253
- });
254
-
255
- // Bulk payroll
256
- await hrm.processBulkPayroll({
257
- organizationId,
258
- month: 11,
259
- year: 2025,
260
- employeeIds: [], // Empty = all active employees
261
- paymentDate: new Date(),
262
- paymentMethod: 'bank',
263
- context
264
- });
265
-
266
- // Payroll history
267
- await hrm.payrollHistory({
268
- employeeId,
269
- organizationId,
270
- month: 11,
271
- year: 2025,
272
- status: 'paid',
273
- pagination: { page: 1, limit: 20 }
274
- });
275
-
276
- // Payroll summary
277
- await hrm.payrollSummary({
278
- organizationId,
279
- month: 11,
280
- year: 2025
281
- });
282
-
283
- // Export payroll data (before auto-deletion)
284
- const records = await hrm.exportPayroll({
285
- organizationId,
286
- startDate: new Date('2023-01-01'),
287
- endDate: new Date('2023-12-31'),
288
- format: 'json'
289
- });
290
- ```
291
-
292
- ## 📊 Data Models
293
-
294
- ### Employee (Your Model + HRM Fields)
295
-
296
- ```javascript
297
- {
298
- // Identity & tenant
299
- userId: ObjectId, // Links to User
300
- organizationId: ObjectId, // Multi-tenant isolation
301
- employeeId: "EMP-001", // Custom ID (unique per org)
302
-
303
- // Employment
304
- employmentType: "full_time", // full_time, part_time, contract, intern
305
- status: "active", // active, on_leave, suspended, terminated
306
- department: "training",
307
- position: "Senior Trainer",
308
-
309
- // Dates
310
- hireDate: Date,
311
- terminationDate: Date,
312
- probationEndDate: Date,
313
-
314
- // Employment history (re-hiring support)
315
- employmentHistory: [{
316
- hireDate: Date,
317
- terminationDate: Date,
318
- reason: String,
319
- finalSalary: Number
320
- }],
321
-
322
- // Compensation
323
- compensation: {
324
- baseAmount: 50000,
325
- frequency: "monthly",
326
- currency: "BDT",
327
-
328
- allowances: [
329
- { type: "housing", amount: 10000, taxable: true },
330
- { type: "transport", amount: 5000, taxable: false }
331
- ],
332
-
333
- deductions: [
334
- { type: "loan", amount: 2000, auto: true }
335
- ],
336
-
337
- grossSalary: 65000, // Auto-calculated
338
- netSalary: 63000, // Auto-calculated
339
- },
340
-
341
- // Bank details
342
- bankDetails: {
343
- accountName: String,
344
- accountNumber: String,
345
- bankName: String
346
- },
347
-
348
- // Payroll stats (pre-calculated)
349
- payrollStats: {
350
- totalPaid: 500000,
351
- lastPaymentDate: Date,
352
- nextPaymentDate: Date,
353
- paymentsThisYear: 10,
354
- averageMonthly: 50000
355
- },
356
-
357
- // YOUR CUSTOM FIELDS
358
- certifications: [...],
359
- specializations: [...],
360
- emergencyContact: {...}
361
- }
362
- ```
363
-
364
- ### PayrollRecord (Universal Ledger)
365
-
366
- ```javascript
367
- {
368
- organizationId: ObjectId,
369
- employeeId: ObjectId,
370
- userId: ObjectId,
371
-
372
- period: {
373
- month: 11,
374
- year: 2025,
375
- startDate: Date,
376
- endDate: Date,
377
- payDate: Date
378
- },
379
-
380
- breakdown: {
381
- baseAmount: 50000,
382
- allowances: [...],
383
- deductions: [...],
384
- grossSalary: 65000,
385
- netSalary: 63000,
386
-
387
- // Smart calculations
388
- workingDays: 30,
389
- actualDays: 25, // If joined mid-month
390
- proRatedAmount: 41667, // Pro-rated salary
391
- attendanceDeduction: 0, // From attendance integration
392
- overtimeAmount: 0,
393
- bonusAmount: 0
394
- },
395
-
396
- transactionId: ObjectId, // Links to Transaction
397
- status: "paid",
398
- paidAt: Date,
399
-
400
- // Export tracking
401
- exported: false, // Must export before TTL deletion
402
- exportedAt: Date
403
- }
404
- ```
405
-
406
- ## ⚙️ Configuration
407
-
408
- ```javascript
409
- // lib/hrm/config.js
410
- export const HRM_CONFIG = {
411
- dataRetention: {
412
- payrollRecordsTTL: 63072000, // 2 years in seconds
413
- exportWarningDays: 30, // Warn before deletion
414
- archiveBeforeDeletion: true,
415
- },
416
-
417
- payroll: {
418
- defaultCurrency: 'BDT',
419
- allowProRating: true, // Mid-month hire calculations
420
- attendanceIntegration: true, // Unpaid leave deductions
421
- autoDeductions: true, // Auto-deduct loans/advances
422
- },
423
-
424
- employment: {
425
- defaultProbationMonths: 3,
426
- allowReHiring: true, // Re-hire terminated employees
427
- trackEmploymentHistory: true,
428
- },
429
-
430
- validation: {
431
- requireBankDetails: false,
432
- allowMultiTenantEmployees: true, // Same user in multiple orgs
433
- },
434
- };
435
- ```
436
-
437
- ## 🔑 Key Concepts
438
-
439
- ### Multi-Tenant Architecture
440
-
441
- Same user can work at multiple gyms:
442
- ```javascript
443
- // User "john@example.com" (userId: 123)
444
- // Works at Gym A
445
- { userId: 123, organizationId: "gymA", employeeId: "EMP-001", status: "active" }
446
-
447
- // Also works at Gym B
448
- { userId: 123, organizationId: "gymB", employeeId: "STAFF-05", status: "active" }
449
- ```
450
-
451
- Indexes ensure uniqueness:
452
- - `{ userId: 1, organizationId: 1 }` unique
453
- - `{ organizationId: 1, employeeId: 1 }` unique
454
-
455
- ### Re-Hiring Flow
456
-
457
- ```javascript
458
- // Employee leaves
459
- await hrm.terminate({
460
- employeeId,
461
- reason: 'resignation',
462
- terminationDate: new Date()
463
- });
464
- // status: 'terminated', data preserved
465
-
466
- // Employee comes back
467
- await hrm.reHire({
468
- employeeId,
469
- hireDate: new Date(),
470
- position: 'Manager', // Optional: new position
471
- compensation: { baseAmount: 60000 } // Optional: new salary
472
- });
473
- // status: 'active', previous stint added to employmentHistory[]
474
- ```
475
-
476
- ### Smart Payroll Calculations
477
-
478
- **Pro-Rating (Mid-Month Hire)**:
479
- ```javascript
480
- // Employee hired on Nov 15
481
- // Working days: 15 out of 30
482
- // Base salary: 60,000
483
- // Pro-rated: 60,000 × (15/30) = 30,000
484
- ```
485
-
486
- **Attendance Integration**:
487
- ```javascript
488
- // Monthly salary: 60,000
489
- // Working days: 30
490
- // Attended days: 25
491
- // Absent days: 5
492
- // Daily rate: 60,000 / 30 = 2,000
493
- // Deduction: 5 × 2,000 = 10,000
494
- // Final: 60,000 - 10,000 = 50,000
495
- ```
496
-
497
- **Auto Deductions**:
498
- ```javascript
499
- compensation: {
500
- baseAmount: 60000,
501
- allowances: [{ type: 'housing', amount: 10000 }],
502
- deductions: [
503
- { type: 'loan', amount: 5000, auto: true }, // Auto-deduct
504
- { type: 'tax', amount: 3000, auto: true }
505
- ],
506
- grossSalary: 70000,
507
- netSalary: 62000 // 70000 - 5000 - 3000
508
- }
509
- ```
510
-
511
- ### Transaction Integration
512
-
513
- Every salary payment creates a Transaction:
514
- ```javascript
515
- {
516
- organizationId,
517
- type: 'expense',
518
- category: 'salary',
519
- amount: 63000,
520
- method: 'bank',
521
- status: 'completed',
522
- referenceId: employeeId,
523
- referenceModel: 'Employee',
524
- metadata: {
525
- employeeId: 'EMP-001',
526
- payrollRecordId: ObjectId(...),
527
- period: { month: 11, year: 2025 },
528
- breakdown: { ... }
529
- }
530
- }
531
- ```
532
-
533
- ### Data Retention & Export
534
-
535
- PayrollRecords auto-delete after 2 years:
536
- ```javascript
537
- // TTL index on PayrollRecord
538
- payrollRecordSchema.index(
539
- { createdAt: 1 },
540
- {
541
- expireAfterSeconds: 63072000, // 2 years
542
- partialFilterExpression: { exported: true } // Only if exported
543
- }
544
- );
545
-
546
- // Export before deletion
547
- const records = await hrm.exportPayroll({
548
- organizationId,
549
- startDate: new Date('2023-01-01'),
550
- endDate: new Date('2023-12-31')
551
- });
552
- // Marks records as exported, making them eligible for deletion
553
- ```
554
-
555
- ## 🎯 Design Philosophy
556
-
557
- - **Stripe/Passport.js inspired**: Clean DSL, dependency injection, reusable components
558
- - **Lightweight**: Not a complex ERP, gym-focused features only
559
- - **Multi-tenant**: Same user can work at multiple organizations
560
- - **Smart defaults**: Pro-rating, attendance integration, automatic calculations
561
- - **Production-ready**: Transaction integration, data retention, comprehensive error handling
562
-
563
- ## Next Steps
564
-
565
- 1. Test bootstrap initialization
566
- 2. Create Fastify routes in `modules/employee/`
567
- 3. Add API handlers
568
- 4. Migrate existing staff from organization module
569
- 5. Deploy and monitor
570
-
571
- ---
572
-
573
- **Built with ❤️ following world-class architecture patterns**
574
- **Ready for multi-tenant gym management**
1
+ # @classytic/payroll
2
+
3
+ Enterprise-grade payroll for Mongoose. Simple, powerful, production-ready.
4
+
5
+ [![npm version](https://badge.fury.io/js/@classytic%2Fpayroll.svg)](https://www.npmjs.com/package/@classytic/payroll)
6
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue.svg)](https://www.typescriptlang.org/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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)