@managemint-solutions/sdk 0.44.0 → 0.49.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.
@@ -0,0 +1,18 @@
1
+ import type { LeaveAdjustmentEntity } from '@managemint-solutions/entities/leave';
2
+ import { type AuditActor, type AuditResource } from '../audit';
3
+ import type { TypedSupabaseClient } from '../client';
4
+ import type { CreateLeaveAdjustmentDto } from './dto';
5
+ /**
6
+ * Manual corrections to an entitlement's balance. Insert and read only: the table has no
7
+ * update or delete grant, so a mistake is corrected by a second adjustment and both stay on
8
+ * record. The balance itself sums them in `LeaveEntitlementsResource`.
9
+ */
10
+ export declare class LeaveAdjustmentsResource {
11
+ private readonly supabase;
12
+ private readonly auth;
13
+ private readonly table;
14
+ constructor(supabase: TypedSupabaseClient, auth: AuditActor, audit: AuditResource);
15
+ /** The adjustments of the given entitlements, oldest first. */
16
+ list(leaveEntitlementIds: string[]): Promise<LeaveAdjustmentEntity[]>;
17
+ create(leaveEntitlementId: string, input: CreateLeaveAdjustmentDto): Promise<LeaveAdjustmentEntity>;
18
+ }
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LeaveAdjustmentsResource = void 0;
4
+ const enum_1 = require("@managemint-solutions/entities/audit-trail/enum");
5
+ const audit_1 = require("../audit");
6
+ const errors_1 = require("../errors");
7
+ const LEAVE_ADJUSTMENT_SELECT = `
8
+ mms_id,
9
+ created_at,
10
+ created_by:user_profiles!created_by (*),
11
+ leave_entitlement_id,
12
+ days,
13
+ reason
14
+ `;
15
+ /**
16
+ * Manual corrections to an entitlement's balance. Insert and read only: the table has no
17
+ * update or delete grant, so a mistake is corrected by a second adjustment and both stay on
18
+ * record. The balance itself sums them in `LeaveEntitlementsResource`.
19
+ */
20
+ class LeaveAdjustmentsResource {
21
+ supabase;
22
+ auth;
23
+ table;
24
+ constructor(supabase, auth, audit) {
25
+ this.supabase = supabase;
26
+ this.auth = auth;
27
+ this.table = (0, audit_1.auditedTable)(supabase, auth, audit, {
28
+ table: 'leave_adjustments',
29
+ entityType: enum_1.AuditTrailEntityType.LEAVE_ADJUSTMENTS,
30
+ });
31
+ }
32
+ /** The adjustments of the given entitlements, oldest first. */
33
+ async list(leaveEntitlementIds) {
34
+ if (leaveEntitlementIds.length === 0)
35
+ return [];
36
+ const { data, error } = await this.supabase
37
+ .from('leave_adjustments')
38
+ .select(LEAVE_ADJUSTMENT_SELECT)
39
+ .eq('organization_id', this.auth.organization_id)
40
+ .in('leave_entitlement_id', leaveEntitlementIds)
41
+ .order('created_at');
42
+ if (error)
43
+ throw (0, errors_1.mapPostgrestError)(error);
44
+ return (data ?? []).map((row) => ({ ...row, days: Number(row.days) }));
45
+ }
46
+ async create(leaveEntitlementId, input) {
47
+ const row = await this.table.insert({
48
+ leave_entitlement_id: leaveEntitlementId,
49
+ days: input.days,
50
+ reason: input.reason,
51
+ organization_id: this.auth.organization_id,
52
+ created_by: this.auth.mms_id,
53
+ created_at: new Date().toISOString(),
54
+ });
55
+ const [created] = (await this.list([leaveEntitlementId])).filter((adjustment) => adjustment.mms_id === row.mms_id);
56
+ return created;
57
+ }
58
+ }
59
+ exports.LeaveAdjustmentsResource = LeaveAdjustmentsResource;
@@ -1,18 +1,19 @@
1
1
  import type { MmsList } from '@managemint-solutions/entities/common';
2
2
  import type { LeaveEntitlementEntity, LeavePeriod } from '@managemint-solutions/entities/leave';
3
+ import { LeaveGrantMethod } from '@managemint-solutions/entities/leave/enum';
3
4
  import { type AuditActor, type AuditResource } from '../audit';
4
5
  import type { TypedSupabaseClient } from '../client';
5
6
  import type { GetLeaveEntitlementsDto, UpdateLeaveEntitlementDto } from './dto';
6
- /** What the request path needs of the entitlement covering a date: the period and the cap. */
7
- export type LeaveEntitlementCover = LeavePeriod & {
8
- mms_id: string;
9
- days_entitled: number;
10
- };
11
- /** The create input after the api has resolved the period defaults. */
7
+ /** The create input after the api has resolved the defaults and copied the type's policy on. */
12
8
  export type CreateLeaveEntitlementInput = LeavePeriod & {
13
9
  user_id: string;
14
10
  leave_type_id: string;
15
11
  days_entitled: number;
12
+ days_carried: number;
13
+ carried_expires_on: string | null;
14
+ grant_method: LeaveGrantMethod;
15
+ initial_until: string | null;
16
+ initial_days_per_month: number | null;
16
17
  max_carry_over_days: number;
17
18
  notes?: string | null;
18
19
  };
@@ -24,32 +25,37 @@ export declare class LeaveEntitlementsResource {
24
25
  list(query: GetLeaveEntitlementsDto): Promise<MmsList<LeaveEntitlementEntity>>;
25
26
  getById(leaveEntitlementId: string): Promise<LeaveEntitlementEntity>;
26
27
  /**
27
- * The entitlement of `userId` for `leaveTypeId` whose period contains `date`, or null when the
28
+ * The entitlement of `userId` for `leaveTypeId` whose period contains `date`, with its balance
29
+ * as it stands on that day - what a request starting then is checked against. Null when the
28
30
  * type is not tracked for them then. The periods of one user and type cannot overlap (the
29
31
  * table's exclusion constraint), so there is at most one.
30
32
  */
31
- findFor(userId: string, leaveTypeId: string, date: string): Promise<LeaveEntitlementCover | null>;
33
+ balanceOn(userId: string, leaveTypeId: string, date: string, excludeRequestId?: string): Promise<LeaveEntitlementEntity | null>;
32
34
  /**
33
35
  * Whether `userId` already has an entitlement of `leaveTypeId` touching any day of
34
36
  * `start`..`end`. The readable half of the rule; the table's exclusion constraint is the half
35
- * that survives a race. `excludeId` leaves out the entitlement being edited.
37
+ * that survives a race.
36
38
  */
37
- overlaps(userId: string, leaveTypeId: string, start: string, end: string, excludeId?: string): Promise<boolean>;
39
+ overlaps(userId: string, leaveTypeId: string, start: string, end: string): Promise<boolean>;
38
40
  /**
39
- * Every entitlement of the organization whose period ends on `periodEnd`, balances included -
40
- * what the leave-year rollover carries forward. Walks the table in pages so a large tenant is
41
- * not cut short by the row cap.
41
+ * Every entitlement of the organization that ended before `today` and has no successor - no
42
+ * later period of the same user and type - balances included: what the rollover carries
43
+ * forward. An ended period keeps qualifying until its next one exists, so a missed run is
44
+ * caught up by the next. PostgREST cannot say "no later row exists", so this reads the ended
45
+ * rows and every row's start, and filters in code. Both reads walk the table in pages so a
46
+ * large tenant is not cut short by the row cap.
42
47
  */
43
- endingOn(periodEnd: string): Promise<LeaveEntitlementEntity[]>;
48
+ endedWithoutSuccessor(today: string): Promise<LeaveEntitlementEntity[]>;
44
49
  create(input: CreateLeaveEntitlementInput): Promise<LeaveEntitlementEntity>;
45
50
  update(leaveEntitlementId: string, input: UpdateLeaveEntitlementDto): Promise<LeaveEntitlementEntity>;
46
51
  /** Hard delete: the type simply becomes untracked for that user and period. */
47
52
  remove(leaveEntitlementId: string): Promise<void>;
48
53
  /**
49
- * Adds the computed balance to each row from one query over the requests that could count
50
- * against any of them - the approved and pending ones of these users and types, starting in
51
- * the span the periods cover. Nothing is stored: a balance is always what the requests say.
52
- * Paged, since `endingOn` hands it a whole tenant at once.
54
+ * Adds the balance to each row (on `asOf`, today by default) from one query over the requests
55
+ * that could count against any of them - the approved and pending ones of these users and
56
+ * types, starting in the span the periods cover - and the adjustments embedded in the rows.
57
+ * Nothing is stored: a balance is always what the requests, the policy and the adjustments
58
+ * say. Paged, since `endedWithoutSuccessor` hands it a whole tenant at once.
53
59
  */
54
60
  private withBalances;
55
61
  }
@@ -6,6 +6,9 @@ const enum_2 = require("@managemint-solutions/entities/leave/enum");
6
6
  const audit_1 = require("../audit");
7
7
  const errors_1 = require("../errors");
8
8
  const transforms_1 = require("../transforms");
9
+ const leave_policy_1 = require("./leave-policy");
10
+ const working_days_1 = require("./working-days");
11
+ // `adjustments` is embedded only to be summed into the balance; it never leaves the resource.
9
12
  const LEAVE_ENTITLEMENT_SELECT = `
10
13
  mms_id,
11
14
  created_at,
@@ -16,14 +19,21 @@ const LEAVE_ENTITLEMENT_SELECT = `
16
19
  leave_type:leave_types!leave_type_id (
17
20
  mms_id,
18
21
  name,
19
- requires_document
22
+ document_after_days
20
23
  ),
21
24
  period_start,
22
25
  period_end,
23
26
  days_entitled,
27
+ days_carried,
28
+ carried_expires_on,
29
+ grant_method,
30
+ initial_until,
31
+ initial_days_per_month,
24
32
  max_carry_over_days,
25
- notes
33
+ notes,
34
+ adjustments:leave_adjustments (days)
26
35
  `;
36
+ const numberOrNull = (value) => (value === null || value === undefined ? null : Number(value));
27
37
  class LeaveEntitlementsResource {
28
38
  supabase;
29
39
  auth;
@@ -75,14 +85,15 @@ class LeaveEntitlementsResource {
75
85
  return row;
76
86
  }
77
87
  /**
78
- * The entitlement of `userId` for `leaveTypeId` whose period contains `date`, or null when the
88
+ * The entitlement of `userId` for `leaveTypeId` whose period contains `date`, with its balance
89
+ * as it stands on that day - what a request starting then is checked against. Null when the
79
90
  * type is not tracked for them then. The periods of one user and type cannot overlap (the
80
91
  * table's exclusion constraint), so there is at most one.
81
92
  */
82
- async findFor(userId, leaveTypeId, date) {
93
+ async balanceOn(userId, leaveTypeId, date, excludeRequestId) {
83
94
  const { data, error } = await this.supabase
84
95
  .from('leave_entitlements')
85
- .select('mms_id, period_start, period_end, days_entitled')
96
+ .select(LEAVE_ENTITLEMENT_SELECT)
86
97
  .eq('organization_id', this.auth.organization_id)
87
98
  .eq('user_id', userId)
88
99
  .eq('leave_type_id', leaveTypeId)
@@ -93,16 +104,16 @@ class LeaveEntitlementsResource {
93
104
  throw (0, errors_1.mapPostgrestError)(error);
94
105
  if (!data)
95
106
  return null;
96
- const cover = data;
97
- return { ...cover, days_entitled: Number(cover.days_entitled) };
107
+ const [row] = await this.withBalances([data], { asOf: date, excludeRequestId });
108
+ return row;
98
109
  }
99
110
  /**
100
111
  * Whether `userId` already has an entitlement of `leaveTypeId` touching any day of
101
112
  * `start`..`end`. The readable half of the rule; the table's exclusion constraint is the half
102
- * that survives a race. `excludeId` leaves out the entitlement being edited.
113
+ * that survives a race.
103
114
  */
104
- async overlaps(userId, leaveTypeId, start, end, excludeId) {
105
- let overlapQuery = this.supabase
115
+ async overlaps(userId, leaveTypeId, start, end) {
116
+ const { count, error } = await this.supabase
106
117
  .from('leave_entitlements')
107
118
  .select('mms_id', { count: 'exact', head: true })
108
119
  .eq('organization_id', this.auth.organization_id)
@@ -110,28 +121,44 @@ class LeaveEntitlementsResource {
110
121
  .eq('leave_type_id', leaveTypeId)
111
122
  .lte('period_start', end)
112
123
  .gte('period_end', start);
113
- if (excludeId) {
114
- overlapQuery = overlapQuery.neq('mms_id', excludeId);
115
- }
116
- const { count, error } = await overlapQuery;
117
124
  if (error)
118
125
  throw (0, errors_1.mapPostgrestError)(error);
119
126
  return (count ?? 0) > 0;
120
127
  }
121
128
  /**
122
- * Every entitlement of the organization whose period ends on `periodEnd`, balances included -
123
- * what the leave-year rollover carries forward. Walks the table in pages so a large tenant is
124
- * not cut short by the row cap.
129
+ * Every entitlement of the organization that ended before `today` and has no successor - no
130
+ * later period of the same user and type - balances included: what the rollover carries
131
+ * forward. An ended period keeps qualifying until its next one exists, so a missed run is
132
+ * caught up by the next. PostgREST cannot say "no later row exists", so this reads the ended
133
+ * rows and every row's start, and filters in code. Both reads walk the table in pages so a
134
+ * large tenant is not cut short by the row cap.
125
135
  */
126
- async endingOn(periodEnd) {
127
- const rows = await (0, transforms_1.allRows)((from, to) => this.supabase
136
+ async endedWithoutSuccessor(today) {
137
+ const ended = await (0, transforms_1.allRows)((from, to) => this.supabase
128
138
  .from('leave_entitlements')
129
139
  .select(LEAVE_ENTITLEMENT_SELECT)
130
140
  .eq('organization_id', this.auth.organization_id)
131
- .eq('period_end', periodEnd)
141
+ .lt('period_end', today)
142
+ .order('mms_id')
143
+ .range(from, to));
144
+ if (ended.length === 0)
145
+ return [];
146
+ const starts = await (0, transforms_1.allRows)((from, to) => this.supabase
147
+ .from('leave_entitlements')
148
+ .select('user_id, leave_type_id, period_start')
149
+ .eq('organization_id', this.auth.organization_id)
132
150
  .order('mms_id')
133
151
  .range(from, to));
134
- return this.withBalances(rows);
152
+ // The periods of one user and type never overlap, so a row has a successor exactly when the
153
+ // latest start of its user and type falls after its end.
154
+ const latestStart = new Map();
155
+ for (const row of starts) {
156
+ const key = `${row.user_id}:${row.leave_type_id}`;
157
+ const latest = latestStart.get(key);
158
+ if (latest === undefined || row.period_start > latest)
159
+ latestStart.set(key, row.period_start);
160
+ }
161
+ return this.withBalances(ended.filter((row) => (latestStart.get(`${row.user.mms_id}:${row.leave_type.mms_id}`) ?? '') <= row.period_end));
135
162
  }
136
163
  async create(input) {
137
164
  const row = await this.table.insert({
@@ -140,6 +167,11 @@ class LeaveEntitlementsResource {
140
167
  period_start: input.period_start,
141
168
  period_end: input.period_end,
142
169
  days_entitled: input.days_entitled,
170
+ days_carried: input.days_carried,
171
+ carried_expires_on: input.carried_expires_on,
172
+ grant_method: input.grant_method,
173
+ initial_until: input.initial_until,
174
+ initial_days_per_month: input.initial_days_per_month,
143
175
  max_carry_over_days: input.max_carry_over_days,
144
176
  notes: input.notes ?? null,
145
177
  organization_id: this.auth.organization_id,
@@ -151,8 +183,9 @@ class LeaveEntitlementsResource {
151
183
  async update(leaveEntitlementId, input) {
152
184
  await this.table.update(leaveEntitlementId, {
153
185
  days_entitled: input.days_entitled,
186
+ days_carried: input.days_carried,
187
+ carried_expires_on: input.carried_expires_on,
154
188
  max_carry_over_days: input.max_carry_over_days,
155
- period_end: input.period_end,
156
189
  notes: input.notes,
157
190
  last_updated_by: this.auth.mms_id,
158
191
  updated_at: new Date().toISOString(),
@@ -164,21 +197,23 @@ class LeaveEntitlementsResource {
164
197
  await this.table.remove(leaveEntitlementId);
165
198
  }
166
199
  /**
167
- * Adds the computed balance to each row from one query over the requests that could count
168
- * against any of them - the approved and pending ones of these users and types, starting in
169
- * the span the periods cover. Nothing is stored: a balance is always what the requests say.
170
- * Paged, since `endingOn` hands it a whole tenant at once.
200
+ * Adds the balance to each row (on `asOf`, today by default) from one query over the requests
201
+ * that could count against any of them - the approved and pending ones of these users and
202
+ * types, starting in the span the periods cover - and the adjustments embedded in the rows.
203
+ * Nothing is stored: a balance is always what the requests, the policy and the adjustments
204
+ * say. Paged, since `endedWithoutSuccessor` hands it a whole tenant at once.
171
205
  */
172
- async withBalances(rows) {
206
+ async withBalances(rows, options = {}) {
173
207
  if (rows.length === 0)
174
208
  return [];
209
+ const asOf = options.asOf ?? (0, working_days_1.todayInJohannesburg)();
175
210
  const userIds = [...new Set(rows.map((row) => row.user.mms_id))];
176
211
  const typeIds = [...new Set(rows.map((row) => row.leave_type.mms_id))];
177
212
  const earliest = rows.map((row) => row.period_start).sort()[0];
178
213
  const latest = rows.map((row) => row.period_end).sort().reverse()[0];
179
214
  const requests = await (0, transforms_1.allRows)((from, to) => this.supabase
180
215
  .from('leave_requests')
181
- .select('user_id, leave_type_id, start_date, days, status')
216
+ .select('mms_id, user_id, leave_type_id, start_date, days, status')
182
217
  .eq('organization_id', this.auth.organization_id)
183
218
  .in('user_id', userIds)
184
219
  .in('leave_type_id', typeIds)
@@ -187,25 +222,22 @@ class LeaveEntitlementsResource {
187
222
  .lte('start_date', latest)
188
223
  .order('mms_id')
189
224
  .range(from, to));
190
- return rows.map((row) => {
191
- const mine = requests.filter((request) => request.user_id === row.user.mms_id &&
192
- request.leave_type_id === row.leave_type.mms_id &&
193
- request.start_date >= row.period_start &&
194
- request.start_date <= row.period_end);
195
- const sum = (status) => mine
196
- .filter((request) => request.status === status)
197
- .reduce((total, request) => total + Number(request.days), 0);
198
- const daysEntitled = Number(row.days_entitled);
199
- const daysUsed = sum(enum_2.LeaveRequestStatus.APPROVED);
200
- const daysPending = sum(enum_2.LeaveRequestStatus.PENDING);
201
- return {
225
+ return rows.map(({ adjustments, ...row }) => {
226
+ const entitlement = {
202
227
  ...row,
203
- days_entitled: daysEntitled,
228
+ days_entitled: Number(row.days_entitled),
229
+ days_carried: Number(row.days_carried),
230
+ initial_days_per_month: numberOrNull(row.initial_days_per_month),
204
231
  max_carry_over_days: Number(row.max_carry_over_days),
205
- days_used: daysUsed,
206
- days_pending: daysPending,
207
- days_remaining: daysEntitled - daysUsed - daysPending,
232
+ leave_type: { ...row.leave_type, document_after_days: numberOrNull(row.leave_type.document_after_days) },
208
233
  };
234
+ const mine = requests.filter((request) => (!options.excludeRequestId || request.mms_id !== options.excludeRequestId) &&
235
+ request.user_id === row.user.mms_id &&
236
+ request.leave_type_id === row.leave_type.mms_id &&
237
+ request.start_date >= row.period_start &&
238
+ request.start_date <= row.period_end);
239
+ const daysAdjusted = (adjustments ?? []).reduce((total, adjustment) => total + Number(adjustment.days), 0);
240
+ return { ...entitlement, ...(0, leave_policy_1.balanceOf)(entitlement, mine, daysAdjusted, asOf) };
209
241
  });
210
242
  }
211
243
  }
@@ -0,0 +1,50 @@
1
+ import type { LeaveBalance, LeavePeriod, LeaveTypeEntity } from '@managemint-solutions/entities/leave';
2
+ import { LeaveGrantMethod, LeaveRequestStatus } from '@managemint-solutions/entities/leave/enum';
3
+ /** The entitlement columns a balance is worked out from - the type's policy as it was copied on. */
4
+ export type BalanceEntitlement = LeavePeriod & {
5
+ days_entitled: number;
6
+ days_carried: number;
7
+ carried_expires_on: string | null;
8
+ grant_method: LeaveGrantMethod;
9
+ initial_until: string | null;
10
+ initial_days_per_month: number | null;
11
+ };
12
+ /** The request columns that count against a balance. */
13
+ export type BalanceRequest = {
14
+ start_date: string;
15
+ days: number;
16
+ status: LeaveRequestStatus | string;
17
+ };
18
+ /** A cycle of a type, and the part of it a person's entitlement covers (from their start date, if later). */
19
+ export type LeaveCycle = {
20
+ cycle: LeavePeriod;
21
+ period: LeavePeriod;
22
+ };
23
+ /**
24
+ * The cycle of `type` containing `date` for someone who started on `employmentStart`. Every
25
+ * cycle is `cycle_years` leave years starting on the first of the organization's leave-year
26
+ * month (`startMonth`, 1-12); the first one starts with the leave year the start date falls
27
+ * in. `period` starts no earlier than the start date, so a joiner's first period is the rest
28
+ * of the cycle. A March start puts 2026-02-10 in the leave year that began 2025-03-01.
29
+ */
30
+ export declare const cyclePeriodFor: (type: Pick<LeaveTypeEntity, "cycle_years">, employmentStart: string, date: string, startMonth: number) => LeaveCycle;
31
+ /** A full cycle's days scaled to the part of it `period` covers, to the half-day precision of the column. */
32
+ export declare const proRataDays: (defaultDays: number, { cycle, period }: LeaveCycle) => number;
33
+ /**
34
+ * The last day of the first-months rate for an entitlement over `period`: `initialMonths`
35
+ * from the start date, or null when that is over before the period begins.
36
+ */
37
+ export declare const initialUntil: (employmentStart: string, initialMonths: number, period: LeavePeriod) => string | null;
38
+ /**
39
+ * The part of `days_entitled` earned by the end of `asOf`. Upfront: everything from the first
40
+ * day. Monthly: an equal share per completed month (a short last month earns its fraction on
41
+ * the period's final day). Either way, inside the first-months window the total is capped at
42
+ * `initial_days_per_month` per completed month.
43
+ */
44
+ export declare const accruedDays: (entitlement: BalanceEntitlement, asOf: string) => number;
45
+ /**
46
+ * The balance of an entitlement on `asOf`, from the live requests starting inside its period
47
+ * and the sum of its adjustments. Carried days are spent first: whatever of them the requests
48
+ * starting on or before `carried_expires_on` did not use is forfeited once that day has passed.
49
+ */
50
+ export declare const balanceOf: (entitlement: BalanceEntitlement, requests: BalanceRequest[], daysAdjusted: number, asOf: string) => LeaveBalance;
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.balanceOf = exports.accruedDays = exports.initialUntil = exports.proRataDays = exports.cyclePeriodFor = void 0;
4
+ const enum_1 = require("@managemint-solutions/entities/leave/enum");
5
+ const working_days_1 = require("./working-days");
6
+ const round = (value, places) => Number(value.toFixed(places));
7
+ const daysIn = (period) => (0, working_days_1.eachDate)(period.period_start, period.period_end).length;
8
+ /**
9
+ * The cycle of `type` containing `date` for someone who started on `employmentStart`. Every
10
+ * cycle is `cycle_years` leave years starting on the first of the organization's leave-year
11
+ * month (`startMonth`, 1-12); the first one starts with the leave year the start date falls
12
+ * in. `period` starts no earlier than the start date, so a joiner's first period is the rest
13
+ * of the cycle. A March start puts 2026-02-10 in the leave year that began 2025-03-01.
14
+ */
15
+ const cyclePeriodFor = (type, employmentStart, date, startMonth) => {
16
+ // The calendar year each leave year starts in: a date before the leave-year month belongs
17
+ // to the leave year that began the calendar year before.
18
+ const firstYear = Number(employmentStart.slice(0, 4)) - (Number(employmentStart.slice(5, 7)) < startMonth ? 1 : 0);
19
+ const dateYear = Number(date.slice(0, 4)) - (Number(date.slice(5, 7)) < startMonth ? 1 : 0);
20
+ const index = Math.floor((dateYear - firstYear) / type.cycle_years);
21
+ const cycleStart = `${firstYear + index * type.cycle_years}-${String(startMonth).padStart(2, '0')}-01`;
22
+ const cycle = {
23
+ period_start: cycleStart,
24
+ period_end: (0, working_days_1.shiftDays)((0, working_days_1.addMonths)(cycleStart, type.cycle_years * 12), -1),
25
+ };
26
+ return {
27
+ cycle,
28
+ period: {
29
+ period_start: cycle.period_start < employmentStart ? employmentStart : cycle.period_start,
30
+ period_end: cycle.period_end,
31
+ },
32
+ };
33
+ };
34
+ exports.cyclePeriodFor = cyclePeriodFor;
35
+ /** A full cycle's days scaled to the part of it `period` covers, to the half-day precision of the column. */
36
+ const proRataDays = (defaultDays, { cycle, period }) => round((defaultDays * daysIn(period)) / daysIn(cycle), 1);
37
+ exports.proRataDays = proRataDays;
38
+ /**
39
+ * The last day of the first-months rate for an entitlement over `period`: `initialMonths`
40
+ * from the start date, or null when that is over before the period begins.
41
+ */
42
+ const initialUntil = (employmentStart, initialMonths, period) => {
43
+ if (initialMonths <= 0)
44
+ return null;
45
+ const until = (0, working_days_1.shiftDays)((0, working_days_1.addMonths)(employmentStart, initialMonths), -1);
46
+ return until >= period.period_start ? until : null;
47
+ };
48
+ exports.initialUntil = initialUntil;
49
+ /**
50
+ * The part of `days_entitled` earned by the end of `asOf`. Upfront: everything from the first
51
+ * day. Monthly: an equal share per completed month (a short last month earns its fraction on
52
+ * the period's final day). Either way, inside the first-months window the total is capped at
53
+ * `initial_days_per_month` per completed month.
54
+ */
55
+ const accruedDays = (entitlement, asOf) => {
56
+ const { period_start, period_end, days_entitled } = entitlement;
57
+ if (asOf < period_start)
58
+ return 0;
59
+ let earned = days_entitled;
60
+ if (entitlement.grant_method === enum_1.LeaveGrantMethod.MONTHLY && asOf < period_end) {
61
+ const whole = (0, working_days_1.completedMonths)(period_start, period_end);
62
+ const lastStart = (0, working_days_1.addMonths)(period_start, whole);
63
+ const lastLength = daysIn({ period_start: lastStart, period_end: (0, working_days_1.shiftDays)((0, working_days_1.addMonths)(period_start, whole + 1), -1) });
64
+ const tail = lastStart <= period_end ? daysIn({ period_start: lastStart, period_end }) / lastLength : 0;
65
+ earned = (days_entitled * (0, working_days_1.completedMonths)(period_start, asOf)) / (whole + tail);
66
+ }
67
+ if (entitlement.initial_until && entitlement.initial_days_per_month !== null && asOf <= entitlement.initial_until) {
68
+ earned = Math.min(earned, entitlement.initial_days_per_month * (0, working_days_1.completedMonths)(period_start, asOf));
69
+ }
70
+ return round(Math.min(earned, days_entitled), 2);
71
+ };
72
+ exports.accruedDays = accruedDays;
73
+ /**
74
+ * The balance of an entitlement on `asOf`, from the live requests starting inside its period
75
+ * and the sum of its adjustments. Carried days are spent first: whatever of them the requests
76
+ * starting on or before `carried_expires_on` did not use is forfeited once that day has passed.
77
+ */
78
+ const balanceOf = (entitlement, requests, daysAdjusted, asOf) => {
79
+ const live = requests.filter((request) => request.status === enum_1.LeaveRequestStatus.APPROVED || request.status === enum_1.LeaveRequestStatus.PENDING);
80
+ const sum = (rows) => rows.reduce((total, request) => total + Number(request.days), 0);
81
+ const daysUsed = sum(live.filter((request) => request.status === enum_1.LeaveRequestStatus.APPROVED));
82
+ const daysPending = sum(live.filter((request) => request.status === enum_1.LeaveRequestStatus.PENDING));
83
+ const expiry = entitlement.carried_expires_on;
84
+ const daysForfeited = expiry && asOf > expiry
85
+ ? Math.max(entitlement.days_carried - sum(live.filter((request) => request.start_date <= expiry)), 0)
86
+ : 0;
87
+ const daysAccrued = (0, exports.accruedDays)(entitlement, asOf);
88
+ return {
89
+ as_of: asOf,
90
+ days_accrued: daysAccrued,
91
+ days_forfeited: round(daysForfeited, 2),
92
+ days_adjusted: round(daysAdjusted, 2),
93
+ days_used: round(daysUsed, 2),
94
+ days_pending: round(daysPending, 2),
95
+ days_remaining: round(daysAccrued + entitlement.days_carried - daysForfeited + daysAdjusted - daysUsed - daysPending, 2),
96
+ };
97
+ };
98
+ exports.balanceOf = balanceOf;
@@ -1,9 +1,9 @@
1
1
  import type { MmsList } from '@managemint-solutions/entities/common';
2
- import type { LeavePeriod, LeaveRequestEntity } from '@managemint-solutions/entities/leave';
2
+ import type { LeaveCalendarEntry, LeaveRequestEntity } from '@managemint-solutions/entities/leave';
3
3
  import { LeaveRequestStatus, LeaveReviewDecision } from '@managemint-solutions/entities/leave/enum';
4
4
  import { type AuditActor, type AuditResource } from '../audit';
5
5
  import type { TypedSupabaseClient } from '../client';
6
- import type { GetLeaveRequestsDto } from './dto';
6
+ import type { GetLeaveCalendarDto, GetLeaveRequestsDto } from './dto';
7
7
  /** A request as the api inserts it: the caller's own, with the days already counted. */
8
8
  export type CreateLeaveRequestRow = {
9
9
  user_id: string;
@@ -47,12 +47,8 @@ export declare class LeaveRequestsResource {
47
47
  * half of the rule; the table's exclusion constraint is the half that survives a race.
48
48
  */
49
49
  overlaps(userId: string, start: string, end: string, excludeId?: string): Promise<boolean>;
50
- /**
51
- * The days of `userId`'s requests of `leaveTypeId` in `statuses` that start inside `period` -
52
- * what an entitlement's balance is checked against. `excludeId` leaves out the request being
53
- * edited or approved so it is not counted against itself.
54
- */
55
- sumDays(userId: string, leaveTypeId: string, period: LeavePeriod, statuses: LeaveRequestStatus[], excludeId?: string): Promise<number>;
50
+ /** Live requests touching `date_from`..`date_to`, for everyone in the organization - the leave calendar. */
51
+ calendar(query: GetLeaveCalendarDto): Promise<LeaveCalendarEntry[]>;
56
52
  create(input: CreateLeaveRequestRow): Promise<LeaveRequestEntity>;
57
53
  /** Edits a request while it is still pending; null when it no longer is. */
58
54
  update(leaveRequestId: string, input: UpdateLeaveRequestRow): Promise<LeaveRequestEntity | null>;
@@ -62,7 +58,10 @@ export declare class LeaveRequestsResource {
62
58
  * can say so instead of overwriting their decision.
63
59
  */
64
60
  review(leaveRequestId: string, input: ReviewLeaveRequestRow): Promise<LeaveRequestEntity | null>;
65
- /** Cancels a request while it is still pending; null when it no longer is - a decided request is final. */
66
- cancel(leaveRequestId: string, cancelledBy: string): Promise<LeaveRequestEntity | null>;
61
+ /**
62
+ * Cancels a request that is still in `expected` - the status the api read and decided on.
63
+ * Null when it has moved on since (reviewed, or cancelled by someone else).
64
+ */
65
+ cancel(leaveRequestId: string, cancelledBy: string, expected: LeaveRequestStatus): Promise<LeaveRequestEntity | null>;
67
66
  }
68
67
  export {};