@kernhq/module-hr 0.11.0 → 0.12.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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/client/mock.ts +467 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kernhq/module-hr",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Kern HR module: people, offices, org chart, calendars",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -32,7 +32,7 @@ import type {
32
32
  ResolvedCalendarDay,
33
33
  WorkingWeek,
34
34
  } from '../contract/models.js'
35
- import type { Period } from '../contract/policies.js'
35
+ import type { Period, Policy, PolicyAssignment, PolicySubjectKind } from '../contract/policies.js'
36
36
 
37
37
  /**
38
38
  * A refusal the client cannot tell from the server's.
@@ -254,6 +254,7 @@ export function createMockHrApi() {
254
254
  {
255
255
  id: id('d001'),
256
256
  displayName: 'Ayşe Yılmaz',
257
+ hiredOn: day(-400),
257
258
  workEmail: 'ayse@example.test',
258
259
  status: 'active',
259
260
  timezone: 'Europe/Istanbul',
@@ -262,6 +263,7 @@ export function createMockHrApi() {
262
263
  {
263
264
  id: id('d002'),
264
265
  displayName: 'Sanne de Vries',
266
+ hiredOn: day(-300),
265
267
  workEmail: 'sanne@example.test',
266
268
  status: 'active',
267
269
  timezone: 'Europe/Amsterdam',
@@ -270,6 +272,7 @@ export function createMockHrApi() {
270
272
  {
271
273
  id: id('d003'),
272
274
  displayName: 'Mehmet Kaya',
275
+ hiredOn: day(-2000),
273
276
  workEmail: 'mehmet@example.test',
274
277
  status: 'on_leave',
275
278
  timezone: 'Europe/Istanbul',
@@ -278,6 +281,7 @@ export function createMockHrApi() {
278
281
  {
279
282
  id: id('d004'),
280
283
  displayName: 'Jonas Weber',
284
+ hiredOn: day(-80),
281
285
  workEmail: 'jonas@example.test',
282
286
  status: 'active',
283
287
  timezone: 'Europe/Berlin',
@@ -369,7 +373,6 @@ export function createMockHrApi() {
369
373
  personalEmail: null,
370
374
  phone: null,
371
375
  photoFileId: null,
372
- hiredOn: day(-400),
373
376
  terminatedOn: null,
374
377
  custom: {},
375
378
  createdAt: iso(400 * 86_400_000),
@@ -1149,6 +1152,270 @@ export function createMockHrApi() {
1149
1152
  return weekday !== 'sat' && weekday !== 'sun' && date <= day(0)
1150
1153
  }).length
1151
1154
 
1155
+ // ---------------------------------------------------------------- accrual policies
1156
+
1157
+ /** The ladder made explicit, straight from `SUBJECT_PRIORITY` in the contract. */
1158
+ const PRIORITY: Record<PolicySubjectKind, number> = {
1159
+ person: 100,
1160
+ office: 80,
1161
+ legal_entity: 60,
1162
+ org_unit: 40,
1163
+ position: 30,
1164
+ workspace: 0,
1165
+ }
1166
+
1167
+ /**
1168
+ * Two policies that differ in more than their name.
1169
+ *
1170
+ * A list whose column reads the same sentence twice demonstrates nothing — these take different
1171
+ * branches of the evaluator: one accrues monthly with a waiting period for joiners, the other on
1172
+ * the employee's own anniversary with seniority tiers.
1173
+ */
1174
+ const policies: Row<Policy>[] = [
1175
+ {
1176
+ id: id('b011'),
1177
+ kind: 'accrual',
1178
+ name: 'Monthly accrual',
1179
+ config: {
1180
+ frequency: 'monthly',
1181
+ daysPerYear: 20,
1182
+ minutesPerDay: 480,
1183
+ seniorityTiers: [],
1184
+ waitingPeriodMonths: 3,
1185
+ calendar: 'gregorian',
1186
+ roundToMinutes: 30,
1187
+ leaveTypeKey: 'annual',
1188
+ },
1189
+ effectiveFrom: `${YEAR}-01-01`,
1190
+ effectiveTo: null,
1191
+ source: 'custom',
1192
+ packKey: null,
1193
+ configHash: 'a1b2c3d4',
1194
+ archivedAt: null,
1195
+ },
1196
+ {
1197
+ id: id('b012'),
1198
+ kind: 'accrual',
1199
+ name: 'Anniversary accrual, with seniority',
1200
+ config: {
1201
+ frequency: 'anniversary',
1202
+ daysPerYear: 22,
1203
+ minutesPerDay: 480,
1204
+ seniorityTiers: [
1205
+ { afterYears: 5, daysPerYear: 26 },
1206
+ { afterYears: 10, daysPerYear: 30 },
1207
+ ],
1208
+ waitingPeriodMonths: 0,
1209
+ calendar: 'gregorian',
1210
+ roundToMinutes: 0,
1211
+ leaveTypeKey: 'annual',
1212
+ },
1213
+ effectiveFrom: `${YEAR}-01-01`,
1214
+ effectiveTo: null,
1215
+ source: 'custom',
1216
+ packKey: null,
1217
+ configHash: 'e5f6a7b8',
1218
+ archivedAt: null,
1219
+ },
1220
+ ]
1221
+
1222
+ /**
1223
+ * Two rungs, on purpose.
1224
+ *
1225
+ * The screen's copy says the nearest subject wins; with everything assigned at one rung nothing on
1226
+ * screen tests that claim. The workspace-wide monthly policy is what most people get, and Sanne
1227
+ * has the anniversary one at the `person` rung — which must visibly beat the office below it.
1228
+ */
1229
+ const policyAssignments: Row<PolicyAssignment>[] = [
1230
+ {
1231
+ id: id('b0a1'),
1232
+ policyId: id('b011'),
1233
+ subjectKind: 'workspace',
1234
+ subjectId: null,
1235
+ effectiveFrom: `${YEAR}-01-01`,
1236
+ effectiveTo: null,
1237
+ priority: PRIORITY.workspace,
1238
+ },
1239
+ {
1240
+ id: id('b0a2'),
1241
+ policyId: id('b012'),
1242
+ subjectKind: 'office',
1243
+ subjectId: id('e002'),
1244
+ effectiveFrom: `${YEAR}-01-01`,
1245
+ effectiveTo: null,
1246
+ priority: PRIORITY.office,
1247
+ },
1248
+ // Mehmet has been here five years, so the tier above `afterYears: 5` is the one that answers
1249
+ // for him — without somebody long-serving the seniority branch of the evaluator never fires and
1250
+ // the second policy's whole reason for existing goes untested.
1251
+ {
1252
+ id: id('b0a4'),
1253
+ policyId: id('b012'),
1254
+ subjectKind: 'person',
1255
+ subjectId: people[2]!.id,
1256
+ effectiveFrom: `${YEAR}-01-01`,
1257
+ effectiveTo: null,
1258
+ priority: PRIORITY.person,
1259
+ },
1260
+ {
1261
+ id: id('b0a3'),
1262
+ policyId: id('b012'),
1263
+ subjectKind: 'person',
1264
+ subjectId: people[1]!.id,
1265
+ effectiveFrom: `${YEAR}-01-01`,
1266
+ effectiveTo: null,
1267
+ priority: PRIORITY.person,
1268
+ },
1269
+ ]
1270
+
1271
+ const assignmentsOf = (policyId: string) => policyAssignments.filter((a) => a.policyId === policyId)
1272
+
1273
+ /**
1274
+ * Which policy applies to somebody, and which rung answered.
1275
+ *
1276
+ * Nearest wins, by the priority the contract publishes — so a policy given to one person beats the
1277
+ * one on their office, which beats their legal entity, their department, their position, and
1278
+ * finally the whole workspace. This is the claim the screen's copy makes, so it has to be the
1279
+ * claim the fixture keeps.
1280
+ */
1281
+ function resolvePolicyFor(personId: string) {
1282
+ const officeId = primaryOfficeId(personId)
1283
+ const job = employments.find((e) => e.personId === personId && e.effectiveTo === null)
1284
+ const matches = (a: Row<PolicyAssignment>) => {
1285
+ switch (a.subjectKind) {
1286
+ case 'person':
1287
+ return a.subjectId === personId
1288
+ case 'office':
1289
+ return a.subjectId === officeId
1290
+ case 'legal_entity':
1291
+ return a.subjectId === job?.legalEntityId
1292
+ case 'org_unit':
1293
+ return a.subjectId === job?.orgUnitId
1294
+ case 'position':
1295
+ return a.subjectId === job?.positionId
1296
+ default:
1297
+ return true
1298
+ }
1299
+ }
1300
+ const best = policyAssignments
1301
+ .filter((a) => {
1302
+ if (a.effectiveTo !== null) return false
1303
+ const policy = policies.find((x) => x.id === a.policyId)
1304
+ return Boolean(policy && policy.archivedAt === null) && matches(a)
1305
+ })
1306
+ .sort((a, b) => b.priority - a.priority)[0]
1307
+ const policy = best ? policies.find((x) => x.id === best.policyId) : undefined
1308
+ return best && policy ? { assignment: best, policy } : null
1309
+ }
1310
+
1311
+ const wholeMonthsBetween = (from: string, to: string) => {
1312
+ const a = new Date(`${from}T00:00:00Z`)
1313
+ const b = new Date(`${to}T00:00:00Z`)
1314
+ let months = (b.getUTCFullYear() - a.getUTCFullYear()) * 12 + (b.getUTCMonth() - a.getUTCMonth())
1315
+ if (b.getUTCDate() < a.getUTCDate()) months -= 1
1316
+ return months
1317
+ }
1318
+
1319
+ /**
1320
+ * What a run would credit — computed, never written.
1321
+ *
1322
+ * `preview` and `run` share this function for the reason the contract gives: a preview computed
1323
+ * differently from the thing it previews is a preview that eventually lies. Only `run` writes,
1324
+ * and it writes from these rows.
1325
+ */
1326
+ function accrualRows(from: string, to: string, onlyPersonId?: string) {
1327
+ const rows: Array<{
1328
+ personId: string
1329
+ displayName: string
1330
+ leaveTypeId: string
1331
+ leaveTypeName: string
1332
+ minutes: number
1333
+ days: number
1334
+ reason: string
1335
+ alreadyAccrued: boolean
1336
+ }> = []
1337
+ const skipped: Array<{ personId: string; displayName: string; reason: string }> = []
1338
+
1339
+ for (const who of people) {
1340
+ if (onlyPersonId && who.id !== onlyPersonId) continue
1341
+ const skip = (reason: string) =>
1342
+ skipped.push({ personId: who.id, displayName: who.displayName, reason })
1343
+
1344
+ if (who.status === 'terminated') {
1345
+ skip('No longer employed')
1346
+ continue
1347
+ }
1348
+ const applicable = resolvePolicyFor(who.id)
1349
+ if (!applicable) {
1350
+ skip('No accrual policy applies')
1351
+ continue
1352
+ }
1353
+ const config = applicable.policy.config as {
1354
+ frequency: string
1355
+ daysPerYear: number
1356
+ minutesPerDay: number
1357
+ seniorityTiers: Array<{ afterYears: number; daysPerYear: number }>
1358
+ waitingPeriodMonths: number
1359
+ roundToMinutes: number
1360
+ leaveTypeKey: string
1361
+ }
1362
+ const served = wholeMonthsBetween(who.hiredOn, from)
1363
+ if (served < config.waitingPeriodMonths) {
1364
+ skip(`Within the ${config.waitingPeriodMonths}-month waiting period — ${served} served`)
1365
+ continue
1366
+ }
1367
+ const leaveType = leaveTypes.find((lt) => lt.key === config.leaveTypeKey && lt.archivedAt === null)
1368
+ if (!leaveType) {
1369
+ skip(`No leave type keyed "${config.leaveTypeKey}"`)
1370
+ continue
1371
+ }
1372
+
1373
+ // Most senior tier reached wins, whatever order they are written in.
1374
+ const years = Math.floor(served / 12)
1375
+ const tier = [...(config.seniorityTiers ?? [])]
1376
+ .filter((t) => years >= t.afterYears)
1377
+ .sort((a, b) => b.afterYears - a.afterYears)[0]
1378
+ const perYear = tier?.daysPerYear ?? config.daysPerYear
1379
+
1380
+ let minutes = Math.round((perYear * config.minutesPerDay) / 12)
1381
+ if (config.roundToMinutes > 0) {
1382
+ minutes = Math.round(minutes / config.roundToMinutes) * config.roundToMinutes
1383
+ }
1384
+ const alreadyAccrued = ledger.some(
1385
+ (e) =>
1386
+ e.personId === who.id &&
1387
+ e.leaveTypeId === leaveType.id &&
1388
+ e.kind === 'accrual' &&
1389
+ e.effectiveOn >= from &&
1390
+ e.effectiveOn <= to,
1391
+ )
1392
+ const basis = tier
1393
+ ? `${perYear} days a year after ${tier.afterYears} years' service`
1394
+ : `${perYear} days a year`
1395
+ rows.push({
1396
+ personId: who.id,
1397
+ displayName: who.displayName,
1398
+ leaveTypeId: leaveType.id,
1399
+ leaveTypeName: leaveType.name,
1400
+ minutes,
1401
+ days: Math.round((minutes / config.minutesPerDay) * 100) / 100,
1402
+ reason: alreadyAccrued
1403
+ ? `${basis}, ${config.frequency} — already credited for this period`
1404
+ : `${basis}, ${config.frequency}`,
1405
+ alreadyAccrued,
1406
+ })
1407
+ }
1408
+
1409
+ return {
1410
+ periodFrom: from,
1411
+ periodTo: to,
1412
+ rows,
1413
+ // What the run would actually add: a row already credited contributes nothing to it.
1414
+ totalMinutes: rows.filter((r) => !r.alreadyAccrued).reduce((sum, r) => sum + r.minutes, 0),
1415
+ skipped,
1416
+ }
1417
+ }
1418
+
1152
1419
  // ---------------------------------------------------------------- approval chains
1153
1420
 
1154
1421
  const chains: Row<ApprovalChain>[] = [
@@ -1477,6 +1744,7 @@ export function createMockHrApi() {
1477
1744
  workEmail: input.workEmail ?? '',
1478
1745
  status: 'active' as const,
1479
1746
  timezone: 'Europe/Istanbul',
1747
+ hiredOn: input.hiredOn ?? day(0),
1480
1748
  employeeNo: input.employeeNo ?? `E-${people.length + 1}`,
1481
1749
  }
1482
1750
  people.push(added)
@@ -3037,6 +3305,203 @@ export function createMockHrApi() {
3037
3305
  },
3038
3306
  },
3039
3307
 
3308
+ policies: {
3309
+ list: async ({
3310
+ workspaceId,
3311
+ kind,
3312
+ includeArchived = false,
3313
+ }: {
3314
+ workspaceId: string
3315
+ kind?: string
3316
+ includeArchived?: boolean
3317
+ }) =>
3318
+ policies
3319
+ .filter((row) => (!kind || row.kind === kind) && (includeArchived || row.archivedAt === null))
3320
+ .map((row) => ({
3321
+ ...row,
3322
+ workspaceId,
3323
+ assignments: assignmentsOf(row.id).map((a) => ({ ...a, workspaceId })),
3324
+ })),
3325
+
3326
+ get: async ({ workspaceId, policyId }: { workspaceId: string; policyId: string }) => {
3327
+ const found = policies.find((row) => row.id === policyId)
3328
+ if (!found) refuse('NOT_FOUND', 'Policy not found')
3329
+ return {
3330
+ ...found,
3331
+ workspaceId,
3332
+ assignments: assignmentsOf(found.id).map((a) => ({ ...a, workspaceId })),
3333
+ }
3334
+ },
3335
+
3336
+ create: async (input: {
3337
+ workspaceId: string
3338
+ kind: Policy['kind']
3339
+ name: string
3340
+ config: Record<string, unknown>
3341
+ effectiveFrom: string
3342
+ effectiveTo?: string | null
3343
+ }) => {
3344
+ const created: Row<Policy> = {
3345
+ id: crypto.randomUUID(),
3346
+ kind: input.kind,
3347
+ name: input.name,
3348
+ config: clone(input.config),
3349
+ effectiveFrom: input.effectiveFrom,
3350
+ effectiveTo: input.effectiveTo ?? null,
3351
+ source: 'custom',
3352
+ packKey: null,
3353
+ // A hash of the config, because that is what a derived row records — a literal would make
3354
+ // every policy look identical to a recomputation deciding whether a figure is stale.
3355
+ configHash: Math.abs(
3356
+ [...JSON.stringify(input.config)].reduce((h, c) => (h * 31 + c.charCodeAt(0)) | 0, 7),
3357
+ )
3358
+ .toString(16)
3359
+ .padStart(8, '0'),
3360
+ archivedAt: null,
3361
+ }
3362
+ policies.push(created)
3363
+ return { ...created, workspaceId: input.workspaceId }
3364
+ },
3365
+
3366
+ update: async (input: {
3367
+ workspaceId: string
3368
+ policyId: string
3369
+ name?: string
3370
+ config?: Record<string, unknown>
3371
+ effectiveTo?: string | null
3372
+ }) => {
3373
+ const found = policies.find((row) => row.id === input.policyId)
3374
+ if (!found) refuse('NOT_FOUND', 'Policy not found')
3375
+ if (input.name !== undefined) found.name = input.name
3376
+ if (input.config !== undefined) found.config = clone(input.config)
3377
+ if (input.effectiveTo !== undefined) found.effectiveTo = input.effectiveTo
3378
+ return { ...found, workspaceId: input.workspaceId }
3379
+ },
3380
+
3381
+ /**
3382
+ * Archived, never deleted, and **the assignments are left where they are**.
3383
+ *
3384
+ * The router neither refuses nor cascades — a ledger entry names the policy that produced it,
3385
+ * so a movement whose policy had vanished would be a number nobody can explain. That means an
3386
+ * archived policy with live assignments is a state an administrator genuinely reaches, and the
3387
+ * fixture can reach it too rather than only in theory.
3388
+ */
3389
+ archive: async ({ policyId }: { workspaceId: string; policyId: string }) => {
3390
+ const found = policies.find((row) => row.id === policyId)
3391
+ if (!found) refuse('NOT_FOUND', 'Policy not found')
3392
+ found.archivedAt = iso()
3393
+ return { ok: true as const }
3394
+ },
3395
+
3396
+ assign: async (input: {
3397
+ workspaceId: string
3398
+ policyId: string
3399
+ subjectKind: PolicySubjectKind
3400
+ subjectId?: string | null
3401
+ effectiveFrom: string
3402
+ effectiveTo?: string | null
3403
+ }) => {
3404
+ const created: Row<PolicyAssignment> = {
3405
+ id: crypto.randomUUID(),
3406
+ policyId: input.policyId,
3407
+ subjectKind: input.subjectKind,
3408
+ // `workspace` needs no id, and storing one would make the rung look narrower than it is.
3409
+ subjectId: input.subjectKind === 'workspace' ? null : (input.subjectId ?? null),
3410
+ effectiveFrom: input.effectiveFrom,
3411
+ effectiveTo: input.effectiveTo ?? null,
3412
+ priority: PRIORITY[input.subjectKind],
3413
+ }
3414
+ policyAssignments.push(created)
3415
+ return { ...created, workspaceId: input.workspaceId }
3416
+ },
3417
+
3418
+ unassign: async ({ assignmentId }: { workspaceId: string; assignmentId: string }) => {
3419
+ const at = policyAssignments.findIndex((a) => a.id === assignmentId)
3420
+ if (at < 0) refuse('NOT_FOUND', 'Policy assignment not found')
3421
+ policyAssignments.splice(at, 1)
3422
+ return { ok: true as const }
3423
+ },
3424
+
3425
+ resolveFor: async ({ workspaceId, personId }: { workspaceId: string; personId: string }) => {
3426
+ const applicable = resolvePolicyFor(personId)
3427
+ void workspaceId
3428
+ return applicable
3429
+ ? [
3430
+ {
3431
+ kind: 'accrual' as const,
3432
+ policyId: applicable.policy.id,
3433
+ policyName: applicable.policy.name,
3434
+ config: applicable.policy.config,
3435
+ from: applicable.assignment.subjectKind,
3436
+ subjectId: applicable.assignment.subjectId,
3437
+ },
3438
+ ]
3439
+ : []
3440
+ },
3441
+ },
3442
+
3443
+ accrual: {
3444
+ /**
3445
+ * A query, and it **writes nothing**.
3446
+ *
3447
+ * The contract makes it a query for exactly this reason: a preview that filed ledger entries
3448
+ * would credit everybody the moment somebody looked at the screen meant to tell them what a
3449
+ * run *would* do. It returns the same rows `run` credits from, so the two cannot disagree.
3450
+ */
3451
+ preview: async ({
3452
+ workspaceId,
3453
+ from,
3454
+ to,
3455
+ personId,
3456
+ }: {
3457
+ workspaceId: string
3458
+ from: string
3459
+ to: string
3460
+ personId?: string
3461
+ }) => {
3462
+ void workspaceId
3463
+ return accrualRows(from, to, personId)
3464
+ },
3465
+
3466
+ /**
3467
+ * Credits the ledger, and is idempotent per person, per leave type, per period.
3468
+ *
3469
+ * A second run over the same window credits nothing, because every row it would write is
3470
+ * already `alreadyAccrued` — an accrual job that double-credits when somebody clicks twice is
3471
+ * worse than one that never ran.
3472
+ */
3473
+ run: async ({
3474
+ workspaceId,
3475
+ from,
3476
+ to,
3477
+ personId,
3478
+ }: {
3479
+ workspaceId: string
3480
+ from: string
3481
+ to: string
3482
+ personId?: string
3483
+ }) => {
3484
+ void workspaceId
3485
+ const preview = accrualRows(from, to, personId)
3486
+ let credited = 0
3487
+ let totalMinutes = 0
3488
+ for (const row of preview.rows) {
3489
+ if (row.alreadyAccrued) continue
3490
+ ledger.push(
3491
+ entry(row.personId, row.leaveTypeId, 'accrual', row.minutes, to, { reason: row.reason }),
3492
+ )
3493
+ credited += 1
3494
+ totalMinutes += row.minutes
3495
+ }
3496
+ return {
3497
+ credited,
3498
+ // Everything the run passed over: those already credited, and those it never reached.
3499
+ skipped: preview.skipped.length + preview.rows.filter((r) => r.alreadyAccrued).length,
3500
+ totalMinutes,
3501
+ }
3502
+ },
3503
+ },
3504
+
3040
3505
  periods: {
3041
3506
  list: async ({
3042
3507
  workspaceId,