@i4e/invest4edu-access-core 0.21.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@i4e/invest4edu-access-core",
3
- "version": "0.21.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": {
@@ -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
- const cost = Number(svcRow.credit_cost) > 0
138
- ? Number(svcRow.credit_cost)
139
- : (Number(feature.credit_cost) > 0 ? Number(feature.credit_cost) : 1);
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 units = n * (Number(entitlement?.carriedCost) > 0 ? Number(entitlement.carriedCost) : 1);
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.
@@ -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
- const unitCost = Number(cost) > 0 ? Number(cost) : 1;
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.