@i4e/invest4edu-access-core 0.32.0 → 0.34.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/README.md +126 -126
- package/package.json +58 -56
- package/src/access-config.js +68 -68
- package/src/access-resolver.js +269 -269
- package/src/access-schema.js +174 -174
- package/src/credits.js +128 -128
- package/src/entitlement-schema.js +194 -194
- package/src/entitlement-store.d.ts +99 -99
- package/src/entitlement-store.js +610 -610
- package/src/entitlement.js +300 -300
- package/src/grid-schema.js +231 -231
- package/src/index.js +84 -74
- package/src/proration.js +155 -155
- package/src/report-audience.js +226 -0
- package/src/reportee-tree.js +129 -129
- package/src/role-capabilities.js +93 -93
- package/src/route-features.js +292 -292
- package/src/route-screen.js +45 -0
- package/src/subscription-lifecycle.js +106 -106
- package/src/tenant-context.js +26 -26
- package/src/tenant-plugin.js +177 -177
- package/src/visible-when.js +108 -108
package/src/access-schema.js
CHANGED
|
@@ -1,174 +1,174 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Access-model schema DEFINITIONS — @i4e/invest4edu-access-core (unified access framework, P1).
|
|
3
|
-
*
|
|
4
|
-
* These are PLAIN OBJECTS, not Mongoose schemas (D4: no models in the package). Each backend
|
|
5
|
-
* registers its own models from these definitions, so there is one source of truth for field
|
|
6
|
-
* names without dragging a second Mongoose instance into the package.
|
|
7
|
-
*
|
|
8
|
-
* Design: nfd-ui-nextjs/IV-CodingAgent/specs/access-management/TDD-unified-access-and-subscription.md
|
|
9
|
-
* Status: nfd-ui-nextjs/IV-CodingAgent/specs/access-management/TRACKER-unified-access.md
|
|
10
|
-
*
|
|
11
|
-
* Taxonomy: Module → Feature → Action
|
|
12
|
-
* - a PAGE is a feature (feature_type: 'screen') — retires Page + page_features CSV
|
|
13
|
-
* - an ACTION is a feature (parent_feature_code) — so a plan can entitle an action
|
|
14
|
-
* One `feature_code` namespace serves RBAC, nav, grids, metering and entitlement.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
/** Feature types. A page is a `screen`; an action is an `action` with a parent. */
|
|
18
|
-
export const FEATURE_TYPES = Object.freeze(["screen", "action", "data", "service", "tool"]);
|
|
19
|
-
|
|
20
|
-
/** Lifecycle status shared by registry collections. */
|
|
21
|
-
export const REGISTRY_STATUSES = Object.freeze(["active", "dormant", "retired"]);
|
|
22
|
-
|
|
23
|
-
/** Per-feature enforcement ladder (mirrors the platform's off→warn→enforce doctrine). */
|
|
24
|
-
export const ROLLOUT_MODES = Object.freeze(["off", "warn", "enforce"]);
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* How a SCREEN presents the product-keyed things it renders — product tiles, product tabs, the
|
|
28
|
-
* product column on Add Order, scheme lists.
|
|
29
|
-
*
|
|
30
|
-
* Deliberately separate from `products[]` on the same feature, which is *reachability* ("this
|
|
31
|
-
* screen exists only for these products"). The two are independent and neither implies the other:
|
|
32
|
-
* Open Orders is product-agnostic (`products: []`, it serves every product) yet renders product
|
|
33
|
-
* tabs that must be filtered; Product Setup is equally agnostic and should hide nothing. Same
|
|
34
|
-
* reachability, opposite presentation — so this cannot be derived, it has to be declared.
|
|
35
|
-
*
|
|
36
|
-
* none render everything. The default, so every existing screen is unchanged.
|
|
37
|
-
* filter hide product-keyed items outside the caller's scope. For transactional screens.
|
|
38
|
-
* readonly show everything, but out-of-scope items are not interactive. For configuration
|
|
39
|
-
* screens, where hiding a product reads as "the catalogue is broken" rather than as
|
|
40
|
-
* "you may not touch this".
|
|
41
|
-
*
|
|
42
|
-
* This is presentation only. The server-side gate is what actually refuses a write — hiding a
|
|
43
|
-
* tile has never stopped a POST.
|
|
44
|
-
*/
|
|
45
|
-
export const PRODUCT_UI_MODES = Object.freeze(["none", "filter", "readonly"]);
|
|
46
|
-
|
|
47
|
-
/** Grant effect. `deny` at the user level replaces the old `revoked_pages`. */
|
|
48
|
-
export const GRANT_EFFECTS = Object.freeze(["allow", "deny"]);
|
|
49
|
-
|
|
50
|
-
/** Subject a grant is attached to. */
|
|
51
|
-
export const GRANT_SUBJECTS = Object.freeze(["role", "user"]);
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* `feature_code` convention: MODULE.SCREEN or MODULE.SCREEN.ACTION.
|
|
55
|
-
* Uppercase segments, `_` within a segment, `.` between. Codes are IMMUTABLE once created —
|
|
56
|
-
* they appear in grants, plan entitlements, usage events and grid configs.
|
|
57
|
-
*/
|
|
58
|
-
export const FEATURE_CODE_PATTERN = /^[A-Z][A-Z0-9_]*(\.[A-Z][A-Z0-9_]*){1,2}$/;
|
|
59
|
-
|
|
60
|
-
export function isValidFeatureCode(code) {
|
|
61
|
-
return typeof code === "string" && FEATURE_CODE_PATTERN.test(code);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Derive a screen's code from an action code (`A.B.C` → `A.B`); null if not an action. */
|
|
65
|
-
export function parentCodeOf(featureCode) {
|
|
66
|
-
if (!isValidFeatureCode(featureCode)) return null;
|
|
67
|
-
const parts = featureCode.split(".");
|
|
68
|
-
return parts.length === 3 ? `${parts[0]}.${parts[1]}` : null;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Module code of any feature code (`A.B[.C]` → `A`). */
|
|
72
|
-
export function moduleCodeOf(featureCode) {
|
|
73
|
-
if (!isValidFeatureCode(featureCode)) return null;
|
|
74
|
-
return featureCode.split(".")[0];
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// ── Collection definitions ────────────────────────────────────────────────────────────────────
|
|
78
|
-
// `collection` is the Mongo collection name each backend must use, so v1 and v2 agree.
|
|
79
|
-
|
|
80
|
-
/** Global: nav grouping + reporting dimension. Carries no grants. */
|
|
81
|
-
export const ACCESS_MODULE_DEF = Object.freeze({
|
|
82
|
-
collection: "access_modules",
|
|
83
|
-
fields: Object.freeze({
|
|
84
|
-
module_code: { type: "String", required: true, unique: true, index: true },
|
|
85
|
-
name: { type: "String", required: true },
|
|
86
|
-
icon: { type: "String" },
|
|
87
|
-
position: { type: "Number", default: 0 },
|
|
88
|
-
status: { type: "String", enum: REGISTRY_STATUSES, default: "active" },
|
|
89
|
-
}),
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Global: THE feature registry. Absorbs Page, Page.page_features, the flat Feature model, and
|
|
94
|
-
* the subscription feature registry (BRI-674/707 — one vocabulary, structurally).
|
|
95
|
-
*/
|
|
96
|
-
export const ACCESS_FEATURE_DEF = Object.freeze({
|
|
97
|
-
collection: "access_features",
|
|
98
|
-
fields: Object.freeze({
|
|
99
|
-
feature_code: { type: "String", required: true, unique: true, index: true, immutable: true },
|
|
100
|
-
name: { type: "String", required: true },
|
|
101
|
-
description: { type: "String" },
|
|
102
|
-
feature_type: { type: "String", enum: FEATURE_TYPES, required: true, index: true },
|
|
103
|
-
module_code: { type: "String", index: true },
|
|
104
|
-
parent_feature_code: { type: "String", default: null, index: true }, // set for actions
|
|
105
|
-
|
|
106
|
-
route: { type: "String", default: null }, // screens only (absorbs page_route_for_web)
|
|
107
|
-
nav: {
|
|
108
|
-
show_in_nav: { type: "Boolean", default: false },
|
|
109
|
-
position: { type: "Number", default: 0 },
|
|
110
|
-
icon: { type: "String" },
|
|
111
|
-
is_external_url: { type: "Boolean", default: false },
|
|
112
|
-
external_url: { type: "String" },
|
|
113
|
-
},
|
|
114
|
-
|
|
115
|
-
products: { type: "[String]", default: [] }, // empty = product-agnostic
|
|
116
|
-
|
|
117
|
-
status: { type: "String", enum: REGISTRY_STATUSES, default: "active", index: true },
|
|
118
|
-
kill_switch: { type: "String", enum: ["on", "off"], default: "off" },
|
|
119
|
-
rollout_mode: { type: "String", enum: ROLLOUT_MODES, default: "off" },
|
|
120
|
-
product_ui: { type: "String", enum: PRODUCT_UI_MODES, default: "none" },
|
|
121
|
-
|
|
122
|
-
// Survives an entitlement block (login, billing/checkout, profile) — see design §7.4.
|
|
123
|
-
always_available: { type: "Boolean", default: false },
|
|
124
|
-
|
|
125
|
-
tracking_enabled: { type: "Boolean", default: false }, // analytics (side DB)
|
|
126
|
-
is_meterable: { type: "Boolean", default: false }, // quota (app DB counters)
|
|
127
|
-
single_usage_purchasable: { type: "Boolean", default: false },
|
|
128
|
-
single_usage_price: { type: "Number", default: null },
|
|
129
|
-
|
|
130
|
-
legacy_page_id: { type: "ObjectId", default: null }, // migration traceability
|
|
131
|
-
created_by: { type: "ObjectId" },
|
|
132
|
-
}),
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Tenant: one row per grant. Replaces RolePrivileges.accessible_pages, UserPrivileges and
|
|
137
|
-
* revoked_pages. Rows (not embedded arrays) so grants are indexable, diffable and expirable.
|
|
138
|
-
*/
|
|
139
|
-
export const ACCESS_GRANT_DEF = Object.freeze({
|
|
140
|
-
collection: "access_grants",
|
|
141
|
-
fields: Object.freeze({
|
|
142
|
-
account_id: { type: "ObjectId", required: true, index: true },
|
|
143
|
-
subject_type: { type: "String", enum: GRANT_SUBJECTS, required: true },
|
|
144
|
-
subject_id: { type: "String", required: true }, // role_code | userId
|
|
145
|
-
feature_code: { type: "String", required: true, index: true },
|
|
146
|
-
// null = unscoped (applies across the user's mapped_products) — the legacy-equivalent default.
|
|
147
|
-
// set = applies only when that product is in the user's mapped_products (default-deny).
|
|
148
|
-
product_code: { type: "String", default: null },
|
|
149
|
-
effect: { type: "String", enum: GRANT_EFFECTS, default: "allow" },
|
|
150
|
-
conditions: { type: "Mixed", default: null },
|
|
151
|
-
expires_at: { type: "Date", default: null },
|
|
152
|
-
granted_by: { type: "ObjectId" },
|
|
153
|
-
}),
|
|
154
|
-
indexes: Object.freeze([
|
|
155
|
-
{ keys: { account_id: 1, subject_type: 1, subject_id: 1 } },
|
|
156
|
-
{ keys: { account_id: 1, feature_code: 1 } },
|
|
157
|
-
]),
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
export default {
|
|
161
|
-
FEATURE_TYPES,
|
|
162
|
-
REGISTRY_STATUSES,
|
|
163
|
-
ROLLOUT_MODES,
|
|
164
|
-
PRODUCT_UI_MODES,
|
|
165
|
-
GRANT_EFFECTS,
|
|
166
|
-
GRANT_SUBJECTS,
|
|
167
|
-
FEATURE_CODE_PATTERN,
|
|
168
|
-
isValidFeatureCode,
|
|
169
|
-
parentCodeOf,
|
|
170
|
-
moduleCodeOf,
|
|
171
|
-
ACCESS_MODULE_DEF,
|
|
172
|
-
ACCESS_FEATURE_DEF,
|
|
173
|
-
ACCESS_GRANT_DEF,
|
|
174
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* Access-model schema DEFINITIONS — @i4e/invest4edu-access-core (unified access framework, P1).
|
|
3
|
+
*
|
|
4
|
+
* These are PLAIN OBJECTS, not Mongoose schemas (D4: no models in the package). Each backend
|
|
5
|
+
* registers its own models from these definitions, so there is one source of truth for field
|
|
6
|
+
* names without dragging a second Mongoose instance into the package.
|
|
7
|
+
*
|
|
8
|
+
* Design: nfd-ui-nextjs/IV-CodingAgent/specs/access-management/TDD-unified-access-and-subscription.md
|
|
9
|
+
* Status: nfd-ui-nextjs/IV-CodingAgent/specs/access-management/TRACKER-unified-access.md
|
|
10
|
+
*
|
|
11
|
+
* Taxonomy: Module → Feature → Action
|
|
12
|
+
* - a PAGE is a feature (feature_type: 'screen') — retires Page + page_features CSV
|
|
13
|
+
* - an ACTION is a feature (parent_feature_code) — so a plan can entitle an action
|
|
14
|
+
* One `feature_code` namespace serves RBAC, nav, grids, metering and entitlement.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Feature types. A page is a `screen`; an action is an `action` with a parent. */
|
|
18
|
+
export const FEATURE_TYPES = Object.freeze(["screen", "action", "data", "service", "tool"]);
|
|
19
|
+
|
|
20
|
+
/** Lifecycle status shared by registry collections. */
|
|
21
|
+
export const REGISTRY_STATUSES = Object.freeze(["active", "dormant", "retired"]);
|
|
22
|
+
|
|
23
|
+
/** Per-feature enforcement ladder (mirrors the platform's off→warn→enforce doctrine). */
|
|
24
|
+
export const ROLLOUT_MODES = Object.freeze(["off", "warn", "enforce"]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How a SCREEN presents the product-keyed things it renders — product tiles, product tabs, the
|
|
28
|
+
* product column on Add Order, scheme lists.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately separate from `products[]` on the same feature, which is *reachability* ("this
|
|
31
|
+
* screen exists only for these products"). The two are independent and neither implies the other:
|
|
32
|
+
* Open Orders is product-agnostic (`products: []`, it serves every product) yet renders product
|
|
33
|
+
* tabs that must be filtered; Product Setup is equally agnostic and should hide nothing. Same
|
|
34
|
+
* reachability, opposite presentation — so this cannot be derived, it has to be declared.
|
|
35
|
+
*
|
|
36
|
+
* none render everything. The default, so every existing screen is unchanged.
|
|
37
|
+
* filter hide product-keyed items outside the caller's scope. For transactional screens.
|
|
38
|
+
* readonly show everything, but out-of-scope items are not interactive. For configuration
|
|
39
|
+
* screens, where hiding a product reads as "the catalogue is broken" rather than as
|
|
40
|
+
* "you may not touch this".
|
|
41
|
+
*
|
|
42
|
+
* This is presentation only. The server-side gate is what actually refuses a write — hiding a
|
|
43
|
+
* tile has never stopped a POST.
|
|
44
|
+
*/
|
|
45
|
+
export const PRODUCT_UI_MODES = Object.freeze(["none", "filter", "readonly"]);
|
|
46
|
+
|
|
47
|
+
/** Grant effect. `deny` at the user level replaces the old `revoked_pages`. */
|
|
48
|
+
export const GRANT_EFFECTS = Object.freeze(["allow", "deny"]);
|
|
49
|
+
|
|
50
|
+
/** Subject a grant is attached to. */
|
|
51
|
+
export const GRANT_SUBJECTS = Object.freeze(["role", "user"]);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `feature_code` convention: MODULE.SCREEN or MODULE.SCREEN.ACTION.
|
|
55
|
+
* Uppercase segments, `_` within a segment, `.` between. Codes are IMMUTABLE once created —
|
|
56
|
+
* they appear in grants, plan entitlements, usage events and grid configs.
|
|
57
|
+
*/
|
|
58
|
+
export const FEATURE_CODE_PATTERN = /^[A-Z][A-Z0-9_]*(\.[A-Z][A-Z0-9_]*){1,2}$/;
|
|
59
|
+
|
|
60
|
+
export function isValidFeatureCode(code) {
|
|
61
|
+
return typeof code === "string" && FEATURE_CODE_PATTERN.test(code);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Derive a screen's code from an action code (`A.B.C` → `A.B`); null if not an action. */
|
|
65
|
+
export function parentCodeOf(featureCode) {
|
|
66
|
+
if (!isValidFeatureCode(featureCode)) return null;
|
|
67
|
+
const parts = featureCode.split(".");
|
|
68
|
+
return parts.length === 3 ? `${parts[0]}.${parts[1]}` : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Module code of any feature code (`A.B[.C]` → `A`). */
|
|
72
|
+
export function moduleCodeOf(featureCode) {
|
|
73
|
+
if (!isValidFeatureCode(featureCode)) return null;
|
|
74
|
+
return featureCode.split(".")[0];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Collection definitions ────────────────────────────────────────────────────────────────────
|
|
78
|
+
// `collection` is the Mongo collection name each backend must use, so v1 and v2 agree.
|
|
79
|
+
|
|
80
|
+
/** Global: nav grouping + reporting dimension. Carries no grants. */
|
|
81
|
+
export const ACCESS_MODULE_DEF = Object.freeze({
|
|
82
|
+
collection: "access_modules",
|
|
83
|
+
fields: Object.freeze({
|
|
84
|
+
module_code: { type: "String", required: true, unique: true, index: true },
|
|
85
|
+
name: { type: "String", required: true },
|
|
86
|
+
icon: { type: "String" },
|
|
87
|
+
position: { type: "Number", default: 0 },
|
|
88
|
+
status: { type: "String", enum: REGISTRY_STATUSES, default: "active" },
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Global: THE feature registry. Absorbs Page, Page.page_features, the flat Feature model, and
|
|
94
|
+
* the subscription feature registry (BRI-674/707 — one vocabulary, structurally).
|
|
95
|
+
*/
|
|
96
|
+
export const ACCESS_FEATURE_DEF = Object.freeze({
|
|
97
|
+
collection: "access_features",
|
|
98
|
+
fields: Object.freeze({
|
|
99
|
+
feature_code: { type: "String", required: true, unique: true, index: true, immutable: true },
|
|
100
|
+
name: { type: "String", required: true },
|
|
101
|
+
description: { type: "String" },
|
|
102
|
+
feature_type: { type: "String", enum: FEATURE_TYPES, required: true, index: true },
|
|
103
|
+
module_code: { type: "String", index: true },
|
|
104
|
+
parent_feature_code: { type: "String", default: null, index: true }, // set for actions
|
|
105
|
+
|
|
106
|
+
route: { type: "String", default: null }, // screens only (absorbs page_route_for_web)
|
|
107
|
+
nav: {
|
|
108
|
+
show_in_nav: { type: "Boolean", default: false },
|
|
109
|
+
position: { type: "Number", default: 0 },
|
|
110
|
+
icon: { type: "String" },
|
|
111
|
+
is_external_url: { type: "Boolean", default: false },
|
|
112
|
+
external_url: { type: "String" },
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
products: { type: "[String]", default: [] }, // empty = product-agnostic
|
|
116
|
+
|
|
117
|
+
status: { type: "String", enum: REGISTRY_STATUSES, default: "active", index: true },
|
|
118
|
+
kill_switch: { type: "String", enum: ["on", "off"], default: "off" },
|
|
119
|
+
rollout_mode: { type: "String", enum: ROLLOUT_MODES, default: "off" },
|
|
120
|
+
product_ui: { type: "String", enum: PRODUCT_UI_MODES, default: "none" },
|
|
121
|
+
|
|
122
|
+
// Survives an entitlement block (login, billing/checkout, profile) — see design §7.4.
|
|
123
|
+
always_available: { type: "Boolean", default: false },
|
|
124
|
+
|
|
125
|
+
tracking_enabled: { type: "Boolean", default: false }, // analytics (side DB)
|
|
126
|
+
is_meterable: { type: "Boolean", default: false }, // quota (app DB counters)
|
|
127
|
+
single_usage_purchasable: { type: "Boolean", default: false },
|
|
128
|
+
single_usage_price: { type: "Number", default: null },
|
|
129
|
+
|
|
130
|
+
legacy_page_id: { type: "ObjectId", default: null }, // migration traceability
|
|
131
|
+
created_by: { type: "ObjectId" },
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Tenant: one row per grant. Replaces RolePrivileges.accessible_pages, UserPrivileges and
|
|
137
|
+
* revoked_pages. Rows (not embedded arrays) so grants are indexable, diffable and expirable.
|
|
138
|
+
*/
|
|
139
|
+
export const ACCESS_GRANT_DEF = Object.freeze({
|
|
140
|
+
collection: "access_grants",
|
|
141
|
+
fields: Object.freeze({
|
|
142
|
+
account_id: { type: "ObjectId", required: true, index: true },
|
|
143
|
+
subject_type: { type: "String", enum: GRANT_SUBJECTS, required: true },
|
|
144
|
+
subject_id: { type: "String", required: true }, // role_code | userId
|
|
145
|
+
feature_code: { type: "String", required: true, index: true },
|
|
146
|
+
// null = unscoped (applies across the user's mapped_products) — the legacy-equivalent default.
|
|
147
|
+
// set = applies only when that product is in the user's mapped_products (default-deny).
|
|
148
|
+
product_code: { type: "String", default: null },
|
|
149
|
+
effect: { type: "String", enum: GRANT_EFFECTS, default: "allow" },
|
|
150
|
+
conditions: { type: "Mixed", default: null },
|
|
151
|
+
expires_at: { type: "Date", default: null },
|
|
152
|
+
granted_by: { type: "ObjectId" },
|
|
153
|
+
}),
|
|
154
|
+
indexes: Object.freeze([
|
|
155
|
+
{ keys: { account_id: 1, subject_type: 1, subject_id: 1 } },
|
|
156
|
+
{ keys: { account_id: 1, feature_code: 1 } },
|
|
157
|
+
]),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
export default {
|
|
161
|
+
FEATURE_TYPES,
|
|
162
|
+
REGISTRY_STATUSES,
|
|
163
|
+
ROLLOUT_MODES,
|
|
164
|
+
PRODUCT_UI_MODES,
|
|
165
|
+
GRANT_EFFECTS,
|
|
166
|
+
GRANT_SUBJECTS,
|
|
167
|
+
FEATURE_CODE_PATTERN,
|
|
168
|
+
isValidFeatureCode,
|
|
169
|
+
parentCodeOf,
|
|
170
|
+
moduleCodeOf,
|
|
171
|
+
ACCESS_MODULE_DEF,
|
|
172
|
+
ACCESS_FEATURE_DEF,
|
|
173
|
+
ACCESS_GRANT_DEF,
|
|
174
|
+
};
|
package/src/credits.js
CHANGED
|
@@ -1,128 +1,128 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Credits — what happens to an unused allowance when a term ends.
|
|
3
|
-
*
|
|
4
|
-
* A credits plan hands out an allowance per term (500 credits a month) and every service spends
|
|
5
|
-
* from it at its own price. At the term boundary the counter resets, and the question nobody can
|
|
6
|
-
* avoid answering is what the unspent remainder was worth. Silence is itself an answer — the
|
|
7
|
-
* default today is that it evaporates, which is fine as a policy and indefensible as an accident.
|
|
8
|
-
*
|
|
9
|
-
* ── Rollover is a POLICY, resolved in layers ─────────────────────────────────────────────────
|
|
10
|
-
* platform default < plan. Same shape and same merge rule as proration, because they are the
|
|
11
|
-
* same kind of decision: what unused value is worth when the ground moves under it.
|
|
12
|
-
*
|
|
13
|
-
* ── The rules that are NOT configurable, because getting them wrong invents credits ──────────
|
|
14
|
-
* · you can never roll more than went unused — the cap trims, it never tops up
|
|
15
|
-
* · an unlimited allowance rolls nothing: there was no scarcity to carry forward
|
|
16
|
-
* · rounding is DOWN, so a rollover never hands out a credit that was not left over
|
|
17
|
-
* · rolled credits expire; carrying them forever turns a monthly allowance into a savings
|
|
18
|
-
* account and makes the liability unbounded. They lapse at the end of the term they land in
|
|
19
|
-
*
|
|
20
|
-
* Kept pure — no database, no clock, no subscription — so the arithmetic is testable on its own.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
/** Every knob, with the conservative answer as the default. */
|
|
24
|
-
export const CREDITS_DEFAULTS = {
|
|
25
|
-
/**
|
|
26
|
-
* none — the remainder lapses at the term boundary (today's behaviour)
|
|
27
|
-
* full — everything unused carries into the next term
|
|
28
|
-
* capped — carries, but no more than `cap_percent` of the allowance
|
|
29
|
-
*/
|
|
30
|
-
rollover_mode: "none",
|
|
31
|
-
/** Ceiling on what may roll, as a percentage of the term's allowance. Only used by `capped`. */
|
|
32
|
-
rollover_cap_percent: 50,
|
|
33
|
-
/**
|
|
34
|
-
* How long rolled credits survive, in terms. 1 = they must be used in the term they land in.
|
|
35
|
-
* Rolled credits never roll again regardless — carrying a carry compounds into a balance that
|
|
36
|
-
* no one budgeted for.
|
|
37
|
-
*/
|
|
38
|
-
rollover_expires_in_terms: 1,
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Merge policy layers. Later wins, and only for keys it actually specifies — a plan that sets
|
|
43
|
-
* only the mode must not silently reset a cap someone configured globally.
|
|
44
|
-
*/
|
|
45
|
-
export function resolveCreditsPolicy(...layers) {
|
|
46
|
-
const out = { ...CREDITS_DEFAULTS };
|
|
47
|
-
for (const layer of layers) {
|
|
48
|
-
if (!layer || typeof layer !== "object") continue;
|
|
49
|
-
for (const k of Object.keys(CREDITS_DEFAULTS)) {
|
|
50
|
-
if (layer[k] !== undefined && layer[k] !== null) out[k] = layer[k];
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return out;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* How many credits carry into the next term.
|
|
58
|
-
*
|
|
59
|
-
* @param {object} a
|
|
60
|
-
* @param {number|null} a.quota the term's allowance. null = unlimited ⇒ nothing to carry
|
|
61
|
-
* @param {number} a.used credits spent this term
|
|
62
|
-
* @param {object} a.policy resolved policy (see resolveCreditsPolicy)
|
|
63
|
-
* @returns {{units:number, reason:string, unused:number, cap:number|null, mode:string}}
|
|
64
|
-
*/
|
|
65
|
-
export function computeRollover({ quota, used = 0, policy = CREDITS_DEFAULTS } = {}) {
|
|
66
|
-
const p = resolveCreditsPolicy(policy);
|
|
67
|
-
const mode = p.rollover_mode;
|
|
68
|
-
const nil = (reason) => ({ units: 0, reason, unused: 0, cap: null, mode });
|
|
69
|
-
|
|
70
|
-
if (mode === "none") return nil("rollover is off");
|
|
71
|
-
// Unlimited means the allowance never constrained anything, so there is no remainder to carry.
|
|
72
|
-
// Rolling "infinity minus what you used" is not a number, and any finite stand-in is invented.
|
|
73
|
-
if (quota == null) return nil("allowance is unlimited — nothing to carry");
|
|
74
|
-
|
|
75
|
-
const allowance = Number(quota);
|
|
76
|
-
if (!Number.isFinite(allowance) || allowance <= 0) return nil("no allowance this term");
|
|
77
|
-
|
|
78
|
-
const spent = Math.max(0, Number(used) || 0);
|
|
79
|
-
// Overage can push `used` past the quota under a warn policy; a negative remainder is not a debt.
|
|
80
|
-
const unused = Math.max(0, allowance - spent);
|
|
81
|
-
if (unused <= 0) return { units: 0, reason: "allowance fully used", unused: 0, cap: null, mode };
|
|
82
|
-
|
|
83
|
-
if (mode === "full") {
|
|
84
|
-
return { units: Math.floor(unused), reason: "carried in full", unused, cap: null, mode };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if (mode === "capped") {
|
|
88
|
-
const pct = Math.max(0, Math.min(100, Number(p.rollover_cap_percent) || 0));
|
|
89
|
-
const cap = Math.floor((allowance * pct) / 100);
|
|
90
|
-
const units = Math.min(Math.floor(unused), cap);
|
|
91
|
-
return {
|
|
92
|
-
units,
|
|
93
|
-
reason: units < unused ? `capped at ${pct}% of the allowance` : "carried in full (under the cap)",
|
|
94
|
-
unused,
|
|
95
|
-
cap,
|
|
96
|
-
mode,
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// An unrecognised mode is a config error. Carrying nothing is the safe reading of it — the
|
|
101
|
-
// alternative invents a balance from a typo.
|
|
102
|
-
return nil(`unknown rollover mode "${mode}"`);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* When rolled credits lapse: the end of the term they can be used in.
|
|
107
|
-
*
|
|
108
|
-
* Needs the length of the term they are landing in, which the caller knows and this module
|
|
109
|
-
* deliberately does not. A term with no end (open-ended subscription) yields null — nothing to
|
|
110
|
-
* expire against, and inventing a date would silently destroy paid-for value.
|
|
111
|
-
*/
|
|
112
|
-
export function rolloverExpiryDate({ nextPeriodStart, nextPeriodEnd, policy = CREDITS_DEFAULTS } = {}) {
|
|
113
|
-
const p = resolveCreditsPolicy(policy);
|
|
114
|
-
if (!nextPeriodEnd) return null;
|
|
115
|
-
const end = new Date(nextPeriodEnd);
|
|
116
|
-
if (Number.isNaN(end.getTime())) return null;
|
|
117
|
-
|
|
118
|
-
const terms = Math.max(1, Math.floor(Number(p.rollover_expires_in_terms) || 1));
|
|
119
|
-
if (terms === 1) return end;
|
|
120
|
-
|
|
121
|
-
// More than one term: extend by the length of the term they land in, which is the only measure
|
|
122
|
-
// of "a term" available here. Without a start we cannot know that length, so we keep the single
|
|
123
|
-
// term rather than guess — under-granting is recoverable, over-granting is not.
|
|
124
|
-
const start = nextPeriodStart ? new Date(nextPeriodStart) : null;
|
|
125
|
-
if (!start || Number.isNaN(start.getTime()) || start >= end) return end;
|
|
126
|
-
const termMs = end.getTime() - start.getTime();
|
|
127
|
-
return new Date(end.getTime() + termMs * (terms - 1));
|
|
128
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Credits — what happens to an unused allowance when a term ends.
|
|
3
|
+
*
|
|
4
|
+
* A credits plan hands out an allowance per term (500 credits a month) and every service spends
|
|
5
|
+
* from it at its own price. At the term boundary the counter resets, and the question nobody can
|
|
6
|
+
* avoid answering is what the unspent remainder was worth. Silence is itself an answer — the
|
|
7
|
+
* default today is that it evaporates, which is fine as a policy and indefensible as an accident.
|
|
8
|
+
*
|
|
9
|
+
* ── Rollover is a POLICY, resolved in layers ─────────────────────────────────────────────────
|
|
10
|
+
* platform default < plan. Same shape and same merge rule as proration, because they are the
|
|
11
|
+
* same kind of decision: what unused value is worth when the ground moves under it.
|
|
12
|
+
*
|
|
13
|
+
* ── The rules that are NOT configurable, because getting them wrong invents credits ──────────
|
|
14
|
+
* · you can never roll more than went unused — the cap trims, it never tops up
|
|
15
|
+
* · an unlimited allowance rolls nothing: there was no scarcity to carry forward
|
|
16
|
+
* · rounding is DOWN, so a rollover never hands out a credit that was not left over
|
|
17
|
+
* · rolled credits expire; carrying them forever turns a monthly allowance into a savings
|
|
18
|
+
* account and makes the liability unbounded. They lapse at the end of the term they land in
|
|
19
|
+
*
|
|
20
|
+
* Kept pure — no database, no clock, no subscription — so the arithmetic is testable on its own.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Every knob, with the conservative answer as the default. */
|
|
24
|
+
export const CREDITS_DEFAULTS = {
|
|
25
|
+
/**
|
|
26
|
+
* none — the remainder lapses at the term boundary (today's behaviour)
|
|
27
|
+
* full — everything unused carries into the next term
|
|
28
|
+
* capped — carries, but no more than `cap_percent` of the allowance
|
|
29
|
+
*/
|
|
30
|
+
rollover_mode: "none",
|
|
31
|
+
/** Ceiling on what may roll, as a percentage of the term's allowance. Only used by `capped`. */
|
|
32
|
+
rollover_cap_percent: 50,
|
|
33
|
+
/**
|
|
34
|
+
* How long rolled credits survive, in terms. 1 = they must be used in the term they land in.
|
|
35
|
+
* Rolled credits never roll again regardless — carrying a carry compounds into a balance that
|
|
36
|
+
* no one budgeted for.
|
|
37
|
+
*/
|
|
38
|
+
rollover_expires_in_terms: 1,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Merge policy layers. Later wins, and only for keys it actually specifies — a plan that sets
|
|
43
|
+
* only the mode must not silently reset a cap someone configured globally.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveCreditsPolicy(...layers) {
|
|
46
|
+
const out = { ...CREDITS_DEFAULTS };
|
|
47
|
+
for (const layer of layers) {
|
|
48
|
+
if (!layer || typeof layer !== "object") continue;
|
|
49
|
+
for (const k of Object.keys(CREDITS_DEFAULTS)) {
|
|
50
|
+
if (layer[k] !== undefined && layer[k] !== null) out[k] = layer[k];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* How many credits carry into the next term.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} a
|
|
60
|
+
* @param {number|null} a.quota the term's allowance. null = unlimited ⇒ nothing to carry
|
|
61
|
+
* @param {number} a.used credits spent this term
|
|
62
|
+
* @param {object} a.policy resolved policy (see resolveCreditsPolicy)
|
|
63
|
+
* @returns {{units:number, reason:string, unused:number, cap:number|null, mode:string}}
|
|
64
|
+
*/
|
|
65
|
+
export function computeRollover({ quota, used = 0, policy = CREDITS_DEFAULTS } = {}) {
|
|
66
|
+
const p = resolveCreditsPolicy(policy);
|
|
67
|
+
const mode = p.rollover_mode;
|
|
68
|
+
const nil = (reason) => ({ units: 0, reason, unused: 0, cap: null, mode });
|
|
69
|
+
|
|
70
|
+
if (mode === "none") return nil("rollover is off");
|
|
71
|
+
// Unlimited means the allowance never constrained anything, so there is no remainder to carry.
|
|
72
|
+
// Rolling "infinity minus what you used" is not a number, and any finite stand-in is invented.
|
|
73
|
+
if (quota == null) return nil("allowance is unlimited — nothing to carry");
|
|
74
|
+
|
|
75
|
+
const allowance = Number(quota);
|
|
76
|
+
if (!Number.isFinite(allowance) || allowance <= 0) return nil("no allowance this term");
|
|
77
|
+
|
|
78
|
+
const spent = Math.max(0, Number(used) || 0);
|
|
79
|
+
// Overage can push `used` past the quota under a warn policy; a negative remainder is not a debt.
|
|
80
|
+
const unused = Math.max(0, allowance - spent);
|
|
81
|
+
if (unused <= 0) return { units: 0, reason: "allowance fully used", unused: 0, cap: null, mode };
|
|
82
|
+
|
|
83
|
+
if (mode === "full") {
|
|
84
|
+
return { units: Math.floor(unused), reason: "carried in full", unused, cap: null, mode };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (mode === "capped") {
|
|
88
|
+
const pct = Math.max(0, Math.min(100, Number(p.rollover_cap_percent) || 0));
|
|
89
|
+
const cap = Math.floor((allowance * pct) / 100);
|
|
90
|
+
const units = Math.min(Math.floor(unused), cap);
|
|
91
|
+
return {
|
|
92
|
+
units,
|
|
93
|
+
reason: units < unused ? `capped at ${pct}% of the allowance` : "carried in full (under the cap)",
|
|
94
|
+
unused,
|
|
95
|
+
cap,
|
|
96
|
+
mode,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// An unrecognised mode is a config error. Carrying nothing is the safe reading of it — the
|
|
101
|
+
// alternative invents a balance from a typo.
|
|
102
|
+
return nil(`unknown rollover mode "${mode}"`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* When rolled credits lapse: the end of the term they can be used in.
|
|
107
|
+
*
|
|
108
|
+
* Needs the length of the term they are landing in, which the caller knows and this module
|
|
109
|
+
* deliberately does not. A term with no end (open-ended subscription) yields null — nothing to
|
|
110
|
+
* expire against, and inventing a date would silently destroy paid-for value.
|
|
111
|
+
*/
|
|
112
|
+
export function rolloverExpiryDate({ nextPeriodStart, nextPeriodEnd, policy = CREDITS_DEFAULTS } = {}) {
|
|
113
|
+
const p = resolveCreditsPolicy(policy);
|
|
114
|
+
if (!nextPeriodEnd) return null;
|
|
115
|
+
const end = new Date(nextPeriodEnd);
|
|
116
|
+
if (Number.isNaN(end.getTime())) return null;
|
|
117
|
+
|
|
118
|
+
const terms = Math.max(1, Math.floor(Number(p.rollover_expires_in_terms) || 1));
|
|
119
|
+
if (terms === 1) return end;
|
|
120
|
+
|
|
121
|
+
// More than one term: extend by the length of the term they land in, which is the only measure
|
|
122
|
+
// of "a term" available here. Without a start we cannot know that length, so we keep the single
|
|
123
|
+
// term rather than guess — under-granting is recoverable, over-granting is not.
|
|
124
|
+
const start = nextPeriodStart ? new Date(nextPeriodStart) : null;
|
|
125
|
+
if (!start || Number.isNaN(start.getTime()) || start >= end) return end;
|
|
126
|
+
const termMs = end.getTime() - start.getTime();
|
|
127
|
+
return new Date(end.getTime() + termMs * (terms - 1));
|
|
128
|
+
}
|