@i4e/invest4edu-access-core 0.23.0 → 0.25.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 +1 -1
- package/src/entitlement.js +64 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@i4e/invest4edu-access-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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": {
|
package/src/entitlement.js
CHANGED
|
@@ -144,6 +144,12 @@ export function resolveEntitlement({
|
|
|
144
144
|
const base = {
|
|
145
145
|
quota: null, used, remaining: null, period: null, subject_key: null,
|
|
146
146
|
overage_policy: "unlimited", metered: false,
|
|
147
|
+
/**
|
|
148
|
+
* A CEILING that shapes a response — top-N picks, tips per answer — as opposed to `quota`,
|
|
149
|
+
* which is a budget that decrements. Null when the plan sets none. Present on every result so
|
|
150
|
+
* a caller never has to tell "no ceiling" from "this resolver is too old to send one".
|
|
151
|
+
*/
|
|
152
|
+
limit: null,
|
|
147
153
|
};
|
|
148
154
|
const open = (reason) => ({ ...base, unlocked: true, allowed: true, reason });
|
|
149
155
|
|
|
@@ -171,13 +177,68 @@ export function resolveEntitlement({
|
|
|
171
177
|
// not silently switch features off for everyone the moment it ships.
|
|
172
178
|
if (!row) return open(R.NOT_IN_PLAN);
|
|
173
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Locked: the plan does not carry this feature at all.
|
|
182
|
+
*
|
|
183
|
+
* A locked row is an ACCESS decision, so no allowance gets past it — that is what makes tiering
|
|
184
|
+
* mean anything. If a shared credit balance could unlock a locked feature, the cheap plan plus a
|
|
185
|
+
* top-up would be strictly better than the expensive plan, and the tier would collapse.
|
|
186
|
+
*
|
|
187
|
+
* A unit bought OUTRIGHT is different, and is the deliberate escape hatch: someone who paid for
|
|
188
|
+
* one portfolio analysis has bought that analysis, not access to the feature. `credits` here is
|
|
189
|
+
* always the balance for THIS feature — the caller resolves credits-mode plans down this same
|
|
190
|
+
* path precisely because their shared pool must not reach it — so consulting it cannot leak the
|
|
191
|
+
* pool. Nothing is on sale individually by default, which keeps this dormant until someone
|
|
192
|
+
* prices a service for it.
|
|
193
|
+
*/
|
|
174
194
|
if (row.unlocked === false) {
|
|
195
|
+
const owned = Math.max(0, Number(credits || 0));
|
|
196
|
+
if (owned >= 1) {
|
|
197
|
+
return {
|
|
198
|
+
...base,
|
|
199
|
+
unlocked: true,
|
|
200
|
+
allowed: true,
|
|
201
|
+
credits: owned,
|
|
202
|
+
remaining: owned,
|
|
203
|
+
usingCredit: true,
|
|
204
|
+
metered: true,
|
|
205
|
+
quota: 0,
|
|
206
|
+
used: Number(used || 0),
|
|
207
|
+
overage_policy: row.overage_policy || "block",
|
|
208
|
+
reason: R.OK,
|
|
209
|
+
period: periodKey(row.period_granularity || "month", {
|
|
210
|
+
now, subscriptionId: subscription._id, periodStart: subscription.period_start,
|
|
211
|
+
}),
|
|
212
|
+
subject_key: subjectKeyFor(row.quota_scope, identity),
|
|
213
|
+
cost: 1,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
175
216
|
return { ...base, unlocked: false, allowed: false, overage_policy: row.overage_policy, reason: R.LOCKED };
|
|
176
217
|
}
|
|
177
218
|
|
|
178
219
|
// Unlocked but not metered → nothing to count.
|
|
220
|
+
/**
|
|
221
|
+
* Unlocked but not counted.
|
|
222
|
+
*
|
|
223
|
+
* `limit` is carried through even though nothing is metered, because a number on an unmetered
|
|
224
|
+
* row is a CEILING rather than a budget: "your top-picks list shows 5" is read on every request
|
|
225
|
+
* and never decrements. Returning it lets a caller shape its response from the plan instead of
|
|
226
|
+
* hard-coding the shape, which is the difference between a config edit and a deploy.
|
|
227
|
+
*
|
|
228
|
+
* `quota` deliberately stays null here — it means "how much is left", and nothing is being
|
|
229
|
+
* spent. Reusing it for a ceiling would make `remaining` a lie and invite a consume() call that
|
|
230
|
+
* should never happen.
|
|
231
|
+
*/
|
|
179
232
|
if (!feature.is_meterable || row.quota === null || row.quota === undefined) {
|
|
180
|
-
|
|
233
|
+
// null/undefined is UNSET, and Number() turns both into 0 — which would read as "show
|
|
234
|
+
// nothing", the exact opposite. Only a real number is a ceiling.
|
|
235
|
+
const raw = row.quota;
|
|
236
|
+
const ceiling = raw === null || raw === undefined || raw === "" ? null : Number(raw);
|
|
237
|
+
return {
|
|
238
|
+
...open(feature.is_meterable ? R.OK : R.NOT_METERED),
|
|
239
|
+
overage_policy: row.overage_policy || "unlimited",
|
|
240
|
+
limit: ceiling !== null && Number.isFinite(ceiling) && ceiling >= 0 ? ceiling : null,
|
|
241
|
+
};
|
|
181
242
|
}
|
|
182
243
|
|
|
183
244
|
const granularity = row.period_granularity || "month";
|
|
@@ -217,6 +278,8 @@ export function resolveEntitlement({
|
|
|
217
278
|
|
|
218
279
|
return {
|
|
219
280
|
unlocked: true,
|
|
281
|
+
// Declared even on a metered result: absence must never be mistaken for "no ceiling".
|
|
282
|
+
limit: null,
|
|
220
283
|
quota: Number(row.quota),
|
|
221
284
|
used: Number(used || 0),
|
|
222
285
|
remaining,
|