@i4e/invest4edu-access-core 0.31.0 → 0.32.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/index.js CHANGED
@@ -1,57 +1,74 @@
1
- /**
2
- * @i4e/invest4edu-access-core — shared access-control primitives for the NFD backends.
3
- * Tenant isolation (D1 keystone) + role capabilities (G7).
4
- */
5
- export { default as als, runWithTenant, runAsSystem, getTenantStore } from "./tenant-context.js";
6
- export {
7
- default as tenantPlugin,
8
- setTenantMode,
9
- getTenantMode,
10
- setTenantWriteMode,
11
- getTenantWriteMode,
12
- TENANT_MODES,
13
- } from "./tenant-plugin.js";
14
- export {
15
- CAPABILITY_FLAGS,
16
- DEFAULT_ROLE_CAPABILITIES,
17
- buildCapabilityMap,
18
- resolveCapabilities,
19
- } from "./role-capabilities.js";
20
- export { resolveReporteeUserIds } from "./reportee-tree.js";
21
- export {
22
- DEFAULT_ACCESS_FLAGS,
23
- ACCESS_FLAG_ENV_NAMES,
24
- flagEnvName,
25
- readEnvFlags,
26
- resolveFlags,
27
- } from "./access-config.js";
28
-
29
- // ── Unified access framework (P1) ─────────────────────────────────────────────────────────────
30
- export {
31
- FEATURE_TYPES,
32
- REGISTRY_STATUSES,
33
- ROLLOUT_MODES,
34
- GRANT_EFFECTS,
35
- GRANT_SUBJECTS,
36
- FEATURE_CODE_PATTERN,
37
- isValidFeatureCode,
38
- parentCodeOf,
39
- moduleCodeOf,
40
- ACCESS_MODULE_DEF,
41
- ACCESS_FEATURE_DEF,
42
- ACCESS_GRANT_DEF,
43
- } from "./access-schema.js";
44
- export {
45
- resolveAccessSnapshot,
46
- mergeGrants,
47
- buildNav,
48
- hasFeature,
49
- isUsable,
50
- ENTITLEMENT_REASONS,
51
- } from "./access-resolver.js";
52
- export {
53
- evaluateVisibleWhen,
54
- filterVisible,
55
- applyRelabel,
56
- resolveColumns,
57
- } from "./visible-when.js";
1
+ /**
2
+ * @i4e/invest4edu-access-core — shared access-control primitives for the NFD backends.
3
+ * Tenant isolation (D1 keystone) + role capabilities (G7).
4
+ */
5
+ export { default as als, runWithTenant, runAsSystem, getTenantStore } from "./tenant-context.js";
6
+ export {
7
+ default as tenantPlugin,
8
+ setTenantMode,
9
+ getTenantMode,
10
+ setTenantWriteMode,
11
+ getTenantWriteMode,
12
+ TENANT_MODES,
13
+ } from "./tenant-plugin.js";
14
+ export {
15
+ CAPABILITY_FLAGS,
16
+ DEFAULT_ROLE_CAPABILITIES,
17
+ buildCapabilityMap,
18
+ resolveCapabilities,
19
+ } from "./role-capabilities.js";
20
+ export { resolveReporteeUserIds } from "./reportee-tree.js";
21
+ export {
22
+ ENTITY,
23
+ ENTITY_STATUS,
24
+ isEntityLoginAllowed,
25
+ entityInactiveMessage,
26
+ checkPortalEntityAccess,
27
+ } from "./entity-status.js";
28
+ export {
29
+ CONFLICTING_ENTITIES,
30
+ conflictingEntityFor,
31
+ entityConflictMessage,
32
+ } from "./user-entity-link.js";
33
+ export {
34
+ CLIENT_APP_ROLE_NAMES,
35
+ ROLE_TYPE_PRIORITY,
36
+ pickPortalRoleName,
37
+ } from "./portal-roles.js";
38
+ export {
39
+ DEFAULT_ACCESS_FLAGS,
40
+ ACCESS_FLAG_ENV_NAMES,
41
+ flagEnvName,
42
+ readEnvFlags,
43
+ resolveFlags,
44
+ } from "./access-config.js";
45
+
46
+ // ── Unified access framework (P1) ─────────────────────────────────────────────────────────────
47
+ export {
48
+ FEATURE_TYPES,
49
+ REGISTRY_STATUSES,
50
+ ROLLOUT_MODES,
51
+ GRANT_EFFECTS,
52
+ GRANT_SUBJECTS,
53
+ FEATURE_CODE_PATTERN,
54
+ isValidFeatureCode,
55
+ parentCodeOf,
56
+ moduleCodeOf,
57
+ ACCESS_MODULE_DEF,
58
+ ACCESS_FEATURE_DEF,
59
+ ACCESS_GRANT_DEF,
60
+ } from "./access-schema.js";
61
+ export {
62
+ resolveAccessSnapshot,
63
+ mergeGrants,
64
+ buildNav,
65
+ hasFeature,
66
+ isUsable,
67
+ ENTITLEMENT_REASONS,
68
+ } from "./access-resolver.js";
69
+ export {
70
+ evaluateVisibleWhen,
71
+ filterVisible,
72
+ applyRelabel,
73
+ resolveColumns,
74
+ } from "./visible-when.js";
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Which roles belong to which surface, and which one names a portal session.
3
+ *
4
+ * One person is one `users` row that may hold a partner role and a client role at the
5
+ * same time. Permissions resolve as a union across roles, and the frontend collapses the
6
+ * array to a single `role_type` string — so both "which roles apply here" and "which one
7
+ * wins" have to be answered deliberately rather than by array order.
8
+ *
9
+ * Lives here because it was written three times: nfd-api-node, nfd-api-node-v2 and the
10
+ * frontend each held the same sixteen names in the same order. The frontend's copy was the
11
+ * one nothing pinned, and it is also the one where drift is quietest — a wrong `role_type`
12
+ * renders as missing screens, not as an error.
13
+ */
14
+
15
+ /** Roles that exist only for the client app. A portal session never carries these. */
16
+ export const CLIENT_APP_ROLE_NAMES = ['CLIENT_USER'];
17
+
18
+ /**
19
+ * Priority order for resolving the single `role_type` a portal session reports.
20
+ *
21
+ * Most-privileged first, so a person wearing several hats is described by the strongest.
22
+ * Partner roles sit ahead of CLIENT_ADMIN deliberately: on the portal, someone who is
23
+ * both a partner and a client-facing admin is there as the partner.
24
+ *
25
+ * Adding a role here is what makes it visible to the frontend's role predicates — a role
26
+ * missing from this list resolves to an empty `role_type`.
27
+ */
28
+ export const ROLE_TYPE_PRIORITY = [
29
+ 'SUPER_ADMIN',
30
+ 'BROKER_ADMIN',
31
+ 'MANUFACTURER_ADMIN',
32
+ 'DISTRIBUTOR_ADMIN',
33
+ 'PARTNER_ADMIN',
34
+ 'BASIC_PLAN_PARTNERS',
35
+ // The default role of every self-signed-up partner. Its absence from the old list is
36
+ // why they resolved to an empty role_type and the client had to guess positionally.
37
+ 'DRAFT_DISTRIBUTOR_USER',
38
+ 'INTERN_USER',
39
+ 'DISTRIBUTOR_TRIAL',
40
+ 'BROKER_PRODUCT_ADMINS',
41
+ 'BACKOFFICE_USER',
42
+ 'HR_USER',
43
+ 'HR',
44
+ 'PAT_SALES',
45
+ 'SALES_USER',
46
+ 'CLIENT_ADMIN',
47
+ ];
48
+
49
+ /**
50
+ * The role that names a session, out of every role name held.
51
+ *
52
+ * Client-app roles are dropped first: they match no portal predicate, so landing on one
53
+ * reads as "neither partner nor client" and hides every partner screen. A role outside
54
+ * the priority list is still better than nothing — it simply cannot be ranked, so the
55
+ * first remaining one wins.
56
+ *
57
+ * @param {string[]} roleNames
58
+ * @returns {string} the winning role name, or '' when none applies to a portal session
59
+ */
60
+ export function pickPortalRoleName(roleNames) {
61
+ const portalNames = (roleNames || [])
62
+ .filter(Boolean)
63
+ .filter((name) => !CLIENT_APP_ROLE_NAMES.includes(name));
64
+
65
+ return ROLE_TYPE_PRIORITY.find((r) => portalNames.includes(r)) || portalNames[0] || '';
66
+ }
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 };