@i4e/invest4edu-access-core 0.32.0 → 0.33.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/src/proration.js CHANGED
@@ -1,155 +1,155 @@
1
- /**
2
- * Proration — what the unused part of a running term is worth when someone upgrades.
3
- *
4
- * Without it, an upgrade STACKS: the new term is appended to the current end date, so the days
5
- * already paid for on the smaller plan sit behind the new one, unusable at the new tier. The
6
- * customer quietly loses that value and nothing on screen says so.
7
- *
8
- * The POLICY is configuration, not code — mode, basis, rounding, cap and scope all arrive from
9
- * the caller (platform default < plan < service), so changing how proration works is an edit
10
- * rather than a deploy. This module is only the arithmetic, kept pure so it can be tested
11
- * without a database, a clock or a subscription.
12
- *
13
- * ── The rules that are NOT configurable, because getting them wrong invents money ────────────
14
- * · credit can never exceed what was actually paid for the current term
15
- * · credit can never exceed the new plan's price (a purchase must not become negative)
16
- * · rounding is toward the CUSTOMER paying more, never less — we do not credit value that was
17
- * not paid for
18
- * · a term that has not started, or has already ended, is worth nothing
19
- */
20
-
21
- /** Every knob, with the conservative answer as the default. */
22
- export const PRORATION_DEFAULTS = {
23
- /**
24
- * none — today's behaviour: no credit, the new term stacks on the old end date
25
- * daily — unused days ÷ total days
26
- * monthly — whole unused months only; a part-month counts for nothing
27
- * points — no cash credit; the unused value is returned as reward points instead
28
- */
29
- mode: "none",
30
- /** Ceiling on the credit as a percentage of the new plan's price. 100 = up to the full price. */
31
- cap_percent: 100,
32
- /** Apply when the PLAN changes, and/or when only the billing cycle changes. */
33
- on_plan_change: true,
34
- on_cycle_change: false,
35
- };
36
-
37
- const MS_PER_DAY = 24 * 60 * 60 * 1000;
38
-
39
- /** Whole days between two instants, never negative. */
40
- function daysBetween(from, to) {
41
- const ms = new Date(to).getTime() - new Date(from).getTime();
42
- return ms <= 0 ? 0 : Math.floor(ms / MS_PER_DAY);
43
- }
44
-
45
- /** Whole months remaining — a part-month is worth nothing under `monthly`. */
46
- function wholeMonthsBetween(from, to) {
47
- const a = new Date(from);
48
- const b = new Date(to);
49
- if (b <= a) return 0;
50
- let months = (b.getFullYear() - a.getFullYear()) * 12 + (b.getMonth() - a.getMonth());
51
- if (b.getDate() < a.getDate()) months -= 1;
52
- return months > 0 ? months : 0;
53
- }
54
-
55
- /**
56
- * Merge policy layers. Later wins, and only for keys it actually specifies — a plan that sets
57
- * only `mode` must not silently reset the cap someone configured globally.
58
- */
59
- export function resolveProrationPolicy(...layers) {
60
- const out = { ...PRORATION_DEFAULTS };
61
- for (const layer of layers) {
62
- if (!layer || typeof layer !== "object") continue;
63
- for (const k of Object.keys(PRORATION_DEFAULTS)) {
64
- if (layer[k] !== undefined && layer[k] !== null) out[k] = layer[k];
65
- }
66
- }
67
- return out;
68
- }
69
-
70
- /**
71
- * What the unused remainder of the current term is worth, in PAISE.
72
- *
73
- * @param {object} a
74
- * @param {number} a.paidAmount what was paid for the CURRENT term (paise, ex-tax)
75
- * @param {Date} a.periodStart when the current term began
76
- * @param {Date} a.periodEnd when it ends (null/absent ⇒ open-ended ⇒ nothing to prorate)
77
- * @param {Date} a.now the moment of the change
78
- * @param {number} a.newPlanPrice price of what they are moving to (paise, ex-tax) — the cap
79
- * @param {boolean} a.isCycleChange true when the plan is unchanged and only the cycle differs
80
- * @param {object} a.policy resolved policy (see resolveProrationPolicy)
81
- * @returns {{credit:number, as_points:boolean, reason:string, days_remaining:number,
82
- * days_total:number, mode:string}}
83
- */
84
- export function prorate({
85
- paidAmount = 0,
86
- periodStart,
87
- periodEnd,
88
- now = new Date(),
89
- newPlanPrice = 0,
90
- isCycleChange = false,
91
- policy = PRORATION_DEFAULTS,
92
- } = {}) {
93
- const p = resolveProrationPolicy(policy);
94
- const nil = (reason) => ({
95
- credit: 0, as_points: false, reason, mode: p.mode, days_remaining: 0, days_total: 0,
96
- });
97
-
98
- if (p.mode === "none") return nil("proration is off");
99
- if (isCycleChange && !p.on_cycle_change) return nil("cycle changes are not prorated");
100
- if (!isCycleChange && !p.on_plan_change) return nil("plan changes are not prorated");
101
-
102
- // An open-ended term (the provisioned default) has no remainder to value: nothing was paid for
103
- // a period that does not end.
104
- if (!periodEnd) return nil("term has no end date");
105
- if (!(paidAmount > 0)) return nil("nothing was paid for the current term");
106
-
107
- const daysTotal = daysBetween(periodStart, periodEnd);
108
- const daysRemaining = daysBetween(now, periodEnd);
109
- if (daysTotal <= 0) return nil("term length is zero");
110
- if (daysRemaining <= 0) return nil("term has already ended");
111
-
112
- let credit;
113
- if (p.mode === "monthly") {
114
- const monthsRemaining = wholeMonthsBetween(now, periodEnd);
115
- const monthsTotal = wholeMonthsBetween(periodStart, periodEnd);
116
- if (monthsTotal <= 0 || monthsRemaining <= 0) {
117
- return { ...nil("less than a whole month remains"), days_remaining: daysRemaining, days_total: daysTotal };
118
- }
119
- credit = Math.floor((paidAmount * monthsRemaining) / monthsTotal);
120
- } else {
121
- // daily, and the basis for `points` too — the difference is only in how it is returned.
122
- credit = Math.floor((paidAmount * daysRemaining) / daysTotal);
123
- }
124
-
125
- // Never more than was paid. Floating-point and clock skew both make this reachable.
126
- credit = Math.min(credit, paidAmount);
127
-
128
- /**
129
- * The cap. Without it, a long unused term can exceed the new plan's price and the purchase
130
- * goes to zero or negative — the customer upgrades for free, or we owe them money for taking
131
- * a bigger plan. Applies to the cash credit only; points are a separate liability that does
132
- * not have to fit inside this purchase.
133
- */
134
- if (p.mode !== "points") {
135
- const ceiling = Math.floor((newPlanPrice * Math.max(0, Math.min(100, p.cap_percent))) / 100);
136
- if (credit > ceiling) {
137
- credit = ceiling;
138
- }
139
- }
140
-
141
- if (credit <= 0) return { ...nil("nothing to credit"), days_remaining: daysRemaining, days_total: daysTotal };
142
-
143
- return {
144
- credit,
145
- as_points: p.mode === "points",
146
- reason: p.mode === "points"
147
- ? `${daysRemaining} unused day(s) returned as points`
148
- : `${daysRemaining} of ${daysTotal} day(s) unused`,
149
- mode: p.mode,
150
- days_remaining: daysRemaining,
151
- days_total: daysTotal,
152
- };
153
- }
154
-
155
- export default { prorate, resolveProrationPolicy, PRORATION_DEFAULTS };
1
+ /**
2
+ * Proration — what the unused part of a running term is worth when someone upgrades.
3
+ *
4
+ * Without it, an upgrade STACKS: the new term is appended to the current end date, so the days
5
+ * already paid for on the smaller plan sit behind the new one, unusable at the new tier. The
6
+ * customer quietly loses that value and nothing on screen says so.
7
+ *
8
+ * The POLICY is configuration, not code — mode, basis, rounding, cap and scope all arrive from
9
+ * the caller (platform default < plan < service), so changing how proration works is an edit
10
+ * rather than a deploy. This module is only the arithmetic, kept pure so it can be tested
11
+ * without a database, a clock or a subscription.
12
+ *
13
+ * ── The rules that are NOT configurable, because getting them wrong invents money ────────────
14
+ * · credit can never exceed what was actually paid for the current term
15
+ * · credit can never exceed the new plan's price (a purchase must not become negative)
16
+ * · rounding is toward the CUSTOMER paying more, never less — we do not credit value that was
17
+ * not paid for
18
+ * · a term that has not started, or has already ended, is worth nothing
19
+ */
20
+
21
+ /** Every knob, with the conservative answer as the default. */
22
+ export const PRORATION_DEFAULTS = {
23
+ /**
24
+ * none — today's behaviour: no credit, the new term stacks on the old end date
25
+ * daily — unused days ÷ total days
26
+ * monthly — whole unused months only; a part-month counts for nothing
27
+ * points — no cash credit; the unused value is returned as reward points instead
28
+ */
29
+ mode: "none",
30
+ /** Ceiling on the credit as a percentage of the new plan's price. 100 = up to the full price. */
31
+ cap_percent: 100,
32
+ /** Apply when the PLAN changes, and/or when only the billing cycle changes. */
33
+ on_plan_change: true,
34
+ on_cycle_change: false,
35
+ };
36
+
37
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
38
+
39
+ /** Whole days between two instants, never negative. */
40
+ function daysBetween(from, to) {
41
+ const ms = new Date(to).getTime() - new Date(from).getTime();
42
+ return ms <= 0 ? 0 : Math.floor(ms / MS_PER_DAY);
43
+ }
44
+
45
+ /** Whole months remaining — a part-month is worth nothing under `monthly`. */
46
+ function wholeMonthsBetween(from, to) {
47
+ const a = new Date(from);
48
+ const b = new Date(to);
49
+ if (b <= a) return 0;
50
+ let months = (b.getFullYear() - a.getFullYear()) * 12 + (b.getMonth() - a.getMonth());
51
+ if (b.getDate() < a.getDate()) months -= 1;
52
+ return months > 0 ? months : 0;
53
+ }
54
+
55
+ /**
56
+ * Merge policy layers. Later wins, and only for keys it actually specifies — a plan that sets
57
+ * only `mode` must not silently reset the cap someone configured globally.
58
+ */
59
+ export function resolveProrationPolicy(...layers) {
60
+ const out = { ...PRORATION_DEFAULTS };
61
+ for (const layer of layers) {
62
+ if (!layer || typeof layer !== "object") continue;
63
+ for (const k of Object.keys(PRORATION_DEFAULTS)) {
64
+ if (layer[k] !== undefined && layer[k] !== null) out[k] = layer[k];
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * What the unused remainder of the current term is worth, in PAISE.
72
+ *
73
+ * @param {object} a
74
+ * @param {number} a.paidAmount what was paid for the CURRENT term (paise, ex-tax)
75
+ * @param {Date} a.periodStart when the current term began
76
+ * @param {Date} a.periodEnd when it ends (null/absent ⇒ open-ended ⇒ nothing to prorate)
77
+ * @param {Date} a.now the moment of the change
78
+ * @param {number} a.newPlanPrice price of what they are moving to (paise, ex-tax) — the cap
79
+ * @param {boolean} a.isCycleChange true when the plan is unchanged and only the cycle differs
80
+ * @param {object} a.policy resolved policy (see resolveProrationPolicy)
81
+ * @returns {{credit:number, as_points:boolean, reason:string, days_remaining:number,
82
+ * days_total:number, mode:string}}
83
+ */
84
+ export function prorate({
85
+ paidAmount = 0,
86
+ periodStart,
87
+ periodEnd,
88
+ now = new Date(),
89
+ newPlanPrice = 0,
90
+ isCycleChange = false,
91
+ policy = PRORATION_DEFAULTS,
92
+ } = {}) {
93
+ const p = resolveProrationPolicy(policy);
94
+ const nil = (reason) => ({
95
+ credit: 0, as_points: false, reason, mode: p.mode, days_remaining: 0, days_total: 0,
96
+ });
97
+
98
+ if (p.mode === "none") return nil("proration is off");
99
+ if (isCycleChange && !p.on_cycle_change) return nil("cycle changes are not prorated");
100
+ if (!isCycleChange && !p.on_plan_change) return nil("plan changes are not prorated");
101
+
102
+ // An open-ended term (the provisioned default) has no remainder to value: nothing was paid for
103
+ // a period that does not end.
104
+ if (!periodEnd) return nil("term has no end date");
105
+ if (!(paidAmount > 0)) return nil("nothing was paid for the current term");
106
+
107
+ const daysTotal = daysBetween(periodStart, periodEnd);
108
+ const daysRemaining = daysBetween(now, periodEnd);
109
+ if (daysTotal <= 0) return nil("term length is zero");
110
+ if (daysRemaining <= 0) return nil("term has already ended");
111
+
112
+ let credit;
113
+ if (p.mode === "monthly") {
114
+ const monthsRemaining = wholeMonthsBetween(now, periodEnd);
115
+ const monthsTotal = wholeMonthsBetween(periodStart, periodEnd);
116
+ if (monthsTotal <= 0 || monthsRemaining <= 0) {
117
+ return { ...nil("less than a whole month remains"), days_remaining: daysRemaining, days_total: daysTotal };
118
+ }
119
+ credit = Math.floor((paidAmount * monthsRemaining) / monthsTotal);
120
+ } else {
121
+ // daily, and the basis for `points` too — the difference is only in how it is returned.
122
+ credit = Math.floor((paidAmount * daysRemaining) / daysTotal);
123
+ }
124
+
125
+ // Never more than was paid. Floating-point and clock skew both make this reachable.
126
+ credit = Math.min(credit, paidAmount);
127
+
128
+ /**
129
+ * The cap. Without it, a long unused term can exceed the new plan's price and the purchase
130
+ * goes to zero or negative — the customer upgrades for free, or we owe them money for taking
131
+ * a bigger plan. Applies to the cash credit only; points are a separate liability that does
132
+ * not have to fit inside this purchase.
133
+ */
134
+ if (p.mode !== "points") {
135
+ const ceiling = Math.floor((newPlanPrice * Math.max(0, Math.min(100, p.cap_percent))) / 100);
136
+ if (credit > ceiling) {
137
+ credit = ceiling;
138
+ }
139
+ }
140
+
141
+ if (credit <= 0) return { ...nil("nothing to credit"), days_remaining: daysRemaining, days_total: daysTotal };
142
+
143
+ return {
144
+ credit,
145
+ as_points: p.mode === "points",
146
+ reason: p.mode === "points"
147
+ ? `${daysRemaining} unused day(s) returned as points`
148
+ : `${daysRemaining} of ${daysTotal} day(s) unused`,
149
+ mode: p.mode,
150
+ days_remaining: daysRemaining,
151
+ days_total: daysTotal,
152
+ };
153
+ }
154
+
155
+ export default { prorate, resolveProrationPolicy, PRORATION_DEFAULTS };
@@ -1,129 +1,129 @@
1
- /**
2
- * Reportee-tree DFS — @i4e/invest4edu-access-core (Track 3 G3 shared primitive).
3
- *
4
- * The one canonical "self + all reportees" traversal, shared by both backends so v1 and v2
5
- * compose the SAME hierarchy scope. Pure + data-source agnostic: the caller passes a flat
6
- * employees list `[{ _id, user_id, reporting_manager }]` (v1 feeds the Redis snapshot, v2 the
7
- * `employees` collection) and one-or-more root user ids (delegation passes `[self, ...givers]`).
8
- *
9
- * ── `reporting_manager` is an employee `_id` (BRI-973) ────────────────────────────────
10
- * It used to hold the manager's **user id**. It now holds the manager's **employee `_id`**,
11
- * so the hierarchy no longer depends on `employees.user_id` and that field can be dropped.
12
- *
13
- * Through the rollout this accepted both shapes; that widening is gone. Only an `_id` that is
14
- * present in the list resolves — an unrecognised reference detaches that manager's downline
15
- * rather than being read as a user id, which is the correct reading now that no row should
16
- * hold one.
17
- *
18
- * ── DEPLOY ORDER: migration first, then this version ──────────────────────────────────
19
- * This is a BREAKING change to the data this function can read, on a 0.x minor. Against
20
- * un-migrated rows it returns ZERO reportees — and since it feeds record visibility and the
21
- * lead access scope, that is a silent narrowing of what people can see rather than an error
22
- * anyone would notice.
23
- *
24
- * So: run nfd-api-node's migrations/verify-reporting-manager-to-employee-id.js and get a
25
- * clean verdict FIRST, then ship this version. The window that matters is a rolling deploy,
26
- * where an upgraded instance and un-migrated rows can coexist for real minutes — the
27
- * migration has to be complete before the first instance carrying this code serves traffic,
28
- * not merely started.
29
- *
30
- * Roots and the returned set stay **user ids** throughout, because that is what the callers
31
- * compare against (`assigned_rm_id`, `rm_id`). Only the manager reference is translated.
32
- *
33
- * ── CALLER CONTRACT: pass the manager's own row ───────────────────────────────────────
34
- * An employee-id reference can only be resolved if the row carrying that `_id` is in the
35
- * list. A user-id reference never needed a lookup, so under the old shape an absent manager
36
- * row cost nothing — under the new shape it silently detaches that manager's entire
37
- * downline.
38
- *
39
- * So a caller must feed EVERY employee for linking, and filter unwanted people out of the
40
- * RESULT. Feeding a pre-filtered list (`{ status: 1 }`, an account scope, anything) drops
41
- * link rows, and what you lose is not the filtered person but everyone beneath them.
42
- *
43
- * The caller this bites is nfd-api-node's `src/cache/employeeCache.js`, which builds the
44
- * hierarchy snapshot. It filtered `{ status: 1 }` and argued the filter was safe because it
45
- * "already breaks the chain at a deactivated manager" — true under the old shape, where the
46
- * manager's user id was the map key whether or not their row was present, and false under
47
- * this one. It now selects every employee and filters the RESULT instead. Any new caller
48
- * has to do the same.
49
- *
50
- * @param {string|string[]} roots root user id(s)
51
- * @param {Array<{_id?:*, user_id:*, reporting_manager:*}>} employees flat employee list
52
- * @param {Object} [options]
53
- * @param {Function} [options.onUnresolvable] called once when the list carries no `_id` at
54
- * all — see below. Defaults to `console.warn`; pass the app logger to route it properly.
55
- * @returns {string[]} de-duplicated reachable set (roots + all reportees) as STRING ids
56
- */
57
- export function resolveReporteeUserIds(roots, employees, options = {}) {
58
- const rootIds = (Array.isArray(roots) ? roots : [roots]).filter((id) => id != null).map(String);
59
-
60
- /**
61
- * employee `_id` -> that employee's user id.
62
- *
63
- * Built first and over the whole list, because a manager may appear anywhere in it — a
64
- * single pass that resolved as it went would miss any manager listed after their reportee.
65
- *
66
- * Employees with no `user_id` are still indexed: such a row cannot be a REPORTEE (nothing
67
- * to return for them) but it can perfectly well be a MANAGER, and dropping it here would
68
- * silently orphan their whole downline. `null` marks "known employee, no portal user".
69
- */
70
- const userIdByEmployeeId = new Map();
71
- for (const emp of employees || []) {
72
- if (!emp || emp._id == null) continue;
73
- userIdByEmployeeId.set(String(emp._id), emp.user_id == null ? null : String(emp.user_id));
74
- }
75
-
76
- /**
77
- * A list with no `_id` on any row is the pre-BRI-973 shape, and nothing in it can ever
78
- * resolve — every caller gets `[roots]` back, which reads as "this manager has no
79
- * reportees" rather than as "this list cannot be understood".
80
- *
81
- * That is the one failure this cannot leave silent. It is not a degraded answer, it is a
82
- * confident wrong one, and it is cheap to tell apart: an employee list of any size with
83
- * an empty index means the caller passed the old shape or dropped `_id` from a projection.
84
- * A warn here costs nothing on a correct call and turns the worst failure mode into an
85
- * obvious one. It does not throw — this sits on the login path, and refusing to resolve a
86
- * scope is a harder failure than reporting one.
87
- */
88
- if ((employees?.length || 0) > 0 && userIdByEmployeeId.size === 0) {
89
- const warn = options.onUnresolvable || ((message) => console.warn(message));
90
- warn(
91
- `[reportee-tree] ${employees.length} employees but not one carries an \`_id\`, so no `
92
- + 'reporting_manager reference can resolve and every caller sees zero reportees. The list '
93
- + 'is in the pre-BRI-973 shape, or `_id` was dropped from the projection.',
94
- );
95
- }
96
-
97
- const byManager = new Map();
98
- for (const emp of employees || []) {
99
- if (!emp || !emp.user_id) continue;
100
- if (emp.reporting_manager == null) continue;
101
-
102
- const raw = String(emp.reporting_manager);
103
- /**
104
- * The reference is an employee `_id`; it is translated to that manager's user id.
105
- *
106
- * `get` rather than the old "fall back to reading it as a user id": an unknown `_id` now
107
- * means either a dangling reference or a row the caller filtered out of the list, and
108
- * both are conditions to drop the edge on, not to guess about. See the caller contract.
109
- */
110
- const managerId = userIdByEmployeeId.get(raw);
111
- // Unknown reference, or a manager with no portal user — no key to hang reportees on.
112
- if (managerId == null) continue;
113
-
114
- if (!byManager.has(managerId)) byManager.set(managerId, []);
115
- byManager.get(managerId).push(String(emp.user_id));
116
- }
117
-
118
- const visited = new Set();
119
- const result = new Set();
120
- const stack = [...rootIds];
121
- while (stack.length) {
122
- const id = stack.pop();
123
- if (!id || visited.has(id)) continue;
124
- visited.add(id);
125
- result.add(id);
126
- for (const rep of byManager.get(id) || []) stack.push(rep);
127
- }
128
- return [...result];
129
- }
1
+ /**
2
+ * Reportee-tree DFS — @i4e/invest4edu-access-core (Track 3 G3 shared primitive).
3
+ *
4
+ * The one canonical "self + all reportees" traversal, shared by both backends so v1 and v2
5
+ * compose the SAME hierarchy scope. Pure + data-source agnostic: the caller passes a flat
6
+ * employees list `[{ _id, user_id, reporting_manager }]` (v1 feeds the Redis snapshot, v2 the
7
+ * `employees` collection) and one-or-more root user ids (delegation passes `[self, ...givers]`).
8
+ *
9
+ * ── `reporting_manager` is an employee `_id` (BRI-973) ────────────────────────────────
10
+ * It used to hold the manager's **user id**. It now holds the manager's **employee `_id`**,
11
+ * so the hierarchy no longer depends on `employees.user_id` and that field can be dropped.
12
+ *
13
+ * Through the rollout this accepted both shapes; that widening is gone. Only an `_id` that is
14
+ * present in the list resolves — an unrecognised reference detaches that manager's downline
15
+ * rather than being read as a user id, which is the correct reading now that no row should
16
+ * hold one.
17
+ *
18
+ * ── DEPLOY ORDER: migration first, then this version ──────────────────────────────────
19
+ * This is a BREAKING change to the data this function can read, on a 0.x minor. Against
20
+ * un-migrated rows it returns ZERO reportees — and since it feeds record visibility and the
21
+ * lead access scope, that is a silent narrowing of what people can see rather than an error
22
+ * anyone would notice.
23
+ *
24
+ * So: run nfd-api-node's migrations/verify-reporting-manager-to-employee-id.js and get a
25
+ * clean verdict FIRST, then ship this version. The window that matters is a rolling deploy,
26
+ * where an upgraded instance and un-migrated rows can coexist for real minutes — the
27
+ * migration has to be complete before the first instance carrying this code serves traffic,
28
+ * not merely started.
29
+ *
30
+ * Roots and the returned set stay **user ids** throughout, because that is what the callers
31
+ * compare against (`assigned_rm_id`, `rm_id`). Only the manager reference is translated.
32
+ *
33
+ * ── CALLER CONTRACT: pass the manager's own row ───────────────────────────────────────
34
+ * An employee-id reference can only be resolved if the row carrying that `_id` is in the
35
+ * list. A user-id reference never needed a lookup, so under the old shape an absent manager
36
+ * row cost nothing — under the new shape it silently detaches that manager's entire
37
+ * downline.
38
+ *
39
+ * So a caller must feed EVERY employee for linking, and filter unwanted people out of the
40
+ * RESULT. Feeding a pre-filtered list (`{ status: 1 }`, an account scope, anything) drops
41
+ * link rows, and what you lose is not the filtered person but everyone beneath them.
42
+ *
43
+ * The caller this bites is nfd-api-node's `src/cache/employeeCache.js`, which builds the
44
+ * hierarchy snapshot. It filtered `{ status: 1 }` and argued the filter was safe because it
45
+ * "already breaks the chain at a deactivated manager" — true under the old shape, where the
46
+ * manager's user id was the map key whether or not their row was present, and false under
47
+ * this one. It now selects every employee and filters the RESULT instead. Any new caller
48
+ * has to do the same.
49
+ *
50
+ * @param {string|string[]} roots root user id(s)
51
+ * @param {Array<{_id?:*, user_id:*, reporting_manager:*}>} employees flat employee list
52
+ * @param {Object} [options]
53
+ * @param {Function} [options.onUnresolvable] called once when the list carries no `_id` at
54
+ * all — see below. Defaults to `console.warn`; pass the app logger to route it properly.
55
+ * @returns {string[]} de-duplicated reachable set (roots + all reportees) as STRING ids
56
+ */
57
+ export function resolveReporteeUserIds(roots, employees, options = {}) {
58
+ const rootIds = (Array.isArray(roots) ? roots : [roots]).filter((id) => id != null).map(String);
59
+
60
+ /**
61
+ * employee `_id` -> that employee's user id.
62
+ *
63
+ * Built first and over the whole list, because a manager may appear anywhere in it — a
64
+ * single pass that resolved as it went would miss any manager listed after their reportee.
65
+ *
66
+ * Employees with no `user_id` are still indexed: such a row cannot be a REPORTEE (nothing
67
+ * to return for them) but it can perfectly well be a MANAGER, and dropping it here would
68
+ * silently orphan their whole downline. `null` marks "known employee, no portal user".
69
+ */
70
+ const userIdByEmployeeId = new Map();
71
+ for (const emp of employees || []) {
72
+ if (!emp || emp._id == null) continue;
73
+ userIdByEmployeeId.set(String(emp._id), emp.user_id == null ? null : String(emp.user_id));
74
+ }
75
+
76
+ /**
77
+ * A list with no `_id` on any row is the pre-BRI-973 shape, and nothing in it can ever
78
+ * resolve — every caller gets `[roots]` back, which reads as "this manager has no
79
+ * reportees" rather than as "this list cannot be understood".
80
+ *
81
+ * That is the one failure this cannot leave silent. It is not a degraded answer, it is a
82
+ * confident wrong one, and it is cheap to tell apart: an employee list of any size with
83
+ * an empty index means the caller passed the old shape or dropped `_id` from a projection.
84
+ * A warn here costs nothing on a correct call and turns the worst failure mode into an
85
+ * obvious one. It does not throw — this sits on the login path, and refusing to resolve a
86
+ * scope is a harder failure than reporting one.
87
+ */
88
+ if ((employees?.length || 0) > 0 && userIdByEmployeeId.size === 0) {
89
+ const warn = options.onUnresolvable || ((message) => console.warn(message));
90
+ warn(
91
+ `[reportee-tree] ${employees.length} employees but not one carries an \`_id\`, so no `
92
+ + 'reporting_manager reference can resolve and every caller sees zero reportees. The list '
93
+ + 'is in the pre-BRI-973 shape, or `_id` was dropped from the projection.',
94
+ );
95
+ }
96
+
97
+ const byManager = new Map();
98
+ for (const emp of employees || []) {
99
+ if (!emp || !emp.user_id) continue;
100
+ if (emp.reporting_manager == null) continue;
101
+
102
+ const raw = String(emp.reporting_manager);
103
+ /**
104
+ * The reference is an employee `_id`; it is translated to that manager's user id.
105
+ *
106
+ * `get` rather than the old "fall back to reading it as a user id": an unknown `_id` now
107
+ * means either a dangling reference or a row the caller filtered out of the list, and
108
+ * both are conditions to drop the edge on, not to guess about. See the caller contract.
109
+ */
110
+ const managerId = userIdByEmployeeId.get(raw);
111
+ // Unknown reference, or a manager with no portal user — no key to hang reportees on.
112
+ if (managerId == null) continue;
113
+
114
+ if (!byManager.has(managerId)) byManager.set(managerId, []);
115
+ byManager.get(managerId).push(String(emp.user_id));
116
+ }
117
+
118
+ const visited = new Set();
119
+ const result = new Set();
120
+ const stack = [...rootIds];
121
+ while (stack.length) {
122
+ const id = stack.pop();
123
+ if (!id || visited.has(id)) continue;
124
+ visited.add(id);
125
+ result.add(id);
126
+ for (const rep of byManager.get(id) || []) stack.push(rep);
127
+ }
128
+ return [...result];
129
+ }