@kernhq/module-hr 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/contract/capabilities.d.ts.map +1 -1
  2. package/dist/contract/capabilities.js +31 -0
  3. package/dist/contract/capabilities.js.map +1 -1
  4. package/dist/contract/index.d.ts +1 -0
  5. package/dist/contract/index.d.ts.map +1 -1
  6. package/dist/contract/index.js +1 -0
  7. package/dist/contract/index.js.map +1 -1
  8. package/dist/contract/permissions.d.ts +23 -0
  9. package/dist/contract/permissions.d.ts.map +1 -1
  10. package/dist/contract/permissions.js +27 -0
  11. package/dist/contract/permissions.js.map +1 -1
  12. package/dist/contract/policies.d.ts +256 -0
  13. package/dist/contract/policies.d.ts.map +1 -0
  14. package/dist/contract/policies.js +175 -0
  15. package/dist/contract/policies.js.map +1 -0
  16. package/dist/contract/router.d.ts +707 -0
  17. package/dist/contract/router.d.ts.map +1 -1
  18. package/dist/contract/router.js +127 -0
  19. package/dist/contract/router.js.map +1 -1
  20. package/dist/policy/accrual.d.ts +127 -0
  21. package/dist/policy/accrual.d.ts.map +1 -0
  22. package/dist/policy/accrual.js +134 -0
  23. package/dist/policy/accrual.js.map +1 -0
  24. package/dist/server/jobs.d.ts.map +1 -1
  25. package/dist/server/jobs.js +186 -1
  26. package/dist/server/jobs.js.map +1 -1
  27. package/dist/server/router.d.ts +772 -0
  28. package/dist/server/router.d.ts.map +1 -1
  29. package/dist/server/router.js +462 -1
  30. package/dist/server/router.js.map +1 -1
  31. package/dist/server/schema.d.ts +612 -1
  32. package/dist/server/schema.d.ts.map +1 -1
  33. package/dist/server/schema.js +76 -0
  34. package/dist/server/schema.js.map +1 -1
  35. package/dist/server/services/policies.d.ts +53 -0
  36. package/dist/server/services/policies.d.ts.map +1 -0
  37. package/dist/server/services/policies.js +158 -0
  38. package/dist/server/services/policies.js.map +1 -0
  39. package/migrations/0004_policies.sql +89 -0
  40. package/migrations/meta/0004_snapshot.json +4035 -0
  41. package/migrations/meta/_journal.json +7 -0
  42. package/package.json +1 -1
  43. package/src/contract/capabilities.ts +31 -0
  44. package/src/contract/index.ts +1 -0
  45. package/src/contract/permissions.ts +28 -0
  46. package/src/contract/policies.ts +207 -0
  47. package/src/contract/router.ts +148 -0
@@ -29,6 +29,13 @@
29
29
  "when": 1787634652573,
30
30
  "tag": "0003_attendance",
31
31
  "breakpoints": true
32
+ },
33
+ {
34
+ "idx": 4,
35
+ "version": "7",
36
+ "when": 1787675369496,
37
+ "tag": "0004_policies",
38
+ "breakpoints": true
32
39
  }
33
40
  ]
34
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kernhq/module-hr",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Kern HR module: people, offices, org chart, calendars",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -74,6 +74,24 @@ export const hrCapabilities = defineCapabilities([
74
74
  defaultEnabled: true,
75
75
  level: 1,
76
76
  },
77
+ {
78
+ id: 'leave_accrual',
79
+ label: 'Accrual',
80
+ description: 'Earn leave over time, with proration, carry-forward and expiry',
81
+ dependsOn: ['leave'],
82
+ // Off by default. Plenty of companies grant a fixed allowance on 1 January and never accrue —
83
+ // and for them an accrual engine is a screen full of settings that change nothing.
84
+ defaultEnabled: false,
85
+ level: 2,
86
+ },
87
+ {
88
+ id: 'periods',
89
+ label: 'Payroll periods',
90
+ description: 'Close a month so a filed payroll cannot move underneath it',
91
+ dependsOn: ['core'],
92
+ defaultEnabled: false,
93
+ level: 2,
94
+ },
77
95
  {
78
96
  id: 'approvals',
79
97
  label: 'Approval chains',
@@ -185,6 +203,19 @@ export const hrCapabilityProcedures: Record<string, readonly string[]> = {
185
203
  'attendance.regularizations.list',
186
204
  'attendance.regularizations.request',
187
205
  ],
206
+ leave_accrual: [
207
+ 'policies.list',
208
+ 'policies.get',
209
+ 'policies.create',
210
+ 'policies.update',
211
+ 'policies.archive',
212
+ 'policies.assign',
213
+ 'policies.unassign',
214
+ 'policies.resolveFor',
215
+ 'accrual.preview',
216
+ 'accrual.run',
217
+ ],
218
+ periods: ['periods.list', 'periods.create', 'periods.lock', 'periods.unlock'],
188
219
  approvals: [
189
220
  'approvals.chains.list',
190
221
  'approvals.chains.create',
@@ -13,5 +13,6 @@ export * from './events.js'
13
13
  export * from './leave.js'
14
14
  export * from './models.js'
15
15
  export * from './permissions.js'
16
+ export * from './policies.js'
16
17
  export * from './router.js'
17
18
  export * from './settings.js'
@@ -273,6 +273,31 @@ export const hrPermissions = definePermissions([
273
273
  dangerous: false,
274
274
  },
275
275
 
276
+ // ---------------------------------------------------------------- policies and periods
277
+ {
278
+ key: 'hr.policy.view',
279
+ label: 'View accrual, overtime and rounding policies',
280
+ scope: 'workspace',
281
+ defaultRoles: ['owner', 'admin'],
282
+ dangerous: false,
283
+ },
284
+ {
285
+ key: 'hr.policy.manage',
286
+ label: 'Change policies and who they apply to',
287
+ description: 'Changes what everybody accrues. A retroactive change is an adjustment, not a rewrite.',
288
+ scope: 'workspace',
289
+ defaultRoles: ['owner'],
290
+ dangerous: true,
291
+ },
292
+ {
293
+ key: 'hr.period.manage',
294
+ label: 'Open, lock and unlock payroll periods',
295
+ description: 'Locking freezes a month against recomputation. Unlocking lets it move again.',
296
+ scope: 'workspace',
297
+ defaultRoles: ['owner'],
298
+ dangerous: true,
299
+ },
300
+
276
301
  // ---------------------------------------------------------------- approvals
277
302
  {
278
303
  key: 'hr.approval.manage',
@@ -334,6 +359,9 @@ export const HR_PERMISSIONS = {
334
359
  attendanceManage: 'hr.attendance.manage',
335
360
  overtimeView: 'hr.overtime.view',
336
361
  overtimeManage: 'hr.overtime.manage',
362
+ policyView: 'hr.policy.view',
363
+ policyManage: 'hr.policy.manage',
364
+ periodManage: 'hr.period.manage',
337
365
  approvalManage: 'hr.approval.manage',
338
366
  approvalDelegate: 'hr.approval.delegate',
339
367
  } as const
@@ -0,0 +1,207 @@
1
+ import { Timestamp, WorkspaceId } from '@kernhq/contracts'
2
+ import { z } from 'zod'
3
+ import { IsoDate } from './models.js'
4
+
5
+ const ws = { workspaceId: WorkspaceId }
6
+
7
+ /**
8
+ * Policies as data, and the ladder that decides which one applies.
9
+ *
10
+ * Leave entitlement, overtime rules and rounding differ per company and per country, and encoding
11
+ * that as `if (country === 'TR')` is how a product acquires a branch per customer. A policy is a
12
+ * row: a kind, a config validated by that kind's schema, an effective range, and an assignment to
13
+ * somebody — a person, an office, a department, or the whole workspace.
14
+ *
15
+ * **One ladder, used by everything**: `person → primary office → legal entity → org unit →
16
+ * position → workspace`, nearest wins. The same order resolves a calendar, so there is never a
17
+ * second precedence rule to remember.
18
+ */
19
+
20
+ export const PolicyKind = z.enum(['accrual', 'carry_forward', 'overtime', 'rounding', 'working_time'])
21
+ export type PolicyKind = z.infer<typeof PolicyKind>
22
+
23
+ /**
24
+ * Rungs of the ladder, and the priority each carries.
25
+ *
26
+ * The numbers are the ladder made explicit so a query can order by them rather than a service
27
+ * knowing the sequence by heart.
28
+ */
29
+ export const PolicySubjectKind = z.enum([
30
+ 'person',
31
+ 'office',
32
+ 'legal_entity',
33
+ 'org_unit',
34
+ 'position',
35
+ 'workspace',
36
+ ])
37
+ export type PolicySubjectKind = z.infer<typeof PolicySubjectKind>
38
+
39
+ export const SUBJECT_PRIORITY: Record<PolicySubjectKind, number> = {
40
+ person: 100,
41
+ office: 80,
42
+ legal_entity: 60,
43
+ org_unit: 40,
44
+ position: 30,
45
+ workspace: 0,
46
+ }
47
+
48
+ // ---------------------------------------------------------------- config per kind
49
+
50
+ export const SeniorityTier = z.object({
51
+ afterYears: z.number().int().min(0).max(60),
52
+ daysPerYear: z.number().min(0).max(365),
53
+ })
54
+
55
+ export const AccrualConfig = z.object({
56
+ frequency: z.enum(['monthly', 'annual', 'anniversary', 'per_hour_worked']),
57
+ daysPerYear: z.number().min(0).max(365),
58
+ minutesPerDay: z.number().int().min(1).max(1440),
59
+ /** Most senior tier reached wins; the order they are written in does not matter. */
60
+ seniorityTiers: z.array(SeniorityTier).default([]),
61
+ waitingPeriodMonths: z.number().int().min(0).max(24).default(0),
62
+ /** Which calendar decides a period boundary. Iran accrues on Jalali months. */
63
+ calendar: z.enum(['gregorian', 'persian']).default('gregorian'),
64
+ roundToMinutes: z.number().int().min(0).max(480).default(0),
65
+ /** Which leave type this accrues into. */
66
+ leaveTypeKey: z.string().min(1).max(48),
67
+ })
68
+ export type AccrualConfig = z.infer<typeof AccrualConfig>
69
+
70
+ export const CarryForwardConfig = z.object({
71
+ leaveTypeKey: z.string().min(1).max(48),
72
+ maxDays: z.number().min(0).max(365),
73
+ /** Months into the new year before carried leave lapses. Null never expires. */
74
+ expiresAfterMonths: z.number().int().min(1).max(24).nullable(),
75
+ })
76
+ export type CarryForwardConfig = z.infer<typeof CarryForwardConfig>
77
+
78
+ export const OvertimeConfig = z.object({
79
+ /** Minutes past the schedule before overtime starts counting. */
80
+ thresholdMinutes: z.number().int().min(0).max(480).default(0),
81
+ /** Cap for the period. Null is uncapped — several jurisdictions cap hours *worked*, not just pay. */
82
+ capMinutesPerYear: z.number().int().min(0).nullable().default(null),
83
+ /** Multiplier for reporting and payroll export. Kern does not compute pay. */
84
+ rate: z.number().min(1).max(5).default(1.5),
85
+ /** Overtime must be approved before it counts towards anything. */
86
+ requiresApproval: z.boolean().default(true),
87
+ /** Convert approved overtime into compensatory leave of this type instead of paying it. */
88
+ compOffLeaveTypeKey: z.string().max(48).nullable().default(null),
89
+ })
90
+ export type OvertimeConfig = z.infer<typeof OvertimeConfig>
91
+
92
+ export const RoundingConfig = z.object({
93
+ stepMinutes: z.number().int().min(0).max(60),
94
+ direction: z.enum(['nearest', 'employee', 'employer']),
95
+ })
96
+ export type RoundingConfig = z.infer<typeof RoundingConfig>
97
+
98
+ export const WorkingTimeConfig = z.object({
99
+ minutesPerDay: z.number().int().min(1).max(1440),
100
+ maxMinutesPerWeek: z.number().int().min(1).max(10080).nullable(),
101
+ /** Minimum rest between shifts, in minutes. Reported, not enforced by refusing a punch. */
102
+ minRestMinutes: z.number().int().min(0).max(1440).nullable(),
103
+ })
104
+ export type WorkingTimeConfig = z.infer<typeof WorkingTimeConfig>
105
+
106
+ /** The config union, keyed by kind. Each kind validates its own shape before it runs. */
107
+ export const PolicyConfig = z.union([
108
+ AccrualConfig,
109
+ CarryForwardConfig,
110
+ OvertimeConfig,
111
+ RoundingConfig,
112
+ WorkingTimeConfig,
113
+ ])
114
+
115
+ export const Policy = z.object({
116
+ id: z.uuid(),
117
+ ...ws,
118
+ kind: PolicyKind,
119
+ name: z.string().min(1).max(120),
120
+ config: z.record(z.string(), z.unknown()),
121
+ effectiveFrom: IsoDate,
122
+ effectiveTo: IsoDate.nullable(),
123
+ source: z.enum(['pack', 'custom']),
124
+ packKey: z.string().max(32).nullable(),
125
+ /** What a derived row records, so a recomputation can tell a stale figure from a current one. */
126
+ configHash: z.string().max(64),
127
+ archivedAt: Timestamp.nullable(),
128
+ })
129
+ export type Policy = z.infer<typeof Policy>
130
+
131
+ export const PolicyAssignment = z.object({
132
+ id: z.uuid(),
133
+ ...ws,
134
+ policyId: z.uuid(),
135
+ subjectKind: PolicySubjectKind,
136
+ /** Null for `workspace`, which needs no id. */
137
+ subjectId: z.uuid().nullable(),
138
+ effectiveFrom: IsoDate,
139
+ effectiveTo: IsoDate.nullable(),
140
+ priority: z.number().int(),
141
+ })
142
+ export type PolicyAssignment = z.infer<typeof PolicyAssignment>
143
+
144
+ /**
145
+ * Which policy of a kind applies to a person on a date, and which rung answered.
146
+ *
147
+ * The rung is not decoration: "why does she accrue differently from her team" is the question this
148
+ * module gets asked, and answering it without a database session is the whole reason the ladder is
149
+ * inspectable.
150
+ */
151
+ export const ResolvedPolicy = z.object({
152
+ kind: PolicyKind,
153
+ policyId: z.uuid().nullable(),
154
+ policyName: z.string().nullable(),
155
+ config: z.record(z.string(), z.unknown()).nullable(),
156
+ from: PolicySubjectKind.nullable(),
157
+ fromSubjectId: z.uuid().nullable(),
158
+ })
159
+ export type ResolvedPolicy = z.infer<typeof ResolvedPolicy>
160
+
161
+ // ---------------------------------------------------------------- periods
162
+
163
+ /**
164
+ * A closed month, and the boundary every recomputation respects.
165
+ *
166
+ * Locking is what makes a filed payroll safe: nothing may silently move a day inside a locked
167
+ * period, so a policy changed with a retroactive `effectiveFrom` produces an adjustment in the open
168
+ * period rather than rewriting history.
169
+ *
170
+ * Per legal entity, because a Dutch entity closes on a different day from a Turkish one.
171
+ */
172
+ export const Period = z.object({
173
+ id: z.uuid(),
174
+ ...ws,
175
+ kind: z.enum(['payroll', 'attendance']),
176
+ legalEntityId: z.uuid().nullable(),
177
+ startsOn: IsoDate,
178
+ endsOn: IsoDate,
179
+ status: z.enum(['open', 'locked']),
180
+ lockedAt: Timestamp.nullable(),
181
+ lockedBy: z.uuid().nullable(),
182
+ note: z.string().max(500).nullable(),
183
+ })
184
+ export type Period = z.infer<typeof Period>
185
+
186
+ /** What an accrual run would do, per person, before it writes anything. */
187
+ export const AccrualPreview = z.object({
188
+ periodFrom: IsoDate,
189
+ periodTo: IsoDate,
190
+ rows: z.array(
191
+ z.object({
192
+ personId: z.uuid(),
193
+ displayName: z.string(),
194
+ leaveTypeId: z.uuid(),
195
+ leaveTypeName: z.string(),
196
+ minutes: z.number().int(),
197
+ days: z.number(),
198
+ /** Why it is that number: entitlement, service, proration, FTE. */
199
+ reason: z.string(),
200
+ /** Already accrued for this period — a re-run must not double-credit. */
201
+ alreadyAccrued: z.boolean(),
202
+ }),
203
+ ),
204
+ totalMinutes: z.number().int(),
205
+ skipped: z.array(z.object({ personId: z.uuid(), displayName: z.string(), reason: z.string() })),
206
+ })
207
+ export type AccrualPreview = z.infer<typeof AccrualPreview>
@@ -53,6 +53,15 @@ import {
53
53
  TimeZone,
54
54
  WorkingWeek,
55
55
  } from './models.js'
56
+ import {
57
+ AccrualPreview,
58
+ Period,
59
+ Policy,
60
+ PolicyAssignment,
61
+ PolicyKind,
62
+ PolicySubjectKind,
63
+ ResolvedPolicy,
64
+ } from './policies.js'
56
65
 
57
66
  const ws = z.object({ workspaceId: WorkspaceId })
58
67
  const t = ['hr'] as const
@@ -602,6 +611,145 @@ export const hrContract = {
602
611
  .output(ok),
603
612
  },
604
613
 
614
+ // ---------------------------------------------------------------- policies
615
+ policies: {
616
+ list: baseContract
617
+ .route({ method: 'GET', path: '/policies', tags: t })
618
+ .input(ws.extend({ kind: PolicyKind.optional(), includeArchived: z.boolean().default(false) }))
619
+ .output(z.array(Policy.extend({ assignments: z.array(PolicyAssignment) }))),
620
+ get: baseContract
621
+ .route({ method: 'GET', path: '/policies/{policyId}', tags: t })
622
+ .input(ws.extend({ policyId: z.uuid() }))
623
+ .output(Policy.extend({ assignments: z.array(PolicyAssignment) })),
624
+ create: baseContract
625
+ .route({ method: 'POST', path: '/policies', tags: t })
626
+ .input(
627
+ ws.extend({
628
+ kind: PolicyKind,
629
+ name: z.string().min(1).max(120),
630
+ /** Validated against the schema for `kind` — a config for the wrong kind is refused. */
631
+ config: z.record(z.string(), z.unknown()),
632
+ effectiveFrom: IsoDate,
633
+ effectiveTo: IsoDate.nullish(),
634
+ }),
635
+ )
636
+ .output(Policy),
637
+ /**
638
+ * Edits a policy in place.
639
+ *
640
+ * A change that should apply from a date rather than retroactively is a **new** policy with a
641
+ * later `effectiveFrom`, not an edit — editing rewrites what was true in the past, and anything
642
+ * already derived from it becomes unexplainable.
643
+ */
644
+ update: baseContract
645
+ .route({ method: 'PATCH', path: '/policies/{policyId}', tags: t })
646
+ .input(
647
+ ws.extend({
648
+ policyId: z.uuid(),
649
+ name: z.string().min(1).max(120).optional(),
650
+ config: z.record(z.string(), z.unknown()).optional(),
651
+ effectiveTo: IsoDate.nullish(),
652
+ }),
653
+ )
654
+ .output(Policy),
655
+ archive: baseContract
656
+ .route({ method: 'DELETE', path: '/policies/{policyId}', tags: t })
657
+ .input(ws.extend({ policyId: z.uuid() }))
658
+ .output(ok),
659
+ assign: baseContract
660
+ .route({ method: 'POST', path: '/policies/{policyId}/assign', tags: t })
661
+ .input(
662
+ ws.extend({
663
+ policyId: z.uuid(),
664
+ subjectKind: PolicySubjectKind,
665
+ /** Null for `workspace`, which needs no id. */
666
+ subjectId: z.uuid().nullish(),
667
+ effectiveFrom: IsoDate,
668
+ effectiveTo: IsoDate.nullish(),
669
+ }),
670
+ )
671
+ .output(PolicyAssignment),
672
+ unassign: baseContract
673
+ .route({ method: 'DELETE', path: '/policies/assignments/{assignmentId}', tags: t })
674
+ .input(ws.extend({ assignmentId: z.uuid() }))
675
+ .output(ok),
676
+ /**
677
+ * Which policy of each kind applies to this person on this date, and which rung answered.
678
+ *
679
+ * "Why does she accrue differently from her team" is the question this module gets asked, and
680
+ * this is what answers it without a database session.
681
+ */
682
+ resolveFor: baseContract
683
+ .route({ method: 'GET', path: '/people/{personId}/policies', tags: t })
684
+ .input(ws.extend({ personId: z.uuid(), on: IsoDate.optional() }))
685
+ .output(z.array(ResolvedPolicy)),
686
+ },
687
+
688
+ // ---------------------------------------------------------------- accrual
689
+ accrual: {
690
+ /**
691
+ * What a run would credit, per person, before it writes anything.
692
+ *
693
+ * Runs the same code the run does. A preview computed differently from the thing it previews is
694
+ * a preview that eventually lies.
695
+ */
696
+ preview: baseContract
697
+ .route({ method: 'POST', path: '/accrual/preview', tags: t })
698
+ .input(ws.extend({ from: IsoDate, to: IsoDate, personId: z.uuid().optional() }))
699
+ .output(AccrualPreview),
700
+ /**
701
+ * Credits the ledger.
702
+ *
703
+ * Idempotent per person, per leave type, per period: a second run for the same window credits
704
+ * nothing, because an accrual job that double-credits when somebody clicks twice is worse than
705
+ * one that never ran.
706
+ */
707
+ run: baseContract
708
+ .route({ method: 'POST', path: '/accrual/run', tags: t })
709
+ .input(ws.extend({ from: IsoDate, to: IsoDate, personId: z.uuid().optional() }))
710
+ .output(
711
+ z.object({
712
+ credited: z.number().int(),
713
+ skipped: z.number().int(),
714
+ totalMinutes: z.number().int(),
715
+ }),
716
+ ),
717
+ },
718
+
719
+ // ---------------------------------------------------------------- periods
720
+ periods: {
721
+ list: baseContract
722
+ .route({ method: 'GET', path: '/periods', tags: t })
723
+ .input(ws.extend({ kind: Period.shape.kind.optional(), ...PageInput.shape }))
724
+ .output(page(Period)),
725
+ create: baseContract
726
+ .route({ method: 'POST', path: '/periods', tags: t })
727
+ .input(
728
+ ws.extend({
729
+ kind: Period.shape.kind.default('payroll'),
730
+ legalEntityId: z.uuid().nullish(),
731
+ startsOn: IsoDate,
732
+ endsOn: IsoDate,
733
+ }),
734
+ )
735
+ .output(Period),
736
+ /** Freezes the month. Every derived day inside it stops being recomputable. */
737
+ lock: baseContract
738
+ .route({ method: 'POST', path: '/periods/{periodId}/lock', tags: t })
739
+ .input(ws.extend({ periodId: z.uuid(), note: z.string().max(500).nullish() }))
740
+ .output(Period.extend({ lockedDays: z.number().int() })),
741
+ /**
742
+ * Reopens it.
743
+ *
744
+ * Dangerous on purpose: a payroll has usually been filed against a locked month, and reopening
745
+ * lets figures move underneath it. The response says how many days became recomputable again.
746
+ */
747
+ unlock: baseContract
748
+ .route({ method: 'POST', path: '/periods/{periodId}/unlock', tags: t })
749
+ .input(ws.extend({ periodId: z.uuid(), reason: z.string().min(1).max(500) }))
750
+ .output(Period.extend({ unlockedDays: z.number().int() })),
751
+ },
752
+
605
753
  // ---------------------------------------------------------------- attendance
606
754
  attendance: {
607
755
  /** Am I clocked in? The one call a clock widget makes. */