@kernhq/module-hr 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/migrations/0004_policies.sql +89 -0
- package/migrations/meta/0004_snapshot.json +4035 -0
- package/package.json +1 -1
- package/src/contract/policies.ts +207 -0
package/package.json
CHANGED
|
@@ -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>
|