@i4e/invest4edu-access-core 0.21.0 → 0.23.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-store.js +12 -4
- package/src/entitlement.js +12 -1
- package/src/subscription-lifecycle.js +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@i4e/invest4edu-access-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.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-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.
|
|
@@ -70,6 +70,12 @@ export const SUBSCRIPTION_EVENT_TYPES = Object.freeze([
|
|
|
70
70
|
// Consumption that happened outside the software — a webinar attended, a VPD session held.
|
|
71
71
|
// There is no request behind it, so this event is the only record of who said it happened.
|
|
72
72
|
"usage_recorded",
|
|
73
|
+
/**
|
|
74
|
+
* A term closed with credits unspent and the plan's policy carried them forward. Worth its own
|
|
75
|
+
* type because a customer WILL ask why their balance is bigger than their allowance, and the
|
|
76
|
+
* answer — which period it came from, how much, and what the cap did — has to be findable.
|
|
77
|
+
*/
|
|
78
|
+
"credits_rolled_over",
|
|
73
79
|
/**
|
|
74
80
|
* The refund conversation, recorded as it happens rather than reconstructed afterwards.
|
|
75
81
|
*
|