@classytic/payroll 1.0.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +168 -489
- package/dist/core/index.d.ts +480 -0
- package/dist/core/index.js +971 -0
- package/dist/core/index.js.map +1 -0
- package/dist/index-CTjHlCzz.d.ts +721 -0
- package/dist/index.d.ts +967 -0
- package/dist/index.js +4352 -0
- package/dist/index.js.map +1 -0
- package/dist/payroll.d.ts +233 -0
- package/dist/payroll.js +2103 -0
- package/dist/payroll.js.map +1 -0
- package/dist/plugin-D9mOr3_d.d.ts +333 -0
- package/dist/schemas/index.d.ts +2869 -0
- package/dist/schemas/index.js +440 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/services/index.d.ts +3 -0
- package/dist/services/index.js +1696 -0
- package/dist/services/index.js.map +1 -0
- package/dist/types-BSYyX2KJ.d.ts +671 -0
- package/dist/utils/index.d.ts +873 -0
- package/dist/utils/index.js +1046 -0
- package/dist/utils/index.js.map +1 -0
- package/package.json +54 -37
- package/dist/types/config.d.ts +0 -162
- package/dist/types/core/compensation.manager.d.ts +0 -54
- package/dist/types/core/employment.manager.d.ts +0 -49
- package/dist/types/core/payroll.manager.d.ts +0 -60
- package/dist/types/enums.d.ts +0 -117
- package/dist/types/factories/compensation.factory.d.ts +0 -196
- package/dist/types/factories/employee.factory.d.ts +0 -149
- package/dist/types/factories/payroll.factory.d.ts +0 -319
- package/dist/types/hrm.orchestrator.d.ts +0 -47
- package/dist/types/index.d.ts +0 -20
- package/dist/types/init.d.ts +0 -30
- package/dist/types/models/payroll-record.model.d.ts +0 -3
- package/dist/types/plugins/employee.plugin.d.ts +0 -2
- package/dist/types/schemas/employment.schema.d.ts +0 -959
- package/dist/types/services/compensation.service.d.ts +0 -94
- package/dist/types/services/employee.service.d.ts +0 -28
- package/dist/types/services/payroll.service.d.ts +0 -30
- package/dist/types/utils/calculation.utils.d.ts +0 -26
- package/dist/types/utils/date.utils.d.ts +0 -35
- package/dist/types/utils/logger.d.ts +0 -12
- package/dist/types/utils/query-builders.d.ts +0 -83
- package/dist/types/utils/validation.utils.d.ts +0 -33
- package/payroll.d.ts +0 -241
- package/src/config.js +0 -177
- package/src/core/compensation.manager.js +0 -242
- package/src/core/employment.manager.js +0 -224
- package/src/core/payroll.manager.js +0 -499
- package/src/enums.js +0 -141
- package/src/factories/compensation.factory.js +0 -198
- package/src/factories/employee.factory.js +0 -173
- package/src/factories/payroll.factory.js +0 -413
- package/src/hrm.orchestrator.js +0 -139
- package/src/index.js +0 -172
- package/src/init.js +0 -62
- package/src/models/payroll-record.model.js +0 -126
- package/src/plugins/employee.plugin.js +0 -164
- package/src/schemas/employment.schema.js +0 -126
- package/src/services/compensation.service.js +0 -231
- package/src/services/employee.service.js +0 -162
- package/src/services/payroll.service.js +0 -213
- package/src/utils/calculation.utils.js +0 -91
- package/src/utils/date.utils.js +0 -120
- package/src/utils/logger.js +0 -36
- package/src/utils/query-builders.js +0 -185
- package/src/utils/validation.utils.js +0 -122
package/dist/payroll.js
ADDED
|
@@ -0,0 +1,2103 @@
|
|
|
1
|
+
import mongoose2, { Types } from 'mongoose';
|
|
2
|
+
|
|
3
|
+
// src/payroll.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/logger.ts
|
|
6
|
+
var createConsoleLogger = () => ({
|
|
7
|
+
info: (message, meta) => {
|
|
8
|
+
if (meta) {
|
|
9
|
+
console.log(`[Payroll] INFO: ${message}`, meta);
|
|
10
|
+
} else {
|
|
11
|
+
console.log(`[Payroll] INFO: ${message}`);
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
error: (message, meta) => {
|
|
15
|
+
if (meta) {
|
|
16
|
+
console.error(`[Payroll] ERROR: ${message}`, meta);
|
|
17
|
+
} else {
|
|
18
|
+
console.error(`[Payroll] ERROR: ${message}`);
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
warn: (message, meta) => {
|
|
22
|
+
if (meta) {
|
|
23
|
+
console.warn(`[Payroll] WARN: ${message}`, meta);
|
|
24
|
+
} else {
|
|
25
|
+
console.warn(`[Payroll] WARN: ${message}`);
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
debug: (message, meta) => {
|
|
29
|
+
if (process.env.NODE_ENV !== "production") {
|
|
30
|
+
if (meta) {
|
|
31
|
+
console.log(`[Payroll] DEBUG: ${message}`, meta);
|
|
32
|
+
} else {
|
|
33
|
+
console.log(`[Payroll] DEBUG: ${message}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
var currentLogger = createConsoleLogger();
|
|
39
|
+
function getLogger() {
|
|
40
|
+
return currentLogger;
|
|
41
|
+
}
|
|
42
|
+
function setLogger(logger) {
|
|
43
|
+
currentLogger = logger;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/config.ts
|
|
47
|
+
var HRM_CONFIG = {
|
|
48
|
+
dataRetention: {
|
|
49
|
+
payrollRecordsTTL: 63072e3,
|
|
50
|
+
// 2 years in seconds
|
|
51
|
+
exportWarningDays: 30,
|
|
52
|
+
archiveBeforeDeletion: true
|
|
53
|
+
},
|
|
54
|
+
payroll: {
|
|
55
|
+
defaultCurrency: "BDT",
|
|
56
|
+
allowProRating: true,
|
|
57
|
+
attendanceIntegration: true,
|
|
58
|
+
autoDeductions: true,
|
|
59
|
+
overtimeEnabled: false,
|
|
60
|
+
overtimeMultiplier: 1.5
|
|
61
|
+
},
|
|
62
|
+
salary: {
|
|
63
|
+
minimumWage: 0,
|
|
64
|
+
maximumAllowances: 10,
|
|
65
|
+
maximumDeductions: 10,
|
|
66
|
+
defaultFrequency: "monthly"
|
|
67
|
+
},
|
|
68
|
+
employment: {
|
|
69
|
+
defaultProbationMonths: 3,
|
|
70
|
+
maxProbationMonths: 6,
|
|
71
|
+
allowReHiring: true,
|
|
72
|
+
trackEmploymentHistory: true
|
|
73
|
+
},
|
|
74
|
+
validation: {
|
|
75
|
+
requireBankDetails: false,
|
|
76
|
+
requireEmployeeId: true,
|
|
77
|
+
uniqueEmployeeIdPerOrg: true,
|
|
78
|
+
allowMultiTenantEmployees: true
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
var TAX_BRACKETS = {
|
|
82
|
+
BDT: [
|
|
83
|
+
{ min: 0, max: 3e5, rate: 0 },
|
|
84
|
+
{ min: 3e5, max: 4e5, rate: 0.05 },
|
|
85
|
+
{ min: 4e5, max: 5e5, rate: 0.1 },
|
|
86
|
+
{ min: 5e5, max: 6e5, rate: 0.15 },
|
|
87
|
+
{ min: 6e5, max: 3e6, rate: 0.2 },
|
|
88
|
+
{ min: 3e6, max: Infinity, rate: 0.25 }
|
|
89
|
+
],
|
|
90
|
+
USD: [
|
|
91
|
+
{ min: 0, max: 1e4, rate: 0.1 },
|
|
92
|
+
{ min: 1e4, max: 4e4, rate: 0.12 },
|
|
93
|
+
{ min: 4e4, max: 85e3, rate: 0.22 },
|
|
94
|
+
{ min: 85e3, max: 165e3, rate: 0.24 },
|
|
95
|
+
{ min: 165e3, max: 215e3, rate: 0.32 },
|
|
96
|
+
{ min: 215e3, max: 54e4, rate: 0.35 },
|
|
97
|
+
{ min: 54e4, max: Infinity, rate: 0.37 }
|
|
98
|
+
]
|
|
99
|
+
};
|
|
100
|
+
var ORG_ROLES = {
|
|
101
|
+
OWNER: {
|
|
102
|
+
key: "owner",
|
|
103
|
+
label: "Owner",
|
|
104
|
+
description: "Full organization access (set by Organization model)"
|
|
105
|
+
},
|
|
106
|
+
MANAGER: {
|
|
107
|
+
key: "manager",
|
|
108
|
+
label: "Manager",
|
|
109
|
+
description: "Management and administrative features"
|
|
110
|
+
},
|
|
111
|
+
TRAINER: {
|
|
112
|
+
key: "trainer",
|
|
113
|
+
label: "Trainer",
|
|
114
|
+
description: "Training and coaching features"
|
|
115
|
+
},
|
|
116
|
+
STAFF: {
|
|
117
|
+
key: "staff",
|
|
118
|
+
label: "Staff",
|
|
119
|
+
description: "General staff access to basic features"
|
|
120
|
+
},
|
|
121
|
+
INTERN: {
|
|
122
|
+
key: "intern",
|
|
123
|
+
label: "Intern",
|
|
124
|
+
description: "Limited access for interns"
|
|
125
|
+
},
|
|
126
|
+
CONSULTANT: {
|
|
127
|
+
key: "consultant",
|
|
128
|
+
label: "Consultant",
|
|
129
|
+
description: "Project-based consultant access"
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
Object.values(ORG_ROLES).map((role) => role.key);
|
|
133
|
+
function mergeConfig(customConfig) {
|
|
134
|
+
if (!customConfig) return HRM_CONFIG;
|
|
135
|
+
return {
|
|
136
|
+
dataRetention: { ...HRM_CONFIG.dataRetention, ...customConfig.dataRetention },
|
|
137
|
+
payroll: { ...HRM_CONFIG.payroll, ...customConfig.payroll },
|
|
138
|
+
salary: { ...HRM_CONFIG.salary, ...customConfig.salary },
|
|
139
|
+
employment: { ...HRM_CONFIG.employment, ...customConfig.employment },
|
|
140
|
+
validation: { ...HRM_CONFIG.validation, ...customConfig.validation }
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// src/core/container.ts
|
|
145
|
+
var Container = class _Container {
|
|
146
|
+
static instance = null;
|
|
147
|
+
_models = null;
|
|
148
|
+
_config = HRM_CONFIG;
|
|
149
|
+
_singleTenant = null;
|
|
150
|
+
_logger;
|
|
151
|
+
_initialized = false;
|
|
152
|
+
constructor() {
|
|
153
|
+
this._logger = getLogger();
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Get singleton instance
|
|
157
|
+
*/
|
|
158
|
+
static getInstance() {
|
|
159
|
+
if (!_Container.instance) {
|
|
160
|
+
_Container.instance = new _Container();
|
|
161
|
+
}
|
|
162
|
+
return _Container.instance;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Reset instance (for testing)
|
|
166
|
+
*/
|
|
167
|
+
static resetInstance() {
|
|
168
|
+
_Container.instance = null;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Initialize container with configuration
|
|
172
|
+
*/
|
|
173
|
+
initialize(config) {
|
|
174
|
+
if (this._initialized) {
|
|
175
|
+
this._logger.warn("Container already initialized, re-initializing");
|
|
176
|
+
}
|
|
177
|
+
this._models = config.models;
|
|
178
|
+
this._config = mergeConfig(config.config);
|
|
179
|
+
this._singleTenant = config.singleTenant ?? null;
|
|
180
|
+
if (config.logger) {
|
|
181
|
+
this._logger = config.logger;
|
|
182
|
+
}
|
|
183
|
+
this._initialized = true;
|
|
184
|
+
this._logger.info("Container initialized", {
|
|
185
|
+
hasEmployeeModel: !!this._models.EmployeeModel,
|
|
186
|
+
hasPayrollRecordModel: !!this._models.PayrollRecordModel,
|
|
187
|
+
hasTransactionModel: !!this._models.TransactionModel,
|
|
188
|
+
hasAttendanceModel: !!this._models.AttendanceModel,
|
|
189
|
+
isSingleTenant: !!this._singleTenant
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Check if container is initialized
|
|
194
|
+
*/
|
|
195
|
+
isInitialized() {
|
|
196
|
+
return this._initialized;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Ensure container is initialized
|
|
200
|
+
*/
|
|
201
|
+
ensureInitialized() {
|
|
202
|
+
if (!this._initialized || !this._models) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
"Payroll not initialized. Call Payroll.initialize() first."
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Get models container
|
|
210
|
+
*/
|
|
211
|
+
getModels() {
|
|
212
|
+
this.ensureInitialized();
|
|
213
|
+
return this._models;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Get Employee model
|
|
217
|
+
*/
|
|
218
|
+
getEmployeeModel() {
|
|
219
|
+
this.ensureInitialized();
|
|
220
|
+
return this._models.EmployeeModel;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Get PayrollRecord model
|
|
224
|
+
*/
|
|
225
|
+
getPayrollRecordModel() {
|
|
226
|
+
this.ensureInitialized();
|
|
227
|
+
return this._models.PayrollRecordModel;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Get Transaction model
|
|
231
|
+
*/
|
|
232
|
+
getTransactionModel() {
|
|
233
|
+
this.ensureInitialized();
|
|
234
|
+
return this._models.TransactionModel;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Get Attendance model (optional)
|
|
238
|
+
*/
|
|
239
|
+
getAttendanceModel() {
|
|
240
|
+
this.ensureInitialized();
|
|
241
|
+
return this._models.AttendanceModel ?? null;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Get configuration
|
|
245
|
+
*/
|
|
246
|
+
getConfig() {
|
|
247
|
+
return this._config;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Get specific config section
|
|
251
|
+
*/
|
|
252
|
+
getConfigSection(section) {
|
|
253
|
+
return this._config[section];
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Check if single-tenant mode
|
|
257
|
+
*/
|
|
258
|
+
isSingleTenant() {
|
|
259
|
+
return !!this._singleTenant;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Get single-tenant config
|
|
263
|
+
*/
|
|
264
|
+
getSingleTenantConfig() {
|
|
265
|
+
return this._singleTenant;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Get organization ID (for single-tenant mode)
|
|
269
|
+
*/
|
|
270
|
+
getOrganizationId() {
|
|
271
|
+
if (!this._singleTenant || !this._singleTenant.organizationId) return null;
|
|
272
|
+
return typeof this._singleTenant.organizationId === "string" ? this._singleTenant.organizationId : this._singleTenant.organizationId.toString();
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Get logger
|
|
276
|
+
*/
|
|
277
|
+
getLogger() {
|
|
278
|
+
return this._logger;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Set logger
|
|
282
|
+
*/
|
|
283
|
+
setLogger(logger) {
|
|
284
|
+
this._logger = logger;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Has attendance integration
|
|
288
|
+
*/
|
|
289
|
+
hasAttendanceIntegration() {
|
|
290
|
+
return !!this._models?.AttendanceModel && this._config.payroll.attendanceIntegration;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Create operation context with defaults
|
|
294
|
+
*/
|
|
295
|
+
createOperationContext(overrides) {
|
|
296
|
+
const context = {};
|
|
297
|
+
if (this._singleTenant?.autoInject && !overrides?.organizationId) {
|
|
298
|
+
context.organizationId = this.getOrganizationId();
|
|
299
|
+
}
|
|
300
|
+
return { ...context, ...overrides };
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
function initializeContainer(config) {
|
|
304
|
+
Container.getInstance().initialize(config);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// src/core/events.ts
|
|
308
|
+
var EventBus = class {
|
|
309
|
+
handlers = /* @__PURE__ */ new Map();
|
|
310
|
+
/**
|
|
311
|
+
* Register an event handler
|
|
312
|
+
*/
|
|
313
|
+
on(event, handler) {
|
|
314
|
+
if (!this.handlers.has(event)) {
|
|
315
|
+
this.handlers.set(event, /* @__PURE__ */ new Set());
|
|
316
|
+
}
|
|
317
|
+
this.handlers.get(event).add(handler);
|
|
318
|
+
return () => this.off(event, handler);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Register a one-time event handler
|
|
322
|
+
*/
|
|
323
|
+
once(event, handler) {
|
|
324
|
+
const wrappedHandler = async (payload) => {
|
|
325
|
+
this.off(event, wrappedHandler);
|
|
326
|
+
await handler(payload);
|
|
327
|
+
};
|
|
328
|
+
return this.on(event, wrappedHandler);
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Remove an event handler
|
|
332
|
+
*/
|
|
333
|
+
off(event, handler) {
|
|
334
|
+
const eventHandlers = this.handlers.get(event);
|
|
335
|
+
if (eventHandlers) {
|
|
336
|
+
eventHandlers.delete(handler);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Emit an event
|
|
341
|
+
*/
|
|
342
|
+
async emit(event, payload) {
|
|
343
|
+
const eventHandlers = this.handlers.get(event);
|
|
344
|
+
if (!eventHandlers || eventHandlers.size === 0) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const handlers = Array.from(eventHandlers);
|
|
348
|
+
await Promise.all(
|
|
349
|
+
handlers.map(async (handler) => {
|
|
350
|
+
try {
|
|
351
|
+
await handler(payload);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
console.error(`Event handler error for ${event}:`, error);
|
|
354
|
+
}
|
|
355
|
+
})
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Emit event synchronously (fire-and-forget)
|
|
360
|
+
*/
|
|
361
|
+
emitSync(event, payload) {
|
|
362
|
+
void this.emit(event, payload);
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Remove all handlers for an event
|
|
366
|
+
*/
|
|
367
|
+
removeAllListeners(event) {
|
|
368
|
+
if (event) {
|
|
369
|
+
this.handlers.delete(event);
|
|
370
|
+
} else {
|
|
371
|
+
this.handlers.clear();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Get listener count for an event
|
|
376
|
+
*/
|
|
377
|
+
listenerCount(event) {
|
|
378
|
+
return this.handlers.get(event)?.size ?? 0;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Get all registered events
|
|
382
|
+
*/
|
|
383
|
+
eventNames() {
|
|
384
|
+
return Array.from(this.handlers.keys());
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
function createEventBus() {
|
|
388
|
+
return new EventBus();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// src/core/plugin.ts
|
|
392
|
+
var PluginManager = class {
|
|
393
|
+
constructor(context) {
|
|
394
|
+
this.context = context;
|
|
395
|
+
}
|
|
396
|
+
plugins = /* @__PURE__ */ new Map();
|
|
397
|
+
hooks = /* @__PURE__ */ new Map();
|
|
398
|
+
/**
|
|
399
|
+
* Register a plugin
|
|
400
|
+
*/
|
|
401
|
+
async register(plugin) {
|
|
402
|
+
if (this.plugins.has(plugin.name)) {
|
|
403
|
+
throw new Error(`Plugin "${plugin.name}" is already registered`);
|
|
404
|
+
}
|
|
405
|
+
if (plugin.hooks) {
|
|
406
|
+
for (const [hookName, handler] of Object.entries(plugin.hooks)) {
|
|
407
|
+
if (handler) {
|
|
408
|
+
this.addHook(hookName, handler);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (plugin.init) {
|
|
413
|
+
await plugin.init(this.context);
|
|
414
|
+
}
|
|
415
|
+
this.plugins.set(plugin.name, plugin);
|
|
416
|
+
this.context.logger.debug(`Plugin "${plugin.name}" registered`);
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Unregister a plugin
|
|
420
|
+
*/
|
|
421
|
+
async unregister(name) {
|
|
422
|
+
const plugin = this.plugins.get(name);
|
|
423
|
+
if (!plugin) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (plugin.destroy) {
|
|
427
|
+
await plugin.destroy();
|
|
428
|
+
}
|
|
429
|
+
this.plugins.delete(name);
|
|
430
|
+
this.context.logger.debug(`Plugin "${name}" unregistered`);
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Add a hook handler
|
|
434
|
+
*/
|
|
435
|
+
addHook(hookName, handler) {
|
|
436
|
+
if (!this.hooks.has(hookName)) {
|
|
437
|
+
this.hooks.set(hookName, []);
|
|
438
|
+
}
|
|
439
|
+
this.hooks.get(hookName).push(handler);
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Execute hooks for a given event
|
|
443
|
+
*/
|
|
444
|
+
async executeHooks(hookName, ...args) {
|
|
445
|
+
const handlers = this.hooks.get(hookName);
|
|
446
|
+
if (!handlers || handlers.length === 0) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
for (const handler of handlers) {
|
|
450
|
+
try {
|
|
451
|
+
await handler(...args);
|
|
452
|
+
} catch (error) {
|
|
453
|
+
this.context.logger.error(`Hook "${hookName}" error:`, { error });
|
|
454
|
+
const errorHandlers = this.hooks.get("onError");
|
|
455
|
+
if (errorHandlers) {
|
|
456
|
+
for (const errorHandler of errorHandlers) {
|
|
457
|
+
try {
|
|
458
|
+
await errorHandler(error, hookName);
|
|
459
|
+
} catch {
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Get registered plugin names
|
|
468
|
+
*/
|
|
469
|
+
getPluginNames() {
|
|
470
|
+
return Array.from(this.plugins.keys());
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Check if plugin is registered
|
|
474
|
+
*/
|
|
475
|
+
hasPlugin(name) {
|
|
476
|
+
return this.plugins.has(name);
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
// src/utils/date.ts
|
|
481
|
+
function addMonths(date, months) {
|
|
482
|
+
const result = new Date(date);
|
|
483
|
+
result.setMonth(result.getMonth() + months);
|
|
484
|
+
return result;
|
|
485
|
+
}
|
|
486
|
+
function startOfMonth(date) {
|
|
487
|
+
const result = new Date(date);
|
|
488
|
+
result.setDate(1);
|
|
489
|
+
result.setHours(0, 0, 0, 0);
|
|
490
|
+
return result;
|
|
491
|
+
}
|
|
492
|
+
function endOfMonth(date) {
|
|
493
|
+
const result = new Date(date);
|
|
494
|
+
result.setMonth(result.getMonth() + 1, 0);
|
|
495
|
+
result.setHours(23, 59, 59, 999);
|
|
496
|
+
return result;
|
|
497
|
+
}
|
|
498
|
+
function diffInDays(start, end) {
|
|
499
|
+
return Math.ceil(
|
|
500
|
+
(new Date(end).getTime() - new Date(start).getTime()) / (1e3 * 60 * 60 * 24)
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
function isWeekday(date) {
|
|
504
|
+
const day = new Date(date).getDay();
|
|
505
|
+
return day >= 1 && day <= 5;
|
|
506
|
+
}
|
|
507
|
+
function getPayPeriod(month, year) {
|
|
508
|
+
const startDate = new Date(year, month - 1, 1);
|
|
509
|
+
return {
|
|
510
|
+
month,
|
|
511
|
+
year,
|
|
512
|
+
startDate: startOfMonth(startDate),
|
|
513
|
+
endDate: endOfMonth(startDate)
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
function getWorkingDaysInMonth(year, month) {
|
|
517
|
+
const start = new Date(year, month - 1, 1);
|
|
518
|
+
const end = endOfMonth(start);
|
|
519
|
+
let count = 0;
|
|
520
|
+
const current = new Date(start);
|
|
521
|
+
while (current <= end) {
|
|
522
|
+
if (isWeekday(current)) {
|
|
523
|
+
count++;
|
|
524
|
+
}
|
|
525
|
+
current.setDate(current.getDate() + 1);
|
|
526
|
+
}
|
|
527
|
+
return count;
|
|
528
|
+
}
|
|
529
|
+
function calculateProbationEnd(hireDate, probationMonths) {
|
|
530
|
+
if (!probationMonths || probationMonths <= 0) return null;
|
|
531
|
+
return addMonths(hireDate, probationMonths);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// src/factories/employee.factory.ts
|
|
535
|
+
var EmployeeFactory = class {
|
|
536
|
+
/**
|
|
537
|
+
* Create employee data object
|
|
538
|
+
*/
|
|
539
|
+
static create(params) {
|
|
540
|
+
const { userId, organizationId, employment, compensation, bankDetails } = params;
|
|
541
|
+
const hireDate = employment.hireDate || /* @__PURE__ */ new Date();
|
|
542
|
+
return {
|
|
543
|
+
userId,
|
|
544
|
+
organizationId,
|
|
545
|
+
employeeId: employment.employeeId || `EMP-${Date.now()}-${Math.random().toString(36).substr(2, 6).toUpperCase()}`,
|
|
546
|
+
employmentType: employment.type || "full_time",
|
|
547
|
+
status: "active",
|
|
548
|
+
department: employment.department,
|
|
549
|
+
position: employment.position,
|
|
550
|
+
hireDate,
|
|
551
|
+
probationEndDate: calculateProbationEnd(
|
|
552
|
+
hireDate,
|
|
553
|
+
employment.probationMonths ?? HRM_CONFIG.employment.defaultProbationMonths
|
|
554
|
+
),
|
|
555
|
+
compensation: this.createCompensation(compensation),
|
|
556
|
+
workSchedule: employment.workSchedule || this.defaultWorkSchedule(),
|
|
557
|
+
bankDetails: bankDetails || {},
|
|
558
|
+
payrollStats: {
|
|
559
|
+
totalPaid: 0,
|
|
560
|
+
paymentsThisYear: 0,
|
|
561
|
+
averageMonthly: 0
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Create compensation object
|
|
567
|
+
*/
|
|
568
|
+
static createCompensation(params) {
|
|
569
|
+
return {
|
|
570
|
+
baseAmount: params.baseAmount,
|
|
571
|
+
frequency: params.frequency || "monthly",
|
|
572
|
+
currency: params.currency || HRM_CONFIG.payroll.defaultCurrency,
|
|
573
|
+
allowances: (params.allowances || []).map((a) => ({
|
|
574
|
+
type: a.type || "other",
|
|
575
|
+
name: a.name || a.type || "other",
|
|
576
|
+
amount: a.amount || 0,
|
|
577
|
+
taxable: a.taxable,
|
|
578
|
+
recurring: a.recurring,
|
|
579
|
+
effectiveFrom: a.effectiveFrom,
|
|
580
|
+
effectiveTo: a.effectiveTo
|
|
581
|
+
})),
|
|
582
|
+
deductions: (params.deductions || []).map((d) => ({
|
|
583
|
+
type: d.type || "other",
|
|
584
|
+
name: d.name || d.type || "other",
|
|
585
|
+
amount: d.amount || 0,
|
|
586
|
+
auto: d.auto,
|
|
587
|
+
recurring: d.recurring,
|
|
588
|
+
description: d.description,
|
|
589
|
+
effectiveFrom: d.effectiveFrom,
|
|
590
|
+
effectiveTo: d.effectiveTo
|
|
591
|
+
})),
|
|
592
|
+
grossSalary: 0,
|
|
593
|
+
netSalary: 0,
|
|
594
|
+
effectiveFrom: /* @__PURE__ */ new Date(),
|
|
595
|
+
lastModified: /* @__PURE__ */ new Date()
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Create allowance object
|
|
600
|
+
*/
|
|
601
|
+
static createAllowance(params) {
|
|
602
|
+
return {
|
|
603
|
+
type: params.type,
|
|
604
|
+
name: params.name || params.type,
|
|
605
|
+
amount: params.amount,
|
|
606
|
+
isPercentage: params.isPercentage ?? false,
|
|
607
|
+
taxable: params.taxable ?? true,
|
|
608
|
+
recurring: params.recurring ?? true,
|
|
609
|
+
effectiveFrom: /* @__PURE__ */ new Date()
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Create deduction object
|
|
614
|
+
*/
|
|
615
|
+
static createDeduction(params) {
|
|
616
|
+
return {
|
|
617
|
+
type: params.type,
|
|
618
|
+
name: params.name || params.type,
|
|
619
|
+
amount: params.amount,
|
|
620
|
+
isPercentage: params.isPercentage ?? false,
|
|
621
|
+
auto: params.auto ?? false,
|
|
622
|
+
recurring: params.recurring ?? true,
|
|
623
|
+
description: params.description,
|
|
624
|
+
effectiveFrom: /* @__PURE__ */ new Date()
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Default work schedule
|
|
629
|
+
*/
|
|
630
|
+
static defaultWorkSchedule() {
|
|
631
|
+
return {
|
|
632
|
+
hoursPerWeek: 40,
|
|
633
|
+
hoursPerDay: 8,
|
|
634
|
+
workingDays: [1, 2, 3, 4, 5],
|
|
635
|
+
// Mon-Fri
|
|
636
|
+
shiftStart: "09:00",
|
|
637
|
+
shiftEnd: "17:00"
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Create termination data
|
|
642
|
+
*/
|
|
643
|
+
static createTermination(params) {
|
|
644
|
+
return {
|
|
645
|
+
terminatedAt: params.date || /* @__PURE__ */ new Date(),
|
|
646
|
+
terminationReason: params.reason,
|
|
647
|
+
terminationNotes: params.notes,
|
|
648
|
+
terminatedBy: {
|
|
649
|
+
userId: params.context?.userId,
|
|
650
|
+
name: params.context?.userName,
|
|
651
|
+
role: params.context?.userRole
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
var HRM_TRANSACTION_CATEGORIES = {
|
|
657
|
+
SALARY: "salary",
|
|
658
|
+
BONUS: "bonus",
|
|
659
|
+
COMMISSION: "commission",
|
|
660
|
+
OVERTIME: "overtime",
|
|
661
|
+
SEVERANCE: "severance"
|
|
662
|
+
};
|
|
663
|
+
function toObjectId(id) {
|
|
664
|
+
if (id instanceof Types.ObjectId) return id;
|
|
665
|
+
return new Types.ObjectId(id);
|
|
666
|
+
}
|
|
667
|
+
var QueryBuilder = class {
|
|
668
|
+
query;
|
|
669
|
+
constructor(initialQuery = {}) {
|
|
670
|
+
this.query = { ...initialQuery };
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Add where condition
|
|
674
|
+
*/
|
|
675
|
+
where(field, value) {
|
|
676
|
+
this.query[field] = value;
|
|
677
|
+
return this;
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Add $in condition
|
|
681
|
+
*/
|
|
682
|
+
whereIn(field, values) {
|
|
683
|
+
this.query[field] = { $in: values };
|
|
684
|
+
return this;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Add $nin condition
|
|
688
|
+
*/
|
|
689
|
+
whereNotIn(field, values) {
|
|
690
|
+
this.query[field] = { $nin: values };
|
|
691
|
+
return this;
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Add $gte condition
|
|
695
|
+
*/
|
|
696
|
+
whereGte(field, value) {
|
|
697
|
+
const existing = this.query[field] || {};
|
|
698
|
+
this.query[field] = { ...existing, $gte: value };
|
|
699
|
+
return this;
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Add $lte condition
|
|
703
|
+
*/
|
|
704
|
+
whereLte(field, value) {
|
|
705
|
+
const existing = this.query[field] || {};
|
|
706
|
+
this.query[field] = { ...existing, $lte: value };
|
|
707
|
+
return this;
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Add $gt condition
|
|
711
|
+
*/
|
|
712
|
+
whereGt(field, value) {
|
|
713
|
+
const existing = this.query[field] || {};
|
|
714
|
+
this.query[field] = { ...existing, $gt: value };
|
|
715
|
+
return this;
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Add $lt condition
|
|
719
|
+
*/
|
|
720
|
+
whereLt(field, value) {
|
|
721
|
+
const existing = this.query[field] || {};
|
|
722
|
+
this.query[field] = { ...existing, $lt: value };
|
|
723
|
+
return this;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Add between condition
|
|
727
|
+
*/
|
|
728
|
+
whereBetween(field, start, end) {
|
|
729
|
+
this.query[field] = { $gte: start, $lte: end };
|
|
730
|
+
return this;
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Add $exists condition
|
|
734
|
+
*/
|
|
735
|
+
whereExists(field) {
|
|
736
|
+
this.query[field] = { $exists: true };
|
|
737
|
+
return this;
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Add $exists: false condition
|
|
741
|
+
*/
|
|
742
|
+
whereNotExists(field) {
|
|
743
|
+
this.query[field] = { $exists: false };
|
|
744
|
+
return this;
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Add $ne condition
|
|
748
|
+
*/
|
|
749
|
+
whereNot(field, value) {
|
|
750
|
+
this.query[field] = { $ne: value };
|
|
751
|
+
return this;
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Add regex condition
|
|
755
|
+
*/
|
|
756
|
+
whereRegex(field, pattern, flags = "i") {
|
|
757
|
+
this.query[field] = { $regex: pattern, $options: flags };
|
|
758
|
+
return this;
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Merge another query
|
|
762
|
+
*/
|
|
763
|
+
merge(otherQuery) {
|
|
764
|
+
this.query = { ...this.query, ...otherQuery };
|
|
765
|
+
return this;
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Build and return the query
|
|
769
|
+
*/
|
|
770
|
+
build() {
|
|
771
|
+
return { ...this.query };
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
var EmployeeQueryBuilder = class extends QueryBuilder {
|
|
775
|
+
/**
|
|
776
|
+
* Filter by organization
|
|
777
|
+
*/
|
|
778
|
+
forOrganization(organizationId) {
|
|
779
|
+
return this.where("organizationId", toObjectId(organizationId));
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Filter by user
|
|
783
|
+
*/
|
|
784
|
+
forUser(userId) {
|
|
785
|
+
return this.where("userId", toObjectId(userId));
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Filter by status(es)
|
|
789
|
+
*/
|
|
790
|
+
withStatus(...statuses) {
|
|
791
|
+
if (statuses.length === 1) {
|
|
792
|
+
return this.where("status", statuses[0]);
|
|
793
|
+
}
|
|
794
|
+
return this.whereIn("status", statuses);
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Filter active employees
|
|
798
|
+
*/
|
|
799
|
+
active() {
|
|
800
|
+
return this.withStatus("active");
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Filter employed employees (not terminated)
|
|
804
|
+
*/
|
|
805
|
+
employed() {
|
|
806
|
+
return this.whereIn("status", ["active", "on_leave", "suspended"]);
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Filter terminated employees
|
|
810
|
+
*/
|
|
811
|
+
terminated() {
|
|
812
|
+
return this.withStatus("terminated");
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Filter by department
|
|
816
|
+
*/
|
|
817
|
+
inDepartment(department) {
|
|
818
|
+
return this.where("department", department);
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Filter by position
|
|
822
|
+
*/
|
|
823
|
+
inPosition(position) {
|
|
824
|
+
return this.where("position", position);
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Filter by employment type
|
|
828
|
+
*/
|
|
829
|
+
withEmploymentType(type) {
|
|
830
|
+
return this.where("employmentType", type);
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Filter by hire date (after)
|
|
834
|
+
*/
|
|
835
|
+
hiredAfter(date) {
|
|
836
|
+
return this.whereGte("hireDate", date);
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Filter by hire date (before)
|
|
840
|
+
*/
|
|
841
|
+
hiredBefore(date) {
|
|
842
|
+
return this.whereLte("hireDate", date);
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Filter by minimum salary
|
|
846
|
+
*/
|
|
847
|
+
withMinSalary(amount) {
|
|
848
|
+
return this.whereGte("compensation.netSalary", amount);
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Filter by maximum salary
|
|
852
|
+
*/
|
|
853
|
+
withMaxSalary(amount) {
|
|
854
|
+
return this.whereLte("compensation.netSalary", amount);
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Filter by salary range
|
|
858
|
+
*/
|
|
859
|
+
withSalaryRange(min, max) {
|
|
860
|
+
return this.whereBetween("compensation.netSalary", min, max);
|
|
861
|
+
}
|
|
862
|
+
};
|
|
863
|
+
var PayrollQueryBuilder = class extends QueryBuilder {
|
|
864
|
+
/**
|
|
865
|
+
* Filter by organization
|
|
866
|
+
*/
|
|
867
|
+
forOrganization(organizationId) {
|
|
868
|
+
return this.where("organizationId", toObjectId(organizationId));
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Filter by employee
|
|
872
|
+
*/
|
|
873
|
+
forEmployee(employeeId) {
|
|
874
|
+
return this.where("employeeId", toObjectId(employeeId));
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Filter by period
|
|
878
|
+
*/
|
|
879
|
+
forPeriod(month, year) {
|
|
880
|
+
if (month !== void 0) {
|
|
881
|
+
this.where("period.month", month);
|
|
882
|
+
}
|
|
883
|
+
if (year !== void 0) {
|
|
884
|
+
this.where("period.year", year);
|
|
885
|
+
}
|
|
886
|
+
return this;
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Filter by status(es)
|
|
890
|
+
*/
|
|
891
|
+
withStatus(...statuses) {
|
|
892
|
+
if (statuses.length === 1) {
|
|
893
|
+
return this.where("status", statuses[0]);
|
|
894
|
+
}
|
|
895
|
+
return this.whereIn("status", statuses);
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Filter paid records
|
|
899
|
+
*/
|
|
900
|
+
paid() {
|
|
901
|
+
return this.withStatus("paid");
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Filter pending records
|
|
905
|
+
*/
|
|
906
|
+
pending() {
|
|
907
|
+
return this.whereIn("status", ["pending", "processing"]);
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* Filter by date range
|
|
911
|
+
*/
|
|
912
|
+
inDateRange(start, end) {
|
|
913
|
+
return this.whereBetween("period.payDate", start, end);
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Filter exported records
|
|
917
|
+
*/
|
|
918
|
+
exported() {
|
|
919
|
+
return this.where("exported", true);
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Filter not exported records
|
|
923
|
+
*/
|
|
924
|
+
notExported() {
|
|
925
|
+
return this.where("exported", false);
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
function employee() {
|
|
929
|
+
return new EmployeeQueryBuilder();
|
|
930
|
+
}
|
|
931
|
+
function payroll() {
|
|
932
|
+
return new PayrollQueryBuilder();
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// src/utils/calculation.ts
|
|
936
|
+
function sumBy(items, getter) {
|
|
937
|
+
return items.reduce((total, item) => total + getter(item), 0);
|
|
938
|
+
}
|
|
939
|
+
function sumAllowances(allowances) {
|
|
940
|
+
return sumBy(allowances, (a) => a.amount);
|
|
941
|
+
}
|
|
942
|
+
function sumDeductions(deductions) {
|
|
943
|
+
return sumBy(deductions, (d) => d.amount);
|
|
944
|
+
}
|
|
945
|
+
function calculateGross(baseAmount, allowances) {
|
|
946
|
+
return baseAmount + sumAllowances(allowances);
|
|
947
|
+
}
|
|
948
|
+
function calculateNet(gross, deductions) {
|
|
949
|
+
return Math.max(0, gross - sumDeductions(deductions));
|
|
950
|
+
}
|
|
951
|
+
function applyTaxBrackets(amount, brackets) {
|
|
952
|
+
let tax = 0;
|
|
953
|
+
for (const bracket of brackets) {
|
|
954
|
+
if (amount > bracket.min) {
|
|
955
|
+
const taxableAmount = Math.min(amount, bracket.max) - bracket.min;
|
|
956
|
+
tax += taxableAmount * bracket.rate;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
return Math.round(tax);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// src/errors/index.ts
|
|
963
|
+
var PayrollError = class _PayrollError extends Error {
|
|
964
|
+
code;
|
|
965
|
+
status;
|
|
966
|
+
context;
|
|
967
|
+
timestamp;
|
|
968
|
+
constructor(message, code = "PAYROLL_ERROR", status = 500, context) {
|
|
969
|
+
super(message);
|
|
970
|
+
this.name = "PayrollError";
|
|
971
|
+
this.code = code;
|
|
972
|
+
this.status = status;
|
|
973
|
+
this.context = context;
|
|
974
|
+
this.timestamp = /* @__PURE__ */ new Date();
|
|
975
|
+
if (Error.captureStackTrace) {
|
|
976
|
+
Error.captureStackTrace(this, _PayrollError);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
toJSON() {
|
|
980
|
+
return {
|
|
981
|
+
name: this.name,
|
|
982
|
+
code: this.code,
|
|
983
|
+
message: this.message,
|
|
984
|
+
status: this.status,
|
|
985
|
+
context: this.context,
|
|
986
|
+
timestamp: this.timestamp.toISOString()
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
var NotInitializedError = class extends PayrollError {
|
|
991
|
+
constructor(message = "Payroll not initialized. Call Payroll.initialize() first.") {
|
|
992
|
+
super(message, "NOT_INITIALIZED", 500);
|
|
993
|
+
this.name = "NotInitializedError";
|
|
994
|
+
}
|
|
995
|
+
};
|
|
996
|
+
var EmployeeNotFoundError = class extends PayrollError {
|
|
997
|
+
constructor(employeeId, context) {
|
|
998
|
+
super(
|
|
999
|
+
employeeId ? `Employee not found: ${employeeId}` : "Employee not found",
|
|
1000
|
+
"EMPLOYEE_NOT_FOUND",
|
|
1001
|
+
404,
|
|
1002
|
+
context
|
|
1003
|
+
);
|
|
1004
|
+
this.name = "EmployeeNotFoundError";
|
|
1005
|
+
}
|
|
1006
|
+
};
|
|
1007
|
+
var DuplicatePayrollError = class extends PayrollError {
|
|
1008
|
+
constructor(employeeId, month, year, context) {
|
|
1009
|
+
super(
|
|
1010
|
+
`Payroll already processed for employee ${employeeId} in ${month}/${year}`,
|
|
1011
|
+
"DUPLICATE_PAYROLL",
|
|
1012
|
+
409,
|
|
1013
|
+
{ employeeId, month, year, ...context }
|
|
1014
|
+
);
|
|
1015
|
+
this.name = "DuplicatePayrollError";
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
var ValidationError = class extends PayrollError {
|
|
1019
|
+
errors;
|
|
1020
|
+
constructor(errors, context) {
|
|
1021
|
+
const errorArray = Array.isArray(errors) ? errors : [errors];
|
|
1022
|
+
super(errorArray.join(", "), "VALIDATION_ERROR", 400, context);
|
|
1023
|
+
this.name = "ValidationError";
|
|
1024
|
+
this.errors = errorArray;
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
var EmployeeTerminatedError = class extends PayrollError {
|
|
1028
|
+
constructor(employeeId, context) {
|
|
1029
|
+
super(
|
|
1030
|
+
employeeId ? `Cannot perform operation on terminated employee: ${employeeId}` : "Cannot perform operation on terminated employee",
|
|
1031
|
+
"EMPLOYEE_TERMINATED",
|
|
1032
|
+
400,
|
|
1033
|
+
context
|
|
1034
|
+
);
|
|
1035
|
+
this.name = "EmployeeTerminatedError";
|
|
1036
|
+
}
|
|
1037
|
+
};
|
|
1038
|
+
var NotEligibleError = class extends PayrollError {
|
|
1039
|
+
constructor(message, context) {
|
|
1040
|
+
super(message, "NOT_ELIGIBLE", 400, context);
|
|
1041
|
+
this.name = "NotEligibleError";
|
|
1042
|
+
}
|
|
1043
|
+
};
|
|
1044
|
+
|
|
1045
|
+
// src/payroll.ts
|
|
1046
|
+
function hasPluginMethod(obj, method) {
|
|
1047
|
+
return typeof obj === "object" && obj !== null && typeof obj[method] === "function";
|
|
1048
|
+
}
|
|
1049
|
+
function assertPluginMethod(obj, method, context) {
|
|
1050
|
+
if (!hasPluginMethod(obj, method)) {
|
|
1051
|
+
throw new Error(
|
|
1052
|
+
`Method '${method}' not found on employee. Did you forget to apply employeePlugin to your Employee schema? Context: ${context}`
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
function isEffectiveForPeriod(item, periodStart, periodEnd) {
|
|
1057
|
+
const effectiveFrom = item.effectiveFrom ? new Date(item.effectiveFrom) : /* @__PURE__ */ new Date(0);
|
|
1058
|
+
const effectiveTo = item.effectiveTo ? new Date(item.effectiveTo) : /* @__PURE__ */ new Date("2099-12-31");
|
|
1059
|
+
return effectiveFrom <= periodEnd && effectiveTo >= periodStart;
|
|
1060
|
+
}
|
|
1061
|
+
var Payroll = class _Payroll {
|
|
1062
|
+
_container;
|
|
1063
|
+
_events;
|
|
1064
|
+
_plugins = null;
|
|
1065
|
+
_initialized = false;
|
|
1066
|
+
constructor() {
|
|
1067
|
+
this._container = Container.getInstance();
|
|
1068
|
+
this._events = createEventBus();
|
|
1069
|
+
}
|
|
1070
|
+
// ========================================
|
|
1071
|
+
// Initialization
|
|
1072
|
+
// ========================================
|
|
1073
|
+
/**
|
|
1074
|
+
* Initialize Payroll with models and configuration
|
|
1075
|
+
*/
|
|
1076
|
+
initialize(config) {
|
|
1077
|
+
const { EmployeeModel, PayrollRecordModel, TransactionModel, AttendanceModel, singleTenant, logger: customLogger, config: customConfig } = config;
|
|
1078
|
+
if (!EmployeeModel || !PayrollRecordModel || !TransactionModel) {
|
|
1079
|
+
throw new Error("EmployeeModel, PayrollRecordModel, and TransactionModel are required");
|
|
1080
|
+
}
|
|
1081
|
+
if (customLogger) {
|
|
1082
|
+
setLogger(customLogger);
|
|
1083
|
+
}
|
|
1084
|
+
initializeContainer({
|
|
1085
|
+
models: {
|
|
1086
|
+
EmployeeModel,
|
|
1087
|
+
PayrollRecordModel,
|
|
1088
|
+
TransactionModel,
|
|
1089
|
+
AttendanceModel: AttendanceModel ?? null
|
|
1090
|
+
},
|
|
1091
|
+
config: customConfig,
|
|
1092
|
+
singleTenant: singleTenant ?? null,
|
|
1093
|
+
logger: customLogger
|
|
1094
|
+
});
|
|
1095
|
+
const pluginContext = {
|
|
1096
|
+
payroll: this,
|
|
1097
|
+
events: this._events,
|
|
1098
|
+
logger: getLogger(),
|
|
1099
|
+
getConfig: (key) => {
|
|
1100
|
+
const config2 = this._container.getConfig();
|
|
1101
|
+
return config2[key];
|
|
1102
|
+
},
|
|
1103
|
+
addHook: (event, handler) => this._events.on(event, handler)
|
|
1104
|
+
};
|
|
1105
|
+
this._plugins = new PluginManager(pluginContext);
|
|
1106
|
+
this._initialized = true;
|
|
1107
|
+
getLogger().info("Payroll initialized", {
|
|
1108
|
+
hasAttendanceIntegration: !!AttendanceModel,
|
|
1109
|
+
isSingleTenant: !!singleTenant
|
|
1110
|
+
});
|
|
1111
|
+
return this;
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* Check if initialized
|
|
1115
|
+
*/
|
|
1116
|
+
isInitialized() {
|
|
1117
|
+
return this._initialized;
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Ensure initialized
|
|
1121
|
+
*/
|
|
1122
|
+
ensureInitialized() {
|
|
1123
|
+
if (!this._initialized) {
|
|
1124
|
+
throw new NotInitializedError();
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Get models
|
|
1129
|
+
*/
|
|
1130
|
+
get models() {
|
|
1131
|
+
this.ensureInitialized();
|
|
1132
|
+
return this._container.getModels();
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* Get config
|
|
1136
|
+
*/
|
|
1137
|
+
get config() {
|
|
1138
|
+
return this._container.getConfig();
|
|
1139
|
+
}
|
|
1140
|
+
// ========================================
|
|
1141
|
+
// Plugin System
|
|
1142
|
+
// ========================================
|
|
1143
|
+
/**
|
|
1144
|
+
* Register a plugin
|
|
1145
|
+
*/
|
|
1146
|
+
async use(plugin) {
|
|
1147
|
+
this.ensureInitialized();
|
|
1148
|
+
await this._plugins.register(plugin);
|
|
1149
|
+
return this;
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Subscribe to events
|
|
1153
|
+
*/
|
|
1154
|
+
on(event, handler) {
|
|
1155
|
+
return this._events.on(event, handler);
|
|
1156
|
+
}
|
|
1157
|
+
// ========================================
|
|
1158
|
+
// Employment Lifecycle
|
|
1159
|
+
// ========================================
|
|
1160
|
+
/**
|
|
1161
|
+
* Hire a new employee
|
|
1162
|
+
*/
|
|
1163
|
+
async hire(params) {
|
|
1164
|
+
this.ensureInitialized();
|
|
1165
|
+
const { userId, employment, compensation, bankDetails, context } = params;
|
|
1166
|
+
const session = context?.session;
|
|
1167
|
+
const organizationId = params.organizationId ?? this._container.getOrganizationId();
|
|
1168
|
+
if (!organizationId) {
|
|
1169
|
+
throw new Error("organizationId is required (or configure single-tenant mode)");
|
|
1170
|
+
}
|
|
1171
|
+
const existingQuery = employee().forUser(userId).forOrganization(organizationId).employed().build();
|
|
1172
|
+
let existing = this.models.EmployeeModel.findOne(existingQuery);
|
|
1173
|
+
if (session) existing = existing.session(session);
|
|
1174
|
+
if (await existing) {
|
|
1175
|
+
throw new Error("User is already an active employee in this organization");
|
|
1176
|
+
}
|
|
1177
|
+
const employeeData = EmployeeFactory.create({
|
|
1178
|
+
userId,
|
|
1179
|
+
organizationId,
|
|
1180
|
+
employment,
|
|
1181
|
+
compensation: {
|
|
1182
|
+
...compensation,
|
|
1183
|
+
currency: compensation.currency || this.config.payroll.defaultCurrency
|
|
1184
|
+
},
|
|
1185
|
+
bankDetails
|
|
1186
|
+
});
|
|
1187
|
+
const [employee2] = await this.models.EmployeeModel.create([employeeData], { session });
|
|
1188
|
+
this._events.emitSync("employee:hired", {
|
|
1189
|
+
employee: {
|
|
1190
|
+
id: employee2._id,
|
|
1191
|
+
employeeId: employee2.employeeId,
|
|
1192
|
+
position: employee2.position,
|
|
1193
|
+
department: employee2.department
|
|
1194
|
+
},
|
|
1195
|
+
organizationId: employee2.organizationId,
|
|
1196
|
+
context
|
|
1197
|
+
});
|
|
1198
|
+
getLogger().info("Employee hired", {
|
|
1199
|
+
employeeId: employee2.employeeId,
|
|
1200
|
+
organizationId: organizationId.toString(),
|
|
1201
|
+
position: employment.position
|
|
1202
|
+
});
|
|
1203
|
+
return employee2;
|
|
1204
|
+
}
|
|
1205
|
+
/**
|
|
1206
|
+
* Update employment details
|
|
1207
|
+
* NOTE: Status changes to 'terminated' must use terminate() method
|
|
1208
|
+
*/
|
|
1209
|
+
async updateEmployment(params) {
|
|
1210
|
+
this.ensureInitialized();
|
|
1211
|
+
const { employeeId, updates, context } = params;
|
|
1212
|
+
const session = context?.session;
|
|
1213
|
+
let query = this.models.EmployeeModel.findById(toObjectId(employeeId));
|
|
1214
|
+
if (session) query = query.session(session);
|
|
1215
|
+
const employee2 = await query;
|
|
1216
|
+
if (!employee2) {
|
|
1217
|
+
throw new EmployeeNotFoundError(employeeId.toString());
|
|
1218
|
+
}
|
|
1219
|
+
if (employee2.status === "terminated") {
|
|
1220
|
+
throw new EmployeeTerminatedError(employee2.employeeId);
|
|
1221
|
+
}
|
|
1222
|
+
if (updates.status === "terminated") {
|
|
1223
|
+
throw new ValidationError(
|
|
1224
|
+
"Cannot set status to terminated directly. Use the terminate() method instead to ensure proper history tracking.",
|
|
1225
|
+
{ field: "status" }
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
const allowedUpdates = ["department", "position", "employmentType", "status", "workSchedule"];
|
|
1229
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
1230
|
+
if (allowedUpdates.includes(key)) {
|
|
1231
|
+
employee2[key] = value;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
await employee2.save({ session });
|
|
1235
|
+
getLogger().info("Employee updated", {
|
|
1236
|
+
employeeId: employee2.employeeId,
|
|
1237
|
+
updates: Object.keys(updates)
|
|
1238
|
+
});
|
|
1239
|
+
return employee2;
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* Terminate employee
|
|
1243
|
+
*/
|
|
1244
|
+
async terminate(params) {
|
|
1245
|
+
this.ensureInitialized();
|
|
1246
|
+
const { employeeId, terminationDate = /* @__PURE__ */ new Date(), reason = "resignation", notes, context } = params;
|
|
1247
|
+
const session = context?.session;
|
|
1248
|
+
let query = this.models.EmployeeModel.findById(toObjectId(employeeId));
|
|
1249
|
+
if (session) query = query.session(session);
|
|
1250
|
+
const employee2 = await query;
|
|
1251
|
+
if (!employee2) {
|
|
1252
|
+
throw new EmployeeNotFoundError(employeeId.toString());
|
|
1253
|
+
}
|
|
1254
|
+
assertPluginMethod(employee2, "terminate", "terminate()");
|
|
1255
|
+
employee2.terminate(reason, terminationDate);
|
|
1256
|
+
if (notes) {
|
|
1257
|
+
employee2.notes = (employee2.notes || "") + `
|
|
1258
|
+
Termination: ${notes}`;
|
|
1259
|
+
}
|
|
1260
|
+
await employee2.save({ session });
|
|
1261
|
+
this._events.emitSync("employee:terminated", {
|
|
1262
|
+
employee: {
|
|
1263
|
+
id: employee2._id,
|
|
1264
|
+
employeeId: employee2.employeeId
|
|
1265
|
+
},
|
|
1266
|
+
terminationDate,
|
|
1267
|
+
reason,
|
|
1268
|
+
organizationId: employee2.organizationId,
|
|
1269
|
+
context
|
|
1270
|
+
});
|
|
1271
|
+
getLogger().info("Employee terminated", {
|
|
1272
|
+
employeeId: employee2.employeeId,
|
|
1273
|
+
reason
|
|
1274
|
+
});
|
|
1275
|
+
return employee2;
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Re-hire terminated employee
|
|
1279
|
+
*/
|
|
1280
|
+
async reHire(params) {
|
|
1281
|
+
this.ensureInitialized();
|
|
1282
|
+
const { employeeId, hireDate = /* @__PURE__ */ new Date(), position, department, compensation, context } = params;
|
|
1283
|
+
const session = context?.session;
|
|
1284
|
+
if (!this.config.employment.allowReHiring) {
|
|
1285
|
+
throw new Error("Re-hiring is not enabled");
|
|
1286
|
+
}
|
|
1287
|
+
let query = this.models.EmployeeModel.findById(toObjectId(employeeId));
|
|
1288
|
+
if (session) query = query.session(session);
|
|
1289
|
+
const employee2 = await query;
|
|
1290
|
+
if (!employee2) {
|
|
1291
|
+
throw new EmployeeNotFoundError(employeeId.toString());
|
|
1292
|
+
}
|
|
1293
|
+
assertPluginMethod(employee2, "reHire", "reHire()");
|
|
1294
|
+
employee2.reHire(hireDate, position, department);
|
|
1295
|
+
if (compensation) {
|
|
1296
|
+
employee2.compensation = { ...employee2.compensation, ...compensation };
|
|
1297
|
+
}
|
|
1298
|
+
await employee2.save({ session });
|
|
1299
|
+
this._events.emitSync("employee:rehired", {
|
|
1300
|
+
employee: {
|
|
1301
|
+
id: employee2._id,
|
|
1302
|
+
employeeId: employee2.employeeId,
|
|
1303
|
+
position: employee2.position
|
|
1304
|
+
},
|
|
1305
|
+
organizationId: employee2.organizationId,
|
|
1306
|
+
context
|
|
1307
|
+
});
|
|
1308
|
+
getLogger().info("Employee re-hired", {
|
|
1309
|
+
employeeId: employee2.employeeId
|
|
1310
|
+
});
|
|
1311
|
+
return employee2;
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* Get employee by ID
|
|
1315
|
+
*/
|
|
1316
|
+
async getEmployee(params) {
|
|
1317
|
+
this.ensureInitialized();
|
|
1318
|
+
const { employeeId, populateUser = true, session } = params;
|
|
1319
|
+
let query = this.models.EmployeeModel.findById(toObjectId(employeeId));
|
|
1320
|
+
if (session) query = query.session(session);
|
|
1321
|
+
if (populateUser) query = query.populate("userId", "name email phone");
|
|
1322
|
+
const employee2 = await query;
|
|
1323
|
+
if (!employee2) {
|
|
1324
|
+
throw new EmployeeNotFoundError(employeeId.toString());
|
|
1325
|
+
}
|
|
1326
|
+
return employee2;
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* List employees
|
|
1330
|
+
*/
|
|
1331
|
+
async listEmployees(params) {
|
|
1332
|
+
this.ensureInitialized();
|
|
1333
|
+
const { organizationId, filters = {}, pagination = {} } = params;
|
|
1334
|
+
let queryBuilder = employee().forOrganization(organizationId);
|
|
1335
|
+
if (filters.status) queryBuilder = queryBuilder.withStatus(filters.status);
|
|
1336
|
+
if (filters.department) queryBuilder = queryBuilder.inDepartment(filters.department);
|
|
1337
|
+
if (filters.employmentType) queryBuilder = queryBuilder.withEmploymentType(filters.employmentType);
|
|
1338
|
+
if (filters.minSalary) queryBuilder = queryBuilder.withMinSalary(filters.minSalary);
|
|
1339
|
+
if (filters.maxSalary) queryBuilder = queryBuilder.withMaxSalary(filters.maxSalary);
|
|
1340
|
+
const query = queryBuilder.build();
|
|
1341
|
+
const page = pagination.page || 1;
|
|
1342
|
+
const limit = pagination.limit || 20;
|
|
1343
|
+
const sort = pagination.sort || { createdAt: -1 };
|
|
1344
|
+
const [docs, totalDocs] = await Promise.all([
|
|
1345
|
+
this.models.EmployeeModel.find(query).populate("userId", "name email phone").sort(sort).skip((page - 1) * limit).limit(limit),
|
|
1346
|
+
this.models.EmployeeModel.countDocuments(query)
|
|
1347
|
+
]);
|
|
1348
|
+
return { docs, totalDocs, page, limit };
|
|
1349
|
+
}
|
|
1350
|
+
// ========================================
|
|
1351
|
+
// Compensation Management
|
|
1352
|
+
// ========================================
|
|
1353
|
+
/**
|
|
1354
|
+
* Update employee salary
|
|
1355
|
+
*/
|
|
1356
|
+
async updateSalary(params) {
|
|
1357
|
+
this.ensureInitialized();
|
|
1358
|
+
const { employeeId, compensation, effectiveFrom = /* @__PURE__ */ new Date(), context } = params;
|
|
1359
|
+
const session = context?.session;
|
|
1360
|
+
let query = this.models.EmployeeModel.findById(toObjectId(employeeId));
|
|
1361
|
+
if (session) query = query.session(session);
|
|
1362
|
+
const employee2 = await query;
|
|
1363
|
+
if (!employee2) {
|
|
1364
|
+
throw new EmployeeNotFoundError(employeeId.toString());
|
|
1365
|
+
}
|
|
1366
|
+
if (employee2.status === "terminated") {
|
|
1367
|
+
throw new EmployeeTerminatedError(employee2.employeeId);
|
|
1368
|
+
}
|
|
1369
|
+
const oldSalary = employee2.compensation.netSalary;
|
|
1370
|
+
if (compensation.baseAmount !== void 0) {
|
|
1371
|
+
employee2.compensation.baseAmount = compensation.baseAmount;
|
|
1372
|
+
}
|
|
1373
|
+
if (compensation.frequency) {
|
|
1374
|
+
employee2.compensation.frequency = compensation.frequency;
|
|
1375
|
+
}
|
|
1376
|
+
if (compensation.currency) {
|
|
1377
|
+
employee2.compensation.currency = compensation.currency;
|
|
1378
|
+
}
|
|
1379
|
+
employee2.compensation.effectiveFrom = effectiveFrom;
|
|
1380
|
+
if (hasPluginMethod(employee2, "updateSalaryCalculations")) {
|
|
1381
|
+
employee2.updateSalaryCalculations();
|
|
1382
|
+
}
|
|
1383
|
+
await employee2.save({ session });
|
|
1384
|
+
this._events.emitSync("salary:updated", {
|
|
1385
|
+
employee: { id: employee2._id, employeeId: employee2.employeeId },
|
|
1386
|
+
previousSalary: oldSalary || 0,
|
|
1387
|
+
newSalary: employee2.compensation.netSalary || 0,
|
|
1388
|
+
effectiveFrom,
|
|
1389
|
+
organizationId: employee2.organizationId,
|
|
1390
|
+
context
|
|
1391
|
+
});
|
|
1392
|
+
getLogger().info("Salary updated", {
|
|
1393
|
+
employeeId: employee2.employeeId,
|
|
1394
|
+
oldSalary,
|
|
1395
|
+
newSalary: employee2.compensation.netSalary
|
|
1396
|
+
});
|
|
1397
|
+
return employee2;
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Add allowance to employee
|
|
1401
|
+
*/
|
|
1402
|
+
async addAllowance(params) {
|
|
1403
|
+
this.ensureInitialized();
|
|
1404
|
+
const { employeeId, type, amount, taxable = true, recurring = true, effectiveFrom = /* @__PURE__ */ new Date(), effectiveTo, context } = params;
|
|
1405
|
+
const session = context?.session;
|
|
1406
|
+
let query = this.models.EmployeeModel.findById(toObjectId(employeeId));
|
|
1407
|
+
if (session) query = query.session(session);
|
|
1408
|
+
const employee2 = await query;
|
|
1409
|
+
if (!employee2) {
|
|
1410
|
+
throw new EmployeeNotFoundError(employeeId.toString());
|
|
1411
|
+
}
|
|
1412
|
+
if (employee2.status === "terminated") {
|
|
1413
|
+
throw new EmployeeTerminatedError(employee2.employeeId);
|
|
1414
|
+
}
|
|
1415
|
+
if (!employee2.compensation.allowances) {
|
|
1416
|
+
employee2.compensation.allowances = [];
|
|
1417
|
+
}
|
|
1418
|
+
employee2.compensation.allowances.push({
|
|
1419
|
+
type,
|
|
1420
|
+
name: type,
|
|
1421
|
+
amount,
|
|
1422
|
+
taxable,
|
|
1423
|
+
recurring,
|
|
1424
|
+
effectiveFrom,
|
|
1425
|
+
effectiveTo
|
|
1426
|
+
});
|
|
1427
|
+
if (hasPluginMethod(employee2, "updateSalaryCalculations")) {
|
|
1428
|
+
employee2.updateSalaryCalculations();
|
|
1429
|
+
}
|
|
1430
|
+
await employee2.save({ session });
|
|
1431
|
+
getLogger().info("Allowance added", {
|
|
1432
|
+
employeeId: employee2.employeeId,
|
|
1433
|
+
type,
|
|
1434
|
+
amount
|
|
1435
|
+
});
|
|
1436
|
+
return employee2;
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Remove allowance from employee
|
|
1440
|
+
*/
|
|
1441
|
+
async removeAllowance(params) {
|
|
1442
|
+
this.ensureInitialized();
|
|
1443
|
+
const { employeeId, type, context } = params;
|
|
1444
|
+
const session = context?.session;
|
|
1445
|
+
let query = this.models.EmployeeModel.findById(toObjectId(employeeId));
|
|
1446
|
+
if (session) query = query.session(session);
|
|
1447
|
+
const employee2 = await query;
|
|
1448
|
+
if (!employee2) {
|
|
1449
|
+
throw new EmployeeNotFoundError(employeeId.toString());
|
|
1450
|
+
}
|
|
1451
|
+
const before = employee2.compensation.allowances?.length || 0;
|
|
1452
|
+
if (hasPluginMethod(employee2, "removeAllowance")) {
|
|
1453
|
+
employee2.removeAllowance(type);
|
|
1454
|
+
} else {
|
|
1455
|
+
if (employee2.compensation.allowances) {
|
|
1456
|
+
employee2.compensation.allowances = employee2.compensation.allowances.filter(
|
|
1457
|
+
(a) => a.type !== type
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
const after = employee2.compensation.allowances?.length || 0;
|
|
1462
|
+
if (before === after) {
|
|
1463
|
+
throw new Error(`Allowance type '${type}' not found`);
|
|
1464
|
+
}
|
|
1465
|
+
await employee2.save({ session });
|
|
1466
|
+
getLogger().info("Allowance removed", {
|
|
1467
|
+
employeeId: employee2.employeeId,
|
|
1468
|
+
type
|
|
1469
|
+
});
|
|
1470
|
+
return employee2;
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* Add deduction to employee
|
|
1474
|
+
*/
|
|
1475
|
+
async addDeduction(params) {
|
|
1476
|
+
this.ensureInitialized();
|
|
1477
|
+
const { employeeId, type, amount, auto = false, recurring = true, description, effectiveFrom = /* @__PURE__ */ new Date(), effectiveTo, context } = params;
|
|
1478
|
+
const session = context?.session;
|
|
1479
|
+
let query = this.models.EmployeeModel.findById(toObjectId(employeeId));
|
|
1480
|
+
if (session) query = query.session(session);
|
|
1481
|
+
const employee2 = await query;
|
|
1482
|
+
if (!employee2) {
|
|
1483
|
+
throw new EmployeeNotFoundError(employeeId.toString());
|
|
1484
|
+
}
|
|
1485
|
+
if (employee2.status === "terminated") {
|
|
1486
|
+
throw new EmployeeTerminatedError(employee2.employeeId);
|
|
1487
|
+
}
|
|
1488
|
+
if (!employee2.compensation.deductions) {
|
|
1489
|
+
employee2.compensation.deductions = [];
|
|
1490
|
+
}
|
|
1491
|
+
employee2.compensation.deductions.push({
|
|
1492
|
+
type,
|
|
1493
|
+
name: type,
|
|
1494
|
+
amount,
|
|
1495
|
+
auto,
|
|
1496
|
+
recurring,
|
|
1497
|
+
description,
|
|
1498
|
+
effectiveFrom,
|
|
1499
|
+
effectiveTo
|
|
1500
|
+
});
|
|
1501
|
+
if (hasPluginMethod(employee2, "updateSalaryCalculations")) {
|
|
1502
|
+
employee2.updateSalaryCalculations();
|
|
1503
|
+
}
|
|
1504
|
+
await employee2.save({ session });
|
|
1505
|
+
getLogger().info("Deduction added", {
|
|
1506
|
+
employeeId: employee2.employeeId,
|
|
1507
|
+
type,
|
|
1508
|
+
amount,
|
|
1509
|
+
auto
|
|
1510
|
+
});
|
|
1511
|
+
return employee2;
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* Remove deduction from employee
|
|
1515
|
+
*/
|
|
1516
|
+
async removeDeduction(params) {
|
|
1517
|
+
this.ensureInitialized();
|
|
1518
|
+
const { employeeId, type, context } = params;
|
|
1519
|
+
const session = context?.session;
|
|
1520
|
+
let query = this.models.EmployeeModel.findById(toObjectId(employeeId));
|
|
1521
|
+
if (session) query = query.session(session);
|
|
1522
|
+
const employee2 = await query;
|
|
1523
|
+
if (!employee2) {
|
|
1524
|
+
throw new EmployeeNotFoundError(employeeId.toString());
|
|
1525
|
+
}
|
|
1526
|
+
const before = employee2.compensation.deductions?.length || 0;
|
|
1527
|
+
if (hasPluginMethod(employee2, "removeDeduction")) {
|
|
1528
|
+
employee2.removeDeduction(type);
|
|
1529
|
+
} else {
|
|
1530
|
+
if (employee2.compensation.deductions) {
|
|
1531
|
+
employee2.compensation.deductions = employee2.compensation.deductions.filter(
|
|
1532
|
+
(d) => d.type !== type
|
|
1533
|
+
);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
const after = employee2.compensation.deductions?.length || 0;
|
|
1537
|
+
if (before === after) {
|
|
1538
|
+
throw new Error(`Deduction type '${type}' not found`);
|
|
1539
|
+
}
|
|
1540
|
+
await employee2.save({ session });
|
|
1541
|
+
getLogger().info("Deduction removed", {
|
|
1542
|
+
employeeId: employee2.employeeId,
|
|
1543
|
+
type
|
|
1544
|
+
});
|
|
1545
|
+
return employee2;
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Update bank details
|
|
1549
|
+
*/
|
|
1550
|
+
async updateBankDetails(params) {
|
|
1551
|
+
this.ensureInitialized();
|
|
1552
|
+
const { employeeId, bankDetails, context } = params;
|
|
1553
|
+
const session = context?.session;
|
|
1554
|
+
let query = this.models.EmployeeModel.findById(toObjectId(employeeId));
|
|
1555
|
+
if (session) query = query.session(session);
|
|
1556
|
+
const employee2 = await query;
|
|
1557
|
+
if (!employee2) {
|
|
1558
|
+
throw new EmployeeNotFoundError(employeeId.toString());
|
|
1559
|
+
}
|
|
1560
|
+
employee2.bankDetails = { ...employee2.bankDetails, ...bankDetails };
|
|
1561
|
+
await employee2.save({ session });
|
|
1562
|
+
getLogger().info("Bank details updated", {
|
|
1563
|
+
employeeId: employee2.employeeId
|
|
1564
|
+
});
|
|
1565
|
+
return employee2;
|
|
1566
|
+
}
|
|
1567
|
+
// ========================================
|
|
1568
|
+
// Payroll Processing
|
|
1569
|
+
// ========================================
|
|
1570
|
+
/**
|
|
1571
|
+
* Process salary for single employee
|
|
1572
|
+
*
|
|
1573
|
+
* ATOMICITY: This method creates its own transaction if none provided.
|
|
1574
|
+
* All database operations (PayrollRecord, Transaction, Employee stats)
|
|
1575
|
+
* are atomic - either all succeed or all fail.
|
|
1576
|
+
*/
|
|
1577
|
+
async processSalary(params) {
|
|
1578
|
+
this.ensureInitialized();
|
|
1579
|
+
const { employeeId, month, year, paymentDate = /* @__PURE__ */ new Date(), paymentMethod = "bank", context } = params;
|
|
1580
|
+
const providedSession = context?.session;
|
|
1581
|
+
const session = providedSession || await mongoose2.startSession();
|
|
1582
|
+
const shouldManageTransaction = !providedSession && session != null;
|
|
1583
|
+
try {
|
|
1584
|
+
if (shouldManageTransaction) {
|
|
1585
|
+
await session.startTransaction();
|
|
1586
|
+
}
|
|
1587
|
+
let query = this.models.EmployeeModel.findById(toObjectId(employeeId)).populate("userId", "name email");
|
|
1588
|
+
if (session) query = query.session(session);
|
|
1589
|
+
const employee2 = await query;
|
|
1590
|
+
if (!employee2) {
|
|
1591
|
+
throw new EmployeeNotFoundError(employeeId.toString());
|
|
1592
|
+
}
|
|
1593
|
+
const canReceive = hasPluginMethod(employee2, "canReceiveSalary") ? employee2.canReceiveSalary() : employee2.status === "active" && (employee2.compensation?.baseAmount || 0) > 0;
|
|
1594
|
+
if (!canReceive) {
|
|
1595
|
+
throw new NotEligibleError("Employee is not eligible to receive salary");
|
|
1596
|
+
}
|
|
1597
|
+
const existingQuery = payroll().forEmployee(employeeId).forPeriod(month, year).whereIn("status", ["paid", "processing"]).build();
|
|
1598
|
+
let existingRecordQuery = this.models.PayrollRecordModel.findOne(existingQuery);
|
|
1599
|
+
if (session) existingRecordQuery = existingRecordQuery.session(session);
|
|
1600
|
+
const existingRecord = await existingRecordQuery;
|
|
1601
|
+
if (existingRecord) {
|
|
1602
|
+
throw new DuplicatePayrollError(employee2.employeeId, month, year);
|
|
1603
|
+
}
|
|
1604
|
+
const period = { ...getPayPeriod(month, year), payDate: paymentDate };
|
|
1605
|
+
const breakdown = await this.calculateSalaryBreakdown(employee2, period, session);
|
|
1606
|
+
const userIdValue = employee2.userId ? typeof employee2.userId === "object" && "_id" in employee2.userId ? employee2.userId._id : employee2.userId : void 0;
|
|
1607
|
+
const [payrollRecord] = await this.models.PayrollRecordModel.create([{
|
|
1608
|
+
organizationId: employee2.organizationId,
|
|
1609
|
+
employeeId: employee2._id,
|
|
1610
|
+
userId: userIdValue,
|
|
1611
|
+
period,
|
|
1612
|
+
breakdown,
|
|
1613
|
+
status: "processing",
|
|
1614
|
+
paymentMethod,
|
|
1615
|
+
processedAt: /* @__PURE__ */ new Date(),
|
|
1616
|
+
processedBy: context?.userId ? toObjectId(context.userId) : void 0
|
|
1617
|
+
}], session ? { session } : {});
|
|
1618
|
+
const [transaction] = await this.models.TransactionModel.create([{
|
|
1619
|
+
organizationId: employee2.organizationId,
|
|
1620
|
+
type: "expense",
|
|
1621
|
+
category: HRM_TRANSACTION_CATEGORIES.SALARY,
|
|
1622
|
+
amount: breakdown.netSalary,
|
|
1623
|
+
method: paymentMethod,
|
|
1624
|
+
status: "completed",
|
|
1625
|
+
date: paymentDate,
|
|
1626
|
+
referenceId: employee2._id,
|
|
1627
|
+
referenceModel: "Employee",
|
|
1628
|
+
handledBy: context?.userId ? toObjectId(context.userId) : void 0,
|
|
1629
|
+
notes: `Salary payment - ${employee2.userId?.name || employee2.employeeId} (${month}/${year})`,
|
|
1630
|
+
metadata: {
|
|
1631
|
+
employeeId: employee2.employeeId,
|
|
1632
|
+
payrollRecordId: payrollRecord._id,
|
|
1633
|
+
period: { month, year },
|
|
1634
|
+
breakdown: {
|
|
1635
|
+
base: breakdown.baseAmount,
|
|
1636
|
+
allowances: sumAllowances(breakdown.allowances),
|
|
1637
|
+
deductions: sumDeductions(breakdown.deductions),
|
|
1638
|
+
tax: breakdown.taxAmount || 0,
|
|
1639
|
+
gross: breakdown.grossSalary,
|
|
1640
|
+
net: breakdown.netSalary
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
}], session ? { session } : {});
|
|
1644
|
+
payrollRecord.transactionId = transaction._id;
|
|
1645
|
+
payrollRecord.status = "paid";
|
|
1646
|
+
payrollRecord.paidAt = paymentDate;
|
|
1647
|
+
await payrollRecord.save(session ? { session } : {});
|
|
1648
|
+
await this.updatePayrollStats(employee2, breakdown.netSalary, paymentDate, session);
|
|
1649
|
+
if (shouldManageTransaction) {
|
|
1650
|
+
await session.commitTransaction();
|
|
1651
|
+
}
|
|
1652
|
+
this._events.emitSync("salary:processed", {
|
|
1653
|
+
employee: {
|
|
1654
|
+
id: employee2._id,
|
|
1655
|
+
employeeId: employee2.employeeId,
|
|
1656
|
+
name: employee2.userId?.name
|
|
1657
|
+
},
|
|
1658
|
+
payroll: {
|
|
1659
|
+
id: payrollRecord._id,
|
|
1660
|
+
period: { month, year },
|
|
1661
|
+
grossAmount: breakdown.grossSalary,
|
|
1662
|
+
netAmount: breakdown.netSalary
|
|
1663
|
+
},
|
|
1664
|
+
transactionId: transaction._id,
|
|
1665
|
+
organizationId: employee2.organizationId,
|
|
1666
|
+
context
|
|
1667
|
+
});
|
|
1668
|
+
getLogger().info("Salary processed", {
|
|
1669
|
+
employeeId: employee2.employeeId,
|
|
1670
|
+
month,
|
|
1671
|
+
year,
|
|
1672
|
+
amount: breakdown.netSalary
|
|
1673
|
+
});
|
|
1674
|
+
return { payrollRecord, transaction, employee: employee2 };
|
|
1675
|
+
} catch (error) {
|
|
1676
|
+
if (shouldManageTransaction && session?.inTransaction()) {
|
|
1677
|
+
await session.abortTransaction();
|
|
1678
|
+
}
|
|
1679
|
+
throw error;
|
|
1680
|
+
} finally {
|
|
1681
|
+
if (shouldManageTransaction && session) {
|
|
1682
|
+
await session.endSession();
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
/**
|
|
1687
|
+
* Process bulk payroll
|
|
1688
|
+
*
|
|
1689
|
+
* ATOMICITY STRATEGY: Each employee is processed in its own transaction.
|
|
1690
|
+
* This allows partial success - some employees can succeed while others fail.
|
|
1691
|
+
* Failed employees don't affect successful ones.
|
|
1692
|
+
*/
|
|
1693
|
+
async processBulkPayroll(params) {
|
|
1694
|
+
this.ensureInitialized();
|
|
1695
|
+
const { organizationId, month, year, employeeIds = [], paymentDate = /* @__PURE__ */ new Date(), paymentMethod = "bank", context } = params;
|
|
1696
|
+
const query = { organizationId: toObjectId(organizationId), status: "active" };
|
|
1697
|
+
if (employeeIds.length > 0) {
|
|
1698
|
+
query._id = { $in: employeeIds.map(toObjectId) };
|
|
1699
|
+
}
|
|
1700
|
+
const employees = await this.models.EmployeeModel.find(query);
|
|
1701
|
+
const results = {
|
|
1702
|
+
successful: [],
|
|
1703
|
+
failed: [],
|
|
1704
|
+
total: employees.length
|
|
1705
|
+
};
|
|
1706
|
+
for (const employee2 of employees) {
|
|
1707
|
+
try {
|
|
1708
|
+
const result = await this.processSalary({
|
|
1709
|
+
employeeId: employee2._id,
|
|
1710
|
+
month,
|
|
1711
|
+
year,
|
|
1712
|
+
paymentDate,
|
|
1713
|
+
paymentMethod,
|
|
1714
|
+
context: { ...context, session: void 0 }
|
|
1715
|
+
// Don't pass session - let processSalary create its own
|
|
1716
|
+
});
|
|
1717
|
+
results.successful.push({
|
|
1718
|
+
employeeId: employee2.employeeId,
|
|
1719
|
+
amount: result.payrollRecord.breakdown.netSalary,
|
|
1720
|
+
transactionId: result.transaction._id
|
|
1721
|
+
});
|
|
1722
|
+
} catch (error) {
|
|
1723
|
+
results.failed.push({
|
|
1724
|
+
employeeId: employee2.employeeId,
|
|
1725
|
+
error: error.message
|
|
1726
|
+
});
|
|
1727
|
+
getLogger().error("Failed to process salary", {
|
|
1728
|
+
employeeId: employee2.employeeId,
|
|
1729
|
+
error: error.message
|
|
1730
|
+
});
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
this._events.emitSync("payroll:completed", {
|
|
1734
|
+
organizationId: toObjectId(organizationId),
|
|
1735
|
+
period: { month, year },
|
|
1736
|
+
summary: {
|
|
1737
|
+
total: results.total,
|
|
1738
|
+
successful: results.successful.length,
|
|
1739
|
+
failed: results.failed.length,
|
|
1740
|
+
totalAmount: results.successful.reduce((sum, r) => sum + r.amount, 0)
|
|
1741
|
+
},
|
|
1742
|
+
context
|
|
1743
|
+
});
|
|
1744
|
+
getLogger().info("Bulk payroll processed", {
|
|
1745
|
+
organizationId: organizationId.toString(),
|
|
1746
|
+
month,
|
|
1747
|
+
year,
|
|
1748
|
+
total: results.total,
|
|
1749
|
+
successful: results.successful.length,
|
|
1750
|
+
failed: results.failed.length
|
|
1751
|
+
});
|
|
1752
|
+
return results;
|
|
1753
|
+
}
|
|
1754
|
+
/**
|
|
1755
|
+
* Get payroll history
|
|
1756
|
+
*/
|
|
1757
|
+
async payrollHistory(params) {
|
|
1758
|
+
this.ensureInitialized();
|
|
1759
|
+
const { employeeId, organizationId, month, year, status, pagination = {} } = params;
|
|
1760
|
+
let queryBuilder = payroll();
|
|
1761
|
+
if (employeeId) queryBuilder = queryBuilder.forEmployee(employeeId);
|
|
1762
|
+
if (organizationId) queryBuilder = queryBuilder.forOrganization(organizationId);
|
|
1763
|
+
if (month || year) queryBuilder = queryBuilder.forPeriod(month, year);
|
|
1764
|
+
if (status) queryBuilder = queryBuilder.withStatus(status);
|
|
1765
|
+
const query = queryBuilder.build();
|
|
1766
|
+
const page = pagination.page || 1;
|
|
1767
|
+
const limit = pagination.limit || 20;
|
|
1768
|
+
const sort = pagination.sort || { "period.year": -1, "period.month": -1 };
|
|
1769
|
+
return this.models.PayrollRecordModel.find(query).populate("employeeId", "employeeId position department").populate("userId", "name email").populate("transactionId", "amount method status date").sort(sort).skip((page - 1) * limit).limit(limit);
|
|
1770
|
+
}
|
|
1771
|
+
/**
|
|
1772
|
+
* Get payroll summary
|
|
1773
|
+
*/
|
|
1774
|
+
async payrollSummary(params) {
|
|
1775
|
+
this.ensureInitialized();
|
|
1776
|
+
const { organizationId, month, year } = params;
|
|
1777
|
+
const query = { organizationId: toObjectId(organizationId) };
|
|
1778
|
+
if (month) query["period.month"] = month;
|
|
1779
|
+
if (year) query["period.year"] = year;
|
|
1780
|
+
const [summary] = await this.models.PayrollRecordModel.aggregate([
|
|
1781
|
+
{ $match: query },
|
|
1782
|
+
{
|
|
1783
|
+
$group: {
|
|
1784
|
+
_id: null,
|
|
1785
|
+
totalGross: { $sum: "$breakdown.grossSalary" },
|
|
1786
|
+
totalNet: { $sum: "$breakdown.netSalary" },
|
|
1787
|
+
totalDeductions: { $sum: { $sum: "$breakdown.deductions.amount" } },
|
|
1788
|
+
totalTax: { $sum: { $ifNull: ["$breakdown.taxAmount", 0] } },
|
|
1789
|
+
employeeCount: { $sum: 1 },
|
|
1790
|
+
paidCount: { $sum: { $cond: [{ $eq: ["$status", "paid"] }, 1, 0] } },
|
|
1791
|
+
pendingCount: { $sum: { $cond: [{ $eq: ["$status", "pending"] }, 1, 0] } }
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
]);
|
|
1795
|
+
return summary || {
|
|
1796
|
+
totalGross: 0,
|
|
1797
|
+
totalNet: 0,
|
|
1798
|
+
totalDeductions: 0,
|
|
1799
|
+
totalTax: 0,
|
|
1800
|
+
employeeCount: 0,
|
|
1801
|
+
paidCount: 0,
|
|
1802
|
+
pendingCount: 0
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
/**
|
|
1806
|
+
* Export payroll data
|
|
1807
|
+
*/
|
|
1808
|
+
async exportPayroll(params) {
|
|
1809
|
+
this.ensureInitialized();
|
|
1810
|
+
const { organizationId, startDate, endDate } = params;
|
|
1811
|
+
const query = {
|
|
1812
|
+
organizationId: toObjectId(organizationId),
|
|
1813
|
+
"period.payDate": { $gte: startDate, $lte: endDate }
|
|
1814
|
+
};
|
|
1815
|
+
const records = await this.models.PayrollRecordModel.find(query).populate("employeeId", "employeeId position department").populate("userId", "name email").populate("transactionId", "amount method status date").sort({ "period.year": -1, "period.month": -1 });
|
|
1816
|
+
await this.models.PayrollRecordModel.updateMany(query, {
|
|
1817
|
+
exported: true,
|
|
1818
|
+
exportedAt: /* @__PURE__ */ new Date()
|
|
1819
|
+
});
|
|
1820
|
+
this._events.emitSync("payroll:exported", {
|
|
1821
|
+
organizationId: toObjectId(organizationId),
|
|
1822
|
+
dateRange: { start: startDate, end: endDate },
|
|
1823
|
+
recordCount: records.length,
|
|
1824
|
+
format: "json"
|
|
1825
|
+
});
|
|
1826
|
+
getLogger().info("Payroll data exported", {
|
|
1827
|
+
organizationId: organizationId.toString(),
|
|
1828
|
+
count: records.length
|
|
1829
|
+
});
|
|
1830
|
+
return records;
|
|
1831
|
+
}
|
|
1832
|
+
// ========================================
|
|
1833
|
+
// Helper Methods
|
|
1834
|
+
// ========================================
|
|
1835
|
+
/**
|
|
1836
|
+
* Calculate salary breakdown with proper handling for:
|
|
1837
|
+
* - Effective dates on allowances/deductions
|
|
1838
|
+
* - Pro-rating for mid-period hires AND terminations
|
|
1839
|
+
* - Tax calculation
|
|
1840
|
+
* - Working days vs calendar days for attendance
|
|
1841
|
+
*/
|
|
1842
|
+
async calculateSalaryBreakdown(employee2, period, session) {
|
|
1843
|
+
const comp = employee2.compensation;
|
|
1844
|
+
let baseAmount = comp.baseAmount;
|
|
1845
|
+
const workingDaysInMonth = getWorkingDaysInMonth(period.year, period.month);
|
|
1846
|
+
const proRating = this.calculateProRatingAdvanced(
|
|
1847
|
+
employee2.hireDate,
|
|
1848
|
+
employee2.terminationDate || null,
|
|
1849
|
+
period.startDate,
|
|
1850
|
+
period.endDate,
|
|
1851
|
+
workingDaysInMonth
|
|
1852
|
+
);
|
|
1853
|
+
if (proRating.isProRated && this.config.payroll.allowProRating) {
|
|
1854
|
+
baseAmount = Math.round(baseAmount * proRating.ratio);
|
|
1855
|
+
}
|
|
1856
|
+
const effectiveAllowances = (comp.allowances || []).filter((a) => isEffectiveForPeriod(a, period.startDate, period.endDate)).filter((a) => a.recurring !== false);
|
|
1857
|
+
const effectiveDeductions = (comp.deductions || []).filter((d) => isEffectiveForPeriod(d, period.startDate, period.endDate)).filter((d) => d.auto || d.recurring);
|
|
1858
|
+
const allowances = effectiveAllowances.map((a) => ({
|
|
1859
|
+
type: a.type,
|
|
1860
|
+
amount: proRating.isProRated && this.config.payroll.allowProRating ? Math.round(a.amount * proRating.ratio) : a.amount,
|
|
1861
|
+
taxable: a.taxable ?? true
|
|
1862
|
+
}));
|
|
1863
|
+
const deductions = effectiveDeductions.map((d) => ({
|
|
1864
|
+
type: d.type,
|
|
1865
|
+
amount: proRating.isProRated && this.config.payroll.allowProRating ? Math.round(d.amount * proRating.ratio) : d.amount,
|
|
1866
|
+
description: d.description
|
|
1867
|
+
}));
|
|
1868
|
+
let attendanceDeduction = 0;
|
|
1869
|
+
if (this.models.AttendanceModel && this.config.payroll.attendanceIntegration) {
|
|
1870
|
+
attendanceDeduction = await this.calculateAttendanceDeduction(
|
|
1871
|
+
employee2._id,
|
|
1872
|
+
employee2.organizationId,
|
|
1873
|
+
period,
|
|
1874
|
+
baseAmount / proRating.workingDays,
|
|
1875
|
+
// Daily rate based on working days
|
|
1876
|
+
proRating.workingDays,
|
|
1877
|
+
session
|
|
1878
|
+
);
|
|
1879
|
+
}
|
|
1880
|
+
if (attendanceDeduction > 0) {
|
|
1881
|
+
deductions.push({
|
|
1882
|
+
type: "absence",
|
|
1883
|
+
amount: attendanceDeduction,
|
|
1884
|
+
description: "Unpaid leave deduction"
|
|
1885
|
+
});
|
|
1886
|
+
}
|
|
1887
|
+
const grossSalary = calculateGross(baseAmount, allowances);
|
|
1888
|
+
const taxableAllowances = allowances.filter((a) => a.taxable);
|
|
1889
|
+
const taxableAmount = baseAmount + sumAllowances(taxableAllowances);
|
|
1890
|
+
let taxAmount = 0;
|
|
1891
|
+
const currency = comp.currency || this.config.payroll.defaultCurrency;
|
|
1892
|
+
const taxBrackets = TAX_BRACKETS[currency] || [];
|
|
1893
|
+
if (taxBrackets.length > 0 && this.config.payroll.autoDeductions) {
|
|
1894
|
+
const annualTaxable = taxableAmount * 12;
|
|
1895
|
+
const annualTax = applyTaxBrackets(annualTaxable, taxBrackets);
|
|
1896
|
+
taxAmount = Math.round(annualTax / 12);
|
|
1897
|
+
}
|
|
1898
|
+
if (taxAmount > 0) {
|
|
1899
|
+
deductions.push({
|
|
1900
|
+
type: "tax",
|
|
1901
|
+
amount: taxAmount,
|
|
1902
|
+
description: "Income tax"
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
const netSalary = calculateNet(grossSalary, deductions);
|
|
1906
|
+
return {
|
|
1907
|
+
baseAmount,
|
|
1908
|
+
allowances,
|
|
1909
|
+
deductions,
|
|
1910
|
+
grossSalary,
|
|
1911
|
+
netSalary,
|
|
1912
|
+
taxableAmount,
|
|
1913
|
+
taxAmount,
|
|
1914
|
+
workingDays: proRating.workingDays,
|
|
1915
|
+
actualDays: proRating.actualDays,
|
|
1916
|
+
proRatedAmount: proRating.isProRated ? baseAmount : 0,
|
|
1917
|
+
attendanceDeduction
|
|
1918
|
+
};
|
|
1919
|
+
}
|
|
1920
|
+
/**
|
|
1921
|
+
* Advanced pro-rating calculation that handles:
|
|
1922
|
+
* - Mid-period hires
|
|
1923
|
+
* - Mid-period terminations
|
|
1924
|
+
* - Working days (not calendar days)
|
|
1925
|
+
*/
|
|
1926
|
+
calculateProRatingAdvanced(hireDate, terminationDate, periodStart, periodEnd, workingDaysInMonth) {
|
|
1927
|
+
const hire = new Date(hireDate);
|
|
1928
|
+
const termination = terminationDate ? new Date(terminationDate) : null;
|
|
1929
|
+
const effectiveStart = hire > periodStart ? hire : periodStart;
|
|
1930
|
+
const effectiveEnd = termination && termination < periodEnd ? termination : periodEnd;
|
|
1931
|
+
if (effectiveStart > periodEnd || termination && termination < periodStart) {
|
|
1932
|
+
return {
|
|
1933
|
+
isProRated: true,
|
|
1934
|
+
ratio: 0,
|
|
1935
|
+
workingDays: workingDaysInMonth,
|
|
1936
|
+
actualDays: 0
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
const totalDays = diffInDays(periodStart, periodEnd) + 1;
|
|
1940
|
+
const actualDays = diffInDays(effectiveStart, effectiveEnd) + 1;
|
|
1941
|
+
const ratio = actualDays / totalDays;
|
|
1942
|
+
const actualWorkingDays = Math.round(workingDaysInMonth * ratio);
|
|
1943
|
+
const isProRated = hire > periodStart || termination !== null && termination < periodEnd;
|
|
1944
|
+
return {
|
|
1945
|
+
isProRated,
|
|
1946
|
+
ratio,
|
|
1947
|
+
workingDays: workingDaysInMonth,
|
|
1948
|
+
actualDays: actualWorkingDays
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
/**
|
|
1952
|
+
* Calculate attendance deduction using working days (not calendar days)
|
|
1953
|
+
*/
|
|
1954
|
+
async calculateAttendanceDeduction(employeeId, organizationId, period, dailyRate, expectedWorkingDays, session) {
|
|
1955
|
+
try {
|
|
1956
|
+
if (!this.models.AttendanceModel) return 0;
|
|
1957
|
+
let query = this.models.AttendanceModel.findOne({
|
|
1958
|
+
tenantId: organizationId,
|
|
1959
|
+
targetId: employeeId,
|
|
1960
|
+
targetModel: "Employee",
|
|
1961
|
+
year: period.year,
|
|
1962
|
+
month: period.month
|
|
1963
|
+
});
|
|
1964
|
+
if (session) query = query.session(session);
|
|
1965
|
+
const attendance = await query;
|
|
1966
|
+
if (!attendance) return 0;
|
|
1967
|
+
const workedDays = attendance.totalWorkDays || 0;
|
|
1968
|
+
const absentDays = Math.max(0, expectedWorkingDays - workedDays);
|
|
1969
|
+
return Math.round(absentDays * dailyRate);
|
|
1970
|
+
} catch (error) {
|
|
1971
|
+
getLogger().warn("Failed to calculate attendance deduction", {
|
|
1972
|
+
employeeId: employeeId.toString(),
|
|
1973
|
+
error: error.message
|
|
1974
|
+
});
|
|
1975
|
+
return 0;
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
async updatePayrollStats(employee2, amount, paymentDate, session) {
|
|
1979
|
+
if (!employee2.payrollStats) {
|
|
1980
|
+
employee2.payrollStats = {
|
|
1981
|
+
totalPaid: 0,
|
|
1982
|
+
paymentsThisYear: 0,
|
|
1983
|
+
averageMonthly: 0
|
|
1984
|
+
};
|
|
1985
|
+
}
|
|
1986
|
+
employee2.payrollStats.totalPaid = (employee2.payrollStats.totalPaid || 0) + amount;
|
|
1987
|
+
employee2.payrollStats.lastPaymentDate = paymentDate;
|
|
1988
|
+
employee2.payrollStats.paymentsThisYear = (employee2.payrollStats.paymentsThisYear || 0) + 1;
|
|
1989
|
+
employee2.payrollStats.averageMonthly = Math.round(
|
|
1990
|
+
employee2.payrollStats.totalPaid / employee2.payrollStats.paymentsThisYear
|
|
1991
|
+
);
|
|
1992
|
+
employee2.payrollStats.nextPaymentDate = addMonths(paymentDate, 1);
|
|
1993
|
+
await employee2.save(session ? { session } : {});
|
|
1994
|
+
}
|
|
1995
|
+
// ========================================
|
|
1996
|
+
// Static Factory
|
|
1997
|
+
// ========================================
|
|
1998
|
+
/**
|
|
1999
|
+
* Create a new Payroll instance
|
|
2000
|
+
*/
|
|
2001
|
+
static create() {
|
|
2002
|
+
return new _Payroll();
|
|
2003
|
+
}
|
|
2004
|
+
};
|
|
2005
|
+
var PayrollBuilder = class {
|
|
2006
|
+
_models = null;
|
|
2007
|
+
_config;
|
|
2008
|
+
_singleTenant = null;
|
|
2009
|
+
_logger;
|
|
2010
|
+
/**
|
|
2011
|
+
* Set models
|
|
2012
|
+
*/
|
|
2013
|
+
withModels(models) {
|
|
2014
|
+
this._models = models;
|
|
2015
|
+
return this;
|
|
2016
|
+
}
|
|
2017
|
+
/**
|
|
2018
|
+
* Set config overrides
|
|
2019
|
+
*/
|
|
2020
|
+
withConfig(config) {
|
|
2021
|
+
this._config = config;
|
|
2022
|
+
return this;
|
|
2023
|
+
}
|
|
2024
|
+
/**
|
|
2025
|
+
* Enable single-tenant mode
|
|
2026
|
+
*
|
|
2027
|
+
* Use this when building a single-organization HRM (no organizationId needed)
|
|
2028
|
+
*
|
|
2029
|
+
* @example
|
|
2030
|
+
* ```typescript
|
|
2031
|
+
* const payroll = createPayrollInstance()
|
|
2032
|
+
* .withModels({ EmployeeModel, PayrollRecordModel, TransactionModel })
|
|
2033
|
+
* .withSingleTenant({ organizationId: 'my-company' })
|
|
2034
|
+
* .build();
|
|
2035
|
+
* ```
|
|
2036
|
+
*/
|
|
2037
|
+
withSingleTenant(config) {
|
|
2038
|
+
this._singleTenant = config;
|
|
2039
|
+
return this;
|
|
2040
|
+
}
|
|
2041
|
+
/**
|
|
2042
|
+
* Enable single-tenant mode (shorthand)
|
|
2043
|
+
*
|
|
2044
|
+
* Alias for withSingleTenant() - consistent with @classytic/clockin API
|
|
2045
|
+
*
|
|
2046
|
+
* @example
|
|
2047
|
+
* ```typescript
|
|
2048
|
+
* const payroll = createPayrollInstance()
|
|
2049
|
+
* .withModels({ ... })
|
|
2050
|
+
* .forSingleTenant() // ← No organizationId needed!
|
|
2051
|
+
* .build();
|
|
2052
|
+
* ```
|
|
2053
|
+
*/
|
|
2054
|
+
forSingleTenant(config = {}) {
|
|
2055
|
+
return this.withSingleTenant(config);
|
|
2056
|
+
}
|
|
2057
|
+
/**
|
|
2058
|
+
* Set custom logger
|
|
2059
|
+
*/
|
|
2060
|
+
withLogger(logger) {
|
|
2061
|
+
this._logger = logger;
|
|
2062
|
+
return this;
|
|
2063
|
+
}
|
|
2064
|
+
/**
|
|
2065
|
+
* Build and initialize Payroll instance
|
|
2066
|
+
*/
|
|
2067
|
+
build() {
|
|
2068
|
+
if (!this._models) {
|
|
2069
|
+
throw new Error("Models are required. Call withModels() first.");
|
|
2070
|
+
}
|
|
2071
|
+
const payroll3 = new Payroll();
|
|
2072
|
+
payroll3.initialize({
|
|
2073
|
+
EmployeeModel: this._models.EmployeeModel,
|
|
2074
|
+
PayrollRecordModel: this._models.PayrollRecordModel,
|
|
2075
|
+
TransactionModel: this._models.TransactionModel,
|
|
2076
|
+
AttendanceModel: this._models.AttendanceModel,
|
|
2077
|
+
config: this._config,
|
|
2078
|
+
singleTenant: this._singleTenant,
|
|
2079
|
+
logger: this._logger
|
|
2080
|
+
});
|
|
2081
|
+
return payroll3;
|
|
2082
|
+
}
|
|
2083
|
+
};
|
|
2084
|
+
function createPayrollInstance() {
|
|
2085
|
+
return new PayrollBuilder();
|
|
2086
|
+
}
|
|
2087
|
+
var payrollInstance = null;
|
|
2088
|
+
function getPayroll() {
|
|
2089
|
+
if (!payrollInstance) {
|
|
2090
|
+
payrollInstance = new Payroll();
|
|
2091
|
+
}
|
|
2092
|
+
return payrollInstance;
|
|
2093
|
+
}
|
|
2094
|
+
function resetPayroll() {
|
|
2095
|
+
payrollInstance = null;
|
|
2096
|
+
Container.resetInstance();
|
|
2097
|
+
}
|
|
2098
|
+
var payroll2 = getPayroll();
|
|
2099
|
+
var payroll_default = payroll2;
|
|
2100
|
+
|
|
2101
|
+
export { Payroll, PayrollBuilder, createPayrollInstance, payroll_default as default, getPayroll, payroll2 as payroll, resetPayroll };
|
|
2102
|
+
//# sourceMappingURL=payroll.js.map
|
|
2103
|
+
//# sourceMappingURL=payroll.js.map
|