@kernhq/module-hr 0.2.0 → 0.4.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 (82) hide show
  1. package/dist/contract/approvals.d.ts +281 -0
  2. package/dist/contract/approvals.d.ts.map +1 -0
  3. package/dist/contract/approvals.js +127 -0
  4. package/dist/contract/approvals.js.map +1 -0
  5. package/dist/contract/attendance.d.ts +240 -0
  6. package/dist/contract/attendance.d.ts.map +1 -0
  7. package/dist/contract/attendance.js +154 -0
  8. package/dist/contract/attendance.js.map +1 -0
  9. package/dist/contract/capabilities.d.ts +1 -1
  10. package/dist/contract/capabilities.d.ts.map +1 -1
  11. package/dist/contract/capabilities.js +80 -0
  12. package/dist/contract/capabilities.js.map +1 -1
  13. package/dist/contract/events.d.ts +60 -0
  14. package/dist/contract/events.d.ts.map +1 -1
  15. package/dist/contract/events.js +60 -0
  16. package/dist/contract/events.js.map +1 -1
  17. package/dist/contract/index.d.ts +3 -0
  18. package/dist/contract/index.d.ts.map +1 -1
  19. package/dist/contract/index.js +3 -0
  20. package/dist/contract/index.js.map +1 -1
  21. package/dist/contract/leave.d.ts +168 -0
  22. package/dist/contract/leave.d.ts.map +1 -0
  23. package/dist/contract/leave.js +150 -0
  24. package/dist/contract/leave.js.map +1 -0
  25. package/dist/contract/permissions.d.ts +100 -0
  26. package/dist/contract/permissions.d.ts.map +1 -1
  27. package/dist/contract/permissions.js +119 -0
  28. package/dist/contract/permissions.js.map +1 -1
  29. package/dist/contract/router.d.ts +2440 -0
  30. package/dist/contract/router.d.ts.map +1 -1
  31. package/dist/contract/router.js +393 -0
  32. package/dist/contract/router.js.map +1 -1
  33. package/dist/policy/time.d.ts +48 -0
  34. package/dist/policy/time.d.ts.map +1 -0
  35. package/dist/policy/time.js +144 -0
  36. package/dist/policy/time.js.map +1 -0
  37. package/dist/policy/working-time.d.ts +98 -0
  38. package/dist/policy/working-time.d.ts.map +1 -0
  39. package/dist/policy/working-time.js +190 -0
  40. package/dist/policy/working-time.js.map +1 -0
  41. package/dist/server/index.d.ts.map +1 -1
  42. package/dist/server/index.js +2 -0
  43. package/dist/server/index.js.map +1 -1
  44. package/dist/server/jobs.d.ts +16 -0
  45. package/dist/server/jobs.d.ts.map +1 -0
  46. package/dist/server/jobs.js +166 -0
  47. package/dist/server/jobs.js.map +1 -0
  48. package/dist/server/router.d.ts +2731 -58
  49. package/dist/server/router.d.ts.map +1 -1
  50. package/dist/server/router.js +1306 -3
  51. package/dist/server/router.js.map +1 -1
  52. package/dist/server/schema.d.ts +3344 -1
  53. package/dist/server/schema.d.ts.map +1 -1
  54. package/dist/server/schema.js +325 -0
  55. package/dist/server/schema.js.map +1 -1
  56. package/dist/server/services/approvals.d.ts +163 -0
  57. package/dist/server/services/approvals.d.ts.map +1 -0
  58. package/dist/server/services/approvals.js +325 -0
  59. package/dist/server/services/approvals.js.map +1 -0
  60. package/dist/server/services/attendance.d.ts +150 -0
  61. package/dist/server/services/attendance.d.ts.map +1 -0
  62. package/dist/server/services/attendance.js +239 -0
  63. package/dist/server/services/attendance.js.map +1 -0
  64. package/dist/server/services/ledger.d.ts +135 -0
  65. package/dist/server/services/ledger.d.ts.map +1 -0
  66. package/dist/server/services/ledger.js +178 -0
  67. package/dist/server/services/ledger.js.map +1 -0
  68. package/dist/server/services/people.d.ts +3 -3
  69. package/migrations/0002_leave.sql +237 -0
  70. package/migrations/0003_attendance.sql +206 -0
  71. package/migrations/meta/0002_snapshot.json +3009 -0
  72. package/migrations/meta/0003_snapshot.json +3681 -0
  73. package/migrations/meta/_journal.json +14 -0
  74. package/package.json +1 -1
  75. package/src/contract/approvals.ts +149 -0
  76. package/src/contract/attendance.ts +180 -0
  77. package/src/contract/capabilities.ts +80 -0
  78. package/src/contract/events.ts +81 -0
  79. package/src/contract/index.ts +3 -0
  80. package/src/contract/leave.ts +171 -0
  81. package/src/contract/permissions.ts +122 -0
  82. package/src/contract/router.ts +481 -0
@@ -0,0 +1,239 @@
1
+ import { KernError, uuidv7 } from '@kernhq/kernel';
2
+ import { and, asc, eq, gte, inArray, isNull, lte, sql } from 'drizzle-orm';
3
+ import { weekdayOf } from '../../policy/calendar.js';
4
+ import { businessDateFor, computeDay, } from '../../policy/working-time.js';
5
+ import { attendanceDays, leaveRequestDays, punches, scheduleAssignments, schedules } from '../schema.js';
6
+ import { inForceOn } from './db.js';
7
+ /** A schedule with no shifts: somebody with no assignment still clocks in, they just owe no hours. */
8
+ export const NO_SCHEDULE = {
9
+ shiftFor: () => null,
10
+ rounding: { stepMinutes: 0, direction: 'nearest' },
11
+ autoClockOutAfterMinutes: null,
12
+ scheduleId: null,
13
+ };
14
+ export class AttendanceService {
15
+ /** The schedule in force for a person on a date, as something the pure layer can use. */
16
+ async scheduleFor(tx, workspaceId, personId, on) {
17
+ const [assignment] = await tx
18
+ .select()
19
+ .from(scheduleAssignments)
20
+ .where(and(eq(scheduleAssignments.workspaceId, workspaceId), eq(scheduleAssignments.personId, personId), inForceOn(scheduleAssignments.effectiveFrom, scheduleAssignments.effectiveTo, on)))
21
+ .limit(1);
22
+ if (!assignment)
23
+ return NO_SCHEDULE;
24
+ const [schedule] = await tx
25
+ .select()
26
+ .from(schedules)
27
+ .where(and(eq(schedules.workspaceId, workspaceId), eq(schedules.id, assignment.scheduleId)))
28
+ .limit(1);
29
+ if (!schedule)
30
+ return NO_SCHEDULE;
31
+ const week = (schedule.week ?? {});
32
+ return {
33
+ scheduleId: schedule.id,
34
+ shiftFor: (date) => {
35
+ const day = week[weekdayOf(date)];
36
+ if (!day)
37
+ return null;
38
+ return {
39
+ start: day.start,
40
+ end: day.end,
41
+ breakMinutes: day.breakMinutes ?? 0,
42
+ graceInMinutes: schedule.graceInMinutes,
43
+ graceOutMinutes: schedule.graceOutMinutes,
44
+ };
45
+ },
46
+ rounding: {
47
+ stepMinutes: schedule.roundingStepMinutes,
48
+ direction: schedule.roundingDirection,
49
+ },
50
+ autoClockOutAfterMinutes: schedule.autoClockOutAfterMinutes,
51
+ };
52
+ }
53
+ /**
54
+ * Record a punch.
55
+ *
56
+ * `at` is the server's clock, always. `clientReportedAt` is kept beside it with the measured skew
57
+ * so an offline sync can be told from a device whose clock is wrong — and a claim beyond the
58
+ * threshold is marked `disputed` rather than silently accepted or silently dropped.
59
+ */
60
+ async record(tx, workspaceId, input, schedule) {
61
+ const serverNow = new Date();
62
+ const at = input.claimedAt ?? serverNow;
63
+ const clientAt = input.clientReportedAt ? new Date(input.clientReportedAt) : null;
64
+ const skewMs = clientAt ? clientAt.getTime() - serverNow.getTime() : null;
65
+ // A claim more than an hour out is not a slow network; it is a clock somebody should look at.
66
+ const trust = input.claimedAt ? (Math.abs(skewMs ?? 0) > 3600_000 ? 'disputed' : 'claimed') : 'trusted';
67
+ const businessDate = businessDateFor(at.getTime(), input.timezone, schedule.shiftFor(new Date(at.getTime()).toISOString().slice(0, 10)));
68
+ const [row] = await tx
69
+ .insert(punches)
70
+ .values({
71
+ id: uuidv7(),
72
+ workspaceId,
73
+ personId: input.personId,
74
+ direction: input.direction,
75
+ at,
76
+ clientReportedAt: clientAt,
77
+ skewMs,
78
+ businessDate,
79
+ timezone: input.timezone,
80
+ method: input.method,
81
+ officeId: input.officeId ?? null,
82
+ geo: input.geo ?? null,
83
+ trust,
84
+ idempotencyKey: input.idempotencyKey ?? null,
85
+ note: input.note ?? null,
86
+ })
87
+ .returning();
88
+ return row;
89
+ }
90
+ /** Live punches for a person on a business date, oldest first. Voided rows are excluded. */
91
+ async punchesOn(tx, workspaceId, personId, businessDate) {
92
+ return tx
93
+ .select()
94
+ .from(punches)
95
+ .where(and(eq(punches.workspaceId, workspaceId), eq(punches.personId, personId), eq(punches.businessDate, businessDate), isNull(punches.voidedByPunchId)))
96
+ .orderBy(asc(punches.at));
97
+ }
98
+ /**
99
+ * Rebuild one day from its punches.
100
+ *
101
+ * Idempotent by construction, which is what makes it safe to call on every punch, from a nightly
102
+ * sweep, and from a support request without thinking about it. A locked day is left alone and
103
+ * reported, never silently skipped — a recomputation that quietly declines to touch a closed
104
+ * month looks identical to one that had nothing to do.
105
+ */
106
+ async recomputeDay(tx, workspaceId, personId, businessDate, timezone, schedule) {
107
+ const [existing] = await tx
108
+ .select({ locked: attendanceDays.locked })
109
+ .from(attendanceDays)
110
+ .where(and(eq(attendanceDays.workspaceId, workspaceId), eq(attendanceDays.personId, personId), eq(attendanceDays.businessDate, businessDate)))
111
+ .limit(1);
112
+ if (existing?.locked)
113
+ return { locked: true };
114
+ const rows = await this.punchesOn(tx, workspaceId, personId, businessDate);
115
+ const punchInputs = rows.map((r) => ({
116
+ at: r.at.getTime(),
117
+ direction: r.direction,
118
+ }));
119
+ // Approved leave excuses the day: somebody on holiday is not absent, and a sheet that says
120
+ // otherwise generates a disciplinary conversation about a day HR themselves approved.
121
+ const [onLeave] = await tx
122
+ .select({ requestId: leaveRequestDays.requestId })
123
+ .from(leaveRequestDays)
124
+ .where(and(eq(leaveRequestDays.workspaceId, workspaceId), eq(leaveRequestDays.personId, personId), eq(leaveRequestDays.date, businessDate), eq(leaveRequestDays.counted, true), eq(leaveRequestDays.status, 'approved')))
125
+ .limit(1);
126
+ const computed = computeDay({
127
+ businessDate,
128
+ timeZone: timezone,
129
+ shift: schedule.shiftFor(businessDate),
130
+ punches: punchInputs,
131
+ rounding: schedule.rounding,
132
+ excused: !!onLeave,
133
+ });
134
+ const status = onLeave && computed.workedMinutes === 0 ? 'leave' : computed.status;
135
+ await tx
136
+ .insert(attendanceDays)
137
+ .values({
138
+ id: uuidv7(),
139
+ workspaceId,
140
+ personId,
141
+ businessDate,
142
+ scheduledMinutes: computed.scheduledMinutes,
143
+ workedMinutes: computed.workedMinutes,
144
+ breakMinutes: computed.breakMinutes,
145
+ overtimeMinutes: computed.overtimeMinutes,
146
+ lateMinutes: computed.lateMinutes,
147
+ earlyLeaveMinutes: computed.earlyLeaveMinutes,
148
+ status,
149
+ leaveRequestId: onLeave?.requestId ?? null,
150
+ anomalies: computed.anomalies,
151
+ firstIn: computed.firstIn ? new Date(computed.firstIn) : null,
152
+ lastOut: computed.lastOut ? new Date(computed.lastOut) : null,
153
+ policyHash: hashSchedule(schedule),
154
+ computedAt: new Date(),
155
+ })
156
+ .onConflictDoUpdate({
157
+ target: [attendanceDays.workspaceId, attendanceDays.personId, attendanceDays.businessDate],
158
+ set: {
159
+ scheduledMinutes: computed.scheduledMinutes,
160
+ workedMinutes: computed.workedMinutes,
161
+ breakMinutes: computed.breakMinutes,
162
+ overtimeMinutes: computed.overtimeMinutes,
163
+ lateMinutes: computed.lateMinutes,
164
+ earlyLeaveMinutes: computed.earlyLeaveMinutes,
165
+ status,
166
+ leaveRequestId: onLeave?.requestId ?? null,
167
+ anomalies: computed.anomalies,
168
+ firstIn: computed.firstIn ? new Date(computed.firstIn) : null,
169
+ lastOut: computed.lastOut ? new Date(computed.lastOut) : null,
170
+ policyHash: hashSchedule(schedule),
171
+ computedAt: new Date(),
172
+ },
173
+ });
174
+ return { locked: false };
175
+ }
176
+ /** Which business dates a person has punches on, in a range. Drives a bulk recompute. */
177
+ async datesWithPunches(tx, workspaceId, personId, from, to) {
178
+ const rows = await tx
179
+ .selectDistinct({ businessDate: punches.businessDate })
180
+ .from(punches)
181
+ .where(and(eq(punches.workspaceId, workspaceId), eq(punches.personId, personId), gte(punches.businessDate, from), lte(punches.businessDate, to)));
182
+ return rows.map((r) => r.businessDate).sort();
183
+ }
184
+ /**
185
+ * Void a punch by writing a correcting row that points at it.
186
+ *
187
+ * The original stays exactly as it was. Two rows is the point: "this was recorded and then
188
+ * corrected" and "this was never recorded" are different facts, and only one of them is true.
189
+ */
190
+ async voidPunch(tx, workspaceId, punchId, reason, actorPersonId) {
191
+ const [original] = await tx
192
+ .select()
193
+ .from(punches)
194
+ .where(and(eq(punches.workspaceId, workspaceId), eq(punches.id, punchId)))
195
+ .limit(1);
196
+ if (!original)
197
+ throw KernError.notFound('Punch');
198
+ if (original.voidedByPunchId)
199
+ throw KernError.conflict('That punch is already voided');
200
+ const [correction] = await tx
201
+ .insert(punches)
202
+ .values({
203
+ id: uuidv7(),
204
+ workspaceId,
205
+ personId: original.personId,
206
+ direction: original.direction,
207
+ at: original.at,
208
+ businessDate: original.businessDate,
209
+ timezone: original.timezone,
210
+ method: 'manual',
211
+ trust: 'trusted',
212
+ note: `Voids ${punchId}: ${reason}`,
213
+ })
214
+ .returning();
215
+ await tx
216
+ .update(punches)
217
+ .set({ voidedByPunchId: correction.id })
218
+ .where(and(eq(punches.id, punchId), eq(punches.businessDate, original.businessDate)));
219
+ // The correcting row is itself voided: it exists to carry the reason and to point at what it
220
+ // replaced, not to be counted as a punch.
221
+ await tx
222
+ .update(punches)
223
+ .set({ voidedByPunchId: correction.id })
224
+ .where(and(eq(punches.id, correction.id), eq(punches.businessDate, original.businessDate)));
225
+ void actorPersonId;
226
+ return { original, correction: correction };
227
+ }
228
+ }
229
+ /**
230
+ * A short stamp of what produced a derived row.
231
+ *
232
+ * Not cryptographic — it only has to change when the schedule does, so a recomputation can tell a
233
+ * stale row from a current one without re-deriving it.
234
+ */
235
+ function hashSchedule(schedule) {
236
+ return `${schedule.scheduleId ?? 'none'}:${schedule.rounding.stepMinutes}:${schedule.rounding.direction}`;
237
+ }
238
+ export { inArray, sql };
239
+ //# sourceMappingURL=attendance.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attendance.js","sourceRoot":"","sources":["../../../src/server/services/attendance.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,135 @@
1
+ import { type Tx } from '@kernhq/kernel';
2
+ import { inArray } from 'drizzle-orm';
3
+ import type { LedgerKind } from '../../contract/index.js';
4
+ /**
5
+ * The leave balance, and the concurrency that makes it hard.
6
+ *
7
+ * A balance is `sum(leave_ledger.amount_minutes)`. Reading that is easy. Spending it safely is not:
8
+ * two overlapping requests can both read the same balance, both see enough for the last day, and
9
+ * both succeed — leaving somebody minus a day and nobody able to say which request caused it.
10
+ *
11
+ * Two things stop that, and they are deliberately in the database rather than here:
12
+ *
13
+ * 1. `SELECT … FOR UPDATE` on the person's cursor row serialises spending. The second transaction
14
+ * waits, re-reads, and sees the first one's consumption.
15
+ * 2. A partial unique index on `(person, date)` for counted days in a live status means the
16
+ * database refuses to double-book a Tuesday whatever this code believes.
17
+ *
18
+ * Application-level checks alone cannot do either. They are here as well, because a readable error
19
+ * beats a constraint violation — but they are not what makes it correct.
20
+ */
21
+ export interface BalanceRow {
22
+ personId: string;
23
+ leaveTypeId: string;
24
+ periodYear: number;
25
+ balanceMinutes: number;
26
+ bookedMinutes: number;
27
+ pendingMinutes: number;
28
+ }
29
+ /** A minute count in whatever unit the type displays. Hours to one place, days to two. */
30
+ export declare const toUnit: (minutes: number, unit: string) => number;
31
+ /** Minutes in one working day. A constant here; a policy in Phase 4, per contract. */
32
+ export declare const MINUTES_PER_DAY: number;
33
+ export declare class LedgerService {
34
+ /**
35
+ * Balances for one person, per leave type.
36
+ *
37
+ * Summed from the ledger rather than read from the cursor: the cursor exists to be locked, and a
38
+ * cache that is also the source of truth is a cache that eventually disagrees with it. The sum is
39
+ * one indexed aggregate over a person's own rows, which is small.
40
+ */
41
+ balances(tx: Tx, workspaceId: string, personId: string, periodYear: number): Promise<{
42
+ personId: string;
43
+ leaveTypeId: string;
44
+ leaveTypeName: string;
45
+ unit: "day" | "half_day" | "hour";
46
+ periodYear: number;
47
+ balanceMinutes: number;
48
+ bookedMinutes: number;
49
+ pendingMinutes: number;
50
+ availableMinutes: number;
51
+ balance: number;
52
+ available: number;
53
+ }[]>;
54
+ /**
55
+ * Take the lock for one person and leave type, creating the cursor row if it is missing.
56
+ *
57
+ * Everything that *spends* balance must call this first, inside the same transaction. It returns
58
+ * the balance as of the moment the lock was acquired, which is the only balance safe to decide on.
59
+ */
60
+ lockAndRead(tx: Tx, workspaceId: string, personId: string, leaveTypeId: string, periodYear: number): Promise<number>;
61
+ /** Append an entry. The only way anything enters the ledger. */
62
+ append(tx: Tx, workspaceId: string, entry: {
63
+ personId: string;
64
+ leaveTypeId: string;
65
+ kind: LedgerKind;
66
+ amountMinutes: number;
67
+ effectiveOn: string;
68
+ periodYear: number;
69
+ requestId?: string | null;
70
+ reversesEntryId?: string | null;
71
+ reason?: string | null;
72
+ createdBy?: string | null;
73
+ policyHash?: string | null;
74
+ }): Promise<{
75
+ createdAt: Date;
76
+ id: string;
77
+ workspaceId: string;
78
+ personId: string;
79
+ reason: string | null;
80
+ kind: string;
81
+ requestId: string | null;
82
+ policyHash: string | null;
83
+ leaveTypeId: string;
84
+ effectiveOn: string;
85
+ amountMinutes: number;
86
+ periodYear: number;
87
+ reversesEntryId: string | null;
88
+ createdBy: string | null;
89
+ }>;
90
+ /**
91
+ * Undo an entry by writing its opposite.
92
+ *
93
+ * Never a delete and never an update. A cancelled request has to leave both the consumption and
94
+ * the reversal visible, because "she booked it and then cancelled" and "she never booked it" are
95
+ * different facts and only one of them is true.
96
+ */
97
+ reverse(tx: Tx, workspaceId: string, entryId: string, reason: string, actorId: string | null, on: string): Promise<{
98
+ createdAt: Date;
99
+ id: string;
100
+ workspaceId: string;
101
+ personId: string;
102
+ reason: string | null;
103
+ kind: string;
104
+ requestId: string | null;
105
+ policyHash: string | null;
106
+ leaveTypeId: string;
107
+ effectiveOn: string;
108
+ amountMinutes: number;
109
+ periodYear: number;
110
+ reversesEntryId: string | null;
111
+ createdBy: string | null;
112
+ }>;
113
+ /** Every entry a request produced, for cancelling it. */
114
+ entriesFor(tx: Tx, workspaceId: string, requestId: string): Promise<{
115
+ id: string;
116
+ workspaceId: string;
117
+ personId: string;
118
+ leaveTypeId: string;
119
+ kind: string;
120
+ amountMinutes: number;
121
+ effectiveOn: string;
122
+ periodYear: number;
123
+ requestId: string | null;
124
+ reversesEntryId: string | null;
125
+ policyHash: string | null;
126
+ reason: string | null;
127
+ createdBy: string | null;
128
+ createdAt: Date;
129
+ }[]>;
130
+ /** Rebuild every cursor from the ledger. Cheap insurance after a bulk import or a bad migration. */
131
+ rebuildCursors(tx: Tx, workspaceId: string, personIds: string[]): Promise<void>;
132
+ }
133
+ export declare const yearOf: (isoDate: string) => number;
134
+ export { inArray };
135
+ //# sourceMappingURL=ledger.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ledger.d.ts","sourceRoot":"","sources":["../../../src/server/services/ledger.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,KAAK,EAAE,EAAU,MAAM,gBAAgB,CAAA;AAC3D,OAAO,EAAW,OAAO,EAAO,MAAM,aAAa,CAAA;AACnD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAA;AAGzD;;;;;;;;;;;;;;;;GAgBG;AAEH,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,MAAM,CAAA;IAChB,WAAW,EAAE,MAAM,CAAA;IACnB,UAAU,EAAE,MAAM,CAAA;IAClB,cAAc,EAAE,MAAM,CAAA;IACtB,aAAa,EAAE,MAAM,CAAA;IACrB,cAAc,EAAE,MAAM,CAAA;CACvB;AAED,0FAA0F;AAC1F,eAAO,MAAM,MAAM,GAAI,SAAS,MAAM,EAAE,MAAM,MAAM,KAAG,MACgD,CAAA;AAEvG,sFAAsF;AACtF,eAAO,MAAM,eAAe,QAAS,CAAA;AAErC,qBAAa,aAAa;IACxB;;;;;;OAMG;IACG,QAAQ,CAAC,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM;;;;cAmDvD,KAAK,GAAG,UAAU,GAAG,MAAM;;;;;;;;;IAYpD;;;;;OAKG;IACG,WAAW,CACf,EAAE,EAAE,EAAE,EACN,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,MAAM,CAAC;IA6BlB,gEAAgE;IAC1D,MAAM,CACV,EAAE,EAAE,EAAE,EACN,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE;QACL,QAAQ,EAAE,MAAM,CAAA;QAChB,WAAW,EAAE,MAAM,CAAA;QACnB,IAAI,EAAE,UAAU,CAAA;QAChB,aAAa,EAAE,MAAM,CAAA;QACrB,WAAW,EAAE,MAAM,CAAA;QACnB,UAAU,EAAE,MAAM,CAAA;QAClB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;QACzB,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;QAC/B,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;QACtB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;QACzB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAC3B;;;;;;;;;;;;;;;;IAyBH;;;;;;OAMG;IACG,OAAO,CACX,EAAE,EAAE,EAAE,EACN,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,GAAG,IAAI,EACtB,EAAE,EAAE,MAAM;;;;;;;;;;;;;;;;IAiCZ,yDAAyD;IACnD,UAAU,CAAC,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM;;;;;;;;;;;;;;;;IAO/D,oGAAoG;IAC9F,cAAc,CAAC,EAAE,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE;CAuBtE;AAED,eAAO,MAAM,MAAM,GAAI,SAAS,MAAM,KAAG,MAAqC,CAAA;AAC9E,OAAO,EAAE,OAAO,EAAE,CAAA"}
@@ -0,0 +1,178 @@
1
+ import { KernError, uuidv7 } from '@kernhq/kernel';
2
+ import { and, eq, inArray, sql } from 'drizzle-orm';
3
+ import { leaveBalanceCursor, leaveLedger, leaveRequestDays, leaveTypes } from '../schema.js';
4
+ /** A minute count in whatever unit the type displays. Hours to one place, days to two. */
5
+ export const toUnit = (minutes, unit) => unit === 'hour' ? Math.round((minutes / 60) * 10) / 10 : Math.round((minutes / (60 * 8)) * 100) / 100;
6
+ /** Minutes in one working day. A constant here; a policy in Phase 4, per contract. */
7
+ export const MINUTES_PER_DAY = 8 * 60;
8
+ export class LedgerService {
9
+ /**
10
+ * Balances for one person, per leave type.
11
+ *
12
+ * Summed from the ledger rather than read from the cursor: the cursor exists to be locked, and a
13
+ * cache that is also the source of truth is a cache that eventually disagrees with it. The sum is
14
+ * one indexed aggregate over a person's own rows, which is small.
15
+ */
16
+ async balances(tx, workspaceId, personId, periodYear) {
17
+ const types = await tx
18
+ .select()
19
+ .from(leaveTypes)
20
+ .where(and(eq(leaveTypes.workspaceId, workspaceId), sql `${leaveTypes.archivedAt} is null`));
21
+ const sums = await tx
22
+ .select({
23
+ leaveTypeId: leaveLedger.leaveTypeId,
24
+ total: sql `coalesce(sum(${leaveLedger.amountMinutes}), 0)::int`,
25
+ })
26
+ .from(leaveLedger)
27
+ .where(and(eq(leaveLedger.workspaceId, workspaceId), eq(leaveLedger.personId, personId), eq(leaveLedger.periodYear, periodYear)))
28
+ .groupBy(leaveLedger.leaveTypeId);
29
+ const byType = new Map(sums.map((r) => [r.leaveTypeId, Number(r.total)]));
30
+ // Pending requests are not spent, but they are not available either — showing a balance that
31
+ // ignores them is how somebody books the same day twice and only finds out at approval.
32
+ const pending = await tx
33
+ .select({
34
+ leaveTypeId: sql `lr.leave_type_id`,
35
+ status: sql `lr.status`,
36
+ minutes: sql `coalesce(sum(lr.minutes), 0)::int`,
37
+ })
38
+ .from(sql `${leaveRequestDays} d join mod_hr.leave_requests lr on lr.id = d.request_id`)
39
+ .where(sql `d.workspace_id = ${workspaceId} and d.person_id = ${personId}
40
+ and lr.status in ('pending','approved')`)
41
+ .groupBy(sql `lr.leave_type_id, lr.status`);
42
+ const pendingBy = new Map();
43
+ const bookedBy = new Map();
44
+ for (const row of pending) {
45
+ const target = row.status === 'approved' ? bookedBy : pendingBy;
46
+ target.set(row.leaveTypeId, (target.get(row.leaveTypeId) ?? 0) + Number(row.minutes));
47
+ }
48
+ return types.map((type) => {
49
+ const balanceMinutes = byType.get(type.id) ?? 0;
50
+ const pendingMinutes = pendingBy.get(type.id) ?? 0;
51
+ return {
52
+ personId,
53
+ leaveTypeId: type.id,
54
+ leaveTypeName: type.name,
55
+ unit: type.unit,
56
+ periodYear,
57
+ balanceMinutes,
58
+ bookedMinutes: bookedBy.get(type.id) ?? 0,
59
+ pendingMinutes,
60
+ availableMinutes: balanceMinutes - pendingMinutes,
61
+ balance: toUnit(balanceMinutes, type.unit),
62
+ available: toUnit(balanceMinutes - pendingMinutes, type.unit),
63
+ };
64
+ });
65
+ }
66
+ /**
67
+ * Take the lock for one person and leave type, creating the cursor row if it is missing.
68
+ *
69
+ * Everything that *spends* balance must call this first, inside the same transaction. It returns
70
+ * the balance as of the moment the lock was acquired, which is the only balance safe to decide on.
71
+ */
72
+ async lockAndRead(tx, workspaceId, personId, leaveTypeId, periodYear) {
73
+ await tx
74
+ .insert(leaveBalanceCursor)
75
+ .values({ id: uuidv7(), workspaceId, personId, leaveTypeId, periodYear })
76
+ .onConflictDoNothing();
77
+ await tx.execute(sql `
78
+ select 1 from ${leaveBalanceCursor}
79
+ where workspace_id = ${workspaceId}
80
+ and person_id = ${personId}
81
+ and leave_type_id = ${leaveTypeId}
82
+ and period_year = ${periodYear}
83
+ for update
84
+ `);
85
+ const [row] = await tx
86
+ .select({ total: sql `coalesce(sum(${leaveLedger.amountMinutes}), 0)::int` })
87
+ .from(leaveLedger)
88
+ .where(and(eq(leaveLedger.workspaceId, workspaceId), eq(leaveLedger.personId, personId), eq(leaveLedger.leaveTypeId, leaveTypeId), eq(leaveLedger.periodYear, periodYear)));
89
+ return Number(row?.total ?? 0);
90
+ }
91
+ /** Append an entry. The only way anything enters the ledger. */
92
+ async append(tx, workspaceId, entry) {
93
+ const [row] = await tx
94
+ .insert(leaveLedger)
95
+ .values({ id: uuidv7(), workspaceId, ...entry })
96
+ .returning();
97
+ await tx
98
+ .update(leaveBalanceCursor)
99
+ .set({
100
+ cachedBalanceMinutes: sql `${leaveBalanceCursor.cachedBalanceMinutes} + ${entry.amountMinutes}`,
101
+ asOfEntryId: row.id,
102
+ version: sql `${leaveBalanceCursor.version} + 1`,
103
+ updatedAt: new Date(),
104
+ })
105
+ .where(and(eq(leaveBalanceCursor.workspaceId, workspaceId), eq(leaveBalanceCursor.personId, entry.personId), eq(leaveBalanceCursor.leaveTypeId, entry.leaveTypeId), eq(leaveBalanceCursor.periodYear, entry.periodYear)));
106
+ return row;
107
+ }
108
+ /**
109
+ * Undo an entry by writing its opposite.
110
+ *
111
+ * Never a delete and never an update. A cancelled request has to leave both the consumption and
112
+ * the reversal visible, because "she booked it and then cancelled" and "she never booked it" are
113
+ * different facts and only one of them is true.
114
+ */
115
+ async reverse(tx, workspaceId, entryId, reason, actorId, on) {
116
+ const [original] = await tx
117
+ .select()
118
+ .from(leaveLedger)
119
+ .where(and(eq(leaveLedger.workspaceId, workspaceId), eq(leaveLedger.id, entryId)))
120
+ .limit(1);
121
+ if (!original)
122
+ throw KernError.notFound('Ledger entry');
123
+ const [already] = await tx
124
+ .select({ id: leaveLedger.id })
125
+ .from(leaveLedger)
126
+ .where(and(eq(leaveLedger.workspaceId, workspaceId), eq(leaveLedger.reversesEntryId, entryId)))
127
+ .limit(1);
128
+ // Reversing twice would credit the balance twice. The check is here rather than in a constraint
129
+ // because a partial unique index on a nullable column is easy to get subtly wrong, and this
130
+ // path is always inside a transaction that already holds the cursor lock.
131
+ if (already)
132
+ throw KernError.conflict('That entry has already been reversed');
133
+ return this.append(tx, workspaceId, {
134
+ personId: original.personId,
135
+ leaveTypeId: original.leaveTypeId,
136
+ kind: 'reversal',
137
+ amountMinutes: -original.amountMinutes,
138
+ effectiveOn: on,
139
+ periodYear: original.periodYear,
140
+ requestId: original.requestId,
141
+ reversesEntryId: original.id,
142
+ reason,
143
+ createdBy: actorId,
144
+ });
145
+ }
146
+ /** Every entry a request produced, for cancelling it. */
147
+ async entriesFor(tx, workspaceId, requestId) {
148
+ return tx
149
+ .select()
150
+ .from(leaveLedger)
151
+ .where(and(eq(leaveLedger.workspaceId, workspaceId), eq(leaveLedger.requestId, requestId)));
152
+ }
153
+ /** Rebuild every cursor from the ledger. Cheap insurance after a bulk import or a bad migration. */
154
+ async rebuildCursors(tx, workspaceId, personIds) {
155
+ if (!personIds.length)
156
+ return;
157
+ await tx.execute(sql `
158
+ update ${leaveBalanceCursor} c
159
+ set cached_balance_minutes = coalesce(s.total, 0),
160
+ version = c.version + 1,
161
+ updated_at = now()
162
+ from (
163
+ select person_id, leave_type_id, period_year, sum(amount_minutes)::int as total
164
+ from ${leaveLedger}
165
+ where workspace_id = ${workspaceId}
166
+ and person_id in (${sql.join(personIds.map((p) => sql `${p}`), sql `, `)})
167
+ group by 1, 2, 3
168
+ ) s
169
+ where c.workspace_id = ${workspaceId}
170
+ and c.person_id = s.person_id
171
+ and c.leave_type_id = s.leave_type_id
172
+ and c.period_year = s.period_year
173
+ `);
174
+ }
175
+ }
176
+ export const yearOf = (isoDate) => Number(isoDate.slice(0, 4));
177
+ export { inArray };
178
+ //# sourceMappingURL=ledger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ledger.js","sourceRoot":"","sources":["../../../src/server/services/ledger.ts"],"names":[],"mappings":"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"}
@@ -22,7 +22,6 @@ export declare class PeopleService {
22
22
  createdAt: string;
23
23
  updatedAt: string;
24
24
  id: string;
25
- workspaceId: string;
26
25
  userId: string | null;
27
26
  employeeNo: string | null;
28
27
  displayName: string;
@@ -33,6 +32,7 @@ export declare class PeopleService {
33
32
  hiredOn: string | null;
34
33
  terminatedOn: string | null;
35
34
  timezone: string | null;
35
+ workspaceId: string;
36
36
  };
37
37
  static toEmployment(row: EmploymentRow): {
38
38
  employmentType: EmploymentType;
@@ -100,10 +100,10 @@ export declare class PeopleService {
100
100
  workspaceId: string;
101
101
  personId: string;
102
102
  effectiveFrom: string;
103
- officeId: string;
104
- isPrimary: boolean;
105
103
  effectiveTo: string | null;
106
104
  reason: string | null;
105
+ officeId: string;
106
+ isPrimary: boolean;
107
107
  };
108
108
  today: () => string;
109
109
  }