@i4e/invest4edu-access-core 0.20.0 → 0.22.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/package.json +3 -2
- package/src/credits.js +128 -0
- package/src/entitlement-store.js +12 -4
- package/src/entitlement.js +12 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@i4e/invest4edu-access-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Shared access-control primitives for NeoFindesk: tenant keystone, role capabilities, reportee tree, feature flags, and the unified access engine (registry schema, snapshot resolver, visibleWhen).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"./route-features": "./src/route-features.js",
|
|
20
20
|
"./subscription-lifecycle": "./src/subscription-lifecycle.js",
|
|
21
21
|
"./entitlement-store": "./src/entitlement-store.js",
|
|
22
|
-
"./proration": "./src/proration.js"
|
|
22
|
+
"./proration": "./src/proration.js",
|
|
23
|
+
"./credits": "./src/credits.js"
|
|
23
24
|
},
|
|
24
25
|
"scripts": {
|
|
25
26
|
"test": "node --test test/"
|
package/src/credits.js
ADDED
|
@@ -0,0 +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
|
+
}
|
package/src/entitlement-store.js
CHANGED
|
@@ -134,9 +134,10 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
134
134
|
// Not named in a credits plan → the same fail-open NOT_IN_PLAN as count mode. A plan
|
|
135
135
|
// that forgot to include a feature is a config gap, not a paywall.
|
|
136
136
|
if (svcRow && svcRow.unlocked !== false) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
137
|
+
// UNSET falls through to the next layer; 0 does not — it is the plan saying "free".
|
|
138
|
+
const priced = (v) => (v === null || v === undefined || v === "" || !Number.isFinite(Number(v)) || Number(v) < 0
|
|
139
|
+
? null : Number(v));
|
|
140
|
+
const cost = priced(svcRow.credit_cost) ?? priced(feature.credit_cost) ?? 1;
|
|
140
141
|
|
|
141
142
|
let packBalance = 0;
|
|
142
143
|
try {
|
|
@@ -332,7 +333,14 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
332
333
|
* here could draw from a bucket nobody checked, at a price nobody quoted.
|
|
333
334
|
*/
|
|
334
335
|
const bucketCode = entitlement?.carriedFeatureCode || featureCode;
|
|
335
|
-
const
|
|
336
|
+
const carried = Number(entitlement?.carriedCost);
|
|
337
|
+
const units = n * (Number.isFinite(carried) && carried >= 0 ? carried : 1);
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* A free action inside a credits plan: authorised, but there is no balance to move. Writing
|
|
341
|
+
* a zero increment would still create the row and make it look like something was spent.
|
|
342
|
+
*/
|
|
343
|
+
if (units === 0) return { consumed: true, free: true };
|
|
336
344
|
|
|
337
345
|
// The SAME bucket that entitlementFor just checked — recomputing it from different inputs
|
|
338
346
|
// would count against a period nobody validated.
|
package/src/entitlement.js
CHANGED
|
@@ -183,7 +183,18 @@ export function resolveEntitlement({
|
|
|
183
183
|
const granularity = row.period_granularity || "month";
|
|
184
184
|
const period = periodKey(granularity, { now, subscriptionId: subscription._id, periodStart: subscription.period_start });
|
|
185
185
|
const subject_key = subjectKeyFor(row.quota_scope, identity);
|
|
186
|
-
|
|
186
|
+
/**
|
|
187
|
+
* Zero is a PRICE, not a missing value.
|
|
188
|
+
*
|
|
189
|
+
* "Included, costs nothing" is a thing a credits plan has to be able to say, and the most
|
|
190
|
+
* important thing it says it about is placing an order: metered, unlimited on every plan today,
|
|
191
|
+
* and the last action that should ever be refused for an empty balance. Coercing 0 up to 1
|
|
192
|
+
* would quietly put revenue behind the credit meter.
|
|
193
|
+
*/
|
|
194
|
+
// null and "" are UNSET, and Number() turns both into 0 — which would read as "free" and hand
|
|
195
|
+
// away exactly what this branch exists to protect. Only a real number counts as a price.
|
|
196
|
+
const raw = cost === null || cost === undefined || cost === "" ? 1 : Number(cost);
|
|
197
|
+
const unitCost = Number.isFinite(raw) && raw >= 0 ? raw : 1;
|
|
187
198
|
const planRemaining = Math.max(0, Number(row.quota) - Number(used || 0));
|
|
188
199
|
// Cost-aware: the action is allowed only if the WHOLE cost fits. Refusing at balance < cost is
|
|
189
200
|
// the agreed rule — a balance never goes negative on an in-flight action.
|