@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.
@@ -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
+ }