@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.
Potentially problematic release.
This version of @classytic/payroll might be problematic. Click here for more details.
- package/README.md +2599 -574
- package/dist/calculators/index.d.ts +433 -0
- package/dist/calculators/index.js +283 -0
- package/dist/calculators/index.js.map +1 -0
- package/dist/core/index.d.ts +314 -0
- package/dist/core/index.js +1166 -0
- package/dist/core/index.js.map +1 -0
- package/dist/employee-identity-DXhgOgXE.d.ts +473 -0
- package/dist/employee.factory-BlZqhiCk.d.ts +189 -0
- package/dist/idempotency-Cw2CWicb.d.ts +52 -0
- package/dist/index.d.ts +902 -0
- package/dist/index.js +9108 -0
- package/dist/index.js.map +1 -0
- package/dist/jurisdiction/index.d.ts +660 -0
- package/dist/jurisdiction/index.js +533 -0
- package/dist/jurisdiction/index.js.map +1 -0
- package/dist/payroll.d.ts +429 -0
- package/dist/payroll.js +5192 -0
- package/dist/payroll.js.map +1 -0
- package/dist/schemas/index.d.ts +3262 -0
- package/dist/schemas/index.js +780 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/services/index.d.ts +582 -0
- package/dist/services/index.js +2172 -0
- package/dist/services/index.js.map +1 -0
- package/dist/shift-compliance/index.d.ts +1171 -0
- package/dist/shift-compliance/index.js +1479 -0
- package/dist/shift-compliance/index.js.map +1 -0
- package/dist/types-BN3K_Uhr.d.ts +1842 -0
- package/dist/utils/index.d.ts +893 -0
- package/dist/utils/index.js +1515 -0
- package/dist/utils/index.js.map +1 -0
- package/package.json +72 -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
|
@@ -0,0 +1,2172 @@
|
|
|
1
|
+
import { Types } from 'mongoose';
|
|
2
|
+
|
|
3
|
+
// src/utils/date.ts
|
|
4
|
+
function addMonths(date, months) {
|
|
5
|
+
const result = new Date(date);
|
|
6
|
+
result.setMonth(result.getMonth() + months);
|
|
7
|
+
return result;
|
|
8
|
+
}
|
|
9
|
+
function startOfMonth(date) {
|
|
10
|
+
const result = new Date(date);
|
|
11
|
+
result.setDate(1);
|
|
12
|
+
result.setHours(0, 0, 0, 0);
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
function endOfMonth(date) {
|
|
16
|
+
const result = new Date(date);
|
|
17
|
+
result.setMonth(result.getMonth() + 1, 0);
|
|
18
|
+
result.setHours(23, 59, 59, 999);
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
function getPayPeriod(month, year) {
|
|
22
|
+
const startDate = new Date(year, month - 1, 1);
|
|
23
|
+
return {
|
|
24
|
+
month,
|
|
25
|
+
year,
|
|
26
|
+
startDate: startOfMonth(startDate),
|
|
27
|
+
endDate: endOfMonth(startDate)
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function getCurrentPeriod(date = /* @__PURE__ */ new Date()) {
|
|
31
|
+
const d = new Date(date);
|
|
32
|
+
return {
|
|
33
|
+
year: d.getFullYear(),
|
|
34
|
+
month: d.getMonth() + 1
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function calculateProbationEnd(hireDate, probationMonths) {
|
|
38
|
+
if (!probationMonths || probationMonths <= 0) return null;
|
|
39
|
+
return addMonths(hireDate, probationMonths);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/config.ts
|
|
43
|
+
var HRM_CONFIG = {
|
|
44
|
+
dataRetention: {
|
|
45
|
+
payrollRecordsTTL: 63072e3,
|
|
46
|
+
// 2 years in seconds
|
|
47
|
+
exportWarningDays: 30,
|
|
48
|
+
archiveBeforeDeletion: true
|
|
49
|
+
},
|
|
50
|
+
payroll: {
|
|
51
|
+
defaultCurrency: "BDT",
|
|
52
|
+
allowProRating: true,
|
|
53
|
+
attendanceIntegration: true,
|
|
54
|
+
autoDeductions: true,
|
|
55
|
+
overtimeEnabled: false,
|
|
56
|
+
overtimeMultiplier: 1.5
|
|
57
|
+
},
|
|
58
|
+
salary: {
|
|
59
|
+
minimumWage: 0,
|
|
60
|
+
maximumAllowances: 10,
|
|
61
|
+
maximumDeductions: 10,
|
|
62
|
+
defaultFrequency: "monthly"
|
|
63
|
+
},
|
|
64
|
+
employment: {
|
|
65
|
+
defaultProbationMonths: 3,
|
|
66
|
+
maxProbationMonths: 6,
|
|
67
|
+
allowReHiring: true,
|
|
68
|
+
trackEmploymentHistory: true
|
|
69
|
+
},
|
|
70
|
+
validation: {
|
|
71
|
+
requireBankDetails: false,
|
|
72
|
+
requireUserId: false,
|
|
73
|
+
// Modern: Allow guest employees by default
|
|
74
|
+
identityMode: "employeeId",
|
|
75
|
+
// Modern: Use human-readable IDs as primary
|
|
76
|
+
identityFallbacks: ["email", "userId"]
|
|
77
|
+
// Smart fallback chain
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
var ORG_ROLES = {
|
|
81
|
+
OWNER: {
|
|
82
|
+
key: "owner",
|
|
83
|
+
label: "Owner",
|
|
84
|
+
description: "Full organization access (set by Organization model)"
|
|
85
|
+
},
|
|
86
|
+
MANAGER: {
|
|
87
|
+
key: "manager",
|
|
88
|
+
label: "Manager",
|
|
89
|
+
description: "Management and administrative features"
|
|
90
|
+
},
|
|
91
|
+
TRAINER: {
|
|
92
|
+
key: "trainer",
|
|
93
|
+
label: "Trainer",
|
|
94
|
+
description: "Training and coaching features"
|
|
95
|
+
},
|
|
96
|
+
STAFF: {
|
|
97
|
+
key: "staff",
|
|
98
|
+
label: "Staff",
|
|
99
|
+
description: "General staff access to basic features"
|
|
100
|
+
},
|
|
101
|
+
INTERN: {
|
|
102
|
+
key: "intern",
|
|
103
|
+
label: "Intern",
|
|
104
|
+
description: "Limited access for interns"
|
|
105
|
+
},
|
|
106
|
+
CONSULTANT: {
|
|
107
|
+
key: "consultant",
|
|
108
|
+
label: "Consultant",
|
|
109
|
+
description: "Project-based consultant access"
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
Object.values(ORG_ROLES).map((role) => role.key);
|
|
113
|
+
|
|
114
|
+
// src/factories/employee.factory.ts
|
|
115
|
+
function normalizeEmail(email) {
|
|
116
|
+
if (!email || typeof email !== "string") return void 0;
|
|
117
|
+
const trimmed = email.trim();
|
|
118
|
+
return trimmed ? trimmed.toLowerCase() : void 0;
|
|
119
|
+
}
|
|
120
|
+
var EmployeeFactory = class {
|
|
121
|
+
/**
|
|
122
|
+
* Create employee data object
|
|
123
|
+
*/
|
|
124
|
+
static create(params, config = HRM_CONFIG) {
|
|
125
|
+
const { userId, organizationId, employment, compensation, bankDetails } = params;
|
|
126
|
+
const hireDate = employment.hireDate || /* @__PURE__ */ new Date();
|
|
127
|
+
const normalizedEmail = normalizeEmail(employment.email);
|
|
128
|
+
return {
|
|
129
|
+
...userId ? { userId } : {},
|
|
130
|
+
// Only include userId if present
|
|
131
|
+
...normalizedEmail ? { email: normalizedEmail } : {},
|
|
132
|
+
// Include normalized email for guest employees
|
|
133
|
+
organizationId,
|
|
134
|
+
employeeId: employment.employeeId || `EMP-${Date.now()}-${Math.random().toString(36).substr(2, 6).toUpperCase()}`,
|
|
135
|
+
employmentType: employment.type || "full_time",
|
|
136
|
+
status: "active",
|
|
137
|
+
department: employment.department,
|
|
138
|
+
position: employment.position,
|
|
139
|
+
hireDate,
|
|
140
|
+
probationEndDate: calculateProbationEnd(
|
|
141
|
+
hireDate,
|
|
142
|
+
employment.probationMonths ?? config.employment.defaultProbationMonths
|
|
143
|
+
),
|
|
144
|
+
compensation: this.createCompensation(compensation, config),
|
|
145
|
+
workSchedule: employment.workSchedule || this.defaultWorkSchedule(),
|
|
146
|
+
bankDetails: bankDetails || {},
|
|
147
|
+
payrollStats: {
|
|
148
|
+
totalPaid: 0,
|
|
149
|
+
paymentsThisYear: 0,
|
|
150
|
+
averageMonthly: 0
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Create compensation object
|
|
156
|
+
*/
|
|
157
|
+
static createCompensation(params, config = HRM_CONFIG) {
|
|
158
|
+
return {
|
|
159
|
+
baseAmount: params.baseAmount,
|
|
160
|
+
frequency: params.frequency || "monthly",
|
|
161
|
+
currency: params.currency || config.payroll.defaultCurrency,
|
|
162
|
+
allowances: (params.allowances || []).map((a) => ({
|
|
163
|
+
type: a.type || "other",
|
|
164
|
+
name: a.name || a.type || "other",
|
|
165
|
+
amount: a.amount || 0,
|
|
166
|
+
taxable: a.taxable,
|
|
167
|
+
recurring: a.recurring,
|
|
168
|
+
effectiveFrom: a.effectiveFrom,
|
|
169
|
+
effectiveTo: a.effectiveTo
|
|
170
|
+
})),
|
|
171
|
+
deductions: (params.deductions || []).map((d) => ({
|
|
172
|
+
type: d.type || "other",
|
|
173
|
+
name: d.name || d.type || "other",
|
|
174
|
+
amount: d.amount || 0,
|
|
175
|
+
auto: d.auto,
|
|
176
|
+
recurring: d.recurring,
|
|
177
|
+
description: d.description,
|
|
178
|
+
effectiveFrom: d.effectiveFrom,
|
|
179
|
+
effectiveTo: d.effectiveTo
|
|
180
|
+
})),
|
|
181
|
+
grossSalary: 0,
|
|
182
|
+
netSalary: 0,
|
|
183
|
+
effectiveFrom: /* @__PURE__ */ new Date(),
|
|
184
|
+
lastModified: /* @__PURE__ */ new Date()
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Create allowance object
|
|
189
|
+
*/
|
|
190
|
+
static createAllowance(params) {
|
|
191
|
+
return {
|
|
192
|
+
type: params.type,
|
|
193
|
+
name: params.name || params.type,
|
|
194
|
+
amount: params.amount,
|
|
195
|
+
isPercentage: params.isPercentage ?? false,
|
|
196
|
+
taxable: params.taxable ?? true,
|
|
197
|
+
recurring: params.recurring ?? true,
|
|
198
|
+
effectiveFrom: /* @__PURE__ */ new Date()
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Create deduction object
|
|
203
|
+
*/
|
|
204
|
+
static createDeduction(params) {
|
|
205
|
+
return {
|
|
206
|
+
type: params.type,
|
|
207
|
+
name: params.name || params.type,
|
|
208
|
+
amount: params.amount,
|
|
209
|
+
isPercentage: params.isPercentage ?? false,
|
|
210
|
+
auto: params.auto ?? false,
|
|
211
|
+
recurring: params.recurring ?? true,
|
|
212
|
+
description: params.description,
|
|
213
|
+
effectiveFrom: /* @__PURE__ */ new Date()
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Default work schedule
|
|
218
|
+
*/
|
|
219
|
+
static defaultWorkSchedule() {
|
|
220
|
+
return {
|
|
221
|
+
hoursPerWeek: 40,
|
|
222
|
+
hoursPerDay: 8,
|
|
223
|
+
workingDays: [1, 2, 3, 4, 5],
|
|
224
|
+
// Mon-Fri
|
|
225
|
+
shiftStart: "09:00",
|
|
226
|
+
shiftEnd: "17:00"
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Create termination data
|
|
231
|
+
*/
|
|
232
|
+
static createTermination(params) {
|
|
233
|
+
return {
|
|
234
|
+
terminatedAt: params.date || /* @__PURE__ */ new Date(),
|
|
235
|
+
terminationReason: params.reason,
|
|
236
|
+
terminationNotes: params.notes,
|
|
237
|
+
terminatedBy: {
|
|
238
|
+
userId: params.context?.userId,
|
|
239
|
+
name: params.context?.userName,
|
|
240
|
+
role: params.context?.userRole
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
function toObjectId(id) {
|
|
246
|
+
if (id instanceof Types.ObjectId) return id;
|
|
247
|
+
return new Types.ObjectId(id);
|
|
248
|
+
}
|
|
249
|
+
var QueryBuilder = class {
|
|
250
|
+
query;
|
|
251
|
+
constructor(initialQuery = {}) {
|
|
252
|
+
this.query = { ...initialQuery };
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Add where condition
|
|
256
|
+
*/
|
|
257
|
+
where(field, value) {
|
|
258
|
+
this.query[field] = value;
|
|
259
|
+
return this;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Add $in condition
|
|
263
|
+
*/
|
|
264
|
+
whereIn(field, values) {
|
|
265
|
+
this.query[field] = { $in: values };
|
|
266
|
+
return this;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Add $nin condition
|
|
270
|
+
*/
|
|
271
|
+
whereNotIn(field, values) {
|
|
272
|
+
this.query[field] = { $nin: values };
|
|
273
|
+
return this;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Add $gte condition
|
|
277
|
+
*/
|
|
278
|
+
whereGte(field, value) {
|
|
279
|
+
const existing = this.query[field] || {};
|
|
280
|
+
this.query[field] = { ...existing, $gte: value };
|
|
281
|
+
return this;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Add $lte condition
|
|
285
|
+
*/
|
|
286
|
+
whereLte(field, value) {
|
|
287
|
+
const existing = this.query[field] || {};
|
|
288
|
+
this.query[field] = { ...existing, $lte: value };
|
|
289
|
+
return this;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Add $gt condition
|
|
293
|
+
*/
|
|
294
|
+
whereGt(field, value) {
|
|
295
|
+
const existing = this.query[field] || {};
|
|
296
|
+
this.query[field] = { ...existing, $gt: value };
|
|
297
|
+
return this;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Add $lt condition
|
|
301
|
+
*/
|
|
302
|
+
whereLt(field, value) {
|
|
303
|
+
const existing = this.query[field] || {};
|
|
304
|
+
this.query[field] = { ...existing, $lt: value };
|
|
305
|
+
return this;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Add between condition
|
|
309
|
+
*/
|
|
310
|
+
whereBetween(field, start, end) {
|
|
311
|
+
this.query[field] = { $gte: start, $lte: end };
|
|
312
|
+
return this;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Add $exists condition
|
|
316
|
+
*/
|
|
317
|
+
whereExists(field) {
|
|
318
|
+
this.query[field] = { $exists: true };
|
|
319
|
+
return this;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Add $exists: false condition
|
|
323
|
+
*/
|
|
324
|
+
whereNotExists(field) {
|
|
325
|
+
this.query[field] = { $exists: false };
|
|
326
|
+
return this;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Add $ne condition
|
|
330
|
+
*/
|
|
331
|
+
whereNot(field, value) {
|
|
332
|
+
this.query[field] = { $ne: value };
|
|
333
|
+
return this;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Add regex condition
|
|
337
|
+
*/
|
|
338
|
+
whereRegex(field, pattern, flags = "i") {
|
|
339
|
+
this.query[field] = { $regex: pattern, $options: flags };
|
|
340
|
+
return this;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Merge another query
|
|
344
|
+
*/
|
|
345
|
+
merge(otherQuery) {
|
|
346
|
+
this.query = { ...this.query, ...otherQuery };
|
|
347
|
+
return this;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Build and return the query
|
|
351
|
+
*/
|
|
352
|
+
build() {
|
|
353
|
+
return { ...this.query };
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
var EmployeeQueryBuilder = class extends QueryBuilder {
|
|
357
|
+
/**
|
|
358
|
+
* Filter by organization
|
|
359
|
+
*/
|
|
360
|
+
forOrganization(organizationId) {
|
|
361
|
+
return this.where("organizationId", toObjectId(organizationId));
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Filter by user
|
|
365
|
+
*/
|
|
366
|
+
forUser(userId) {
|
|
367
|
+
return this.where("userId", toObjectId(userId));
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Filter by employeeId (human-readable ID)
|
|
371
|
+
*/
|
|
372
|
+
forEmployeeId(employeeId) {
|
|
373
|
+
return this.where("employeeId", employeeId);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Filter by email (for guest employees)
|
|
377
|
+
*/
|
|
378
|
+
forEmail(email) {
|
|
379
|
+
return this.where("email", email.toLowerCase().trim());
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Filter guest employees (no userId)
|
|
383
|
+
*/
|
|
384
|
+
guestEmployees() {
|
|
385
|
+
return this.where("userId", null);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Filter user-linked employees (has userId)
|
|
389
|
+
*/
|
|
390
|
+
userLinkedEmployees() {
|
|
391
|
+
return this.where("userId", { $ne: null });
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Filter by status(es)
|
|
395
|
+
*/
|
|
396
|
+
withStatus(...statuses) {
|
|
397
|
+
if (statuses.length === 1) {
|
|
398
|
+
return this.where("status", statuses[0]);
|
|
399
|
+
}
|
|
400
|
+
return this.whereIn("status", statuses);
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Filter active employees
|
|
404
|
+
*/
|
|
405
|
+
active() {
|
|
406
|
+
return this.withStatus("active");
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Filter employed employees (not terminated)
|
|
410
|
+
*/
|
|
411
|
+
employed() {
|
|
412
|
+
return this.whereIn("status", ["active", "on_leave", "suspended"]);
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Filter terminated employees
|
|
416
|
+
*/
|
|
417
|
+
terminated() {
|
|
418
|
+
return this.withStatus("terminated");
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Filter by department
|
|
422
|
+
*/
|
|
423
|
+
inDepartment(department) {
|
|
424
|
+
return this.where("department", department);
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Filter by position
|
|
428
|
+
*/
|
|
429
|
+
inPosition(position) {
|
|
430
|
+
return this.where("position", position);
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Filter by employment type
|
|
434
|
+
*/
|
|
435
|
+
withEmploymentType(type) {
|
|
436
|
+
return this.where("employmentType", type);
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Filter by hire date (after)
|
|
440
|
+
*/
|
|
441
|
+
hiredAfter(date) {
|
|
442
|
+
return this.whereGte("hireDate", date);
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Filter by hire date (before)
|
|
446
|
+
*/
|
|
447
|
+
hiredBefore(date) {
|
|
448
|
+
return this.whereLte("hireDate", date);
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Filter by minimum salary
|
|
452
|
+
*/
|
|
453
|
+
withMinSalary(amount) {
|
|
454
|
+
return this.whereGte("compensation.netSalary", amount);
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Filter by maximum salary
|
|
458
|
+
*/
|
|
459
|
+
withMaxSalary(amount) {
|
|
460
|
+
return this.whereLte("compensation.netSalary", amount);
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Filter by salary range
|
|
464
|
+
*/
|
|
465
|
+
withSalaryRange(min2, max2) {
|
|
466
|
+
return this.whereBetween("compensation.netSalary", min2, max2);
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
var PayrollQueryBuilder = class extends QueryBuilder {
|
|
470
|
+
/**
|
|
471
|
+
* Filter by organization
|
|
472
|
+
*/
|
|
473
|
+
forOrganization(organizationId) {
|
|
474
|
+
return this.where("organizationId", toObjectId(organizationId));
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Filter by employee
|
|
478
|
+
*
|
|
479
|
+
* Note: PayrollRecord.employeeId is always ObjectId _id
|
|
480
|
+
* If passing a string business ID, resolve to _id first
|
|
481
|
+
*/
|
|
482
|
+
forEmployee(employeeId) {
|
|
483
|
+
return this.where("employeeId", toObjectId(employeeId));
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Filter by period
|
|
487
|
+
*/
|
|
488
|
+
forPeriod(month, year) {
|
|
489
|
+
if (month !== void 0) {
|
|
490
|
+
this.where("period.month", month);
|
|
491
|
+
}
|
|
492
|
+
if (year !== void 0) {
|
|
493
|
+
this.where("period.year", year);
|
|
494
|
+
}
|
|
495
|
+
return this;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Filter by status(es)
|
|
499
|
+
*/
|
|
500
|
+
withStatus(...statuses) {
|
|
501
|
+
if (statuses.length === 1) {
|
|
502
|
+
return this.where("status", statuses[0]);
|
|
503
|
+
}
|
|
504
|
+
return this.whereIn("status", statuses);
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Filter paid records
|
|
508
|
+
*/
|
|
509
|
+
paid() {
|
|
510
|
+
return this.withStatus("paid");
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Filter pending records
|
|
514
|
+
*/
|
|
515
|
+
pending() {
|
|
516
|
+
return this.whereIn("status", ["pending", "processing"]);
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Filter by date range
|
|
520
|
+
*/
|
|
521
|
+
inDateRange(start, end) {
|
|
522
|
+
return this.whereBetween("period.payDate", start, end);
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Filter exported records
|
|
526
|
+
*/
|
|
527
|
+
exported() {
|
|
528
|
+
return this.where("exported", true);
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Filter not exported records
|
|
532
|
+
*/
|
|
533
|
+
notExported() {
|
|
534
|
+
return this.where("exported", false);
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
function employee() {
|
|
538
|
+
return new EmployeeQueryBuilder();
|
|
539
|
+
}
|
|
540
|
+
function payroll() {
|
|
541
|
+
return new PayrollQueryBuilder();
|
|
542
|
+
}
|
|
543
|
+
var TAX_TYPE = {
|
|
544
|
+
INCOME_TAX: "income_tax",
|
|
545
|
+
SOCIAL_SECURITY: "social_security",
|
|
546
|
+
HEALTH_INSURANCE: "health_insurance",
|
|
547
|
+
PENSION: "pension",
|
|
548
|
+
EMPLOYMENT_INSURANCE: "employment_insurance",
|
|
549
|
+
LOCAL_TAX: "local_tax",
|
|
550
|
+
OTHER: "other"
|
|
551
|
+
};
|
|
552
|
+
var TAX_STATUS = {
|
|
553
|
+
PENDING: "pending"};
|
|
554
|
+
|
|
555
|
+
// src/utils/validation.ts
|
|
556
|
+
function isActive(employee2) {
|
|
557
|
+
return employee2?.status === "active";
|
|
558
|
+
}
|
|
559
|
+
function isOnLeave(employee2) {
|
|
560
|
+
return employee2?.status === "on_leave";
|
|
561
|
+
}
|
|
562
|
+
function isSuspended(employee2) {
|
|
563
|
+
return employee2?.status === "suspended";
|
|
564
|
+
}
|
|
565
|
+
function isEmployed(employee2) {
|
|
566
|
+
return isActive(employee2) || isOnLeave(employee2) || isSuspended(employee2);
|
|
567
|
+
}
|
|
568
|
+
function canReceiveSalary(employee2) {
|
|
569
|
+
return (isActive(employee2) || isOnLeave(employee2)) && (employee2.compensation?.baseAmount ?? 0) > 0;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// src/utils/logger.ts
|
|
573
|
+
var createConsoleLogger = () => ({
|
|
574
|
+
info: (message, meta) => {
|
|
575
|
+
if (meta) {
|
|
576
|
+
console.log(`[Payroll] INFO: ${message}`, meta);
|
|
577
|
+
} else {
|
|
578
|
+
console.log(`[Payroll] INFO: ${message}`);
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
error: (message, meta) => {
|
|
582
|
+
if (meta) {
|
|
583
|
+
console.error(`[Payroll] ERROR: ${message}`, meta);
|
|
584
|
+
} else {
|
|
585
|
+
console.error(`[Payroll] ERROR: ${message}`);
|
|
586
|
+
}
|
|
587
|
+
},
|
|
588
|
+
warn: (message, meta) => {
|
|
589
|
+
if (meta) {
|
|
590
|
+
console.warn(`[Payroll] WARN: ${message}`, meta);
|
|
591
|
+
} else {
|
|
592
|
+
console.warn(`[Payroll] WARN: ${message}`);
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
debug: (message, meta) => {
|
|
596
|
+
if (process.env.NODE_ENV !== "production") {
|
|
597
|
+
if (meta) {
|
|
598
|
+
console.log(`[Payroll] DEBUG: ${message}`, meta);
|
|
599
|
+
} else {
|
|
600
|
+
console.log(`[Payroll] DEBUG: ${message}`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
var currentLogger = createConsoleLogger();
|
|
606
|
+
var logger = {
|
|
607
|
+
info: (message, meta) => {
|
|
608
|
+
currentLogger.info(message, meta);
|
|
609
|
+
},
|
|
610
|
+
error: (message, meta) => {
|
|
611
|
+
currentLogger.error(message, meta);
|
|
612
|
+
},
|
|
613
|
+
warn: (message, meta) => {
|
|
614
|
+
currentLogger.warn(message, meta);
|
|
615
|
+
},
|
|
616
|
+
debug: (message, meta) => {
|
|
617
|
+
currentLogger.debug(message, meta);
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
// src/services/employee.service.ts
|
|
622
|
+
var EmployeeService = class {
|
|
623
|
+
constructor(EmployeeModel, config) {
|
|
624
|
+
this.EmployeeModel = EmployeeModel;
|
|
625
|
+
this.config = config || HRM_CONFIG;
|
|
626
|
+
}
|
|
627
|
+
config;
|
|
628
|
+
/**
|
|
629
|
+
* Find employee by ID with organization validation
|
|
630
|
+
*
|
|
631
|
+
* ⚠️ SECURITY: Always validates employee belongs to specified organization
|
|
632
|
+
*
|
|
633
|
+
* @throws {Error} If employee not found or doesn't belong to organization
|
|
634
|
+
*/
|
|
635
|
+
async findById(employeeId, organizationId, options = {}) {
|
|
636
|
+
const query = {
|
|
637
|
+
_id: toObjectId(employeeId),
|
|
638
|
+
organizationId: toObjectId(organizationId)
|
|
639
|
+
};
|
|
640
|
+
let mongooseQuery = this.EmployeeModel.findOne(query);
|
|
641
|
+
if (options.session) {
|
|
642
|
+
mongooseQuery = mongooseQuery.session(options.session);
|
|
643
|
+
}
|
|
644
|
+
if (options.populate) {
|
|
645
|
+
mongooseQuery = mongooseQuery.populate("userId", "name email phone");
|
|
646
|
+
}
|
|
647
|
+
return mongooseQuery.exec();
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Find employee by user and organization
|
|
651
|
+
*/
|
|
652
|
+
async findByUserId(userId, organizationId, options = {}) {
|
|
653
|
+
const query = employee().forUser(userId).forOrganization(organizationId).build();
|
|
654
|
+
let mongooseQuery = this.EmployeeModel.findOne(query);
|
|
655
|
+
if (options.session) {
|
|
656
|
+
mongooseQuery = mongooseQuery.session(options.session);
|
|
657
|
+
}
|
|
658
|
+
return mongooseQuery.exec();
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Find employee by employeeId (human-readable ID)
|
|
662
|
+
*/
|
|
663
|
+
async findByEmployeeId(employeeId, organizationId, options = {}) {
|
|
664
|
+
const query = employee().forEmployeeId(employeeId).forOrganization(organizationId).build();
|
|
665
|
+
let mongooseQuery = this.EmployeeModel.findOne(query);
|
|
666
|
+
if (options.session) {
|
|
667
|
+
mongooseQuery = mongooseQuery.session(options.session);
|
|
668
|
+
}
|
|
669
|
+
return mongooseQuery.exec();
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Find employee by email (guest employees)
|
|
673
|
+
*/
|
|
674
|
+
async findByEmail(email, organizationId, options = {}) {
|
|
675
|
+
const query = employee().forEmail(email).forOrganization(organizationId).build();
|
|
676
|
+
let mongooseQuery = this.EmployeeModel.findOne(query);
|
|
677
|
+
if (options.session) {
|
|
678
|
+
mongooseQuery = mongooseQuery.session(options.session);
|
|
679
|
+
}
|
|
680
|
+
return mongooseQuery.exec();
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Find all guest employees (no userId)
|
|
684
|
+
*/
|
|
685
|
+
async findGuestEmployees(organizationId, options = {}) {
|
|
686
|
+
const query = employee().forOrganization(organizationId).guestEmployees().build();
|
|
687
|
+
let mongooseQuery = this.EmployeeModel.find(query);
|
|
688
|
+
if (options.session) {
|
|
689
|
+
mongooseQuery = mongooseQuery.session(options.session);
|
|
690
|
+
}
|
|
691
|
+
return mongooseQuery.exec();
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Find active employees in organization
|
|
695
|
+
*/
|
|
696
|
+
async findActive(organizationId, options = {}) {
|
|
697
|
+
const query = employee().forOrganization(organizationId).active().build();
|
|
698
|
+
let mongooseQuery = this.EmployeeModel.find(query, options.projection);
|
|
699
|
+
if (options.session) {
|
|
700
|
+
mongooseQuery = mongooseQuery.session(options.session);
|
|
701
|
+
}
|
|
702
|
+
return mongooseQuery.exec();
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Find employed employees (not terminated)
|
|
706
|
+
*/
|
|
707
|
+
async findEmployed(organizationId, options = {}) {
|
|
708
|
+
const query = employee().forOrganization(organizationId).employed().build();
|
|
709
|
+
let mongooseQuery = this.EmployeeModel.find(query, options.projection);
|
|
710
|
+
if (options.session) {
|
|
711
|
+
mongooseQuery = mongooseQuery.session(options.session);
|
|
712
|
+
}
|
|
713
|
+
return mongooseQuery.exec();
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Find employees by department
|
|
717
|
+
*/
|
|
718
|
+
async findByDepartment(organizationId, department, options = {}) {
|
|
719
|
+
const query = employee().forOrganization(organizationId).inDepartment(department).active().build();
|
|
720
|
+
let mongooseQuery = this.EmployeeModel.find(query);
|
|
721
|
+
if (options.session) {
|
|
722
|
+
mongooseQuery = mongooseQuery.session(options.session);
|
|
723
|
+
}
|
|
724
|
+
return mongooseQuery.exec();
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Find employees eligible for payroll
|
|
728
|
+
*/
|
|
729
|
+
async findEligibleForPayroll(organizationId, options = {}) {
|
|
730
|
+
const query = employee().forOrganization(organizationId).employed().build();
|
|
731
|
+
let mongooseQuery = this.EmployeeModel.find(query);
|
|
732
|
+
if (options.session) {
|
|
733
|
+
mongooseQuery = mongooseQuery.session(options.session);
|
|
734
|
+
}
|
|
735
|
+
const employees = await mongooseQuery.exec();
|
|
736
|
+
return employees.filter((emp) => canReceiveSalary(emp));
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Create new employee
|
|
740
|
+
*/
|
|
741
|
+
async create(params, options = {}) {
|
|
742
|
+
const employeeData = EmployeeFactory.create(params, this.config);
|
|
743
|
+
let employee2;
|
|
744
|
+
if (!params.userId) {
|
|
745
|
+
const dataToInsert = {};
|
|
746
|
+
for (const [key, value] of Object.entries(employeeData)) {
|
|
747
|
+
if (key === "userId" || key === "email") continue;
|
|
748
|
+
dataToInsert[key] = value;
|
|
749
|
+
}
|
|
750
|
+
if (employeeData.email && employeeData.email !== "") {
|
|
751
|
+
dataToInsert.email = employeeData.email;
|
|
752
|
+
}
|
|
753
|
+
const now = /* @__PURE__ */ new Date();
|
|
754
|
+
dataToInsert.createdAt = now;
|
|
755
|
+
dataToInsert.updatedAt = now;
|
|
756
|
+
const insertOptions = options.session ? { session: options.session } : {};
|
|
757
|
+
const result = await this.EmployeeModel.collection.insertOne(
|
|
758
|
+
dataToInsert,
|
|
759
|
+
insertOptions
|
|
760
|
+
);
|
|
761
|
+
employee2 = await this.EmployeeModel.findById(result.insertedId).session(options.session || null).exec();
|
|
762
|
+
} else {
|
|
763
|
+
const [created] = await this.EmployeeModel.create([employeeData], {
|
|
764
|
+
session: options.session
|
|
765
|
+
});
|
|
766
|
+
employee2 = created;
|
|
767
|
+
}
|
|
768
|
+
logger.info("Employee created", {
|
|
769
|
+
employeeId: employee2.employeeId,
|
|
770
|
+
organizationId: employee2.organizationId.toString()
|
|
771
|
+
});
|
|
772
|
+
return employee2;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Update employee status with organization validation
|
|
776
|
+
*
|
|
777
|
+
* ⚠️ SECURITY: Validates employee belongs to organization before update
|
|
778
|
+
*/
|
|
779
|
+
async updateStatus(employeeId, organizationId, status, context = {}, options = {}) {
|
|
780
|
+
const employee2 = await this.findById(employeeId, organizationId, options);
|
|
781
|
+
if (!employee2) {
|
|
782
|
+
throw new Error(`Employee not found in organization ${organizationId}`);
|
|
783
|
+
}
|
|
784
|
+
employee2.status = status;
|
|
785
|
+
await employee2.save({ session: options.session });
|
|
786
|
+
logger.info("Employee status updated", {
|
|
787
|
+
employeeId: employee2.employeeId,
|
|
788
|
+
organizationId: organizationId.toString(),
|
|
789
|
+
newStatus: status
|
|
790
|
+
});
|
|
791
|
+
return employee2;
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Update employee compensation with organization validation
|
|
795
|
+
*
|
|
796
|
+
* ⚠️ SECURITY: Validates employee belongs to organization before update
|
|
797
|
+
*
|
|
798
|
+
* NOTE: This merges the compensation fields rather than replacing the entire object.
|
|
799
|
+
* To update allowances/deductions, use addAllowance/removeAllowance methods.
|
|
800
|
+
*/
|
|
801
|
+
async updateCompensation(employeeId, organizationId, compensation, options = {}) {
|
|
802
|
+
const currentEmployee = await this.findById(employeeId, organizationId, options);
|
|
803
|
+
if (!currentEmployee) {
|
|
804
|
+
throw new Error(`Employee not found in organization ${organizationId}`);
|
|
805
|
+
}
|
|
806
|
+
const updateFields = {
|
|
807
|
+
"compensation.lastModified": /* @__PURE__ */ new Date()
|
|
808
|
+
};
|
|
809
|
+
if (compensation.baseAmount !== void 0) {
|
|
810
|
+
updateFields["compensation.baseAmount"] = compensation.baseAmount;
|
|
811
|
+
}
|
|
812
|
+
if (compensation.currency !== void 0) {
|
|
813
|
+
updateFields["compensation.currency"] = compensation.currency;
|
|
814
|
+
}
|
|
815
|
+
if (compensation.frequency !== void 0) {
|
|
816
|
+
updateFields["compensation.frequency"] = compensation.frequency;
|
|
817
|
+
}
|
|
818
|
+
if (compensation.effectiveFrom !== void 0) {
|
|
819
|
+
updateFields["compensation.effectiveFrom"] = compensation.effectiveFrom;
|
|
820
|
+
}
|
|
821
|
+
const query = {
|
|
822
|
+
_id: toObjectId(employeeId),
|
|
823
|
+
organizationId: toObjectId(organizationId)
|
|
824
|
+
};
|
|
825
|
+
const employee2 = await this.EmployeeModel.findOneAndUpdate(
|
|
826
|
+
query,
|
|
827
|
+
{ $set: updateFields },
|
|
828
|
+
{ new: true, runValidators: true, session: options.session }
|
|
829
|
+
);
|
|
830
|
+
if (!employee2) {
|
|
831
|
+
throw new Error(`Employee not found in organization ${organizationId}`);
|
|
832
|
+
}
|
|
833
|
+
logger.info("Employee compensation updated", {
|
|
834
|
+
employeeId: employee2.employeeId,
|
|
835
|
+
organizationId: organizationId.toString()
|
|
836
|
+
});
|
|
837
|
+
return employee2;
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Get employee statistics for organization
|
|
841
|
+
*/
|
|
842
|
+
async getEmployeeStats(organizationId, options = {}) {
|
|
843
|
+
const query = employee().forOrganization(organizationId).build();
|
|
844
|
+
let mongooseQuery = this.EmployeeModel.find(query);
|
|
845
|
+
if (options.session) {
|
|
846
|
+
mongooseQuery = mongooseQuery.session(options.session);
|
|
847
|
+
}
|
|
848
|
+
const employees = await mongooseQuery.exec();
|
|
849
|
+
return {
|
|
850
|
+
total: employees.length,
|
|
851
|
+
active: employees.filter(isActive).length,
|
|
852
|
+
employed: employees.filter(isEmployed).length,
|
|
853
|
+
canReceiveSalary: employees.filter(canReceiveSalary).length,
|
|
854
|
+
byStatus: this.groupByStatus(employees),
|
|
855
|
+
byDepartment: this.groupByDepartment(employees)
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Group employees by status
|
|
860
|
+
*/
|
|
861
|
+
groupByStatus(employees) {
|
|
862
|
+
return employees.reduce(
|
|
863
|
+
(acc, emp) => {
|
|
864
|
+
acc[emp.status] = (acc[emp.status] || 0) + 1;
|
|
865
|
+
return acc;
|
|
866
|
+
},
|
|
867
|
+
{}
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Group employees by department
|
|
872
|
+
*/
|
|
873
|
+
groupByDepartment(employees) {
|
|
874
|
+
return employees.reduce(
|
|
875
|
+
(acc, emp) => {
|
|
876
|
+
const dept = emp.department || "unassigned";
|
|
877
|
+
acc[dept] = (acc[dept] || 0) + 1;
|
|
878
|
+
return acc;
|
|
879
|
+
},
|
|
880
|
+
{}
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Check if employee is active
|
|
885
|
+
*/
|
|
886
|
+
isActive(employee2) {
|
|
887
|
+
return isActive(employee2);
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Check if employee is employed
|
|
891
|
+
*/
|
|
892
|
+
isEmployed(employee2) {
|
|
893
|
+
return isEmployed(employee2);
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Check if employee can receive salary
|
|
897
|
+
*/
|
|
898
|
+
canReceiveSalary(employee2) {
|
|
899
|
+
return canReceiveSalary(employee2);
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
function createEmployeeService(EmployeeModel, config) {
|
|
903
|
+
return new EmployeeService(EmployeeModel, config);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// src/utils/calculation.ts
|
|
907
|
+
function sumBy(items, getter) {
|
|
908
|
+
return items.reduce((total, item) => total + getter(item), 0);
|
|
909
|
+
}
|
|
910
|
+
function sumAllowances(allowances) {
|
|
911
|
+
return sumBy(allowances, (a) => a.amount);
|
|
912
|
+
}
|
|
913
|
+
function sumDeductions(deductions) {
|
|
914
|
+
return sumBy(deductions, (d) => d.amount);
|
|
915
|
+
}
|
|
916
|
+
function applyPercentage(amount, percentage) {
|
|
917
|
+
return Math.round(amount * (percentage / 100));
|
|
918
|
+
}
|
|
919
|
+
function calculateGross(baseAmount, allowances) {
|
|
920
|
+
return baseAmount + sumAllowances(allowances);
|
|
921
|
+
}
|
|
922
|
+
function calculateNet(gross, deductions) {
|
|
923
|
+
return Math.max(0, gross - sumDeductions(deductions));
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// src/factories/payroll.factory.ts
|
|
927
|
+
var PayrollFactory = class {
|
|
928
|
+
/**
|
|
929
|
+
* Create payroll data object
|
|
930
|
+
*/
|
|
931
|
+
static create(params) {
|
|
932
|
+
const {
|
|
933
|
+
employeeId,
|
|
934
|
+
organizationId,
|
|
935
|
+
baseAmount,
|
|
936
|
+
allowances = [],
|
|
937
|
+
deductions = [],
|
|
938
|
+
period = {},
|
|
939
|
+
metadata = {}
|
|
940
|
+
} = params;
|
|
941
|
+
const calculatedAllowances = this.calculateAllowances(baseAmount, allowances);
|
|
942
|
+
const calculatedDeductions = this.calculateDeductions(baseAmount, deductions);
|
|
943
|
+
const gross = calculateGross(baseAmount, calculatedAllowances);
|
|
944
|
+
const net = calculateNet(gross, calculatedDeductions);
|
|
945
|
+
return {
|
|
946
|
+
employeeId,
|
|
947
|
+
organizationId,
|
|
948
|
+
period: this.createPeriod(period),
|
|
949
|
+
breakdown: {
|
|
950
|
+
baseAmount,
|
|
951
|
+
allowances: calculatedAllowances,
|
|
952
|
+
deductions: calculatedDeductions,
|
|
953
|
+
grossSalary: gross,
|
|
954
|
+
netSalary: net
|
|
955
|
+
},
|
|
956
|
+
status: "pending",
|
|
957
|
+
processedAt: null,
|
|
958
|
+
paidAt: null,
|
|
959
|
+
metadata: {
|
|
960
|
+
currency: metadata.currency || HRM_CONFIG.payroll.defaultCurrency,
|
|
961
|
+
paymentMethod: metadata.paymentMethod,
|
|
962
|
+
notes: metadata.notes
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Create pay period
|
|
968
|
+
*/
|
|
969
|
+
static createPeriod(params) {
|
|
970
|
+
const now = /* @__PURE__ */ new Date();
|
|
971
|
+
const month = params.month || now.getMonth() + 1;
|
|
972
|
+
const year = params.year || now.getFullYear();
|
|
973
|
+
const period = getPayPeriod(month, year);
|
|
974
|
+
return {
|
|
975
|
+
...period,
|
|
976
|
+
payDate: params.payDate || /* @__PURE__ */ new Date()
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Calculate allowances from base amount
|
|
981
|
+
*/
|
|
982
|
+
static calculateAllowances(baseAmount, allowances) {
|
|
983
|
+
return allowances.map((allowance) => {
|
|
984
|
+
const amount = allowance.isPercentage && allowance.value !== void 0 ? Math.round(baseAmount * allowance.value / 100) : allowance.amount;
|
|
985
|
+
return {
|
|
986
|
+
type: allowance.type,
|
|
987
|
+
amount,
|
|
988
|
+
taxable: allowance.taxable ?? true
|
|
989
|
+
};
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Calculate deductions from base amount
|
|
994
|
+
*/
|
|
995
|
+
static calculateDeductions(baseAmount, deductions) {
|
|
996
|
+
return deductions.map((deduction) => {
|
|
997
|
+
const amount = deduction.isPercentage && deduction.value !== void 0 ? Math.round(baseAmount * deduction.value / 100) : deduction.amount;
|
|
998
|
+
return {
|
|
999
|
+
type: deduction.type,
|
|
1000
|
+
amount,
|
|
1001
|
+
description: deduction.description
|
|
1002
|
+
};
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Create bonus object
|
|
1007
|
+
*/
|
|
1008
|
+
static createBonus(params) {
|
|
1009
|
+
return {
|
|
1010
|
+
type: params.type,
|
|
1011
|
+
amount: params.amount,
|
|
1012
|
+
reason: params.reason,
|
|
1013
|
+
approvedBy: params.approvedBy,
|
|
1014
|
+
approvedAt: /* @__PURE__ */ new Date()
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Mark payroll as paid (immutable)
|
|
1019
|
+
* Sets both top-level transactionId and metadata for compatibility
|
|
1020
|
+
*/
|
|
1021
|
+
static markAsPaid(payroll2, params = {}) {
|
|
1022
|
+
return {
|
|
1023
|
+
...payroll2,
|
|
1024
|
+
status: "paid",
|
|
1025
|
+
paidAt: params.paidAt || /* @__PURE__ */ new Date(),
|
|
1026
|
+
processedAt: payroll2.processedAt || params.paidAt || /* @__PURE__ */ new Date(),
|
|
1027
|
+
transactionId: params.transactionId || payroll2.transactionId,
|
|
1028
|
+
metadata: {
|
|
1029
|
+
...payroll2.metadata,
|
|
1030
|
+
transactionId: params.transactionId,
|
|
1031
|
+
paymentMethod: params.paymentMethod || payroll2.metadata?.paymentMethod
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Mark payroll as processed (immutable)
|
|
1037
|
+
*/
|
|
1038
|
+
static markAsProcessed(payroll2, params = {}) {
|
|
1039
|
+
return {
|
|
1040
|
+
...payroll2,
|
|
1041
|
+
status: "processing",
|
|
1042
|
+
processedAt: params.processedAt || /* @__PURE__ */ new Date()
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
var BatchPayrollFactory = class {
|
|
1047
|
+
/**
|
|
1048
|
+
* Create payroll records for multiple employees
|
|
1049
|
+
*/
|
|
1050
|
+
static createBatch(employees, params) {
|
|
1051
|
+
return employees.map(
|
|
1052
|
+
(employee2) => PayrollFactory.create({
|
|
1053
|
+
employeeId: employee2._id,
|
|
1054
|
+
organizationId: params.organizationId || employee2.organizationId,
|
|
1055
|
+
baseAmount: employee2.compensation.baseAmount,
|
|
1056
|
+
allowances: employee2.compensation.allowances || [],
|
|
1057
|
+
deductions: employee2.compensation.deductions || [],
|
|
1058
|
+
period: { month: params.month, year: params.year },
|
|
1059
|
+
metadata: { currency: employee2.compensation.currency }
|
|
1060
|
+
})
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Calculate total payroll amounts
|
|
1065
|
+
*/
|
|
1066
|
+
static calculateTotalPayroll(payrolls) {
|
|
1067
|
+
return payrolls.reduce(
|
|
1068
|
+
(totals, payroll2) => ({
|
|
1069
|
+
count: totals.count + 1,
|
|
1070
|
+
totalGross: totals.totalGross + payroll2.breakdown.grossSalary,
|
|
1071
|
+
totalNet: totals.totalNet + payroll2.breakdown.netSalary,
|
|
1072
|
+
totalAllowances: totals.totalAllowances + sumAllowances(payroll2.breakdown.allowances),
|
|
1073
|
+
totalDeductions: totals.totalDeductions + sumDeductions(payroll2.breakdown.deductions)
|
|
1074
|
+
}),
|
|
1075
|
+
{ count: 0, totalGross: 0, totalNet: 0, totalAllowances: 0, totalDeductions: 0 }
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
};
|
|
1079
|
+
|
|
1080
|
+
// src/services/payroll.service.ts
|
|
1081
|
+
var PayrollService = class {
|
|
1082
|
+
constructor(PayrollModel, employeeService) {
|
|
1083
|
+
this.PayrollModel = PayrollModel;
|
|
1084
|
+
this.employeeService = employeeService;
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Find payroll by ID
|
|
1088
|
+
*/
|
|
1089
|
+
async findById(payrollId, options = {}) {
|
|
1090
|
+
let query = this.PayrollModel.findById(toObjectId(payrollId));
|
|
1091
|
+
if (options.session) {
|
|
1092
|
+
query = query.session(options.session);
|
|
1093
|
+
}
|
|
1094
|
+
return query.exec();
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Find payrolls by employee
|
|
1098
|
+
*/
|
|
1099
|
+
async findByEmployee(employeeId, organizationId, options = {}) {
|
|
1100
|
+
const query = payroll().forEmployee(employeeId).forOrganization(organizationId).build();
|
|
1101
|
+
let mongooseQuery = this.PayrollModel.find(query).sort({ "period.year": -1, "period.month": -1 }).limit(options.limit || 12);
|
|
1102
|
+
if (options.session) {
|
|
1103
|
+
mongooseQuery = mongooseQuery.session(options.session);
|
|
1104
|
+
}
|
|
1105
|
+
return mongooseQuery.exec();
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Find payrolls for a period
|
|
1109
|
+
*/
|
|
1110
|
+
async findForPeriod(organizationId, month, year, options = {}) {
|
|
1111
|
+
const query = payroll().forOrganization(organizationId).forPeriod(month, year).build();
|
|
1112
|
+
let mongooseQuery = this.PayrollModel.find(query);
|
|
1113
|
+
if (options.session) {
|
|
1114
|
+
mongooseQuery = mongooseQuery.session(options.session);
|
|
1115
|
+
}
|
|
1116
|
+
return mongooseQuery.exec();
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Find pending payrolls
|
|
1120
|
+
*/
|
|
1121
|
+
async findPending(organizationId, month, year, options = {}) {
|
|
1122
|
+
const query = payroll().forOrganization(organizationId).forPeriod(month, year).pending().build();
|
|
1123
|
+
let mongooseQuery = this.PayrollModel.find(query);
|
|
1124
|
+
if (options.session) {
|
|
1125
|
+
mongooseQuery = mongooseQuery.session(options.session);
|
|
1126
|
+
}
|
|
1127
|
+
return mongooseQuery.exec();
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Find payroll by employee and period
|
|
1131
|
+
*/
|
|
1132
|
+
async findByEmployeeAndPeriod(employeeId, organizationId, month, year, options = {}) {
|
|
1133
|
+
const query = payroll().forEmployee(employeeId).forOrganization(organizationId).forPeriod(month, year).build();
|
|
1134
|
+
let mongooseQuery = this.PayrollModel.findOne(query);
|
|
1135
|
+
if (options.session) {
|
|
1136
|
+
mongooseQuery = mongooseQuery.session(options.session);
|
|
1137
|
+
}
|
|
1138
|
+
return mongooseQuery.exec();
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Create payroll record
|
|
1142
|
+
*/
|
|
1143
|
+
async create(data, options = {}) {
|
|
1144
|
+
const [payroll2] = await this.PayrollModel.create([data], {
|
|
1145
|
+
session: options.session
|
|
1146
|
+
});
|
|
1147
|
+
logger.info("Payroll record created", {
|
|
1148
|
+
payrollId: payroll2._id.toString(),
|
|
1149
|
+
employeeId: payroll2.employeeId.toString()
|
|
1150
|
+
});
|
|
1151
|
+
return payroll2;
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Generate payroll for employee with organization validation
|
|
1155
|
+
*
|
|
1156
|
+
* ⚠️ SECURITY: Validates employee belongs to organization
|
|
1157
|
+
*/
|
|
1158
|
+
async generateForEmployee(employeeId, organizationId, month, year, options = {}) {
|
|
1159
|
+
const employee2 = await this.employeeService.findById(employeeId, organizationId, options);
|
|
1160
|
+
if (!employee2) {
|
|
1161
|
+
throw new Error(`Employee not found in organization ${organizationId}`);
|
|
1162
|
+
}
|
|
1163
|
+
if (!canReceiveSalary(employee2)) {
|
|
1164
|
+
throw new Error("Employee not eligible for payroll");
|
|
1165
|
+
}
|
|
1166
|
+
const existing = await this.findByEmployeeAndPeriod(
|
|
1167
|
+
employeeId,
|
|
1168
|
+
organizationId,
|
|
1169
|
+
month,
|
|
1170
|
+
year,
|
|
1171
|
+
options
|
|
1172
|
+
);
|
|
1173
|
+
if (existing) {
|
|
1174
|
+
throw new Error("Payroll already exists for this period");
|
|
1175
|
+
}
|
|
1176
|
+
const payrollData = PayrollFactory.create({
|
|
1177
|
+
employeeId,
|
|
1178
|
+
organizationId,
|
|
1179
|
+
baseAmount: employee2.compensation.baseAmount,
|
|
1180
|
+
allowances: employee2.compensation.allowances || [],
|
|
1181
|
+
deductions: employee2.compensation.deductions || [],
|
|
1182
|
+
period: { month, year },
|
|
1183
|
+
metadata: { currency: employee2.compensation.currency }
|
|
1184
|
+
});
|
|
1185
|
+
return this.create(payrollData, options);
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Generate batch payroll
|
|
1189
|
+
*/
|
|
1190
|
+
async generateBatch(organizationId, month, year, options = {}) {
|
|
1191
|
+
const employees = await this.employeeService.findEligibleForPayroll(
|
|
1192
|
+
organizationId,
|
|
1193
|
+
options
|
|
1194
|
+
);
|
|
1195
|
+
if (employees.length === 0) {
|
|
1196
|
+
return {
|
|
1197
|
+
success: true,
|
|
1198
|
+
generated: 0,
|
|
1199
|
+
skipped: 0,
|
|
1200
|
+
payrolls: [],
|
|
1201
|
+
message: "No eligible employees"
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
const existingPayrolls = await this.findForPeriod(
|
|
1205
|
+
organizationId,
|
|
1206
|
+
month,
|
|
1207
|
+
year,
|
|
1208
|
+
options
|
|
1209
|
+
);
|
|
1210
|
+
const existingEmployeeIds = new Set(
|
|
1211
|
+
existingPayrolls.map((p) => p.employeeId.toString())
|
|
1212
|
+
);
|
|
1213
|
+
const eligibleEmployees = employees.filter(
|
|
1214
|
+
(emp) => !existingEmployeeIds.has(emp._id.toString())
|
|
1215
|
+
);
|
|
1216
|
+
if (eligibleEmployees.length === 0) {
|
|
1217
|
+
return {
|
|
1218
|
+
success: true,
|
|
1219
|
+
generated: 0,
|
|
1220
|
+
skipped: employees.length,
|
|
1221
|
+
payrolls: [],
|
|
1222
|
+
message: "Payrolls already exist for all employees"
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
const payrollsData = BatchPayrollFactory.createBatch(eligibleEmployees, {
|
|
1226
|
+
month,
|
|
1227
|
+
year,
|
|
1228
|
+
organizationId
|
|
1229
|
+
});
|
|
1230
|
+
const created = await this.PayrollModel.insertMany(payrollsData, {
|
|
1231
|
+
session: options.session
|
|
1232
|
+
});
|
|
1233
|
+
logger.info("Batch payroll generated", {
|
|
1234
|
+
organizationId: organizationId.toString(),
|
|
1235
|
+
month,
|
|
1236
|
+
year,
|
|
1237
|
+
count: created.length
|
|
1238
|
+
});
|
|
1239
|
+
return {
|
|
1240
|
+
success: true,
|
|
1241
|
+
generated: created.length,
|
|
1242
|
+
skipped: existingEmployeeIds.size,
|
|
1243
|
+
payrolls: created,
|
|
1244
|
+
message: `Generated ${created.length} payrolls`
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Mark payroll as paid with organization validation
|
|
1249
|
+
*
|
|
1250
|
+
* ⚠️ SECURITY: Validates payroll belongs to organization
|
|
1251
|
+
*/
|
|
1252
|
+
async markAsPaid(payrollId, organizationId, paymentDetails = {}, options = {}) {
|
|
1253
|
+
const query = {
|
|
1254
|
+
_id: toObjectId(payrollId),
|
|
1255
|
+
organizationId: toObjectId(organizationId)
|
|
1256
|
+
};
|
|
1257
|
+
let payrollFindQuery = this.PayrollModel.findOne(query);
|
|
1258
|
+
if (options.session) {
|
|
1259
|
+
payrollFindQuery = payrollFindQuery.session(options.session);
|
|
1260
|
+
}
|
|
1261
|
+
const payroll2 = await payrollFindQuery;
|
|
1262
|
+
if (!payroll2) {
|
|
1263
|
+
throw new Error(`Payroll not found in organization ${organizationId}`);
|
|
1264
|
+
}
|
|
1265
|
+
if (payroll2.status === "paid") {
|
|
1266
|
+
throw new Error("Payroll already paid");
|
|
1267
|
+
}
|
|
1268
|
+
const payrollObj = payroll2.toObject();
|
|
1269
|
+
const updatedData = PayrollFactory.markAsPaid(payrollObj, paymentDetails);
|
|
1270
|
+
const updated = await this.PayrollModel.findByIdAndUpdate(
|
|
1271
|
+
payrollId,
|
|
1272
|
+
updatedData,
|
|
1273
|
+
{ new: true, runValidators: true, session: options.session }
|
|
1274
|
+
);
|
|
1275
|
+
if (!updated) {
|
|
1276
|
+
throw new Error("Failed to update payroll");
|
|
1277
|
+
}
|
|
1278
|
+
logger.info("Payroll marked as paid", {
|
|
1279
|
+
payrollId: payrollId.toString()
|
|
1280
|
+
});
|
|
1281
|
+
return updated;
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Mark payroll as processed with organization validation
|
|
1285
|
+
*
|
|
1286
|
+
* ⚠️ SECURITY: Validates payroll belongs to organization
|
|
1287
|
+
*/
|
|
1288
|
+
async markAsProcessed(payrollId, organizationId, options = {}) {
|
|
1289
|
+
const query = {
|
|
1290
|
+
_id: toObjectId(payrollId),
|
|
1291
|
+
organizationId: toObjectId(organizationId)
|
|
1292
|
+
};
|
|
1293
|
+
let payrollFindQuery = this.PayrollModel.findOne(query);
|
|
1294
|
+
if (options.session) {
|
|
1295
|
+
payrollFindQuery = payrollFindQuery.session(options.session);
|
|
1296
|
+
}
|
|
1297
|
+
const payroll2 = await payrollFindQuery;
|
|
1298
|
+
if (!payroll2) {
|
|
1299
|
+
throw new Error(`Payroll not found in organization ${organizationId}`);
|
|
1300
|
+
}
|
|
1301
|
+
const payrollObj = payroll2.toObject();
|
|
1302
|
+
const updatedData = PayrollFactory.markAsProcessed(payrollObj);
|
|
1303
|
+
const updated = await this.PayrollModel.findByIdAndUpdate(
|
|
1304
|
+
payrollId,
|
|
1305
|
+
updatedData,
|
|
1306
|
+
{ new: true, runValidators: true, session: options.session }
|
|
1307
|
+
);
|
|
1308
|
+
if (!updated) {
|
|
1309
|
+
throw new Error("Failed to update payroll");
|
|
1310
|
+
}
|
|
1311
|
+
return updated;
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* Calculate period summary
|
|
1315
|
+
*/
|
|
1316
|
+
async calculatePeriodSummary(organizationId, month, year, options = {}) {
|
|
1317
|
+
const payrolls = await this.findForPeriod(organizationId, month, year, options);
|
|
1318
|
+
const summary = BatchPayrollFactory.calculateTotalPayroll(payrolls);
|
|
1319
|
+
return {
|
|
1320
|
+
period: { month, year },
|
|
1321
|
+
...summary,
|
|
1322
|
+
byStatus: this.groupByStatus(payrolls)
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Get employee payroll history
|
|
1327
|
+
*/
|
|
1328
|
+
async getEmployeePayrollHistory(employeeId, organizationId, limit = 12, options = {}) {
|
|
1329
|
+
return this.findByEmployee(employeeId, organizationId, { ...options, limit });
|
|
1330
|
+
}
|
|
1331
|
+
/**
|
|
1332
|
+
* Get overview stats
|
|
1333
|
+
*/
|
|
1334
|
+
async getOverviewStats(organizationId, options = {}) {
|
|
1335
|
+
const { month, year } = getCurrentPeriod();
|
|
1336
|
+
const result = await this.calculatePeriodSummary(organizationId, month, year, options);
|
|
1337
|
+
return {
|
|
1338
|
+
currentPeriod: result.period,
|
|
1339
|
+
count: result.count,
|
|
1340
|
+
totalGross: result.totalGross,
|
|
1341
|
+
totalNet: result.totalNet,
|
|
1342
|
+
totalAllowances: result.totalAllowances,
|
|
1343
|
+
totalDeductions: result.totalDeductions,
|
|
1344
|
+
byStatus: result.byStatus
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Group payrolls by status
|
|
1349
|
+
*/
|
|
1350
|
+
groupByStatus(payrolls) {
|
|
1351
|
+
return payrolls.reduce(
|
|
1352
|
+
(acc, payroll2) => {
|
|
1353
|
+
acc[payroll2.status] = (acc[payroll2.status] || 0) + 1;
|
|
1354
|
+
return acc;
|
|
1355
|
+
},
|
|
1356
|
+
{}
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
};
|
|
1360
|
+
function createPayrollService(PayrollModel, employeeService) {
|
|
1361
|
+
return new PayrollService(PayrollModel, employeeService);
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// src/factories/compensation.factory.ts
|
|
1365
|
+
var CompensationFactory = class {
|
|
1366
|
+
/**
|
|
1367
|
+
* Create compensation object
|
|
1368
|
+
*/
|
|
1369
|
+
static create(params) {
|
|
1370
|
+
const {
|
|
1371
|
+
baseAmount,
|
|
1372
|
+
frequency = "monthly",
|
|
1373
|
+
currency = HRM_CONFIG.payroll.defaultCurrency,
|
|
1374
|
+
allowances = [],
|
|
1375
|
+
deductions = [],
|
|
1376
|
+
effectiveFrom = /* @__PURE__ */ new Date()
|
|
1377
|
+
} = params;
|
|
1378
|
+
return {
|
|
1379
|
+
baseAmount,
|
|
1380
|
+
frequency,
|
|
1381
|
+
currency,
|
|
1382
|
+
allowances: allowances.map((a) => this.createAllowance(a, baseAmount)),
|
|
1383
|
+
deductions: deductions.map((d) => this.createDeduction(d, baseAmount)),
|
|
1384
|
+
effectiveFrom,
|
|
1385
|
+
lastModified: /* @__PURE__ */ new Date()
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* Create allowance
|
|
1390
|
+
*/
|
|
1391
|
+
static createAllowance(params, baseAmount) {
|
|
1392
|
+
const amount = params.isPercentage && baseAmount ? applyPercentage(baseAmount, params.value) : params.value;
|
|
1393
|
+
return {
|
|
1394
|
+
type: params.type,
|
|
1395
|
+
name: params.name || params.type,
|
|
1396
|
+
amount,
|
|
1397
|
+
isPercentage: params.isPercentage ?? false,
|
|
1398
|
+
value: params.isPercentage ? params.value : void 0,
|
|
1399
|
+
taxable: params.taxable ?? true,
|
|
1400
|
+
recurring: true,
|
|
1401
|
+
effectiveFrom: /* @__PURE__ */ new Date()
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Create deduction
|
|
1406
|
+
*/
|
|
1407
|
+
static createDeduction(params, baseAmount) {
|
|
1408
|
+
const amount = params.isPercentage && baseAmount ? applyPercentage(baseAmount, params.value) : params.value;
|
|
1409
|
+
return {
|
|
1410
|
+
type: params.type,
|
|
1411
|
+
name: params.name || params.type,
|
|
1412
|
+
amount,
|
|
1413
|
+
isPercentage: params.isPercentage ?? false,
|
|
1414
|
+
value: params.isPercentage ? params.value : void 0,
|
|
1415
|
+
auto: params.auto ?? false,
|
|
1416
|
+
recurring: true,
|
|
1417
|
+
effectiveFrom: /* @__PURE__ */ new Date()
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Update base amount (immutable)
|
|
1422
|
+
*/
|
|
1423
|
+
static updateBaseAmount(compensation, newAmount, effectiveFrom = /* @__PURE__ */ new Date()) {
|
|
1424
|
+
return {
|
|
1425
|
+
...compensation,
|
|
1426
|
+
baseAmount: newAmount,
|
|
1427
|
+
lastModified: effectiveFrom
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
/**
|
|
1431
|
+
* Add allowance (immutable)
|
|
1432
|
+
*/
|
|
1433
|
+
static addAllowance(compensation, allowance) {
|
|
1434
|
+
return {
|
|
1435
|
+
...compensation,
|
|
1436
|
+
allowances: [
|
|
1437
|
+
...compensation.allowances,
|
|
1438
|
+
this.createAllowance(allowance, compensation.baseAmount)
|
|
1439
|
+
],
|
|
1440
|
+
lastModified: /* @__PURE__ */ new Date()
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* Remove allowance (immutable)
|
|
1445
|
+
*/
|
|
1446
|
+
static removeAllowance(compensation, allowanceType) {
|
|
1447
|
+
return {
|
|
1448
|
+
...compensation,
|
|
1449
|
+
allowances: compensation.allowances.filter((a) => a.type !== allowanceType),
|
|
1450
|
+
lastModified: /* @__PURE__ */ new Date()
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Add deduction (immutable)
|
|
1455
|
+
*/
|
|
1456
|
+
static addDeduction(compensation, deduction) {
|
|
1457
|
+
return {
|
|
1458
|
+
...compensation,
|
|
1459
|
+
deductions: [
|
|
1460
|
+
...compensation.deductions,
|
|
1461
|
+
this.createDeduction(deduction, compensation.baseAmount)
|
|
1462
|
+
],
|
|
1463
|
+
lastModified: /* @__PURE__ */ new Date()
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Remove deduction (immutable)
|
|
1468
|
+
*/
|
|
1469
|
+
static removeDeduction(compensation, deductionType) {
|
|
1470
|
+
return {
|
|
1471
|
+
...compensation,
|
|
1472
|
+
deductions: compensation.deductions.filter((d) => d.type !== deductionType),
|
|
1473
|
+
lastModified: /* @__PURE__ */ new Date()
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* Calculate compensation breakdown
|
|
1478
|
+
*/
|
|
1479
|
+
static calculateBreakdown(compensation) {
|
|
1480
|
+
const { baseAmount, allowances, deductions } = compensation;
|
|
1481
|
+
const calculatedAllowances = allowances.map((a) => ({
|
|
1482
|
+
...a,
|
|
1483
|
+
calculatedAmount: a.isPercentage && a.value !== void 0 ? applyPercentage(baseAmount, a.value) : a.amount
|
|
1484
|
+
}));
|
|
1485
|
+
const calculatedDeductions = deductions.map((d) => ({
|
|
1486
|
+
...d,
|
|
1487
|
+
calculatedAmount: d.isPercentage && d.value !== void 0 ? applyPercentage(baseAmount, d.value) : d.amount
|
|
1488
|
+
}));
|
|
1489
|
+
const grossAmount = calculateGross(
|
|
1490
|
+
baseAmount,
|
|
1491
|
+
calculatedAllowances.map((a) => ({ amount: a.calculatedAmount }))
|
|
1492
|
+
);
|
|
1493
|
+
const netAmount = calculateNet(
|
|
1494
|
+
grossAmount,
|
|
1495
|
+
calculatedDeductions.map((d) => ({ amount: d.calculatedAmount }))
|
|
1496
|
+
);
|
|
1497
|
+
return {
|
|
1498
|
+
baseAmount,
|
|
1499
|
+
allowances: calculatedAllowances,
|
|
1500
|
+
deductions: calculatedDeductions,
|
|
1501
|
+
grossAmount,
|
|
1502
|
+
netAmount: Math.max(0, netAmount)
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
/**
|
|
1506
|
+
* Apply salary increment (immutable)
|
|
1507
|
+
*/
|
|
1508
|
+
static applyIncrement(compensation, params) {
|
|
1509
|
+
const newBaseAmount = params.amount ? compensation.baseAmount + params.amount : compensation.baseAmount * (1 + (params.percentage || 0) / 100);
|
|
1510
|
+
return this.updateBaseAmount(
|
|
1511
|
+
compensation,
|
|
1512
|
+
Math.round(newBaseAmount),
|
|
1513
|
+
params.effectiveFrom
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1516
|
+
};
|
|
1517
|
+
var CompensationBuilder = class {
|
|
1518
|
+
data = {
|
|
1519
|
+
baseAmount: 0,
|
|
1520
|
+
frequency: "monthly",
|
|
1521
|
+
currency: HRM_CONFIG.payroll.defaultCurrency,
|
|
1522
|
+
allowances: [],
|
|
1523
|
+
deductions: []
|
|
1524
|
+
};
|
|
1525
|
+
/**
|
|
1526
|
+
* Set base amount
|
|
1527
|
+
*/
|
|
1528
|
+
withBase(amount, frequency = "monthly", currency = HRM_CONFIG.payroll.defaultCurrency) {
|
|
1529
|
+
this.data.baseAmount = amount;
|
|
1530
|
+
this.data.frequency = frequency;
|
|
1531
|
+
this.data.currency = currency;
|
|
1532
|
+
return this;
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* Add allowance
|
|
1536
|
+
*/
|
|
1537
|
+
addAllowance(type, value, isPercentage = false, name) {
|
|
1538
|
+
this.data.allowances = [
|
|
1539
|
+
...this.data.allowances || [],
|
|
1540
|
+
{ type, value, isPercentage, name }
|
|
1541
|
+
];
|
|
1542
|
+
return this;
|
|
1543
|
+
}
|
|
1544
|
+
/**
|
|
1545
|
+
* Add deduction
|
|
1546
|
+
*/
|
|
1547
|
+
addDeduction(type, value, isPercentage = false, name) {
|
|
1548
|
+
this.data.deductions = [
|
|
1549
|
+
...this.data.deductions || [],
|
|
1550
|
+
{ type, value, isPercentage, name }
|
|
1551
|
+
];
|
|
1552
|
+
return this;
|
|
1553
|
+
}
|
|
1554
|
+
/**
|
|
1555
|
+
* Set effective date
|
|
1556
|
+
*/
|
|
1557
|
+
effectiveFrom(date) {
|
|
1558
|
+
this.data.effectiveFrom = date;
|
|
1559
|
+
return this;
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* Build compensation
|
|
1563
|
+
*/
|
|
1564
|
+
build() {
|
|
1565
|
+
if (!this.data.baseAmount) {
|
|
1566
|
+
throw new Error("baseAmount is required");
|
|
1567
|
+
}
|
|
1568
|
+
return CompensationFactory.create(this.data);
|
|
1569
|
+
}
|
|
1570
|
+
};
|
|
1571
|
+
var CompensationPresets = {
|
|
1572
|
+
/**
|
|
1573
|
+
* Basic compensation (base only)
|
|
1574
|
+
*/
|
|
1575
|
+
basic(baseAmount) {
|
|
1576
|
+
return new CompensationBuilder().withBase(baseAmount).build();
|
|
1577
|
+
},
|
|
1578
|
+
/**
|
|
1579
|
+
* With house rent allowance
|
|
1580
|
+
*/
|
|
1581
|
+
withHouseRent(baseAmount, rentPercentage = 50) {
|
|
1582
|
+
return new CompensationBuilder().withBase(baseAmount).addAllowance("housing", rentPercentage, true, "House Rent").build();
|
|
1583
|
+
},
|
|
1584
|
+
/**
|
|
1585
|
+
* With medical allowance
|
|
1586
|
+
*/
|
|
1587
|
+
withMedical(baseAmount, medicalPercentage = 10) {
|
|
1588
|
+
return new CompensationBuilder().withBase(baseAmount).addAllowance("medical", medicalPercentage, true, "Medical Allowance").build();
|
|
1589
|
+
},
|
|
1590
|
+
/**
|
|
1591
|
+
* Standard package (house rent + medical + transport)
|
|
1592
|
+
*/
|
|
1593
|
+
standard(baseAmount) {
|
|
1594
|
+
return new CompensationBuilder().withBase(baseAmount).addAllowance("housing", 50, true, "House Rent").addAllowance("medical", 10, true, "Medical Allowance").addAllowance("transport", 5, true, "Transport Allowance").build();
|
|
1595
|
+
},
|
|
1596
|
+
/**
|
|
1597
|
+
* With provident fund
|
|
1598
|
+
*/
|
|
1599
|
+
withProvidentFund(baseAmount, pfPercentage = 10) {
|
|
1600
|
+
return new CompensationBuilder().withBase(baseAmount).addAllowance("housing", 50, true, "House Rent").addAllowance("medical", 10, true, "Medical Allowance").addDeduction("provident_fund", pfPercentage, true, "Provident Fund").build();
|
|
1601
|
+
},
|
|
1602
|
+
/**
|
|
1603
|
+
* Executive package
|
|
1604
|
+
*/
|
|
1605
|
+
executive(baseAmount) {
|
|
1606
|
+
return new CompensationBuilder().withBase(baseAmount).addAllowance("housing", 60, true, "House Rent").addAllowance("medical", 15, true, "Medical Allowance").addAllowance("transport", 10, true, "Transport Allowance").addAllowance("mobile", 5, true, "Mobile Allowance").addDeduction("provident_fund", 10, true, "Provident Fund").build();
|
|
1607
|
+
}
|
|
1608
|
+
};
|
|
1609
|
+
|
|
1610
|
+
// src/services/compensation.service.ts
|
|
1611
|
+
var CompensationService = class {
|
|
1612
|
+
constructor(EmployeeModel) {
|
|
1613
|
+
this.EmployeeModel = EmployeeModel;
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* Get employee compensation with organization validation
|
|
1617
|
+
*
|
|
1618
|
+
* ⚠️ SECURITY: Validates employee belongs to organization
|
|
1619
|
+
*/
|
|
1620
|
+
async getEmployeeCompensation(employeeId, organizationId, options = {}) {
|
|
1621
|
+
const employee2 = await this.findEmployee(employeeId, organizationId, options);
|
|
1622
|
+
return employee2.compensation;
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* Calculate compensation breakdown with organization validation
|
|
1626
|
+
*
|
|
1627
|
+
* ⚠️ SECURITY: Validates employee belongs to organization
|
|
1628
|
+
*/
|
|
1629
|
+
async calculateBreakdown(employeeId, organizationId, options = {}) {
|
|
1630
|
+
const compensation = await this.getEmployeeCompensation(employeeId, organizationId, options);
|
|
1631
|
+
return CompensationFactory.calculateBreakdown(compensation);
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Update base amount with organization validation
|
|
1635
|
+
*
|
|
1636
|
+
* ⚠️ SECURITY: Validates employee belongs to organization before update
|
|
1637
|
+
*/
|
|
1638
|
+
async updateBaseAmount(employeeId, organizationId, newAmount, effectiveFrom = /* @__PURE__ */ new Date(), options = {}) {
|
|
1639
|
+
const employee2 = await this.findEmployee(employeeId, organizationId, options);
|
|
1640
|
+
const updatedCompensation = CompensationFactory.updateBaseAmount(
|
|
1641
|
+
employee2.compensation,
|
|
1642
|
+
newAmount,
|
|
1643
|
+
effectiveFrom
|
|
1644
|
+
);
|
|
1645
|
+
employee2.compensation = updatedCompensation;
|
|
1646
|
+
await employee2.save({ session: options.session });
|
|
1647
|
+
logger.info("Compensation base amount updated", {
|
|
1648
|
+
employeeId: employee2.employeeId,
|
|
1649
|
+
organizationId: organizationId.toString(),
|
|
1650
|
+
newAmount
|
|
1651
|
+
});
|
|
1652
|
+
return this.calculateBreakdown(employeeId, organizationId, options);
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* Apply salary increment with organization validation
|
|
1656
|
+
*
|
|
1657
|
+
* ⚠️ SECURITY: Validates employee belongs to organization before update
|
|
1658
|
+
*/
|
|
1659
|
+
async applyIncrement(employeeId, organizationId, params, options = {}) {
|
|
1660
|
+
const employee2 = await this.findEmployee(employeeId, organizationId, options);
|
|
1661
|
+
const previousAmount = employee2.compensation.baseAmount;
|
|
1662
|
+
const updatedCompensation = CompensationFactory.applyIncrement(
|
|
1663
|
+
employee2.compensation,
|
|
1664
|
+
params
|
|
1665
|
+
);
|
|
1666
|
+
employee2.compensation = updatedCompensation;
|
|
1667
|
+
await employee2.save({ session: options.session });
|
|
1668
|
+
logger.info("Salary increment applied", {
|
|
1669
|
+
employeeId: employee2.employeeId,
|
|
1670
|
+
organizationId: organizationId.toString(),
|
|
1671
|
+
previousAmount,
|
|
1672
|
+
newAmount: updatedCompensation.baseAmount,
|
|
1673
|
+
percentage: params.percentage
|
|
1674
|
+
});
|
|
1675
|
+
return this.calculateBreakdown(employeeId, organizationId, options);
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* Add allowance with organization validation
|
|
1679
|
+
*
|
|
1680
|
+
* ⚠️ SECURITY: Validates employee belongs to organization before update
|
|
1681
|
+
*/
|
|
1682
|
+
async addAllowance(employeeId, organizationId, allowance, options = {}) {
|
|
1683
|
+
const employee2 = await this.findEmployee(employeeId, organizationId, options);
|
|
1684
|
+
const updatedCompensation = CompensationFactory.addAllowance(
|
|
1685
|
+
employee2.compensation,
|
|
1686
|
+
allowance
|
|
1687
|
+
);
|
|
1688
|
+
employee2.compensation = updatedCompensation;
|
|
1689
|
+
await employee2.save({ session: options.session });
|
|
1690
|
+
logger.info("Allowance added", {
|
|
1691
|
+
employeeId: employee2.employeeId,
|
|
1692
|
+
organizationId: organizationId.toString(),
|
|
1693
|
+
type: allowance.type,
|
|
1694
|
+
value: allowance.value
|
|
1695
|
+
});
|
|
1696
|
+
return this.calculateBreakdown(employeeId, organizationId, options);
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* Remove allowance with organization validation
|
|
1700
|
+
*
|
|
1701
|
+
* ⚠️ SECURITY: Validates employee belongs to organization before update
|
|
1702
|
+
*/
|
|
1703
|
+
async removeAllowance(employeeId, organizationId, allowanceType, options = {}) {
|
|
1704
|
+
const employee2 = await this.findEmployee(employeeId, organizationId, options);
|
|
1705
|
+
const updatedCompensation = CompensationFactory.removeAllowance(
|
|
1706
|
+
employee2.compensation,
|
|
1707
|
+
allowanceType
|
|
1708
|
+
);
|
|
1709
|
+
employee2.compensation = updatedCompensation;
|
|
1710
|
+
await employee2.save({ session: options.session });
|
|
1711
|
+
logger.info("Allowance removed", {
|
|
1712
|
+
employeeId: employee2.employeeId,
|
|
1713
|
+
organizationId: organizationId.toString(),
|
|
1714
|
+
type: allowanceType
|
|
1715
|
+
});
|
|
1716
|
+
return this.calculateBreakdown(employeeId, organizationId, options);
|
|
1717
|
+
}
|
|
1718
|
+
/**
|
|
1719
|
+
* Add deduction with organization validation
|
|
1720
|
+
*
|
|
1721
|
+
* ⚠️ SECURITY: Validates employee belongs to organization before update
|
|
1722
|
+
*/
|
|
1723
|
+
async addDeduction(employeeId, organizationId, deduction, options = {}) {
|
|
1724
|
+
const employee2 = await this.findEmployee(employeeId, organizationId, options);
|
|
1725
|
+
const updatedCompensation = CompensationFactory.addDeduction(
|
|
1726
|
+
employee2.compensation,
|
|
1727
|
+
deduction
|
|
1728
|
+
);
|
|
1729
|
+
employee2.compensation = updatedCompensation;
|
|
1730
|
+
await employee2.save({ session: options.session });
|
|
1731
|
+
logger.info("Deduction added", {
|
|
1732
|
+
employeeId: employee2.employeeId,
|
|
1733
|
+
organizationId: organizationId.toString(),
|
|
1734
|
+
type: deduction.type,
|
|
1735
|
+
value: deduction.value
|
|
1736
|
+
});
|
|
1737
|
+
return this.calculateBreakdown(employeeId, organizationId, options);
|
|
1738
|
+
}
|
|
1739
|
+
/**
|
|
1740
|
+
* Remove deduction with organization validation
|
|
1741
|
+
*
|
|
1742
|
+
* ⚠️ SECURITY: Validates employee belongs to organization before update
|
|
1743
|
+
*/
|
|
1744
|
+
async removeDeduction(employeeId, organizationId, deductionType, options = {}) {
|
|
1745
|
+
const employee2 = await this.findEmployee(employeeId, organizationId, options);
|
|
1746
|
+
const updatedCompensation = CompensationFactory.removeDeduction(
|
|
1747
|
+
employee2.compensation,
|
|
1748
|
+
deductionType
|
|
1749
|
+
);
|
|
1750
|
+
employee2.compensation = updatedCompensation;
|
|
1751
|
+
await employee2.save({ session: options.session });
|
|
1752
|
+
logger.info("Deduction removed", {
|
|
1753
|
+
employeeId: employee2.employeeId,
|
|
1754
|
+
organizationId: organizationId.toString(),
|
|
1755
|
+
type: deductionType
|
|
1756
|
+
});
|
|
1757
|
+
return this.calculateBreakdown(employeeId, organizationId, options);
|
|
1758
|
+
}
|
|
1759
|
+
/**
|
|
1760
|
+
* Set standard compensation with organization validation
|
|
1761
|
+
*
|
|
1762
|
+
* ⚠️ SECURITY: Validates employee belongs to organization before update
|
|
1763
|
+
*/
|
|
1764
|
+
async setStandardCompensation(employeeId, organizationId, baseAmount, options = {}) {
|
|
1765
|
+
const employee2 = await this.findEmployee(employeeId, organizationId, options);
|
|
1766
|
+
employee2.compensation = CompensationPresets.standard(baseAmount);
|
|
1767
|
+
await employee2.save({ session: options.session });
|
|
1768
|
+
logger.info("Standard compensation set", {
|
|
1769
|
+
employeeId: employee2.employeeId,
|
|
1770
|
+
organizationId: organizationId.toString(),
|
|
1771
|
+
baseAmount
|
|
1772
|
+
});
|
|
1773
|
+
return this.calculateBreakdown(employeeId, organizationId, options);
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* Compare compensation between two employees
|
|
1777
|
+
*
|
|
1778
|
+
* ⚠️ SECURITY: Validates both employees belong to organization
|
|
1779
|
+
*/
|
|
1780
|
+
async compareCompensation(employeeId1, employeeId2, organizationId, options = {}) {
|
|
1781
|
+
const breakdown1 = await this.calculateBreakdown(employeeId1, organizationId, options);
|
|
1782
|
+
const breakdown2 = await this.calculateBreakdown(employeeId2, organizationId, options);
|
|
1783
|
+
return {
|
|
1784
|
+
employee1: breakdown1,
|
|
1785
|
+
employee2: breakdown2,
|
|
1786
|
+
difference: {
|
|
1787
|
+
base: breakdown2.baseAmount - breakdown1.baseAmount,
|
|
1788
|
+
gross: breakdown2.grossAmount - breakdown1.grossAmount,
|
|
1789
|
+
net: breakdown2.netAmount - breakdown1.netAmount
|
|
1790
|
+
},
|
|
1791
|
+
ratio: {
|
|
1792
|
+
base: breakdown1.baseAmount > 0 ? breakdown2.baseAmount / breakdown1.baseAmount : 0,
|
|
1793
|
+
gross: breakdown1.grossAmount > 0 ? breakdown2.grossAmount / breakdown1.grossAmount : 0,
|
|
1794
|
+
net: breakdown1.netAmount > 0 ? breakdown2.netAmount / breakdown1.netAmount : 0
|
|
1795
|
+
}
|
|
1796
|
+
};
|
|
1797
|
+
}
|
|
1798
|
+
/**
|
|
1799
|
+
* Get department compensation stats
|
|
1800
|
+
*/
|
|
1801
|
+
async getDepartmentCompensationStats(organizationId, department, options = {}) {
|
|
1802
|
+
let query = this.EmployeeModel.find({
|
|
1803
|
+
organizationId: toObjectId(organizationId),
|
|
1804
|
+
department,
|
|
1805
|
+
status: { $in: ["active", "on_leave"] }
|
|
1806
|
+
});
|
|
1807
|
+
if (options.session) {
|
|
1808
|
+
query = query.session(options.session);
|
|
1809
|
+
}
|
|
1810
|
+
const employees = await query.exec();
|
|
1811
|
+
const breakdowns = employees.map(
|
|
1812
|
+
(emp) => CompensationFactory.calculateBreakdown(emp.compensation)
|
|
1813
|
+
);
|
|
1814
|
+
const totals = breakdowns.reduce(
|
|
1815
|
+
(acc, breakdown) => ({
|
|
1816
|
+
totalBase: acc.totalBase + breakdown.baseAmount,
|
|
1817
|
+
totalGross: acc.totalGross + breakdown.grossAmount,
|
|
1818
|
+
totalNet: acc.totalNet + breakdown.netAmount
|
|
1819
|
+
}),
|
|
1820
|
+
{ totalBase: 0, totalGross: 0, totalNet: 0 }
|
|
1821
|
+
);
|
|
1822
|
+
const count = employees.length || 1;
|
|
1823
|
+
return {
|
|
1824
|
+
department,
|
|
1825
|
+
employeeCount: employees.length,
|
|
1826
|
+
...totals,
|
|
1827
|
+
averageBase: Math.round(totals.totalBase / count),
|
|
1828
|
+
averageGross: Math.round(totals.totalGross / count),
|
|
1829
|
+
averageNet: Math.round(totals.totalNet / count)
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
/**
|
|
1833
|
+
* Get organization compensation stats
|
|
1834
|
+
*/
|
|
1835
|
+
async getOrganizationCompensationStats(organizationId, options = {}) {
|
|
1836
|
+
let query = this.EmployeeModel.find({
|
|
1837
|
+
organizationId: toObjectId(organizationId),
|
|
1838
|
+
status: { $in: ["active", "on_leave"] }
|
|
1839
|
+
});
|
|
1840
|
+
if (options.session) {
|
|
1841
|
+
query = query.session(options.session);
|
|
1842
|
+
}
|
|
1843
|
+
const employees = await query.exec();
|
|
1844
|
+
const breakdowns = employees.map(
|
|
1845
|
+
(emp) => CompensationFactory.calculateBreakdown(emp.compensation)
|
|
1846
|
+
);
|
|
1847
|
+
const totals = breakdowns.reduce(
|
|
1848
|
+
(acc, breakdown) => ({
|
|
1849
|
+
totalBase: acc.totalBase + breakdown.baseAmount,
|
|
1850
|
+
totalGross: acc.totalGross + breakdown.grossAmount,
|
|
1851
|
+
totalNet: acc.totalNet + breakdown.netAmount
|
|
1852
|
+
}),
|
|
1853
|
+
{ totalBase: 0, totalGross: 0, totalNet: 0 }
|
|
1854
|
+
);
|
|
1855
|
+
const byDepartment = {};
|
|
1856
|
+
employees.forEach((emp, i) => {
|
|
1857
|
+
const dept = emp.department || "unassigned";
|
|
1858
|
+
if (!byDepartment[dept]) {
|
|
1859
|
+
byDepartment[dept] = { count: 0, totalNet: 0 };
|
|
1860
|
+
}
|
|
1861
|
+
byDepartment[dept].count++;
|
|
1862
|
+
byDepartment[dept].totalNet += breakdowns[i].netAmount;
|
|
1863
|
+
});
|
|
1864
|
+
const count = employees.length || 1;
|
|
1865
|
+
return {
|
|
1866
|
+
employeeCount: employees.length,
|
|
1867
|
+
...totals,
|
|
1868
|
+
averageBase: Math.round(totals.totalBase / count),
|
|
1869
|
+
averageGross: Math.round(totals.totalGross / count),
|
|
1870
|
+
averageNet: Math.round(totals.totalNet / count),
|
|
1871
|
+
byDepartment
|
|
1872
|
+
};
|
|
1873
|
+
}
|
|
1874
|
+
/**
|
|
1875
|
+
* Find employee helper with organization validation
|
|
1876
|
+
*
|
|
1877
|
+
* ⚠️ SECURITY: Always validates employee belongs to organization
|
|
1878
|
+
*/
|
|
1879
|
+
async findEmployee(employeeId, organizationId, options = {}) {
|
|
1880
|
+
const query = {
|
|
1881
|
+
_id: toObjectId(employeeId),
|
|
1882
|
+
organizationId: toObjectId(organizationId)
|
|
1883
|
+
};
|
|
1884
|
+
let mongooseQuery = this.EmployeeModel.findOne(query);
|
|
1885
|
+
if (options.session) {
|
|
1886
|
+
mongooseQuery = mongooseQuery.session(options.session);
|
|
1887
|
+
}
|
|
1888
|
+
const employee2 = await mongooseQuery.exec();
|
|
1889
|
+
if (!employee2) {
|
|
1890
|
+
throw new Error(`Employee not found in organization ${organizationId}`);
|
|
1891
|
+
}
|
|
1892
|
+
return employee2;
|
|
1893
|
+
}
|
|
1894
|
+
};
|
|
1895
|
+
function createCompensationService(EmployeeModel) {
|
|
1896
|
+
return new CompensationService(EmployeeModel);
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// src/services/tax-withholding.service.ts
|
|
1900
|
+
var TaxWithholdingService = class {
|
|
1901
|
+
constructor(TaxWithholdingModel, TransactionModel, events) {
|
|
1902
|
+
this.TaxWithholdingModel = TaxWithholdingModel;
|
|
1903
|
+
this.TransactionModel = TransactionModel;
|
|
1904
|
+
this.events = events;
|
|
1905
|
+
}
|
|
1906
|
+
/**
|
|
1907
|
+
* Create tax withholding records from payroll breakdown
|
|
1908
|
+
*
|
|
1909
|
+
* Extracts tax deductions from the breakdown and creates separate
|
|
1910
|
+
* TaxWithholding records for each tax type
|
|
1911
|
+
*/
|
|
1912
|
+
async createFromBreakdown(params) {
|
|
1913
|
+
const {
|
|
1914
|
+
organizationId,
|
|
1915
|
+
employeeId,
|
|
1916
|
+
userId,
|
|
1917
|
+
payrollRecordId,
|
|
1918
|
+
transactionId,
|
|
1919
|
+
period,
|
|
1920
|
+
breakdown,
|
|
1921
|
+
currency = "BDT",
|
|
1922
|
+
session,
|
|
1923
|
+
context
|
|
1924
|
+
} = params;
|
|
1925
|
+
const taxDeductions = breakdown.deductions?.filter(
|
|
1926
|
+
(d) => d.type === "tax" || this.isTaxDeduction(d.type)
|
|
1927
|
+
) || [];
|
|
1928
|
+
if (taxDeductions.length === 0) {
|
|
1929
|
+
return [];
|
|
1930
|
+
}
|
|
1931
|
+
const withholdings = [];
|
|
1932
|
+
for (const deduction of taxDeductions) {
|
|
1933
|
+
const taxType = this.mapDeductionTypeToTaxType(deduction.type);
|
|
1934
|
+
const taxRate = breakdown.taxableAmount && breakdown.taxableAmount > 0 ? deduction.amount / breakdown.taxableAmount : 0;
|
|
1935
|
+
const withholdingData = {
|
|
1936
|
+
organizationId,
|
|
1937
|
+
employeeId,
|
|
1938
|
+
userId,
|
|
1939
|
+
payrollRecordId,
|
|
1940
|
+
transactionId,
|
|
1941
|
+
period,
|
|
1942
|
+
amount: deduction.amount,
|
|
1943
|
+
currency,
|
|
1944
|
+
taxType,
|
|
1945
|
+
taxRate,
|
|
1946
|
+
taxableAmount: breakdown.taxableAmount || breakdown.grossSalary,
|
|
1947
|
+
status: TAX_STATUS.PENDING
|
|
1948
|
+
};
|
|
1949
|
+
const [withholding] = await this.TaxWithholdingModel.create([withholdingData], {
|
|
1950
|
+
session
|
|
1951
|
+
});
|
|
1952
|
+
withholdings.push(withholding);
|
|
1953
|
+
if (this.events) {
|
|
1954
|
+
this.events.emitSync("tax:withheld", {
|
|
1955
|
+
withholding: {
|
|
1956
|
+
id: withholding._id,
|
|
1957
|
+
taxType: withholding.taxType,
|
|
1958
|
+
amount: withholding.amount
|
|
1959
|
+
},
|
|
1960
|
+
employee: {
|
|
1961
|
+
id: employeeId,
|
|
1962
|
+
employeeId: ""
|
|
1963
|
+
// Will be filled by caller if needed
|
|
1964
|
+
},
|
|
1965
|
+
payrollRecord: {
|
|
1966
|
+
id: payrollRecordId
|
|
1967
|
+
},
|
|
1968
|
+
period: {
|
|
1969
|
+
month: period.month,
|
|
1970
|
+
year: period.year
|
|
1971
|
+
},
|
|
1972
|
+
organizationId,
|
|
1973
|
+
context
|
|
1974
|
+
});
|
|
1975
|
+
}
|
|
1976
|
+
logger.info("Tax withholding created", {
|
|
1977
|
+
withholdingId: withholding._id.toString(),
|
|
1978
|
+
employeeId: employeeId.toString(),
|
|
1979
|
+
taxType,
|
|
1980
|
+
amount: deduction.amount,
|
|
1981
|
+
period: `${period.month}/${period.year}`
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
return withholdings;
|
|
1985
|
+
}
|
|
1986
|
+
/**
|
|
1987
|
+
* Get pending tax withholdings with optional filters
|
|
1988
|
+
*/
|
|
1989
|
+
async getPending(params) {
|
|
1990
|
+
const { organizationId, fromPeriod, toPeriod, taxType, employeeId } = params;
|
|
1991
|
+
const options = {};
|
|
1992
|
+
if (fromPeriod) {
|
|
1993
|
+
options.fromMonth = fromPeriod.month;
|
|
1994
|
+
options.fromYear = fromPeriod.year;
|
|
1995
|
+
}
|
|
1996
|
+
if (toPeriod) {
|
|
1997
|
+
options.toMonth = toPeriod.month;
|
|
1998
|
+
options.toYear = toPeriod.year;
|
|
1999
|
+
}
|
|
2000
|
+
if (taxType) {
|
|
2001
|
+
options.taxType = taxType;
|
|
2002
|
+
}
|
|
2003
|
+
let query = this.TaxWithholdingModel.findPending(
|
|
2004
|
+
toObjectId(organizationId),
|
|
2005
|
+
options
|
|
2006
|
+
);
|
|
2007
|
+
if (employeeId) {
|
|
2008
|
+
query = query.where({ employeeId: toObjectId(employeeId) });
|
|
2009
|
+
}
|
|
2010
|
+
return query.exec();
|
|
2011
|
+
}
|
|
2012
|
+
/**
|
|
2013
|
+
* Get tax summary aggregated by type, period, or employee
|
|
2014
|
+
*/
|
|
2015
|
+
async getSummary(params) {
|
|
2016
|
+
const { organizationId, fromPeriod, toPeriod, groupBy = "type" } = params;
|
|
2017
|
+
if (groupBy === "type") {
|
|
2018
|
+
const byType = await this.TaxWithholdingModel.getSummaryByType(
|
|
2019
|
+
toObjectId(organizationId),
|
|
2020
|
+
fromPeriod,
|
|
2021
|
+
toPeriod
|
|
2022
|
+
);
|
|
2023
|
+
const totalAmount = byType.reduce((sum2, item) => sum2 + item.totalAmount, 0);
|
|
2024
|
+
const count = byType.reduce((sum2, item) => sum2 + item.count, 0);
|
|
2025
|
+
return {
|
|
2026
|
+
totalAmount,
|
|
2027
|
+
count,
|
|
2028
|
+
byType,
|
|
2029
|
+
period: {
|
|
2030
|
+
fromMonth: fromPeriod.month,
|
|
2031
|
+
fromYear: fromPeriod.year,
|
|
2032
|
+
toMonth: toPeriod.month,
|
|
2033
|
+
toYear: toPeriod.year
|
|
2034
|
+
}
|
|
2035
|
+
};
|
|
2036
|
+
}
|
|
2037
|
+
throw new Error(`groupBy '${groupBy}' not yet implemented`);
|
|
2038
|
+
}
|
|
2039
|
+
/**
|
|
2040
|
+
* Mark tax withholdings as paid
|
|
2041
|
+
*
|
|
2042
|
+
* Updates status, optionally creates government payment transaction,
|
|
2043
|
+
* and emits tax:paid event
|
|
2044
|
+
*/
|
|
2045
|
+
async markPaid(params) {
|
|
2046
|
+
const {
|
|
2047
|
+
organizationId,
|
|
2048
|
+
withholdingIds,
|
|
2049
|
+
createTransaction = false,
|
|
2050
|
+
referenceNumber,
|
|
2051
|
+
paidAt = /* @__PURE__ */ new Date(),
|
|
2052
|
+
notes,
|
|
2053
|
+
context
|
|
2054
|
+
} = params;
|
|
2055
|
+
const session = context?.session;
|
|
2056
|
+
const withholdings = await this.TaxWithholdingModel.find({
|
|
2057
|
+
_id: { $in: withholdingIds.map(toObjectId) },
|
|
2058
|
+
organizationId: toObjectId(organizationId)
|
|
2059
|
+
}).session(session || null);
|
|
2060
|
+
if (withholdings.length === 0) {
|
|
2061
|
+
throw new Error("No tax withholdings found with provided IDs");
|
|
2062
|
+
}
|
|
2063
|
+
const totalAmount = withholdings.reduce((sum2, w) => sum2 + w.amount, 0);
|
|
2064
|
+
let governmentTransaction = null;
|
|
2065
|
+
if (createTransaction && this.TransactionModel) {
|
|
2066
|
+
const transactionData = {
|
|
2067
|
+
organizationId: toObjectId(organizationId),
|
|
2068
|
+
type: "tax_payment",
|
|
2069
|
+
flow: "outflow",
|
|
2070
|
+
tags: ["tax", "government", "withholding"],
|
|
2071
|
+
amount: totalAmount,
|
|
2072
|
+
grossAmount: totalAmount,
|
|
2073
|
+
currency: withholdings[0].currency || "BDT",
|
|
2074
|
+
status: "completed",
|
|
2075
|
+
date: paidAt,
|
|
2076
|
+
description: `Tax payment to government - ${referenceNumber || "Multiple withholdings"}`,
|
|
2077
|
+
notes,
|
|
2078
|
+
metadata: {
|
|
2079
|
+
withholdingIds: withholdingIds.map((id) => id.toString()),
|
|
2080
|
+
referenceNumber
|
|
2081
|
+
}
|
|
2082
|
+
};
|
|
2083
|
+
[governmentTransaction] = await this.TransactionModel.create([transactionData], {
|
|
2084
|
+
session
|
|
2085
|
+
});
|
|
2086
|
+
}
|
|
2087
|
+
for (const withholding of withholdings) {
|
|
2088
|
+
withholding.markAsPaid(
|
|
2089
|
+
governmentTransaction?._id,
|
|
2090
|
+
referenceNumber,
|
|
2091
|
+
paidAt
|
|
2092
|
+
);
|
|
2093
|
+
await withholding.save({ session });
|
|
2094
|
+
}
|
|
2095
|
+
if (this.events) {
|
|
2096
|
+
this.events.emitSync("tax:paid", {
|
|
2097
|
+
withholdings: withholdings.map((w) => ({
|
|
2098
|
+
id: w._id,
|
|
2099
|
+
taxType: w.taxType,
|
|
2100
|
+
amount: w.amount
|
|
2101
|
+
})),
|
|
2102
|
+
transaction: governmentTransaction ? {
|
|
2103
|
+
id: governmentTransaction._id,
|
|
2104
|
+
amount: governmentTransaction.amount
|
|
2105
|
+
} : void 0,
|
|
2106
|
+
totalAmount,
|
|
2107
|
+
referenceNumber,
|
|
2108
|
+
paidAt,
|
|
2109
|
+
organizationId: toObjectId(organizationId),
|
|
2110
|
+
context
|
|
2111
|
+
});
|
|
2112
|
+
}
|
|
2113
|
+
logger.info("Tax withholdings marked as paid", {
|
|
2114
|
+
count: withholdings.length,
|
|
2115
|
+
totalAmount,
|
|
2116
|
+
referenceNumber,
|
|
2117
|
+
transactionId: governmentTransaction?._id.toString()
|
|
2118
|
+
});
|
|
2119
|
+
return {
|
|
2120
|
+
withholdings,
|
|
2121
|
+
transaction: governmentTransaction
|
|
2122
|
+
};
|
|
2123
|
+
}
|
|
2124
|
+
/**
|
|
2125
|
+
* Get tax withholdings for a specific payroll record
|
|
2126
|
+
*/
|
|
2127
|
+
async getByPayrollRecord(payrollRecordId) {
|
|
2128
|
+
return this.TaxWithholdingModel.getByPayrollRecord(toObjectId(payrollRecordId)).exec();
|
|
2129
|
+
}
|
|
2130
|
+
/**
|
|
2131
|
+
* Get tax withholdings for a specific employee
|
|
2132
|
+
*/
|
|
2133
|
+
async getByEmployee(employeeId, options) {
|
|
2134
|
+
return this.TaxWithholdingModel.findByEmployee(toObjectId(employeeId), options).exec();
|
|
2135
|
+
}
|
|
2136
|
+
// ============================================================================
|
|
2137
|
+
// Private Helpers
|
|
2138
|
+
// ============================================================================
|
|
2139
|
+
/**
|
|
2140
|
+
* Check if deduction type is a tax deduction
|
|
2141
|
+
*/
|
|
2142
|
+
isTaxDeduction(deductionType) {
|
|
2143
|
+
const taxTypes = ["tax", "income_tax", "social_security", "health_insurance", "pension", "employment_insurance", "local_tax"];
|
|
2144
|
+
return taxTypes.includes(deductionType.toLowerCase());
|
|
2145
|
+
}
|
|
2146
|
+
/**
|
|
2147
|
+
* Map deduction type to TaxType enum
|
|
2148
|
+
*/
|
|
2149
|
+
mapDeductionTypeToTaxType(deductionType) {
|
|
2150
|
+
const typeMap = {
|
|
2151
|
+
"tax": TAX_TYPE.INCOME_TAX,
|
|
2152
|
+
"income_tax": TAX_TYPE.INCOME_TAX,
|
|
2153
|
+
"social_security": TAX_TYPE.SOCIAL_SECURITY,
|
|
2154
|
+
"health_insurance": TAX_TYPE.HEALTH_INSURANCE,
|
|
2155
|
+
"pension": TAX_TYPE.PENSION,
|
|
2156
|
+
"employment_insurance": TAX_TYPE.EMPLOYMENT_INSURANCE,
|
|
2157
|
+
"local_tax": TAX_TYPE.LOCAL_TAX
|
|
2158
|
+
};
|
|
2159
|
+
return typeMap[deductionType.toLowerCase()] || TAX_TYPE.OTHER;
|
|
2160
|
+
}
|
|
2161
|
+
};
|
|
2162
|
+
function createTaxWithholdingService(config) {
|
|
2163
|
+
return new TaxWithholdingService(
|
|
2164
|
+
config.TaxWithholdingModel,
|
|
2165
|
+
config.TransactionModel,
|
|
2166
|
+
config.events
|
|
2167
|
+
);
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
export { CompensationService, EmployeeService, PayrollService, TaxWithholdingService, createCompensationService, createEmployeeService, createPayrollService, createTaxWithholdingService };
|
|
2171
|
+
//# sourceMappingURL=index.js.map
|
|
2172
|
+
//# sourceMappingURL=index.js.map
|